Compare commits

...
Author SHA1 Message Date
陈大猫 6ba1b7b34a fix(plugins): migrate remaining permission fixture hosts 2026-10-29 13:56:21 +08:00
陈大猫 c309a455ed fix(plugins): contain host-view errors, isolate editing, and migrate fixtures 2026-10-29 13:42:53 +08:00
bincxz 39a0008876 Merge remote-tracking branch 'origin/main' into codex/host-rendered-plugin-system 2026-10-29 11:34:49 +08:00
bincxz f5a802d610 feat: rebuild plugin pages with host rendering and native settings 2026-10-29 11:34:40 +08:00
陈大猫 7d5d234fdd fix(host-browser): accept invalid TLS certs for page tabs (#4050)
Fixes #4049

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix(ai): reject malformed /rebind numbers

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix(ai): export prepareRestoredSessions helper

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

Fixes #4007

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

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

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

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

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

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

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

* fix: snapshot keychain username when remembering KI password

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

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

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

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

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

* fix: snapshot keychain username when remembering KI password

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

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

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

---------

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

* test: update extracted input harnesses for host mode

* test: provide required sync vault fields

* test: cover legacy cloud host migration after import

* fix: preserve non-SSH input compatibility behavior

* fix: keep Mosh and Eternal Terminal input policy unchanged

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

* fix: retain connection input mode across renderer hibernation

* docs: distinguish input timing and native SSH evidence

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

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

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

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

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

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

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

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

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

Fixes #3940
2026-10-26 11:45:28 +08:00
648 changed files with 38833 additions and 5275 deletions

No files matched your search

+9
View File
@@ -330,6 +330,9 @@ jobs:
APPLE_TEAM_ID: ${{ matrix.name == 'macos' && secrets.APPLE_TEAM_ID || '' }}
run: npm run ${{ matrix.pack_script }}
- name: Test packaged SSH cipher negotiation
run: node scripts/test-packaged-ssh-ciphers.cjs
- name: Test tray panel layout
run: npm run test:tray-panel-layout
@@ -631,6 +634,9 @@ jobs:
done
npm run pack:linux-x64
- name: Test packaged SSH cipher negotiation
run: node scripts/test-packaged-ssh-ciphers.cjs
- name: Verify packaged node-pty Linux runtime
run: bash scripts/ensure-node-pty-linux.sh verify x64
@@ -765,6 +771,9 @@ jobs:
ELECTRON_BUILDER_PUBLISH: "never"
run: npm run pack:linux-arm64
- name: Test packaged SSH cipher negotiation
run: node scripts/test-packaged-ssh-ciphers.cjs
- name: Verify packaged node-pty Linux runtime
run: bash scripts/ensure-node-pty-linux.sh verify arm64
+11
View File
@@ -53,11 +53,17 @@ jobs:
run: |
npm run check:plugin-contract
npm run test:plugin-runtime
npm run test:plugin-examples
node --test --import tsx components/settings/tabs/Plugin*.test.tsx
- name: Test
run: npm test
- name: Test SSH cipher negotiation in Electron's crypto runtime
env:
NETCATTY_SSH_TEST_RUNTIME: electron
run: node --test electron/bridges/sshChaChaTransport.test.cjs
- name: Test browser first-link lifecycle
run: xvfb-run -a ./node_modules/.bin/electron --no-sandbox electron/hostBrowser/hostBrowser.firstLink.live.test.cjs
@@ -144,5 +150,10 @@ jobs:
- name: Build
run: npm run build
- name: Test SSH cipher negotiation in Windows Electron's crypto runtime
env:
NETCATTY_SSH_TEST_RUNTIME: electron
run: node --test electron/bridges/sshChaChaTransport.test.cjs
- name: Exercise two SSH password prompts in the desktop app
run: node scripts/terminal-broadcast-ssh.live.cjs
+3
View File
@@ -170,3 +170,6 @@ build_with_vs2022.bat
/resources/et/*/et.exe
/resources/et/*/et-*-dlls/
/resources/et/*/*.dll
# Local plugin example staging and bundles
/examples/plugins/*/.package/
+5
View File
@@ -127,6 +127,11 @@ second private RPC shape when extending the host.
- `PluginRpcRouter` owns correlation, cancellation, deadlines, stream credit and
protocol-failure containment. Runtime identity is assigned by the host and is
never accepted from request parameters.
- Custom views use only host-rendered DOM: plugin scripts stay in the isolated
runtime; the host validates incoming trees, styles, resources and events before
rendering. Never reintroduce native child views, iframe UI, or plugin code in
the host renderer. See `docs/plugin-platform/host-rendered-ui.md` for authoring,
lifecycle, limits and verification.
- App quit goes through `runPluginShutdown()` after the dirty-editor guard; do
not add another independent quit interception path.
+2
View File
@@ -12,6 +12,7 @@ import { ToastProvider } from './components/ui/toast';
import { TooltipProvider } from './components/ui/tooltip';
import { ScriptAutomationRoot } from './components/scripts/ScriptAutomationRoot';
import { ExternalMcpApprovalsHost } from './components/ai/ExternalMcpApprovalsHost';
import { PluginPermissionHost } from './components/plugins/PluginPermissionHost';
import { PluginHostInteractionHost } from './components/plugins/PluginHostInteractionHost';
import { PluginHostActionHost } from './components/plugins/PluginHostActionHost';
import { PluginAuthenticationHost } from './components/plugins/PluginAuthenticationHost';
@@ -88,6 +89,7 @@ function AppWithProviders({ settings, appLock }: { settings: SettingsState; appL
<ScriptAutomationRoot />
<ExternalMcpApprovalsHost />
<PluginAuthenticationHost />
<PluginPermissionHost />
<PluginHostInteractionHost />
<MultilinePasteConfirmHost />
<VaultPublisher>
+215 -1
View File
@@ -7,8 +7,9 @@ import {
handleKeyboardInteractiveSubmitImpl,
handleTrayPanelConnectRequestImpl,
} from './app/AppHandlers.ts';
import { resolveHostAuth } from '../domain/sshAuth.ts';
import { resolveShellSetting } from '../lib/useDiscoveredShells.ts';
import type { Host } from '../types';
import type { Host, Identity } from '../types';
const baseHost: Host = {
id: 'host-1',
@@ -194,6 +195,219 @@ test('keyboard-interactive submit can save login password for the session host',
assert.equal(bridgeResponses.length, 1);
});
test('keyboard-interactive submit detaches keychain identity when remembering a password change', async () => {
const sharedIdentity: Identity = {
id: 'id-1',
label: 'shared',
username: 'root',
authMethod: 'password',
password: 'stale-keychain-password',
created: 0,
};
let hosts: Host[] = [{
...baseHost,
identityId: 'id-1',
password: 'old-host-password',
savePassword: false,
}];
let queue = [{
requestId: 'ki-rotate',
sessionId: 'session-1',
hostname: baseHost.hostname,
allowSavePassword: true,
}];
let identityUpdates = 0;
// resolveHostAuth prefers identity.password, so the stale keychain secret
// shadows whatever is already stored on the host.
assert.equal(
resolveHostAuth({ host: hosts[0], keys: [], identities: [sharedIdentity] }).password,
'stale-keychain-password',
);
await handleKeyboardInteractiveSubmitImpl(
() => ({
hosts,
identities: [sharedIdentity],
keyboardInteractiveQueue: queue,
netcattyBridge: {
get: () => ({
respondKeyboardInteractive: () => ({ success: true }),
}),
},
sessions: [{
id: 'session-1',
hostId: baseHost.id,
hostname: baseHost.hostname,
}],
setKeyboardInteractiveQueue: (updater: (items: typeof queue) => typeof queue) => {
queue = updater(queue);
},
t: (key: string) => key,
toast: { error: () => {} },
updateHosts: (nextHosts: Host[]) => {
hosts = nextHosts;
},
updateIdentities: () => {
identityUpdates += 1;
},
}),
'ki-rotate',
['rotated-password'],
'rotated-password',
);
assert.equal(hosts[0].identityId, '');
assert.equal(hosts[0].password, 'rotated-password');
assert.equal(hosts[0].savePassword, true);
assert.equal(hosts[0].username, baseHost.username);
assert.equal(identityUpdates, 0);
assert.equal(sharedIdentity.password, 'stale-keychain-password');
assert.equal(
resolveHostAuth({ host: hosts[0], keys: [], identities: [sharedIdentity] }).password,
'rotated-password',
);
assert.deepEqual(queue, []);
});
test('keyboard-interactive submit snapshots a different keychain username when remembering a password', async () => {
const sharedIdentity: Identity = {
id: 'id-1',
label: 'shared',
username: 'deploy',
authMethod: 'password',
password: 'stale-keychain-password',
created: 0,
};
let hosts: Host[] = [{
...baseHost,
username: 'root',
identityId: 'id-1',
password: 'old-host-password',
savePassword: false,
}];
let queue = [{
requestId: 'ki-rotate-user',
sessionId: 'session-1',
hostname: baseHost.hostname,
allowSavePassword: true,
}];
let identityUpdates = 0;
await handleKeyboardInteractiveSubmitImpl(
() => ({
hosts,
identities: [sharedIdentity],
keyboardInteractiveQueue: queue,
netcattyBridge: {
get: () => ({
respondKeyboardInteractive: () => ({ success: true }),
}),
},
sessions: [{
id: 'session-1',
hostId: baseHost.id,
hostname: baseHost.hostname,
}],
setKeyboardInteractiveQueue: (updater: (items: typeof queue) => typeof queue) => {
queue = updater(queue);
},
t: (key: string) => key,
toast: { error: () => {} },
updateHosts: (nextHosts: Host[]) => {
hosts = nextHosts;
},
updateIdentities: () => {
identityUpdates += 1;
},
}),
'ki-rotate-user',
['new-password'],
'new-password',
);
assert.equal(hosts[0].identityId, '');
assert.equal(hosts[0].password, 'new-password');
assert.equal(hosts[0].savePassword, true);
assert.equal(hosts[0].username, 'deploy');
assert.equal(identityUpdates, 0);
assert.equal(sharedIdentity.username, 'deploy');
assert.equal(sharedIdentity.password, 'stale-keychain-password');
const resolved = resolveHostAuth({ host: hosts[0], keys: [], identities: [sharedIdentity] });
assert.equal(resolved.password, 'new-password');
assert.equal(resolved.username, 'deploy');
assert.deepEqual(queue, []);
});
test('keyboard-interactive submit fills a blank host username from the keychain identity', async () => {
const sharedIdentity: Identity = {
id: 'id-1',
label: 'shared',
username: 'alice',
authMethod: 'password',
password: 'stale-keychain-password',
created: 0,
};
let hosts: Host[] = [{
...baseHost,
username: '',
identityId: 'id-1',
password: 'old-host-password',
savePassword: false,
}];
let queue = [{
requestId: 'ki-blank-user',
sessionId: 'session-1',
hostname: baseHost.hostname,
allowSavePassword: true,
}];
let identityUpdates = 0;
await handleKeyboardInteractiveSubmitImpl(
() => ({
hosts,
identities: [sharedIdentity],
keyboardInteractiveQueue: queue,
netcattyBridge: {
get: () => ({
respondKeyboardInteractive: () => ({ success: true }),
}),
},
sessions: [{
id: 'session-1',
hostId: baseHost.id,
hostname: baseHost.hostname,
}],
setKeyboardInteractiveQueue: (updater: (items: typeof queue) => typeof queue) => {
queue = updater(queue);
},
t: (key: string) => key,
toast: { error: () => {} },
updateHosts: (nextHosts: Host[]) => {
hosts = nextHosts;
},
updateIdentities: () => {
identityUpdates += 1;
},
}),
'ki-blank-user',
['new-password'],
'new-password',
);
assert.equal(hosts[0].identityId, '');
assert.equal(hosts[0].username, 'alice');
assert.equal(hosts[0].password, 'new-password');
assert.equal(hosts[0].savePassword, true);
assert.equal(identityUpdates, 0);
assert.equal(sharedIdentity.username, 'alice');
assert.equal(sharedIdentity.password, 'stale-keychain-password');
const resolved = resolveHostAuth({ host: hosts[0], keys: [], identities: [sharedIdentity] });
assert.equal(resolved.password, 'new-password');
assert.equal(resolved.username, 'alice');
assert.deepEqual(queue, []);
});
test('keyboard-interactive submit does not save secondary password when allowSavePassword is false', async () => {
let hosts: Host[] = [{
...baseHost,
+23 -6
View File
@@ -16,6 +16,7 @@ import { isPortForwardingAutoReconnectEnabled } from '../../domain/portForwardin
import { buildNumberShortcutTabTargets } from './tabShortcutTargets';
import { captureInheritedCwd } from '../state/inheritedCwd';
import { fromEditorTabId, isEditorTabId } from '../state/activeTabStore';
import { isPluginViewEditingTarget } from '../state/pluginKeybindings';
import { resolveBatchTabCloseFocus } from '../state/pluginViewTabStore';
type AppContextGetter = () => Record<string, any>;
@@ -264,7 +265,7 @@ export function handleGlobalHotkeyKeyDownImpl(getCtx: AppContextGetter, e: Keybo
}
}
const isFormElement = target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable;
const isFormElement = target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable || isPluginViewEditingTarget(target);
const isMonacoElement =
target instanceof HTMLElement &&
!!target.closest?.('.monaco-editor, .monaco-diff-editor, .monaco-inputbox');
@@ -393,6 +394,7 @@ export async function handleKeyboardInteractiveSubmitImpl(
const {
hosts,
hostsRef,
identities,
keyboardInteractiveQueue,
sessions,
setKeyboardInteractiveQueue,
@@ -424,12 +426,27 @@ export async function handleKeyboardInteractiveSubmitImpl(
// Save password to host if requested - never for second-factor / EDR prompts
// (allowSavePassword === false) so a secondary secret cannot overwrite the
// host login password (#2150 / Codex review on #2151).
// Detach a Keychain identity when one is attached. resolveHostAuth prefers
// identity.password and identity.username, so snapshot the identity username
// onto the host before clearing identityId (#3982). Same as
// applyVaultHostUpdate's password detach. Do not mutate the identity.
if (savePassword && host && request?.allowSavePassword !== false) {
updateHosts(latestHosts.map((h: Host) => h.id === host.id ? {
...h,
password: savePassword,
savePassword: true,
} : h));
updateHosts(latestHosts.map((h: Host) => {
if (h.id !== host.id) return h;
const identity = h.identityId
? identities?.find((item: { id?: string; username?: string }) => item.id === h.identityId)
: undefined;
const detachedUsername = identity?.username?.trim();
return {
...h,
password: savePassword,
savePassword: true,
...(h.identityId ? {
identityId: '',
...(detachedUsername ? { username: detachedUsername } : {}),
} : {}),
};
}));
}
// Remove from queue by requestId
setKeyboardInteractiveQueue(prev => prev.filter(r => r.requestId !== requestId));
+4 -1
View File
@@ -59,6 +59,7 @@ import { collectSessionIds } from '../../domain/workspace';
import { resolveAgentHostWorkspace } from '../../domain/agentHostWorkspace';
import { useHostOpenSessionReuse } from '../state/useHostOpenSessionReuse';
import type { PaneMagnificationController } from '../../domain/paneMagnification';
import { isPluginViewEditingTarget } from '../state/pluginKeybindings';
import { resolveCloseIntent } from '../state/resolveCloseIntent';
import { resolveSnippetsShortcutIntent } from '../state/resolveSnippetsShortcutIntent';
import { isPrimaryModifierWBinding, resolveWindowCommandCloseIntent } from '../state/windowCommandClose';
@@ -716,7 +717,7 @@ export function AppSideEffects() {
}, [isVaultInitialized, pendingTrayPanelConnectHostIds]);
// Handle keyboard-interactive submit
const handleKeyboardInteractiveSubmit = useCallback((requestId: string, responses: string[], savePassword?: string) => { return handleKeyboardInteractiveSubmitImpl(() => ({ hosts, hostsRef, keyboardInteractiveQueue, netcattyBridge, requestId, responses, savePassword, sessions, setKeyboardInteractiveQueue, t, toast, updateHosts }), requestId, responses, savePassword); }, [keyboardInteractiveQueue, sessions, hosts, t, updateHosts]);
const handleKeyboardInteractiveSubmit = useCallback((requestId: string, responses: string[], savePassword?: string) => { return handleKeyboardInteractiveSubmitImpl(() => ({ hosts, hostsRef, identities, keyboardInteractiveQueue, netcattyBridge, requestId, responses, savePassword, sessions, setKeyboardInteractiveQueue, t, toast, updateHosts }), requestId, responses, savePassword); }, [identities, keyboardInteractiveQueue, sessions, hosts, t, updateHosts]);
// Handle keyboard-interactive cancel
const handleKeyboardInteractiveCancel = useCallback((requestId: string) => { return handleKeyboardInteractiveCancelImpl(() => ({ netcattyBridge, requestId, setKeyboardInteractiveQueue, t, toast }), requestId); }, [t]);
@@ -1156,6 +1157,8 @@ export function AppSideEffects() {
const topmostOpenDialog = openDialogs[openDialogs.length - 1] ?? null;
const topmostDialogClose = topmostOpenDialog?.querySelector<HTMLElement>('[data-dialog-close="true"]');
if (!topmostDialogClose && isPluginViewEditingTarget(document.activeElement)) return;
const intent = resolveWindowCommandCloseIntent({
activeTabId: activeTabStore.getActiveTabId(),
editorTabIds: editorTabs.map((tab) => toEditorTabId(tab.id)),
+4
View File
@@ -26,6 +26,8 @@ import { openQuickConnectFromInput } from '../quickConnectBridge';
import { AppHostTreeLayer } from './AppHostTreeLayer';
import { AppHostEditorLayer } from './AppHostEditorLayer';
import { AppPluginKeybindingHost } from './AppPluginKeybindingHost';
import { PluginAIContextHost } from '../../components/plugins/PluginAIContextHost';
import { PluginAgentHost } from '../../components/plugins/PluginAgentHost';
import { shouldOpenHostEditOnWorkSurface } from './workTabSurface';
import { useConnectionLogsStore } from '../state/connectionLogsStore';
import {
@@ -856,6 +858,8 @@ function AppViewInner({ domains }: AppViewProps) {
sessions={sessions}
workspaces={workspaces}
/>
<PluginAIContextHost sessions={sessions} workspaces={workspaces} />
<PluginAgentHost sessions={sessions} />
</div>
{/* Global "quick add / edit snippet" modal, triggered by the
+47
View File
@@ -525,6 +525,8 @@ export const enAiMessages: Messages = {
'ai.chat.alwaysAllow': 'Always',
'ai.chat.slashStopDesc': 'Stop the current AI turn and cancel in-flight tools',
'ai.chat.slashCompactDesc': 'Summarize earlier conversation context',
'ai.chat.slashRenameDesc': 'Rename the current chat (/rename <title>)',
'ai.chat.slashChatsDesc': 'List the chats visible to this panel',
'ai.chat.reject': 'Reject',
'ai.chat.toolLabel': 'Tool',
'ai.chat.targetLabel': 'Target',
@@ -587,6 +589,28 @@ export const enAiMessages: Messages = {
'ai.chat.recent': 'Recent',
'ai.chat.viewAll': 'View All',
'ai.chat.untitled': 'Untitled',
'ai.chat.renameChat': 'Rename chat',
'ai.chat.renameUsage': 'Usage: /rename <new title>',
'ai.chat.renameNoActive': 'No active chat to rename.',
'ai.chat.renameDone': 'Chat renamed to "{title}".',
'ai.chat.exportSessions': 'Export AI chats',
'ai.chat.importSessions': 'Import AI chats',
'ai.chat.exportDone': 'AI chats exported to {path}.',
'ai.chat.exportDoneNoPath': 'AI chats exported.',
'ai.chat.exportUnavailable': 'AI chat export is unavailable in this environment.',
'ai.chat.importDone': 'Imported {n} AI chat(s).',
'ai.chat.importNone': 'No AI chats found in the selected file.',
'ai.chat.chatsHeader': 'Chats visible in this panel:',
'ai.chat.chatsEmpty': 'No chats visible in this panel.',
'ai.chat.chatsScopeWorkspace': 'Workspace',
'ai.chat.chatsScopeTerminal': 'Terminal',
'ai.chat.chatsScopeGlobal': 'Global',
'ai.chat.chatsScopeDangling': 'Dissociated',
'ai.chat.slashRebindDesc': 'Rebind a disconnected chat to this panel (/rebind <n>)',
'ai.chat.chatsRebindHint': 'Disconnected chats can be restored with /rebind <n>.',
'ai.chat.rebindUsage': 'Usage: /rebind <number from /chats>',
'ai.chat.rebindDone': 'Chat "{title}" rebound to this panel.',
'ai.chat.rebindUnavailable': 'No rebind target in this panel scope.',
'ai.chat.justNow': 'Just now',
'ai.chat.minutesAgo': '{n}m ago',
'ai.chat.hoursAgo': '{n}h ago',
@@ -795,4 +819,27 @@ export const enAiMessages: Messages = {
'zmodem.overwrite.skip': 'Skip',
'zmodem.overwrite.cancel': 'Cancel',
'settings.shortcuts.resetToDefault': 'Reset to default',
"ai.toolSettings.immediate": "Changes take effect immediately. Closing this window does not undo them.",
"ai.toolSettings.privacyOn": "Text redaction enabled",
"ai.toolSettings.privacyOff": "Text redaction disabled",
"ai.toolSettings.ruleSummary": "{cidrs} additional IP ranges · {patterns} custom patterns",
"ai.toolSettings.configurePrivacy": "Configure privacy",
"ai.toolSettings.unsaved": "Unsaved changes",
"ai.toolSettings.viewSkills": "View CLI guidance",
"ai.toolSettings.viewPrompt": "View setup prompt",
"ai.toolSettings.behavior": "MCP behavior",
"ai.toolSettings.configureBehavior": "Configure behavior",
"ai.toolSettings.configureClients": "Configure clients",
"ai.toolSettings.httpOn": "HTTP enabled",
"ai.toolSettings.httpOff": "HTTP disabled",
"ai.toolSettings.loopback": "Local connections only (127.0.0.1)",
"ai.toolSettings.configureHttp": "Configure HTTP",
"ai.toolSettings.hideSecret": "Hide secret",
"ai.toolSettings.showSecret": "Show secret",
"ai.toolSettings.manualSetup": "Manual setup",
"ai.toolSettings.format": "Format",
"ai.toolSettings.connectionDetails": "Connection details",
"ai.toolSettings.conflict": "Settings changed in another window. Your draft is preserved. Cancel and reopen to load the saved settings.",
"ai.toolSettings.retry": "Retry",
"ai.toolSettings.applyingHttp": "Applying HTTP configuration...",
};
+59 -8
View File
@@ -1,6 +1,32 @@
import type { Messages } from '../types';
export const enCoreMessages: Messages = {
"settings.plugins.landing.checkUpdates": "Check for updates",
"settings.plugins.landing.checkingUpdates": "Checking…",
"settings.plugins.landing.updateSummary": "Checked: {checked} · Published updates: {updates} · Not checked: {skipped}.",
"settings.plugins.landing.updateSourcesFailed": "Unavailable sources: {count}.",
"settings.plugins.landing.updateReview": "Review updates in the plugin manager before installing.",
"settings.plugins.landing.updateAvailable": "Version {version} available",
"settings.plugins.landing.loading": "Loading installed plugins…",
"settings.plugins.landing.settingsUnavailable": "No plugin settings are available in this context.",
"settings.plugins.landing.options": "Plugin options",
"settings.plugins.landing.manager": "Plugin manager",
"settings.plugins.landing.managerDescription": "Browse plugins and manage installed packages and sources.",
"settings.plugins.landing.browse": "Browse",
"settings.plugins.landing.refresh": "Refresh installed plugins",
"settings.plugins.landing.unavailable": "Plugin management is unavailable in this session.",
"settings.plugins.landing.empty": "No plugins installed. Browse the plugin manager to get started.",
"settings.plugins.landing.search": "Search installed plugins",
"settings.plugins.landing.settingsFor": "Settings for {name}",
"settings.plugins.landing.enableSettings": "Enable this plugin to open its available settings.",
"settings.plugins.landing.sourceUnknown": "Source unknown",
"settings.plugins.landing.status.running": "Running",
"settings.plugins.landing.status.starting": "Starting",
"settings.plugins.landing.status.stopping": "Stopping",
"settings.plugins.landing.status.error": "Error",
"settings.plugins.landing.status.quarantined": "Quarantined",
"settings.plugins.landing.status.unknown": "Status unknown",
"settings.plugins.landing.status.idle": "Enabled, idle",
// Common
'common.save': 'Save',
'common.cancel': 'Cancel',
@@ -135,7 +161,12 @@ export const enCoreMessages: Messages = {
'settings.system.clearTempFiles': 'Clear temp files',
'settings.system.clearing': 'Clearing...',
'settings.system.clearResult': 'Deleted {deleted} file(s), {failed} failed.',
'settings.system.tempDirectoryHint': 'Temporary files are created when opening remote files with external applications. They are automatically cleaned up when SFTP sessions close.',
'settings.system.tempDirChoose': 'Choose folder',
'settings.system.tempDirManual': 'Enter an absolute folder path',
'settings.system.tempDirApply': 'Apply',
'settings.system.tempDirReset': 'Reset to default',
'settings.system.tempDirError': 'Unable to use this folder.',
'settings.system.tempDirectoryHint': 'Netcatty creates a Netcatty temp subfolder in the chosen folder for SFTP staging and external editing. Changing the folder during a transfer may affect that transfer.',
'settings.system.credentials.title': 'Credential Protection',
'settings.system.credentials.status': 'Status',
'settings.system.credentials.checking': 'Checking...',
@@ -556,6 +587,8 @@ Highlight the focused split pane:
'settings.appearance.uiFont.desc': 'Choose the font for the application interface',
'settings.appearance.windowOpacity': 'Window Opacity',
'settings.appearance.windowOpacity.desc': 'Adjust the transparency of the entire application window. Lower values also fade terminal text. Some Linux desktop environments may not support this.',
'settings.appearance.windowBackgroundBlur': 'Frosted glass background',
'settings.appearance.windowBackgroundBlur.desc': 'Blur the desktop behind the window. At 100% opacity the effect is hidden, but your choice is preserved.',
'settings.appearance.appIcon': 'App Icon',
'settings.appearance.appIcon.desc': 'Choose a dock/taskbar icon style to make Netcatty easier to spot when switching apps. On Windows, the notification area icon uses the same style. Applies at runtime; the installed app bundle icon is unchanged.',
'settings.appearance.appIcon.original': 'Original',
@@ -1236,6 +1269,8 @@ Highlight the focused split pane:
'deepLink.jms.unsupported': 'Unsupported JumpServer protocol: {{protocol}}',
'settings.tab.plugins': 'Plugins',
"settings.plugins.manager.installed": "Installed plugins",
"settings.plugins.author.official": "Official maintainer",
"settings.plugins.author.officialMeaning": "Maintained by the Netcatty project. This is not a safety certification.",
"settings.plugins.manager.import": "Import .ncpkg",
"settings.plugins.manager.preview": "Imported packages stay disabled until you review and enable them.",
"settings.plugins.manager.empty": "Import a local plugin package to get started.",
@@ -1262,21 +1297,33 @@ Highlight the focused split pane:
"plugins.interaction.share": "Share with plugin",
"plugins.interaction.copy": "Copy to clipboard",
"plugins.interaction.failed": "This request expired or could not be completed.",
"plugins.command.failed": "The plugin command could not be completed. Check its required selection and permissions.",
"plugins.interaction.terminalDisclosure": "Only the current selection from this terminal is shared, up to 4096 UTF-8 bytes. Terminal text can contain passwords, tokens, and other secrets. Inspect the exact preview before sharing. No redaction guarantee is made.",
"plugins.interaction.terminalTarget": "Selected terminal",
"plugins.interaction.terminalPreview": "Preview terminal selection",
"plugins.interaction.draftNote": "Editable draft for review only. This dialog sends no terminal input or Enter. Copy text yourself and use the normal terminal UI if you choose to send it. Your edits stay in this dialog.",
"plugins.interaction.draft": "Terminal input draft",
"plugins.interaction.exactText": "Exact text with escaped controls",
"plugins.interaction.closeDraft": "Close draft",
"settings.plugins.permissions.current": "Matches the current activation",
"settings.plugins.permissions.inactive": "Inactive for the current activation",
"settings.plugins.permissions.title": "Saved grants",
"settings.plugins.permissions.note": "Declared capabilities default to Allow. Ask requests a scoped decision; Deny blocks access immediately. Target selection and draft editing still use the host UI.",
"settings.plugins.permissions.allow": "Allow",
"settings.plugins.permissions.ask": "Ask",
"settings.plugins.permissions.deny": "Deny",
"settings.plugins.permissions.title": "Permissions",
"settings.plugins.permissions.refresh": "Refresh",
"settings.plugins.permissions.loading": "Loading grants...",
"settings.plugins.permissions.loading": "Loading permissions...",
"settings.plugins.permissions.empty": "No saved grants. One-use decisions are not retained.",
"settings.plugins.permissions.always": "Until revoked",
"settings.plugins.permissions.application": "Until Netcatty exits",
"settings.plugins.permissions.session": "Until the session closes",
"settings.plugins.permissions.audit": "Recent security events (up to 100)",
"settings.plugins.permissions.revoke": "Disable and revoke all grants",
"settings.plugins.permissions.revokeNote": "This disables the plugin, cancels its work, and removes saved grants. Enable it again to request new access.",
"settings.plugins.permissions.revokeNote": "This disables the plugin, cancels its work, and removes saved grants. Your Allow / Ask / Deny settings are retained.",
"settings.plugins.permissions.unavailable": "Plugin permission management is unavailable",
"settings.plugins.manager.permissions": "Declared permissions",
"settings.plugins.manager.permissionNote": "From the manifest, not a grant. Enabled allows lazy activation; required permissions are requested before first activation and optional permissions when used. Sensitive operations still pass host checks.",
"settings.plugins.manager.permissionNote": "Declared supported capabilities default to Allow. Change each capability to Ask or Deny in the permission controls. Host validation and resource scope always apply.",
"settings.plugins.manager.noPermissions": "No privileged permissions declared.",
"settings.plugins.manager.required": "Required",
"settings.plugins.manager.optional": "Optional",
@@ -1319,7 +1366,7 @@ Highlight the focused split pane:
"settings.plugins.market.sourceOnly": "First-party source example. Build with the local CLI and import the .ncpkg; no downloadable release has been published.",
"settings.plugins.market.readmeNote": "Pinned to the commit above. HTML is inert and remote images are not loaded. Written by the plugin author.",
"settings.plugins.manager.noVault": "System key storage is unavailable. Secret and credential operations are blocked; browsing and non-secret plugins still work.",
"settings.plugins.manager.sensitive": "Sensitive access: review the data and resource scope before granting.",
"settings.plugins.manager.sensitive": "Sensitive access: this capability may expose private data. Its mode can be changed in permission settings.",
"settings.plugins.manager.dual": "Browser is the default runtime. This package also includes a Node entrypoint with full device authority; developer mode only.",
"settings.plugins.manager.advancedDeclaration": "Browser entrypoint with advanced permission declarations. Not eligible for normal browser-only mode; review every declared permission.",
"settings.plugins.market.retry": "Retry",
@@ -1328,11 +1375,14 @@ Highlight the focused split pane:
"settings.plugins.market.search": "Search name, author or description",
"settings.plugins.market.noResults": "No matching plugins. Try another search.",
"settings.plugins.market.manifest": "Compatibility and permissions",
"settings.plugins.manager.unbounded": "No narrower resource scope is declared. Check the actual scope in the permission prompt.",
"settings.plugins.manager.unbounded": "No narrower resource scope is declared. Host validation and the selected permission mode still apply.",
"settings.plugins.openView": "Open",
"settings.plugins.openManager": "Open plugin manager",
"settings.plugins.sources.conflict": "This ID is installed from another source. Uninstall and explicitly reset its private data before switching. Existing source:",
"settings.plugins.sources.clearPrivate": "Also clear this source’s private data, grants and secrets to reinstall from another source. This cannot be undone.",
"settings.plugins.sources.short.official": "Official",
"settings.plugins.sources.short.thirdparty": "Third-party",
"settings.plugins.sources.short.local": "Local",
"settings.plugins.sources.official": "Official marketplace",
"settings.plugins.sources.thirdparty": "Third-party marketplace",
"settings.plugins.sources.manage": "Manage sources",
@@ -1351,6 +1401,7 @@ Highlight the focused split pane:
"settings.plugins.sources.local": "Local import",
"settings.plugins.sources.all": "All sources",
"settings.plugins.title": "Plugins",
"settings.plugins.configuration": "Plugin settings",
"settings.plugins.description": "Manage your plugins and explore the catalog.",
'settings.plugins.loading': 'Loading plugin settings…',
'settings.plugins.empty': 'No enabled plugin contributes settings.',
@@ -1381,7 +1432,7 @@ Highlight the focused split pane:
'plugins.authentication.responseFailedWithMessage': 'Could not send the authentication response: {message}. Try again or cancel the request.',
'plugins.authentication.continue': 'Continue',
'plugins.hostActions.select': 'Share selected host metadata',
'plugins.hostActions.selectionNote': 'Share saved labels, addresses, usernames, protocols, groups and tags with this plugin. Group selection includes only the hosts selected now. Connecting requires a separate confirmation.',
'plugins.hostActions.selectionNote': 'Only the selected hosts\' saved labels, addresses, usernames, protocols, groups and tags are shared. Group selection includes only the hosts selected now. Later edit or connection requests follow this plugin\'s permission settings.',
'plugins.hostActions.create': 'Review host draft',
'plugins.hostActions.edit': 'Review host changes',
'plugins.hostActions.connect': 'Confirm connection',
+4
View File
@@ -1,4 +1,8 @@
export const enMonitorMessages = {
"monitor.systemDisks": "System partitions ({count})",
"monitor.systemDisksAlert": "System partition usage exceeds 80%; expanded automatically.",
"monitor.diskIoTotal": "Total",
"monitor.partitionIoHint": "I/O of the block device backing this mount. Mounts sharing a device show the same rates. Unavailable until two valid samples or when no block device can be identified; not supported on macOS.",
"monitor.diskIo": "Disk I/O",
"monitor.diskRead": "Read",
"monitor.diskWrite": "Write",
+3
View File
@@ -975,6 +975,9 @@ export const enVaultMessages: Messages = {
'hostDetails.sshTimeouts.tcpConnect.desc': 'Seconds to wait for the network connection to this host.',
'hostDetails.sshTimeouts.authReady': 'SSH auth timeout',
'hostDetails.sshTimeouts.authReady.desc': 'Seconds to wait after connecting for SSH negotiation and authentication to finish.',
'hostDetails.sshInputCompatibility': 'Input compatibility: send character by character',
'hostDetails.sshInputCompatibility.desc': 'Try enabling this if Chinese input or short pastes lose characters.',
'hostDetails.sshInputCompatibility.reconnect': 'Saved changes take effect after reconnecting.',
'hostDetails.backspaceBehavior': 'Backspace Behavior',
'hostDetails.backspaceBehavior.default': 'Default',
'hostDetails.jumpHosts': 'Proxy via Hosts',
+47
View File
@@ -525,6 +525,8 @@ export const esAiMessages: Messages = {
'ai.chat.alwaysAllow': 'Siempre',
'ai.chat.slashStopDesc': 'Detener el turno actual de IA y cancelar las herramientas en curso',
'ai.chat.slashCompactDesc': 'Resumir el contexto anterior de la conversación',
'ai.chat.slashRenameDesc': 'Renombrar el chat actual (/rename <título>)',
'ai.chat.slashChatsDesc': 'Listar los chats visibles en este panel',
'ai.chat.reject': 'Rechazar',
'ai.chat.toolLabel': 'Herramienta',
'ai.chat.targetLabel': 'Destino',
@@ -587,6 +589,28 @@ export const esAiMessages: Messages = {
'ai.chat.recent': 'Recientes',
'ai.chat.viewAll': 'Ver todo',
'ai.chat.untitled': 'Sin título',
'ai.chat.renameChat': 'Renombrar chat',
'ai.chat.renameUsage': 'Uso: /rename <nuevo título>',
'ai.chat.renameNoActive': 'No hay ningún chat activo para renombrar.',
'ai.chat.renameDone': 'Chat renombrado a «{title}».',
'ai.chat.exportSessions': 'Exportar chats de IA',
'ai.chat.importSessions': 'Importar chats de IA',
'ai.chat.exportDone': 'Chats de IA exportados a {path}.',
'ai.chat.exportDoneNoPath': 'Chats de IA exportados.',
'ai.chat.exportUnavailable': 'La exportación de chats de IA no está disponible en este entorno.',
'ai.chat.importDone': 'Se importaron {n} chat(s) de IA.',
'ai.chat.importNone': 'No se encontraron chats de IA en el archivo seleccionado.',
'ai.chat.chatsHeader': 'Chats visibles en este panel:',
'ai.chat.chatsEmpty': 'No hay chats visibles en este panel.',
'ai.chat.chatsScopeWorkspace': 'Workspace',
'ai.chat.chatsScopeTerminal': 'Terminal',
'ai.chat.chatsScopeGlobal': 'Global',
'ai.chat.chatsScopeDangling': 'Desasociado',
'ai.chat.slashRebindDesc': 'Reasociar un chat desconectado a este panel (/rebind <n>)',
'ai.chat.chatsRebindHint': 'Los chats desconectados se pueden restaurar con /rebind <n>.',
'ai.chat.rebindUsage': 'Uso: /rebind <número de /chats>',
'ai.chat.rebindDone': 'Chat «{title}» reasociado a este panel.',
'ai.chat.rebindUnavailable': 'No hay destino de reasociación en el ámbito de este panel.',
'ai.chat.justNow': 'Ahora mismo',
'ai.chat.minutesAgo': 'hace {n} min',
'ai.chat.hoursAgo': 'hace {n} h',
@@ -795,4 +819,27 @@ export const esAiMessages: Messages = {
'zmodem.overwrite.skip': 'Omitir',
'zmodem.overwrite.cancel': 'Cancelar',
'settings.shortcuts.resetToDefault': 'Restablecer al predeterminado',
"ai.toolSettings.immediate": "Los cambios se aplican inmediatamente. Cerrar esta ventana no los deshace.",
"ai.toolSettings.privacyOn": "Ocultación de texto activada",
"ai.toolSettings.privacyOff": "Ocultación de texto desactivada",
"ai.toolSettings.ruleSummary": "{cidrs} rangos IP adicionales · {patterns} patrones personalizados",
"ai.toolSettings.configurePrivacy": "Configurar privacidad",
"ai.toolSettings.unsaved": "Cambios sin guardar",
"ai.toolSettings.viewSkills": "Ver instrucciones de CLI",
"ai.toolSettings.viewPrompt": "Ver instrucciones de conexión",
"ai.toolSettings.behavior": "Comportamiento de MCP",
"ai.toolSettings.configureBehavior": "Configurar comportamiento",
"ai.toolSettings.configureClients": "Configurar clientes",
"ai.toolSettings.httpOn": "HTTP activado",
"ai.toolSettings.httpOff": "HTTP desactivado",
"ai.toolSettings.loopback": "Solo conexiones locales (127.0.0.1)",
"ai.toolSettings.configureHttp": "Configurar HTTP",
"ai.toolSettings.hideSecret": "Ocultar secreto",
"ai.toolSettings.showSecret": "Mostrar secreto",
"ai.toolSettings.manualSetup": "Configuración manual",
"ai.toolSettings.format": "Formato",
"ai.toolSettings.connectionDetails": "Detalles de conexión",
"ai.toolSettings.conflict": "La configuración cambió en otra ventana. Se conserva el borrador. Cancela y vuelve a abrir para cargar la configuración guardada.",
"ai.toolSettings.retry": "Reintentar",
"ai.toolSettings.applyingHttp": "Aplicando la configuración HTTP...",
};
+57 -6
View File
@@ -1,6 +1,32 @@
import type { Messages } from '../types';
export const esCoreMessages: Messages = {
"settings.plugins.landing.checkUpdates": "Buscar actualizaciones",
"settings.plugins.landing.checkingUpdates": "Buscando…",
"settings.plugins.landing.updateSummary": "Comprobados: {checked} · Actualizaciones publicadas: {updates} · Sin comprobar: {skipped}.",
"settings.plugins.landing.updateSourcesFailed": "Fuentes no disponibles: {count}.",
"settings.plugins.landing.updateReview": "Revisa las actualizaciones en el gestor de plugins antes de instalarlas.",
"settings.plugins.landing.updateAvailable": "Versión {version} disponible",
"settings.plugins.landing.loading": "Cargando plugins instalados…",
"settings.plugins.landing.settingsUnavailable": "No hay ajustes del plugin disponibles en este contexto.",
"settings.plugins.landing.options": "Opciones de plugins",
"settings.plugins.landing.manager": "Gestor de plugins",
"settings.plugins.landing.managerDescription": "Explora plugins y gestiona los paquetes instalados y sus fuentes.",
"settings.plugins.landing.browse": "Explorar",
"settings.plugins.landing.refresh": "Actualizar la lista de plugins instalados",
"settings.plugins.landing.unavailable": "La gestión de plugins no está disponible en esta sesión.",
"settings.plugins.landing.empty": "No hay plugins instalados. Abre el gestor de plugins para empezar.",
"settings.plugins.landing.search": "Buscar plugins instalados",
"settings.plugins.landing.settingsFor": "Ajustes de {name}",
"settings.plugins.landing.enableSettings": "Activa este plugin para abrir sus ajustes disponibles.",
"settings.plugins.landing.sourceUnknown": "Fuente desconocida",
"settings.plugins.landing.status.running": "En ejecución",
"settings.plugins.landing.status.starting": "Iniciando",
"settings.plugins.landing.status.stopping": "Deteniendo",
"settings.plugins.landing.status.error": "Error",
"settings.plugins.landing.status.quarantined": "En cuarentena",
"settings.plugins.landing.status.unknown": "Estado desconocido",
"settings.plugins.landing.status.idle": "Activado, en espera",
// Common
'common.save': 'Guardar',
'common.cancel': 'Cancelar',
@@ -135,7 +161,12 @@ export const esCoreMessages: Messages = {
'settings.system.clearTempFiles': 'Limpiar archivos temporales',
'settings.system.clearing': 'Limpiando...',
'settings.system.clearResult': 'Se eliminaron {deleted} archivo(s), {failed} fallaron.',
'settings.system.tempDirectoryHint': 'Los archivos temporales se crean al abrir archivos remotos con aplicaciones externas. Se limpian automáticamente cuando las sesiones SFTP se cierran.',
'settings.system.tempDirChoose': 'Elegir carpeta',
'settings.system.tempDirManual': 'Introduce una ruta de carpeta absoluta',
'settings.system.tempDirApply': 'Aplicar',
'settings.system.tempDirReset': 'Restablecer predeterminado',
'settings.system.tempDirError': 'No se puede usar esta carpeta.',
'settings.system.tempDirectoryHint': 'Se usa para el almacenamiento temporal de transferencias SFTP y archivos abiertos con aplicaciones externas. Cambiar la carpeta durante una transferencia puede afectar esa transferencia.',
'settings.system.credentials.title': 'Protección de credenciales',
'settings.system.credentials.status': 'Estado',
'settings.system.credentials.checking': 'Comprobando...',
@@ -556,6 +587,8 @@ Resalta el panel dividido enfocado:
'settings.appearance.uiFont.desc': 'Elige la fuente para la interfaz de la aplicación',
'settings.appearance.windowOpacity': 'Opacidad de la ventana',
'settings.appearance.windowOpacity.desc': 'Ajusta la transparencia de toda la ventana de la aplicación. Los valores más bajos también atenúan el texto de la terminal. Algunos entornos de escritorio de Linux pueden no admitir esto.',
'settings.appearance.windowBackgroundBlur': 'Fondo de cristal esmerilado',
'settings.appearance.windowBackgroundBlur.desc': 'Desenfoca el escritorio detrás de la ventana. Con una opacidad del 100 %, el efecto no es visible, pero se conserva tu elección.',
'settings.appearance.appIcon': 'Ícono de la aplicación',
'settings.appearance.appIcon.desc': 'Elige un estilo de ícono para el dock o la barra de tareas que haga más fácil distinguir Netcatty al cambiar de aplicación. En Windows, el icono del área de notificación usa el mismo estilo. Se aplica en tiempo de ejecución; el ícono del paquete instalado no cambia.',
'settings.appearance.appIcon.original': 'Original',
@@ -1235,6 +1268,8 @@ Resalta el panel dividido enfocado:
'deepLink.jms.invalid': 'Enlace JumpServer inválido',
'deepLink.jms.unsupported': 'Protocolo JumpServer no compatible: {{protocol}}',
'settings.tab.plugins': 'Plugins',
"settings.plugins.author.official": "Mantenimiento oficial",
"settings.plugins.author.officialMeaning": "Mantenido por el proyecto Netcatty. No es una certificación de seguridad.",
"settings.plugins.manager.installed": "Plugins instalados",
"settings.plugins.manager.import": "Importar .ncpkg",
"settings.plugins.manager.preview": "Los paquetes importados permanecen desactivados hasta que los revises y actives.",
@@ -1262,9 +1297,21 @@ Resalta el panel dividido enfocado:
"plugins.interaction.share": "Compartir con el complemento",
"plugins.interaction.copy": "Copiar al portapapeles",
"plugins.interaction.failed": "La solicitud caducó o no pudo completarse.",
"plugins.command.failed": "No se pudo completar el comando del complemento. Comprueba la selección requerida y los permisos.",
"plugins.interaction.terminalDisclosure": "Only the current selection from this terminal is shared, up to 4096 UTF-8 bytes. Terminal text can contain passwords, tokens, and other secrets. Inspect the exact preview before sharing. No redaction guarantee is made.",
"plugins.interaction.terminalTarget": "Selected terminal",
"plugins.interaction.terminalPreview": "Preview terminal selection",
"plugins.interaction.draftNote": "Editable draft for review only. This dialog sends no terminal input or Enter. Copy text yourself and use the normal terminal UI if you choose to send it. Your edits stay in this dialog.",
"plugins.interaction.draft": "Terminal input draft",
"plugins.interaction.exactText": "Exact text with escaped controls",
"plugins.interaction.closeDraft": "Close draft",
"settings.plugins.permissions.current": "Coincide con la ejecución actual",
"settings.plugins.permissions.inactive": "No activa para la ejecución actual",
"settings.plugins.permissions.title": "Autorizaciones guardadas",
"settings.plugins.permissions.note": "Las capacidades declaradas se permiten de forma predeterminada. Preguntar solicita una decisión con un alcance concreto; Denegar bloquea el acceso de inmediato. La selección de destinos y la edición de borradores siguen usando la interfaz de la aplicación.",
"settings.plugins.permissions.allow": "Permitir",
"settings.plugins.permissions.ask": "Preguntar",
"settings.plugins.permissions.deny": "Denegar",
"settings.plugins.permissions.title": "Permisos",
"settings.plugins.permissions.refresh": "Actualizar",
"settings.plugins.permissions.loading": "Cargando autorizaciones...",
"settings.plugins.permissions.empty": "No hay autorizaciones guardadas. Las decisiones de un solo uso no se conservan.",
@@ -1276,7 +1323,7 @@ Resalta el panel dividido enfocado:
"settings.plugins.permissions.revokeNote": "Desactiva el complemento, cancela su trabajo y elimina las autorizaciones guardadas. Actívelo para solicitar acceso de nuevo.",
"settings.plugins.permissions.unavailable": "La gestión de permisos no está disponible",
"settings.plugins.manager.permissions": "Permisos declarados",
"settings.plugins.manager.permissionNote": "Del manifiesto, no es una autorización. Activado permite la activación diferida; los permisos obligatorios se solicitan antes del primer uso y los opcionales cuando se necesitan. Las operaciones sensibles siguen verificándose.",
"settings.plugins.manager.permissionNote": "Las capacidades declaradas y compatibles se permiten de forma predeterminada. Puede cambiar cada capacidad a Preguntar o Denegar en los controles de permisos. Siempre se verifican los destinos y el alcance de los recursos.",
"settings.plugins.manager.noPermissions": "No se han declarado permisos privilegiados.",
"settings.plugins.manager.required": "Obligatorio",
"settings.plugins.manager.optional": "Opcional",
@@ -1319,7 +1366,7 @@ Resalta el panel dividido enfocado:
"settings.plugins.market.sourceOnly": "Ejemplo propio con código fuente. Compílalo con la CLI local e importa el .ncpkg; no hay versión publicada para descargar.",
"settings.plugins.market.readmeNote": "Fijado al commit indicado. El HTML no se ejecuta y las imágenes remotas no se cargan. Contenido del autor.",
"settings.plugins.manager.noVault": "El almacén de claves del sistema no está disponible. Se bloquean secretos y credenciales; el catálogo y los plugins que no los usan siguen disponibles.",
"settings.plugins.manager.sensitive": "Acceso sensible: revisa los datos y el alcance antes de autorizar.",
"settings.plugins.manager.sensitive": "Acceso sensible: esta capacidad puede exponer datos privados. Su modo se puede cambiar en los ajustes de permisos.",
"settings.plugins.manager.dual": "El navegador es la opción predeterminada. El paquete también incluye una entrada Node con acceso completo al dispositivo; solo para desarrollo.",
"settings.plugins.manager.advancedDeclaration": "Entrada de navegador con permisos avanzados declarados. No se admite en el modo normal; revisa cada permiso.",
"settings.plugins.market.retry": "Reintentar",
@@ -1328,11 +1375,14 @@ Resalta el panel dividido enfocado:
"settings.plugins.market.search": "Buscar nombre, autor o descripción",
"settings.plugins.market.noResults": "No hay plugins coincidentes. Prueba otra búsqueda.",
"settings.plugins.market.manifest": "Compatibilidad y permisos",
"settings.plugins.manager.unbounded": "No se declara un alcance de recursos más limitado. Revisa el alcance real en la solicitud de permiso.",
"settings.plugins.manager.unbounded": "No se declara un alcance de recursos más limitado. Se siguen aplicando la validación del anfitrión y el modo de permiso seleccionado.",
"settings.plugins.openView": "Abrir",
"settings.plugins.openManager": "Abrir gestor de plugins",
"settings.plugins.sources.conflict": "This ID is installed from another source. Uninstall and explicitly reset its private data before switching. Existing source:",
"settings.plugins.sources.clearPrivate": "Also clear this source’s private data, grants and secrets to reinstall from another source. This cannot be undone.",
"settings.plugins.sources.short.official": "Oficial",
"settings.plugins.sources.short.thirdparty": "Terceros",
"settings.plugins.sources.short.local": "Local",
"settings.plugins.sources.official": "Official marketplace",
"settings.plugins.sources.thirdparty": "Third-party marketplace",
"settings.plugins.sources.manage": "Manage sources",
@@ -1351,6 +1401,7 @@ Resalta el panel dividido enfocado:
"settings.plugins.sources.local": "Local import",
"settings.plugins.sources.all": "All sources",
"settings.plugins.title": "Plugins",
"settings.plugins.configuration": "Ajustes del plugin",
"settings.plugins.description": "Gestiona tus plugins y explora el catálogo.",
'settings.plugins.loading': 'Cargando ajustes del plugin…',
'settings.plugins.empty': 'Ningún plugin activado aporta ajustes.',
@@ -1381,7 +1432,7 @@ Resalta el panel dividido enfocado:
'plugins.authentication.responseFailedWithMessage': 'No se pudo enviar la respuesta de autenticación: {message}. Inténtalo de nuevo o cancela la solicitud.',
'plugins.authentication.continue': 'Continuar',
'plugins.hostActions.select': 'Compartir metadatos de los equipos seleccionados',
'plugins.hostActions.selectionNote': 'Comparte nombres, direcciones, usuarios, protocolos, grupos y etiquetas guardados con este complemento. Los grupos solo incluyen los equipos seleccionados ahora. Conectar requiere otra confirmación.',
'plugins.hostActions.selectionNote': 'Solo se comparten los nombres, direcciones, usuarios, protocolos, grupos y etiquetas guardados de los equipos seleccionados. Los grupos solo incluyen los equipos seleccionados ahora. Las solicitudes posteriores de edición o conexión siguen los permisos de este complemento.',
'plugins.hostActions.create': 'Revisar borrador de equipo',
'plugins.hostActions.edit': 'Revisar cambios del equipo',
'plugins.hostActions.connect': 'Confirmar conexión',
+4
View File
@@ -1,4 +1,8 @@
export const esMonitorMessages = {
"monitor.systemDisks": "Particiones del sistema ({count})",
"monitor.systemDisksAlert": "El uso de una partición del sistema supera el 80 %; se ha expandido automáticamente.",
"monitor.diskIoTotal": "Total",
"monitor.partitionIoHint": "E/S del dispositivo de bloques de este montaje. Los montajes del mismo dispositivo muestran las mismas tasas. No disponible hasta tener dos muestras válidas o sin un dispositivo identificable; no compatible con macOS.",
"monitor.diskIo": "E/S de disco",
"monitor.diskRead": "Lectura",
"monitor.diskWrite": "Escritura",
+3
View File
@@ -965,6 +965,9 @@ export const esVaultMessages: Messages = {
'hostDetails.sshTimeouts.tcpConnect.desc': 'Segundos de espera para la conexión de red a este host.',
'hostDetails.sshTimeouts.authReady': 'Tiempo de espera de autenticación SSH',
'hostDetails.sshTimeouts.authReady.desc': 'Segundos de espera después de conectar para que terminen la negociación y la autenticación SSH.',
'hostDetails.sshInputCompatibility': 'Compatibilidad de entrada: enviar carácter por carácter',
'hostDetails.sshInputCompatibility.desc': 'Actívalo si se pierden caracteres al escribir chino o pegar textos cortos.',
'hostDetails.sshInputCompatibility.reconnect': 'Los cambios guardados se aplican al volver a conectar.',
'hostDetails.backspaceBehavior': 'Comportamiento de la tecla Retroceso',
'hostDetails.backspaceBehavior.default': 'Predeterminado',
'hostDetails.jumpHosts': 'Proxy mediante hosts',
+47
View File
@@ -464,6 +464,8 @@ export const ruAiMessages: Messages = {
'ai.chat.alwaysAllow': 'Всегда разрешать',
'ai.chat.slashStopDesc': 'Остановить текущий ход AI и отменить выполняющиеся инструменты',
'ai.chat.slashCompactDesc': 'Сжать ранний контекст разговора',
'ai.chat.slashRenameDesc': 'Переименовать текущий чат (/rename <название>)',
'ai.chat.slashChatsDesc': 'Показать чаты, видимые в этой панели',
'ai.chat.reject': 'Отклонить',
'ai.chat.toolLabel': 'Инструмент',
'ai.chat.targetLabel': 'Цель',
@@ -526,6 +528,28 @@ export const ruAiMessages: Messages = {
'ai.chat.recent': 'Недавние',
'ai.chat.viewAll': 'Показать всё',
'ai.chat.untitled': 'Без названия',
'ai.chat.renameChat': 'Переименовать чат',
'ai.chat.renameUsage': 'Использование: /rename <новое название>',
'ai.chat.renameNoActive': 'Нет активного чата для переименования.',
'ai.chat.renameDone': 'Чат переименован в «{title}».',
'ai.chat.exportSessions': 'Экспортировать чаты ИИ',
'ai.chat.importSessions': 'Импортировать чаты ИИ',
'ai.chat.exportDone': 'Чаты ИИ экспортированы в {path}.',
'ai.chat.exportDoneNoPath': 'Чаты ИИ экспортированы.',
'ai.chat.exportUnavailable': 'Экспорт чатов ИИ недоступен в этой среде.',
'ai.chat.importDone': 'Импортировано чатов ИИ: {n}.',
'ai.chat.importNone': 'В выбранном файле не найдено чатов ИИ.',
'ai.chat.chatsHeader': 'Чаты, видимые в этой панели:',
'ai.chat.chatsEmpty': 'В этой панели нет видимых чатов.',
'ai.chat.chatsScopeWorkspace': 'Workspace',
'ai.chat.chatsScopeTerminal': 'Терминал',
'ai.chat.chatsScopeGlobal': 'Глобально',
'ai.chat.chatsScopeDangling': 'Связь потеряна',
'ai.chat.slashRebindDesc': 'Привязать потерянный чат к этой панели (/rebind <n>)',
'ai.chat.chatsRebindHint': 'Потерянные чаты можно восстановить командой /rebind <n>.',
'ai.chat.rebindUsage': 'Использование: /rebind <номер из /chats>',
'ai.chat.rebindDone': 'Чат «{title}» привязан к этой панели.',
'ai.chat.rebindUnavailable': 'В области этой панели нет цели для привязки.',
'ai.chat.justNow': 'Только что',
'ai.chat.minutesAgo': '{n}м назад',
'ai.chat.hoursAgo': '{n}ч назад',
@@ -724,4 +748,27 @@ export const ruAiMessages: Messages = {
'zmodem.overwrite.skip': 'Пропустить',
'zmodem.overwrite.cancel': 'Отмена',
'settings.shortcuts.resetToDefault': 'Сбросить по умолчанию',
"ai.toolSettings.immediate": "Изменения применяются сразу. Закрытие окна не отменяет их.",
"ai.toolSettings.privacyOn": "Скрытие текста включено",
"ai.toolSettings.privacyOff": "Скрытие текста выключено",
"ai.toolSettings.ruleSummary": "Дополнительные диапазоны IP: {cidrs} · Пользовательские шаблоны: {patterns}",
"ai.toolSettings.configurePrivacy": "Настроить приватность",
"ai.toolSettings.unsaved": "Есть несохранённые изменения",
"ai.toolSettings.viewSkills": "Инструкции CLI",
"ai.toolSettings.viewPrompt": "Инструкции подключения",
"ai.toolSettings.behavior": "Поведение MCP",
"ai.toolSettings.configureBehavior": "Настроить поведение",
"ai.toolSettings.configureClients": "Настроить клиенты",
"ai.toolSettings.httpOn": "HTTP включён",
"ai.toolSettings.httpOff": "HTTP выключен",
"ai.toolSettings.loopback": "Только локальные подключения (127.0.0.1)",
"ai.toolSettings.configureHttp": "Настроить HTTP",
"ai.toolSettings.hideSecret": "Скрыть секрет",
"ai.toolSettings.showSecret": "Показать секрет",
"ai.toolSettings.manualSetup": "Ручная настройка",
"ai.toolSettings.format": "Формат",
"ai.toolSettings.connectionDetails": "Параметры подключения",
"ai.toolSettings.conflict": "Настройки изменены в другом окне. Черновик сохранён. Отмените и откройте окно снова, чтобы загрузить сохранённые настройки.",
"ai.toolSettings.retry": "Повторить",
"ai.toolSettings.applyingHttp": "Применение настроек HTTP...",
};
+26 -5
View File
@@ -134,7 +134,12 @@ export const ruCoreMessages: Messages = {
'settings.system.clearTempFiles': 'Очистить временные файлы',
'settings.system.clearing': 'Очистка...',
'settings.system.clearResult': 'Удалено файлов: {deleted}, ошибок: {failed}.',
'settings.system.tempDirectoryHint': 'Временные файлы создаются при открытии удалённых файлов во внешних приложениях. Они автоматически очищаются при закрытии SFTP-сессий.',
'settings.system.tempDirChoose': 'Выбрать папку',
'settings.system.tempDirManual': 'Введите абсолютный путь к папке',
'settings.system.tempDirApply': 'Применить',
'settings.system.tempDirReset': 'Сбросить по умолчанию',
'settings.system.tempDirError': 'Не удалось использовать эту папку.',
'settings.system.tempDirectoryHint': 'Используется для промежуточного хранения SFTP-передач и файлов, открытых во внешних приложениях. Смена папки во время передачи может повлиять на эту передачу.',
'settings.system.credentials.title': 'Защита учётных данных',
'settings.system.credentials.status': 'Статус',
'settings.system.credentials.checking': 'Проверка...',
@@ -525,6 +530,8 @@ export const ruCoreMessages: Messages = {
'settings.appearance.uiFont.desc': 'Выберите шрифт для интерфейса приложения',
'settings.appearance.windowOpacity': 'Прозрачность окна',
'settings.appearance.windowOpacity.desc': 'Настройте прозрачность всего окна приложения. При низких значениях текст терминала тоже бледнеет. В некоторых средах Linux это может не поддерживаться.',
'settings.appearance.windowBackgroundBlur': 'Матовое стекло',
'settings.appearance.windowBackgroundBlur.desc': 'Размывает рабочий стол за окном. При непрозрачности 100 % эффект не виден, но ваш выбор сохраняется.',
'settings.appearance.appIcon': 'Значок приложения',
'settings.appearance.appIcon.desc': 'Выберите стиль значка для Dock/панели задач, чтобы Netcatty было проще найти при переключении приложений. В Windows значок области уведомлений использует тот же стиль. Применяется во время работы; значок установленного пакета не меняется.',
'settings.appearance.appIcon.original': 'Оригинал',
@@ -1130,6 +1137,8 @@ export const ruCoreMessages: Messages = {
'deepLink.jms.unsupported': 'Неподдерживаемый протокол JumpServer: {{protocol}}',
'settings.tab.plugins': 'Плагины',
"settings.plugins.author.official": "Официальная поддержка",
"settings.plugins.author.officialMeaning": "Поддерживается проектом Netcatty. Это не сертификат безопасности.",
"settings.plugins.manager.installed": "Установленные плагины",
"settings.plugins.manager.import": "Импорт .ncpkg",
"settings.plugins.manager.preview": "Импортированные пакеты отключены до проверки и включения.",
@@ -1157,6 +1166,14 @@ export const ruCoreMessages: Messages = {
"plugins.interaction.share": "Передать плагину",
"plugins.interaction.copy": "Копировать в буфер обмена",
"plugins.interaction.failed": "Запрос истёк или не выполнен.",
"plugins.command.failed": "Не удалось выполнить команду плагина. Проверьте необходимые выбранные объекты и разрешения.",
"plugins.interaction.terminalDisclosure": "Only the current selection from this terminal is shared, up to 4096 UTF-8 bytes. Terminal text can contain passwords, tokens, and other secrets. Inspect the exact preview before sharing. No redaction guarantee is made.",
"plugins.interaction.terminalTarget": "Selected terminal",
"plugins.interaction.terminalPreview": "Preview terminal selection",
"plugins.interaction.draftNote": "Editable draft for review only. This dialog sends no terminal input or Enter. Copy text yourself and use the normal terminal UI if you choose to send it. Your edits stay in this dialog.",
"plugins.interaction.draft": "Terminal input draft",
"plugins.interaction.exactText": "Exact text with escaped controls",
"plugins.interaction.closeDraft": "Close draft",
"settings.plugins.permissions.current": "Соответствует текущему запуску",
"settings.plugins.permissions.inactive": "Не действует для текущего запуска",
"settings.plugins.permissions.title": "Сохранённые разрешения",
@@ -1171,7 +1188,7 @@ export const ruCoreMessages: Messages = {
"settings.plugins.permissions.revokeNote": "Плагин будет отключён, его задачи отменены, а сохранённые разрешения удалены. Включите его для нового запроса доступа.",
"settings.plugins.permissions.unavailable": "Управление разрешениями недоступно",
"settings.plugins.manager.permissions": "Заявленные разрешения",
"settings.plugins.manager.permissionNote": "Из манифеста, а не предоставленные права. Включение допускает отложенную активацию; обязательные разрешения запрашиваются перед ней, необязательные — при использовании. Чувствительные операции проверяются приложением.",
"settings.plugins.manager.permissionNote": "Заявленные поддерживаемые возможности по умолчанию разрешены. Для каждой можно выбрать запрос или запрет в настройках разрешений. Проверки приложения и область ресурсов действуют всегда.",
"settings.plugins.manager.noPermissions": "Привилегированные разрешения не заявлены.",
"settings.plugins.manager.required": "Обязательно",
"settings.plugins.manager.optional": "Необязательно",
@@ -1214,7 +1231,7 @@ export const ruCoreMessages: Messages = {
"settings.plugins.market.sourceOnly": "Пример из этого проекта. Соберите локальной CLI и импортируйте .ncpkg; готовый пакет для скачивания не опубликован.",
"settings.plugins.market.readmeNote": "Зафиксирован указанный коммит. HTML не выполняется, внешние изображения не загружаются. Текст автора плагина.",
"settings.plugins.manager.noVault": "Системное хранилище ключей недоступно. Операции с секретами и учётными данными заблокированы; каталог и плагины без секретов доступны.",
"settings.plugins.manager.sensitive": "Чувствительный доступ: проверьте данные и область ресурсов перед разрешением.",
"settings.plugins.manager.sensitive": "Чувствительный доступ: эта возможность может раскрыть личные данные. Её режим можно изменить в настройках разрешений.",
"settings.plugins.manager.dual": "По умолчанию используется браузерная среда. Пакет также содержит вход Node с полным доступом к устройству; только для разработчиков.",
"settings.plugins.manager.advancedDeclaration": "Браузерная точка входа с заявленными расширенными разрешениями. Не допускается в обычном режиме; проверьте все разрешения.",
"settings.plugins.market.retry": "Повторить",
@@ -1223,11 +1240,14 @@ export const ruCoreMessages: Messages = {
"settings.plugins.market.search": "Поиск по имени, автору или описанию",
"settings.plugins.market.noResults": "Совпадений нет. Измените запрос.",
"settings.plugins.market.manifest": "Совместимость и разрешения",
"settings.plugins.manager.unbounded": "Более узкая область ресурсов не заявлена. Проверьте фактическую область в запросе разрешения.",
"settings.plugins.manager.unbounded": "Более узкая область ресурсов не заявлена. Проверки приложения и выбранный режим разрешений продолжают действовать.",
"settings.plugins.openView": "Открыть",
"settings.plugins.openManager": "Открыть управление плагинами",
"settings.plugins.sources.conflict": "This ID is installed from another source. Uninstall and explicitly reset its private data before switching. Existing source:",
"settings.plugins.sources.clearPrivate": "Also clear this source’s private data, grants and secrets to reinstall from another source. This cannot be undone.",
"settings.plugins.sources.short.official": "Официальный",
"settings.plugins.sources.short.thirdparty": "Сторонний",
"settings.plugins.sources.short.local": "Локальный",
"settings.plugins.sources.official": "Official marketplace",
"settings.plugins.sources.thirdparty": "Third-party marketplace",
"settings.plugins.sources.manage": "Manage sources",
@@ -1246,6 +1266,7 @@ export const ruCoreMessages: Messages = {
"settings.plugins.sources.local": "Local import",
"settings.plugins.sources.all": "All sources",
"settings.plugins.title": "Плагины",
"settings.plugins.configuration": "Настройки плагина",
"settings.plugins.description": "Управляйте плагинами и просматривайте каталог.",
'settings.plugins.loading': 'Загрузка настроек плагинов…',
'settings.plugins.empty': 'Включённые плагины не предоставляют настройки.',
@@ -1276,7 +1297,7 @@ export const ruCoreMessages: Messages = {
'plugins.authentication.responseFailedWithMessage': 'Не удалось отправить ответ аутентификации: {message}. Повторите попытку или отмените запрос.',
'plugins.authentication.continue': 'Продолжить',
'plugins.hostActions.select': 'Передать метаданные выбранных хостов',
'plugins.hostActions.selectionNote': 'Плагину будут переданы сохранённые имена, адреса, пользователи, протоколы, группы и теги. Группа включает только выбранные сейчас хосты. Подключение требует отдельного подтверждения.',
'plugins.hostActions.selectionNote': 'Передаются только метаданные выбранных хостов: сохранённые имена, адреса, пользователи, протоколы, группы и теги. Группа включает только выбранные сейчас хосты. Дальнейшие запросы изменения или подключения зависят от настроек разрешений плагина.',
'plugins.hostActions.create': 'Проверить черновик хоста',
'plugins.hostActions.edit': 'Проверить изменения хоста',
'plugins.hostActions.connect': 'Подтвердить подключение',
+4
View File
@@ -1,4 +1,8 @@
export const ruMonitorMessages = {
"monitor.systemDisks": "Системные разделы ({count})",
"monitor.systemDisksAlert": "Использование системного раздела превышает 80 %; группа раскрыта автоматически.",
"monitor.diskIoTotal": "Всего",
"monitor.partitionIoHint": "Ввод-вывод блочного устройства точки монтирования. Точки одного устройства показывают одинаковые скорости. Недоступно до двух корректных замеров или без определённого устройства; macOS не поддерживается.",
"monitor.diskIo": "Дисковый ввод-вывод",
"monitor.diskRead": "Чтение",
"monitor.diskWrite": "Запись",
+3
View File
@@ -1014,6 +1014,9 @@ export const ruVaultMessages: Messages = {
'hostDetails.sshTimeouts.tcpConnect.desc': 'Сколько секунд ждать сетевого подключения к этому хосту.',
'hostDetails.sshTimeouts.authReady': 'Тайм-аут SSH-входа',
'hostDetails.sshTimeouts.authReady.desc': 'Сколько секунд ждать согласования SSH и завершения аутентификации после подключения.',
'hostDetails.sshInputCompatibility': 'Совместимость ввода: отправка по символам',
'hostDetails.sshInputCompatibility.desc': 'Включите, если при вводе китайского текста или короткой вставке теряются символы.',
'hostDetails.sshInputCompatibility.reconnect': 'Сохранённые изменения применяются после повторного подключения.',
'hostDetails.backspaceBehavior': 'Поведение Backspace',
'hostDetails.backspaceBehavior.default': 'По умолчанию',
'hostDetails.jumpHosts': 'Прокси через хосты',
@@ -29,6 +29,16 @@ const ALL_SETTINGS_LOCALES = [
...LOCALIZED_SETTINGS_LOCALES,
];
test("Host selection disclosure describes later permission-controlled actions without promising another confirmation", () => {
const permissionWords: Record<string, RegExp> = { en: /permission settings/i, es: /permisos/i, ru: /разрешени/u, 'zh-CN': /权限设置/u, 'zh-TW': /權限設定/u };
const oldPromise = /requires a separate confirmation|requiere otra confirmación|требует отдельного подтверждения|需要单独确认|需要另外確認/iu;
for (const locale of ALL_SETTINGS_LOCALES) {
const note = locale.messages['plugins.hostActions.selectionNote'];
assert.match(note, permissionWords[locale.name], locale.name);
assert.doesNotMatch(note, oldPromise, locale.name);
}
});
test("localized settings include names for every default shortcut", () => {
for (const locale of ALL_SETTINGS_LOCALES) {
const missing = DEFAULT_KEY_BINDINGS
+47
View File
@@ -525,6 +525,8 @@ export const zhCNAiMessages: Messages = {
'ai.chat.alwaysAllow': '始终',
'ai.chat.slashStopDesc': '停止当前 AI 回合并取消进行中的工具',
'ai.chat.slashCompactDesc': '压缩较早的会话上下文',
'ai.chat.slashRenameDesc': '重命名当前聊天(/rename <标题>)',
'ai.chat.slashChatsDesc': '列出此面板可见的聊天',
'ai.chat.reject': '拒绝',
'ai.chat.toolLabel': '工具',
'ai.chat.targetLabel': '目标',
@@ -587,6 +589,28 @@ export const zhCNAiMessages: Messages = {
'ai.chat.recent': '最近',
'ai.chat.viewAll': '查看全部',
'ai.chat.untitled': '无标题',
'ai.chat.renameChat': '重命名聊天',
'ai.chat.renameUsage': '用法:/rename <新标题>',
'ai.chat.renameNoActive': '当前没有可重命名的聊天。',
'ai.chat.renameDone': '聊天已重命名为「{title}」。',
'ai.chat.exportSessions': '导出 AI 会话',
'ai.chat.importSessions': '导入 AI 会话',
'ai.chat.exportDone': 'AI 会话已导出到 {path}。',
'ai.chat.exportDoneNoPath': 'AI 会话已导出。',
'ai.chat.exportUnavailable': '当前环境不支持导出 AI 会话。',
'ai.chat.importDone': '已导入 {n} 个 AI 会话。',
'ai.chat.importNone': '所选文件中没有可导入的 AI 会话。',
'ai.chat.chatsHeader': '此面板可见的聊天:',
'ai.chat.chatsEmpty': '此面板没有可见的聊天。',
'ai.chat.chatsScopeWorkspace': '工作区',
'ai.chat.chatsScopeTerminal': '终端',
'ai.chat.chatsScopeGlobal': '全局',
'ai.chat.chatsScopeDangling': '已断开关联',
'ai.chat.slashRebindDesc': '将断联的聊天重绑到此面板(/rebind <序号>)',
'ai.chat.chatsRebindHint': '断联的聊天可用 /rebind <序号> 恢复。',
'ai.chat.rebindUsage': '用法:/rebind <序号>',
'ai.chat.rebindDone': '聊天“{title}”已重绑到此面板。',
'ai.chat.rebindUnavailable': '当前面板范围内没有可用的重绑目标。',
'ai.chat.justNow': '刚刚',
'ai.chat.minutesAgo': '{n}分钟前',
'ai.chat.hoursAgo': '{n}小时前',
@@ -795,4 +819,27 @@ export const zhCNAiMessages: Messages = {
'zmodem.overwrite.skip': '跳过',
'zmodem.overwrite.cancel': '取消',
'settings.shortcuts.resetToDefault': '重置为默认',
"ai.toolSettings.immediate": "更改立即生效,关闭此窗口不会撤销。",
"ai.toolSettings.privacyOn": "已启用文本脱敏",
"ai.toolSettings.privacyOff": "未启用文本脱敏",
"ai.toolSettings.ruleSummary": "{cidrs} 个附加 IP 范围 · {patterns} 条自定义规则",
"ai.toolSettings.configurePrivacy": "配置隐私",
"ai.toolSettings.unsaved": "有未保存的更改",
"ai.toolSettings.viewSkills": "查看 CLI 指引",
"ai.toolSettings.viewPrompt": "查看接入提示词",
"ai.toolSettings.behavior": "MCP 行为",
"ai.toolSettings.configureBehavior": "配置行为",
"ai.toolSettings.configureClients": "配置客户端",
"ai.toolSettings.httpOn": "HTTP 已启用",
"ai.toolSettings.httpOff": "HTTP 未启用",
"ai.toolSettings.loopback": "仅本地连接(127.0.0.1)",
"ai.toolSettings.configureHttp": "配置 HTTP",
"ai.toolSettings.hideSecret": "隐藏密钥",
"ai.toolSettings.showSecret": "显示密钥",
"ai.toolSettings.manualSetup": "手动配置",
"ai.toolSettings.format": "格式",
"ai.toolSettings.connectionDetails": "连接详情",
"ai.toolSettings.conflict": "设置已在另一窗口更改,当前草稿已保留。请取消并重新打开以加载已保存的设置。",
"ai.toolSettings.retry": "重试",
"ai.toolSettings.applyingHttp": "正在应用 HTTP 配置…",
};
+59 -8
View File
@@ -1,6 +1,32 @@
import type { Messages } from '../types';
export const zhCNCoreMessages: Messages = {
"settings.plugins.landing.checkUpdates": "检查更新",
"settings.plugins.landing.checkingUpdates": "正在检查…",
"settings.plugins.landing.updateSummary": "已检查:{checked} · 已发布更新:{updates} · 未检查:{skipped}。",
"settings.plugins.landing.updateSourcesFailed": "不可用来源:{count}。",
"settings.plugins.landing.updateReview": "安装前请在插件管理器中查看更新详情。",
"settings.plugins.landing.updateAvailable": "可更新至 {version}",
"settings.plugins.landing.loading": "正在载入已安装插件…",
"settings.plugins.landing.settingsUnavailable": "当前上下文中没有可用的插件设置。",
"settings.plugins.landing.options": "插件选项",
"settings.plugins.landing.manager": "插件管理器",
"settings.plugins.landing.managerDescription": "浏览插件,管理已安装的插件包及来源。",
"settings.plugins.landing.browse": "浏览",
"settings.plugins.landing.refresh": "刷新已安装插件",
"settings.plugins.landing.unavailable": "当前会话无法使用插件管理。",
"settings.plugins.landing.empty": "尚未安装插件。可在插件管理器中浏览或导入插件。",
"settings.plugins.landing.search": "搜索已安装插件",
"settings.plugins.landing.settingsFor": "{name} 的设置",
"settings.plugins.landing.enableSettings": "启用此插件后可打开其可用设置。",
"settings.plugins.landing.sourceUnknown": "来源未知",
"settings.plugins.landing.status.running": "运行中",
"settings.plugins.landing.status.starting": "启动中",
"settings.plugins.landing.status.stopping": "停止中",
"settings.plugins.landing.status.error": "错误",
"settings.plugins.landing.status.quarantined": "已隔离",
"settings.plugins.landing.status.unknown": "状态未知",
"settings.plugins.landing.status.idle": "已启用,空闲",
// Common
'common.save': '保存',
'common.cancel': '取消',
@@ -139,7 +165,12 @@ export const zhCNCoreMessages: Messages = {
'settings.system.clearTempFiles': '清理临时文件',
'settings.system.clearing': '清理中...',
'settings.system.clearResult': '已删除 {deleted} 个文件,{failed} 个失败。',
'settings.system.tempDirectoryHint': '临时文件在使用外部应用打开远程文件时创建。SFTP 会话关闭时会自动清理。',
'settings.system.tempDirChoose': '选择文件夹',
'settings.system.tempDirManual': '输入绝对文件夹路径',
'settings.system.tempDirApply': '应用',
'settings.system.tempDirReset': '恢复默认',
'settings.system.tempDirError': '无法使用此文件夹。',
'settings.system.tempDirectoryHint': 'Netcatty 在所选文件夹中创建 Netcatty 临时子文件夹,用于 SFTP 传输暂存和外部编辑文件。传输期间更改文件夹可能影响该传输。',
'settings.system.credentials.title': '凭据保护',
'settings.system.credentials.status': '状态',
'settings.system.credentials.checking': '检查中...',
@@ -560,6 +591,8 @@ Vaults 标签保持扁平,不使用这块背景。
'settings.appearance.uiFont.desc': '选择软件界面使用的字体',
'settings.appearance.windowOpacity': '窗口透明度',
'settings.appearance.windowOpacity.desc': '调节整个应用窗口的透明度,方便叠在其他内容上方。较低时终端文字也会变淡;部分 Linux 桌面环境可能不支持。',
'settings.appearance.windowBackgroundBlur': '毛玻璃背景',
'settings.appearance.windowBackgroundBlur.desc': '模糊窗口后方的桌面。100% 不透明时效果不可见,但会保留你的开关设置。',
'settings.appearance.appIcon': '应用图标',
'settings.appearance.appIcon.desc': '选择 Dock/任务栏图标风格,切换应用时更容易找到 Netcatty。Windows 通知区域图标会使用同一套风格。运行时生效,安装包内的图标不会改变。',
'settings.appearance.appIcon.original': '原始',
@@ -1151,6 +1184,8 @@ Vaults 标签保持扁平,不使用这块背景。
'qs.default': '默认',
'settings.tab.plugins': '插件',
"settings.plugins.manager.installed": "已安装插件",
"settings.plugins.author.official": "官方维护",
"settings.plugins.author.officialMeaning": "由 Netcatty 项目维护。这不是安全认证。",
"settings.plugins.manager.import": "导入 .ncpkg",
"settings.plugins.manager.preview": "导入的插件保持停用,请检查后再启用。",
"settings.plugins.manager.empty": "导入本地插件包以开始使用。",
@@ -1177,21 +1212,33 @@ Vaults 标签保持扁平,不使用这块背景。
"plugins.interaction.share": "分享给插件",
"plugins.interaction.copy": "复制到剪贴板",
"plugins.interaction.failed": "此请求已过期或无法完成。",
"plugins.command.failed": "无法完成插件命令,请检查所需选区或所选对象及权限。",
"plugins.interaction.terminalDisclosure": "仅分享此终端的当前选区,最多 4096 UTF-8 字节。终端文本可能包含密码、令牌或其他秘密。分享前请检查完整预览;不保证自动脱敏。",
"plugins.interaction.terminalTarget": "所选终端",
"plugins.interaction.terminalPreview": "预览终端选区",
"plugins.interaction.draftNote": "此可编辑草稿仅供审阅,不向终端发送输入或回车。如决定发送,请自行复制并使用常规终端界面。编辑内容仅保留在此对话框。",
"plugins.interaction.draft": "终端输入草稿",
"plugins.interaction.exactText": "转义控制字符后的完整文本",
"plugins.interaction.closeDraft": "关闭草稿",
"settings.plugins.permissions.current": "匹配当前运行实例",
"settings.plugins.permissions.inactive": "对当前运行实例不生效",
"settings.plugins.permissions.title": "已保存的授权",
"settings.plugins.permissions.note": "已声明且受支持的权限默认允许。询问时显示一次范围明确的确认;拒绝会立即阻止访问。选择目标和编辑草稿仍使用宿主界面。",
"settings.plugins.permissions.allow": "允许",
"settings.plugins.permissions.ask": "询问",
"settings.plugins.permissions.deny": "拒绝",
"settings.plugins.permissions.title": "权限",
"settings.plugins.permissions.refresh": "刷新",
"settings.plugins.permissions.loading": "正在读取授权...",
"settings.plugins.permissions.loading": "正在加载权限...",
"settings.plugins.permissions.empty": "没有已保存的授权。单次决定不会保留。",
"settings.plugins.permissions.always": "直到撤销",
"settings.plugins.permissions.application": "直到退出 Netcatty",
"settings.plugins.permissions.session": "直到会话关闭",
"settings.plugins.permissions.audit": "最近的安全事件(最多 100 条)",
"settings.plugins.permissions.revoke": "停用并撤销全部授权",
"settings.plugins.permissions.revokeNote": "此操作会停用插件、取消其工作并删除已保存的授权。重新启用后可申请新授权。",
"settings.plugins.permissions.revokeNote": "此操作会停用插件、取消其工作并删除已保存的授权。允许 / 询问 / 拒绝设置会保留。",
"settings.plugins.permissions.unavailable": "插件授权管理不可用",
"settings.plugins.manager.permissions": "声明的权限",
"settings.plugins.manager.permissionNote": "来自 manifest,不代表已授权。启用表示允许按需激活;必需权限在首次激活前申请,可选权限在用到时申请。敏感操作仍受宿主检查。",
"settings.plugins.manager.permissionNote": "已声明且受支持的权限默认允许。可以在权限设置中逐项改为询问或拒绝;宿主验证和资源范围限制始终生效。",
"settings.plugins.manager.noPermissions": "未声明特权权限。",
"settings.plugins.manager.required": "必需",
"settings.plugins.manager.optional": "可选",
@@ -1234,7 +1281,7 @@ Vaults 标签保持扁平,不使用这块背景。
"settings.plugins.market.sourceOnly": "第一方源码示例。请用本地 CLI 构建并导入 .ncpkg;尚未发布可下载发行包。",
"settings.plugins.market.readmeNote": "固定到上面的提交。HTML 不执行,远程图片不加载。内容由插件作者提供。",
"settings.plugins.manager.noVault": "系统密钥存储不可用。秘密和凭据操作被阻止;浏览市场和不使用秘密的插件仍可使用。",
"settings.plugins.manager.sensitive": "敏感访问:授权前请检查涉及的数据和资源范围。",
"settings.plugins.manager.sensitive": "敏感访问:此能力可能涉及私密数据,可在权限设置中调整其模式。",
"settings.plugins.manager.dual": "默认使用浏览器运行时,但包中还包含拥有完整设备权限的 Node 入口;仅限开发者模式。",
"settings.plugins.manager.advancedDeclaration": "浏览器入口声明了高级权限。不适用于普通浏览器插件模式;请逐项检查声明权限。",
"settings.plugins.market.retry": "重试",
@@ -1243,11 +1290,14 @@ Vaults 标签保持扁平,不使用这块背景。
"settings.plugins.market.search": "搜索名称、作者或描述",
"settings.plugins.market.noResults": "没有匹配插件,请尝试其他关键词。",
"settings.plugins.market.manifest": "兼容性与权限",
"settings.plugins.manager.unbounded": "未声明更细的资源范围。请在权限提示中检查实际授权范围。",
"settings.plugins.manager.unbounded": "未声明更细的资源范围,仍受应用校验和所选权限模式约束。",
"settings.plugins.openView": "打开",
"settings.plugins.openManager": "打开插件管理",
"settings.plugins.sources.conflict": "此 ID 已从其他来源安装。请先卸载并明确清理旧来源数据,再换源重装。原来源:",
"settings.plugins.sources.clearPrivate": "同时清除本来源的私有数据、授权和秘密,以便显式换源重装。此操作不可撤销。",
"settings.plugins.sources.short.official": "官方",
"settings.plugins.sources.short.thirdparty": "第三方",
"settings.plugins.sources.short.local": "本地",
"settings.plugins.sources.official": "官方市场",
"settings.plugins.sources.thirdparty": "第三方市场",
"settings.plugins.sources.manage": "管理来源",
@@ -1266,6 +1316,7 @@ Vaults 标签保持扁平,不使用这块背景。
"settings.plugins.sources.local": "本地导入",
"settings.plugins.sources.all": "全部来源",
"settings.plugins.title": "插件",
"settings.plugins.configuration": "插件设置",
"settings.plugins.description": "管理已安装插件,浏览精选目录。",
'settings.plugins.loading': '正在加载插件设置…',
'settings.plugins.empty': '当前没有已启用插件提供设置。',
@@ -1296,7 +1347,7 @@ Vaults 标签保持扁平,不使用这块背景。
'plugins.authentication.responseFailedWithMessage': '无法发送认证响应:{message}。请重试或取消此请求。',
'plugins.authentication.continue': '继续',
'plugins.hostActions.select': '分享所选主机的元数据',
'plugins.hostActions.selectionNote': '向此插件分享已保存的名称、地址、用户名、协议、分组和标签。选择分组仅包含本次选中的成员。连接主机需要单独确认。',
'plugins.hostActions.selectionNote': '仅向此插件共享所选主机的元数据:名称、地址、用户名、协议、分组和标签。选择分组仅包含本次选中的成员。后续编辑或连接请求遵循此插件的权限设置。',
'plugins.hostActions.create': '预览主机草稿',
'plugins.hostActions.edit': '预览主机修改',
'plugins.hostActions.connect': '确认连接',
@@ -1,4 +1,8 @@
export const zhCNMonitorMessages = {
"monitor.systemDisks": "系统分区({count})",
"monitor.systemDisksAlert": "系统分区使用率超过 80%,已自动展开。",
"monitor.diskIoTotal": "汇总",
"monitor.partitionIoHint": "此挂载点对应块设备的读写速率;共用设备的挂载点显示相同速率。取得两次有效采样前或无法识别块设备时显示 --;暂不支持 macOS。",
"monitor.diskIo": "磁盘 I/O",
"monitor.diskRead": "读取",
"monitor.diskWrite": "写入",
+3
View File
@@ -384,6 +384,9 @@ export const zhCNVaultMessages: Messages = {
'hostDetails.sshTimeouts.tcpConnect.desc': '等待与该主机建立网络连接的最长时间。',
'hostDetails.sshTimeouts.authReady': '认证超时(秒)',
'hostDetails.sshTimeouts.authReady.desc': '网络连接建立后,等待 SSH 协商和认证完成的最长时间。',
'hostDetails.sshInputCompatibility': '输入兼容模式:逐字发送',
'hostDetails.sshInputCompatibility.desc': '中文输入或短粘贴出现丢字时尝试开启。',
'hostDetails.sshInputCompatibility.reconnect': '保存后重新连接生效。',
'hostDetails.backspaceBehavior': 'Backspace 行为',
'hostDetails.backspaceBehavior.default': '默认',
'hostDetails.jumpHosts': '通过主机代理',
+47
View File
@@ -515,6 +515,8 @@ export const zhTWAiMessages: Messages = {
'ai.chat.alwaysAllow': '始終',
'ai.chat.slashStopDesc': '停止目前 AI 回合並取消進行中的工具',
'ai.chat.slashCompactDesc': '壓縮較早的對話上下文',
'ai.chat.slashRenameDesc': '重新命名目前聊天(/rename <標題>)',
'ai.chat.slashChatsDesc': '列出此面板可見的聊天',
'ai.chat.reject': '拒絕',
'ai.chat.toolLabel': '工具',
'ai.chat.targetLabel': '目標',
@@ -577,6 +579,28 @@ export const zhTWAiMessages: Messages = {
'ai.chat.recent': '最近',
'ai.chat.viewAll': '檢視全部',
'ai.chat.untitled': '無標題',
'ai.chat.renameChat': '重新命名聊天',
'ai.chat.renameUsage': '用法:/rename <新標題>',
'ai.chat.renameNoActive': '目前沒有可重新命名的聊天。',
'ai.chat.renameDone': '聊天已重新命名為「{title}」。',
'ai.chat.exportSessions': '匯出 AI 會話',
'ai.chat.importSessions': '匯入 AI 會話',
'ai.chat.exportDone': 'AI 會話已匯出到 {path}。',
'ai.chat.exportDoneNoPath': 'AI 會話已匯出。',
'ai.chat.exportUnavailable': '目前環境不支援匯出 AI 會話。',
'ai.chat.importDone': '已匯入 {n} 個 AI 會話。',
'ai.chat.importNone': '所選檔案中沒有可匯入的 AI 會話。',
'ai.chat.chatsHeader': '此面板可見的聊天:',
'ai.chat.chatsEmpty': '此面板沒有可見的聊天。',
'ai.chat.chatsScopeWorkspace': '工作區',
'ai.chat.chatsScopeTerminal': '終端',
'ai.chat.chatsScopeGlobal': '全域',
'ai.chat.chatsScopeDangling': '已斷開關聯',
'ai.chat.slashRebindDesc': '將斷開關聯的聊天重新綁定到此面板(/rebind <序號>)',
'ai.chat.chatsRebindHint': '斷開關聯的聊天可用 /rebind <序號> 恢復。',
'ai.chat.rebindUsage': '用法:/rebind <序號>',
'ai.chat.rebindDone': '聊天「{title}」已重新綁定到此面板。',
'ai.chat.rebindUnavailable': '目前面板範圍內沒有可用的重新綁定目標。',
'ai.chat.justNow': '剛剛',
'ai.chat.minutesAgo': '{n}分鐘前',
'ai.chat.hoursAgo': '{n}小時前',
@@ -785,4 +809,27 @@ export const zhTWAiMessages: Messages = {
'zmodem.overwrite.skip': '跳過',
'zmodem.overwrite.cancel': '取消',
'settings.shortcuts.resetToDefault': '重設為預設值',
"ai.toolSettings.immediate": "變更立即生效,關閉此視窗不會復原。",
"ai.toolSettings.privacyOn": "已啟用文字遮蔽",
"ai.toolSettings.privacyOff": "未啟用文字遮蔽",
"ai.toolSettings.ruleSummary": "{cidrs} 個額外 IP 範圍 · {patterns} 條自訂規則",
"ai.toolSettings.configurePrivacy": "設定隱私",
"ai.toolSettings.unsaved": "有未儲存的變更",
"ai.toolSettings.viewSkills": "查看 CLI 指引",
"ai.toolSettings.viewPrompt": "查看接入提示詞",
"ai.toolSettings.behavior": "MCP 行為",
"ai.toolSettings.configureBehavior": "設定行為",
"ai.toolSettings.configureClients": "設定用戶端",
"ai.toolSettings.httpOn": "HTTP 已啟用",
"ai.toolSettings.httpOff": "HTTP 未啟用",
"ai.toolSettings.loopback": "僅本機連線(127.0.0.1)",
"ai.toolSettings.configureHttp": "設定 HTTP",
"ai.toolSettings.hideSecret": "隱藏金鑰",
"ai.toolSettings.showSecret": "顯示金鑰",
"ai.toolSettings.manualSetup": "手動設定",
"ai.toolSettings.format": "格式",
"ai.toolSettings.connectionDetails": "連線詳情",
"ai.toolSettings.conflict": "設定已在其他視窗變更,目前草稿已保留。請取消並重新開啟以載入已儲存的設定。",
"ai.toolSettings.retry": "重試",
"ai.toolSettings.applyingHttp": "正在套用 HTTP 設定…",
};
+59 -8
View File
@@ -1,6 +1,32 @@
import type { Messages } from '../types';
export const zhTWCoreMessages: Messages = {
"settings.plugins.landing.checkUpdates": "檢查更新",
"settings.plugins.landing.checkingUpdates": "正在檢查…",
"settings.plugins.landing.updateSummary": "已檢查:{checked} · 已發佈更新:{updates} · 未檢查:{skipped}。",
"settings.plugins.landing.updateSourcesFailed": "無法使用的來源:{count}。",
"settings.plugins.landing.updateReview": "安裝前請在外掛管理器中查看更新詳情。",
"settings.plugins.landing.updateAvailable": "可更新至 {version}",
"settings.plugins.landing.loading": "正在載入已安裝外掛…",
"settings.plugins.landing.settingsUnavailable": "目前內容中沒有可用的外掛設定。",
"settings.plugins.landing.options": "外掛選項",
"settings.plugins.landing.manager": "外掛管理器",
"settings.plugins.landing.managerDescription": "瀏覽外掛,管理已安裝的外掛套件及來源。",
"settings.plugins.landing.browse": "瀏覽",
"settings.plugins.landing.refresh": "重新整理已安裝外掛",
"settings.plugins.landing.unavailable": "目前工作階段無法使用外掛管理。",
"settings.plugins.landing.empty": "尚未安裝外掛。可在外掛管理器中瀏覽或匯入外掛。",
"settings.plugins.landing.search": "搜尋已安裝外掛",
"settings.plugins.landing.settingsFor": "{name} 的設定",
"settings.plugins.landing.enableSettings": "啟用此外掛後可開啟其可用設定。",
"settings.plugins.landing.sourceUnknown": "來源不明",
"settings.plugins.landing.status.running": "執行中",
"settings.plugins.landing.status.starting": "啟動中",
"settings.plugins.landing.status.stopping": "停止中",
"settings.plugins.landing.status.error": "錯誤",
"settings.plugins.landing.status.quarantined": "已隔離",
"settings.plugins.landing.status.unknown": "狀態不明",
"settings.plugins.landing.status.idle": "已啟用,閒置",
// Common
'common.save': '儲存',
'common.cancel': '取消',
@@ -141,7 +167,12 @@ export const zhTWCoreMessages: Messages = {
'settings.system.clearTempFiles': '清理臨時檔案',
'settings.system.clearing': '清理中...',
'settings.system.clearResult': '已刪除 {deleted} 個檔案,{failed} 個失敗。',
'settings.system.tempDirectoryHint': '臨時檔案在使用外部應用程式開啟遠端檔案時建立。SFTP 工作階段關閉時會自動清理。',
'settings.system.tempDirChoose': '選擇資料夾',
'settings.system.tempDirManual': '輸入絕對資料夾路徑',
'settings.system.tempDirApply': '套用',
'settings.system.tempDirReset': '恢復預設',
'settings.system.tempDirError': '無法使用此資料夾。',
'settings.system.tempDirectoryHint': '用於 SFTP 傳輸暫存與外部編輯檔案。傳輸期間變更資料夾可能影響該傳輸。',
'settings.system.credentials.title': '憑證保護',
'settings.system.credentials.status': '狀態',
'settings.system.credentials.checking': '檢查中...',
@@ -562,6 +593,8 @@ Vaults 分頁維持扁平,不使用這塊背景。
'settings.appearance.uiFont.desc': '選擇軟體介面使用的字型',
'settings.appearance.windowOpacity': '視窗透明度',
'settings.appearance.windowOpacity.desc': '調節整個應用程式視窗的透明度,方便疊在其他內容上方。較低時終端文字也會變淡;部分 Linux 桌面環境可能不支援。',
'settings.appearance.windowBackgroundBlur': '毛玻璃背景',
'settings.appearance.windowBackgroundBlur.desc': '模糊視窗後方的桌面。100% 不透明時效果不可見,但會保留你的開關設定。',
'settings.appearance.appIcon': '應用程式圖示',
'settings.appearance.appIcon.desc': '選擇 Dock/工作列圖示風格,切換應用程式時更容易找到 Netcatty。Windows 通知區域圖示會使用同一套風格。執行時生效,安裝檔內的圖示不會改變。',
'settings.appearance.appIcon.original': '原始',
@@ -1153,6 +1186,8 @@ Vaults 分頁維持扁平,不使用這塊背景。
'qs.default': '預設',
'settings.tab.plugins': '外掛程式',
"settings.plugins.author.official": "官方維護",
"settings.plugins.author.officialMeaning": "由 Netcatty 專案維護。這不是安全認證。",
"settings.plugins.manager.installed": "已安裝插件",
"settings.plugins.manager.import": "導入 .ncpkg",
"settings.plugins.manager.preview": "導入的插件保持停用,請檢查後再啟用。",
@@ -1180,21 +1215,33 @@ Vaults 分頁維持扁平,不使用這塊背景。
"plugins.interaction.share": "分享給外掛",
"plugins.interaction.copy": "複製到剪貼簿",
"plugins.interaction.failed": "此請求已過期或無法完成。",
"plugins.command.failed": "無法完成外掛指令,請檢查所需選取範圍或所選項目及權限。",
"plugins.interaction.terminalDisclosure": "僅分享此終端的目前選取範圍,最多 4096 UTF-8 位元組。終端文字可能包含密碼、權杖或其他秘密。分享前請檢查完整預覽;不保證自動遮蔽。",
"plugins.interaction.terminalTarget": "所選終端",
"plugins.interaction.terminalPreview": "預覽終端選取範圍",
"plugins.interaction.draftNote": "此可編輯草稿僅供審閱,不向終端傳送輸入或 Enter。如決定傳送,請自行複製並使用一般終端介面。編輯內容僅保留在此對話框。",
"plugins.interaction.draft": "終端輸入草稿",
"plugins.interaction.exactText": "逸出控制字元後的完整文字",
"plugins.interaction.closeDraft": "關閉草稿",
"settings.plugins.permissions.current": "符合目前執行個體",
"settings.plugins.permissions.inactive": "對目前執行個體不生效",
"settings.plugins.permissions.title": "已儲存的授權",
"settings.plugins.permissions.note": "已宣告且受支援的權限預設允許。詢問時顯示一次範圍明確的確認;拒絕會立即阻止存取。選擇目標和編輯草稿仍使用宿主介面。",
"settings.plugins.permissions.allow": "允許",
"settings.plugins.permissions.ask": "詢問",
"settings.plugins.permissions.deny": "拒絕",
"settings.plugins.permissions.title": "權限",
"settings.plugins.permissions.refresh": "重新整理",
"settings.plugins.permissions.loading": "正在讀取授權...",
"settings.plugins.permissions.loading": "正在載入權限...",
"settings.plugins.permissions.empty": "沒有已儲存的授權。單次決定不會保留。",
"settings.plugins.permissions.always": "直到撤銷",
"settings.plugins.permissions.application": "直到退出 Netcatty",
"settings.plugins.permissions.session": "直到工作階段關閉",
"settings.plugins.permissions.audit": "最近的安全事件(最多 100 筆)",
"settings.plugins.permissions.revoke": "停用並撤銷全部授權",
"settings.plugins.permissions.revokeNote": "此操作會停用外掛、取消其工作並刪除已儲存的授權。重新啟用後可申請新授權。",
"settings.plugins.permissions.revokeNote": "此操作會停用外掛、取消其工作並刪除已儲存的授權。允許 / 詢問 / 拒絕設定會保留。",
"settings.plugins.permissions.unavailable": "外掛授權管理無法使用",
"settings.plugins.manager.permissions": "聲明的權限",
"settings.plugins.manager.permissionNote": "來自 manifest,不代表已授權。啟用表示允許按需激活;必需權限在首次激活前申請,可選權限在用到時申請。敏感操作仍受宿主檢查。",
"settings.plugins.manager.permissionNote": "已宣告且受支援的權限預設允許。可以在權限設定中逐項改為詢問或拒絕;宿主驗證和資源範圍限制始終生效。",
"settings.plugins.manager.noPermissions": "未聲明特權權限。",
"settings.plugins.manager.required": "必需",
"settings.plugins.manager.optional": "可選",
@@ -1214,11 +1261,14 @@ Vaults 分頁維持扁平,不使用這塊背景。
"settings.plugins.manager.browser": "瀏覽器運行時:特權訪問通過宿主權限代理。",
"settings.plugins.manager.advanced": "高級運行時:擁有完整 Node 文件系統和網絡能力,代理權限無法限制這些訪問。僅供開發預覽。",
"settings.plugins.manager.data": "本地 KV 僅保留在此設備。只有明確標記為同步的非秘密設置進入加密雲 sidecar。插件包和權限授權不參與同步。",
"settings.plugins.manager.unbounded": "未宣告更細的資源範圍。請在權限提示中檢查實際授權範圍。",
"settings.plugins.manager.unbounded": "未宣告更細的資源範圍,仍受應用程式驗證和所選權限模式約束。",
"settings.plugins.openView": "開啟",
"settings.plugins.openManager": "開啟插件管理",
"settings.plugins.sources.conflict": "此 ID 已从其他来源安装。请先卸载并明确清理旧来源数据,再换源重装。原来源:",
"settings.plugins.sources.clearPrivate": "同时清除本来源的私有数据、授权和秘密,以便显式换源重装。此操作不可撤销。",
"settings.plugins.sources.short.official": "官方",
"settings.plugins.sources.short.thirdparty": "第三方",
"settings.plugins.sources.short.local": "本機",
"settings.plugins.sources.official": "官方市场",
"settings.plugins.sources.thirdparty": "第三方市场",
"settings.plugins.sources.manage": "管理来源",
@@ -1237,6 +1287,7 @@ Vaults 分頁維持扁平,不使用這塊背景。
"settings.plugins.sources.local": "本地导入",
"settings.plugins.sources.all": "全部来源",
"settings.plugins.title": "插件",
"settings.plugins.configuration": "插件設定",
"settings.plugins.description": "管理已安裝插件,瀏覽精選目錄。",
'settings.plugins.loading': '正在載入外掛程式設定…',
"settings.plugins.market.results": "{count} 個插件",
@@ -1262,7 +1313,7 @@ Vaults 分頁維持扁平,不使用這塊背景。
"settings.plugins.market.sourceOnly": "第一方源碼示例。請用本地 CLI 構建並導入 .ncpkg;尚未發佈可下載發行包。",
"settings.plugins.market.readmeNote": "固定到上面的提交。HTML 不執行,遠程圖片不加載。內容由插件作者提供。",
"settings.plugins.manager.noVault": "系統密鑰存儲不可用。秘密和憑據操作被阻止;瀏覽市場和不使用秘密的插件仍可使用。",
"settings.plugins.manager.sensitive": "敏感訪問:授權前請檢查涉及的數據和資源範圍。",
"settings.plugins.manager.sensitive": "敏感存取:此能力可能涉及私密資料,可在權限設定中調整其模式。",
"settings.plugins.manager.dual": "默認使用瀏覽器運行時,但包中還包含擁有完整設備權限的 Node 入口;僅限開發者模式。",
"settings.plugins.manager.advancedDeclaration": "瀏覽器入口聲明瞭高級權限。不適用於普通瀏覽器插件模式;請逐項檢查聲明權限。",
"settings.plugins.market.retry": "重試",
@@ -1299,7 +1350,7 @@ Vaults 分頁維持扁平,不使用這塊背景。
'plugins.authentication.responseFailedWithMessage': '無法送出驗證回應:{message}。請重試或取消此要求。',
'plugins.authentication.continue': '繼續',
'plugins.hostActions.select': '分享所選主機的中繼資料',
'plugins.hostActions.selectionNote': '向此外掛分享已儲存的名稱、位址、使用者名稱、協定、群組和標籤。選擇群組僅包含本次選取的成員。連線需要另外確認。',
'plugins.hostActions.selectionNote': '僅向此外掛分享所選主機的中繼資料:名稱、位址、使用者名稱、協定、群組和標籤。選擇群組僅包含本次選取的成員。後續編輯或連線請求遵循此外掛的權限設定。',
'plugins.hostActions.create': '預覽主機草稿',
'plugins.hostActions.edit': '預覽主機修改',
'plugins.hostActions.connect': '確認連線',
@@ -1,4 +1,8 @@
export const zhTWMonitorMessages = {
"monitor.systemDisks": "系統分割區({count})",
"monitor.systemDisksAlert": "系統分割區使用率超過 80%,已自動展開。",
"monitor.diskIoTotal": "彙總",
"monitor.partitionIoHint": "此掛載點對應區塊裝置的讀寫速率;共用裝置的掛載點顯示相同速率。取得兩次有效取樣前或無法識別區塊裝置時顯示 --;暫不支援 macOS。",
"monitor.diskIo": "磁碟 I/O",
"monitor.diskRead": "讀取",
"monitor.diskWrite": "寫入",
+3
View File
@@ -384,6 +384,9 @@ export const zhTWVaultMessages: Messages = {
'hostDetails.sshTimeouts.tcpConnect.desc': '等待與該主機建立網路連線的最長時間。',
'hostDetails.sshTimeouts.authReady': '驗證逾時(秒)',
'hostDetails.sshTimeouts.authReady.desc': '網路連線建立後,等待 SSH 交握和驗證完成的最長時間。',
'hostDetails.sshInputCompatibility': '輸入相容模式:逐字傳送',
'hostDetails.sshInputCompatibility.desc': '中文輸入或短貼上出現漏字時嘗試開啟。',
'hostDetails.sshInputCompatibility.reconnect': '儲存後重新連線生效。',
'hostDetails.backspaceBehavior': 'Backspace 行為',
'hostDetails.backspaceBehavior.default': '預設',
'hostDetails.jumpHosts': '透過主機代理',
+1 -47
View File
@@ -2,14 +2,9 @@ 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";
import { pruneInactiveScopedSessions } from "./aiScopeCleanup.ts";
function createSession(id: string, scope: AISession["scope"], externalSessionId?: string): AISession {
return {
@@ -24,47 +19,6 @@ function createSession(id: string, scope: AISession["scope"], externalSessionId?
};
}
test("pruneInactiveScopedTransientState removes closed workspace and terminal scope state", () => {
const activeSessionIdMap = {
"terminal:open-terminal": "session-open",
"terminal:closed-terminal": "session-closed-terminal",
"workspace:open-workspace": "session-open-workspace",
"workspace:closed-workspace": "session-closed-workspace",
};
const draftsByScope = {
"terminal:open-terminal": createEmptyDraft("catty"),
"terminal:closed-terminal": createEmptyDraft("catty"),
"workspace:open-workspace": createEmptyDraft("catty"),
"workspace:closed-workspace": createEmptyDraft("catty"),
};
const panelViewByScope = {
"terminal:open-terminal": { mode: "draft" },
"terminal:closed-terminal": { mode: "session", sessionId: "session-closed-terminal" },
"workspace:open-workspace": { mode: "draft" },
"workspace:closed-workspace": { mode: "session", sessionId: "session-closed-workspace" },
} satisfies Record<string, AIPanelView>;
const next = pruneInactiveScopedTransientState(
activeSessionIdMap,
draftsByScope,
panelViewByScope,
new Set(["open-terminal", "open-workspace"]),
);
assert.deepEqual(next.activeSessionIdMap, {
"terminal:open-terminal": "session-open",
"workspace:open-workspace": "session-open-workspace",
});
assert.deepEqual(next.draftsByScope, {
"terminal:open-terminal": draftsByScope["terminal:open-terminal"],
"workspace:open-workspace": draftsByScope["workspace:open-workspace"],
});
assert.deepEqual(next.panelViewByScope, {
"terminal:open-terminal": panelViewByScope["terminal:open-terminal"],
"workspace:open-workspace": panelViewByScope["workspace:open-workspace"],
});
});
test("pruneInactiveScopedSessions reports inactive targets without deleting persisted history", () => {
const sessions = [
createSession("terminal-restorable", {
-36
View File
@@ -6,7 +6,6 @@ import type {
type DraftsByScope = Partial<Record<string, AIDraft>>;
type PanelViewByScope = Partial<Record<string, AIPanelView>>;
type ActiveSessionIdMap = Record<string, string | null>;
function isInactiveScopedTarget(
scopeKey: string,
@@ -55,41 +54,6 @@ export function pruneInactiveScopedState(
};
}
export function pruneInactiveScopedTransientState(
activeSessionIdMap: ActiveSessionIdMap,
draftsByScope: DraftsByScope,
panelViewByScope: PanelViewByScope,
activeTargetIds: Set<string>,
): {
activeSessionIdMap: ActiveSessionIdMap;
draftsByScope: DraftsByScope;
panelViewByScope: PanelViewByScope;
} {
let activeSessionMapChanged = false;
const nextActiveSessionIdMap: ActiveSessionIdMap = {};
for (const [scopeKey, sessionId] of Object.entries(activeSessionIdMap)) {
if (isInactiveScopedTarget(scopeKey, activeTargetIds)) {
activeSessionMapChanged = true;
continue;
}
nextActiveSessionIdMap[scopeKey] = sessionId;
}
const nextScopedState = pruneInactiveScopedState(
draftsByScope,
panelViewByScope,
activeTargetIds,
);
return {
activeSessionIdMap: activeSessionMapChanged ? nextActiveSessionIdMap : activeSessionIdMap,
draftsByScope: nextScopedState.draftsByScope,
panelViewByScope: nextScopedState.panelViewByScope,
};
}
export function pruneInactiveScopedSessions(
sessions: AISession[],
activeTargetIds: Set<string>,
+147
View File
@@ -0,0 +1,147 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { AISession } from '../../infrastructure/ai/types';
import {
DebouncedBackupMirror,
isMissingOrCorruptSessionsStore,
mergeImportedSessions,
parseBackupSessions,
readLiveSessionsArray,
shouldRestoreFromBackup,
} from './aiSessionsBackup';
import { prepareRestoredSessions, pruneSessionsForStorage } from './aiStateSnapshots';
function makeSession(id: string, updatedAt = 1_000): AISession {
return {
id,
title: id,
agentId: 'catty',
scope: { type: 'workspace', targetId: 'ws-1' },
messages: [],
createdAt: updatedAt,
updatedAt,
} as never;
}
test('isMissingOrCorruptSessionsStore distinguishes missing or corrupt stores from valid arrays', () => {
assert.equal(isMissingOrCorruptSessionsStore(null), true);
assert.equal(isMissingOrCorruptSessionsStore(''), true);
assert.equal(isMissingOrCorruptSessionsStore('[]'), false);
assert.equal(isMissingOrCorruptSessionsStore('{corrupt'), true);
assert.equal(isMissingOrCorruptSessionsStore('{}'), true);
assert.equal(isMissingOrCorruptSessionsStore('[{"id":"s1"}]'), false);
});
test('readLiveSessionsArray normalizes missing and corrupt stores', () => {
for (const raw of [null, undefined, '', '{}', 'not-json']) {
assert.deepEqual(readLiveSessionsArray(raw), []);
}
const sessions = [makeSession('s1')];
assert.deepEqual(readLiveSessionsArray(JSON.stringify(sessions)), sessions);
});
test('parseBackupSessions ignores corrupt payloads and drops malformed entries', () => {
assert.equal(parseBackupSessions(null), null);
assert.equal(parseBackupSessions(''), null);
assert.equal(parseBackupSessions('not json'), null);
assert.equal(parseBackupSessions('{"id":"s1"}'), null);
assert.equal(parseBackupSessions(JSON.stringify([{ id: 's1' }])), null);
const sessions = parseBackupSessions(JSON.stringify([
{ id: 's1' },
{ nope: true },
{ id: 'missing-scope', title: 'Missing scope', agentId: 'catty', messages: [], updatedAt: 1000 },
{ ...makeSession('null-scope'), scope: null },
{ ...makeSession('missing-title'), title: undefined },
{ ...makeSession('missing-agent'), agentId: undefined },
{ ...makeSession('null-message'), messages: [null] },
{ ...makeSession('incomplete-message'), messages: [{ id: 'm' }] },
{ ...makeSession('missing-content'), messages: [{ id: 'm', role: 'user', timestamp: 1000 }] },
{ ...makeSession('invalid-content'), messages: [{ id: 'm', role: 'user', timestamp: 1000, content: null }] },
{ ...makeSession('invalid-timestamp'), messages: [{ id: 'm', role: 'user', timestamp: null, content: 'hello' }] },
null,
{ ...makeSession('s2'), messages: [{ id: 'm', role: 'user', timestamp: 1000, content: 'hello' }] },
]));
assert.deepEqual(sessions?.map((session) => session.id), ['s2']);
});
test('restore preserves all sessions when cloud sync is enabled', () => {
const sessions = Array.from({ length: 51 }, (_, index) => makeSession(`s${index}`, index));
assert.deepEqual(prepareRestoredSessions(sessions, true), sessions);
const local = prepareRestoredSessions(sessions, false);
assert.equal(local.length, 50);
assert.equal(local.some(session => session.id === 's0'), false);
});
test('malformed imports cannot reach pruning or inject broken sessions', () => {
const current = [makeSession('live')];
const malformed = parseBackupSessions(JSON.stringify([{ id: 's1' }]));
assert.deepEqual(mergeImportedSessions(current, pruneSessionsForStorage(malformed ?? [])), current);
const valid = parseBackupSessions(JSON.stringify([{ id: 's1' }, makeSession('valid')]));
const merged = mergeImportedSessions(current, pruneSessionsForStorage(valid ?? []));
assert.deepEqual(new Set(merged.map((session) => session.id)), new Set(['live', 'valid']));
});
test('restore happens only on a missing or corrupt live store with a non-empty backup', () => {
const backup = JSON.stringify([makeSession('s1')]);
// Empty (or wiped) store -> restore.
assert.equal(shouldRestoreFromBackup(null, backup), true);
assert.equal(shouldRestoreFromBackup('', backup), true);
assert.equal(shouldRestoreFromBackup('[]', backup), false);
assert.equal(shouldRestoreFromBackup('{corrupt', backup), true);
// Non-empty live store -> never restore (explicit deletions must win).
assert.equal(shouldRestoreFromBackup('[{"id":"live"}]', backup), false);
// Empty/corrupt backup -> nothing to restore.
assert.equal(shouldRestoreFromBackup(null, null), false);
assert.equal(shouldRestoreFromBackup(null, '[]'), false);
assert.equal(shouldRestoreFromBackup(null, '{corrupt'), false);
});
test('mergeImportedSessions keys by id with the newer updatedAt winning', () => {
const current = [makeSession('keep', 100), makeSession('older', 100)];
const imported = [makeSession('newer', 200), makeSession('older', 50), makeSession('added', 1)];
const merged = mergeImportedSessions(current, imported);
const byId = new Map(merged.map((session) => [session.id, session]));
assert.equal(byId.get('older')?.updatedAt, 100);
assert.equal(byId.get('newer')?.updatedAt, 200);
assert.equal(byId.has('added'), true);
assert.equal(byId.has('keep'), true);
// Sorted newest-first for stable history ordering.
assert.deepEqual(
merged.map((session) => session.updatedAt),
[...merged.map((session) => session.updatedAt)].sort((a, b) => b - a),
);
});
test('DebouncedBackupMirror writes once per batch with the last payload', async () => {
const writes: string[] = [];
const mirror = new DebouncedBackupMirror((json) => writes.push(json), 15);
mirror.schedule('{"gen":1}');
mirror.schedule('{"gen":2}');
await new Promise((resolve) => setTimeout(resolve, 30));
mirror.schedule('{"gen":3}');
mirror.schedule('{"gen":4}');
await new Promise((resolve) => setTimeout(resolve, 40));
assert.deepEqual(writes, ['{"gen":2}', '{"gen":4}']);
});
test('DebouncedBackupMirror flushNow writes the pending payload immediately', () => {
const writes: string[] = [];
const mirror = new DebouncedBackupMirror((json) => writes.push(json), 60_000);
mirror.schedule('{"gen":1}');
mirror.flushNow();
assert.deepEqual(writes, ['{"gen":1}']);
// A second flush with nothing pending is a no-op.
mirror.flushNow();
assert.deepEqual(writes, ['{"gen":1}']);
});
test('readLiveSessionsArray treats non-array JSON as empty (corrupt)', () => {
assert.deepEqual(readLiveSessionsArray(null), []);
assert.deepEqual(readLiveSessionsArray(''), []);
assert.deepEqual(readLiveSessionsArray('{}'), []);
assert.deepEqual(readLiveSessionsArray('{"sessions":[]}'), []);
assert.deepEqual(readLiveSessionsArray('not-json'), []);
assert.deepEqual(readLiveSessionsArray('[]'), []);
assert.equal(readLiveSessionsArray('[{"id":"a"}]').length, 1);
});
+156
View File
@@ -0,0 +1,156 @@
import type { AISession } from '../../infrastructure/ai/types';
/**
* Renderer-side logic for the AI session durability layer (issue #4015 R2
* follow-up): a disk mirror of the localStorage session store plus a
* restore-on-empty-store boot path. The IPC plumbing lives in
* `electron/bridges/aiSessionsBackupBridge.cjs`; this module keeps the
* decisions pure so they can be tested without Electron.
*/
/** Debounce window: at most one backup write per mutation burst (~2s). */
export const AI_SESSIONS_BACKUP_DEBOUNCE_MS = 2000;
export interface AiSessionsBackupBridge {
aiSessionsBackupWrite?: (payload: { json: string }) => Promise<{ ok: boolean }>;
aiSessionsBackupReadLatest?: () => Promise<{ ok: boolean; json?: string }>;
aiSessionsBackupExport?: (payload: { json: string }) => Promise<{ success: boolean; filePath?: string }>;
aiSessionsBackupImport?: () => Promise<{ success: boolean; json?: string }>;
}
/** Restore only when the live store is missing or malformed. A valid empty
* array represents an intentional deletion and must remain authoritative. */
export function isMissingOrCorruptSessionsStore(raw: string | null | undefined): boolean {
if (raw == null || raw === '') return true;
try {
return !Array.isArray(JSON.parse(raw));
} catch {
return true;
}
}
/** Read the live sessions array from a raw localStorage string. Returns [] when
* missing/corrupt (including parseable non-arrays such as `{}`), so callers never
* treat a non-array as AISession[]. */
export function readLiveSessionsArray(raw: string | null | undefined): AISession[] {
if (isMissingOrCorruptSessionsStore(raw)) return [];
try {
const parsed = JSON.parse(raw as string);
return Array.isArray(parsed) ? parsed as AISession[] : [];
} catch {
return [];
}
}
/**
* Parse a backup payload into sessions. Returns null when the payload is
* corrupt or malformed (ignored gracefully); also drops entries without a
* usable required fields. Backup generations are written by the same serializer as the
* live store, so entries are expected to be full AISession objects.
*/
export function parseBackupSessions(json: string | null | undefined): AISession[] | null {
if (typeof json !== 'string' || json === '') return null;
let parsed: unknown;
try {
parsed = JSON.parse(json);
} catch {
return null;
}
if (!Array.isArray(parsed)) return null;
const sessions = parsed.filter(
(entry): entry is AISession => (
typeof entry === 'object' && entry !== null
&& typeof (entry as AISession).id === 'string'
&& typeof (entry as AISession).title === 'string'
&& typeof (entry as AISession).agentId === 'string'
&& typeof (entry as AISession).scope === 'object'
&& (entry as AISession).scope !== null
&& typeof (entry as AISession).scope.type === 'string'
&& Array.isArray((entry as AISession).messages)
&& (entry as AISession).messages.every(message => (
typeof message === 'object' && message !== null
&& typeof message.id === 'string'
&& typeof message.role === 'string'
&& typeof message.content === 'string'
&& typeof message.timestamp === 'number'
&& Number.isFinite(message.timestamp)
))
&& typeof (entry as AISession).updatedAt === 'number'
&& Number.isFinite((entry as AISession).updatedAt)
),
);
return sessions.length > 0 ? sessions : null;
}
/**
* Decide whether to restore on boot: only when the live store is missing or corrupt AND
* the backup actually contains at least one session.
*/
export function shouldRestoreFromBackup(
liveRaw: string | null | undefined,
backupJson: string | null | undefined,
): boolean {
if (!isMissingOrCorruptSessionsStore(liveRaw)) return false;
const sessions = parseBackupSessions(backupJson);
return sessions != null && sessions.length > 0;
}
/**
* Merge an imported backup into the current store: entries are keyed by id
* and the newer `updatedAt` wins. Explicit user action, so it may overwrite
* the live store (unlike the boot restore, which is empty-only).
*/
export function mergeImportedSessions(current: AISession[], imported: AISession[]): AISession[] {
const byId = new Map<string, AISession>();
for (const session of current) byId.set(session.id, session);
for (const session of imported) {
const existing = byId.get(session.id);
if (!existing || (session.updatedAt ?? 0) >= (existing.updatedAt ?? 0)) {
byId.set(session.id, session);
}
}
return Array.from(byId.values()).sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0));
}
/**
* Debounced mirror scheduler. One rolling timer per process: the last
* mutation batch wins, and the write happens `debounceMs` after the batch
* settles (at most once per batch, per the durability spec).
*/
export class DebouncedBackupMirror {
private timer: ReturnType<typeof setTimeout> | null = null;
private pendingJson: string | null = null;
constructor(
private readonly write: (json: string) => void,
private readonly debounceMs: number = AI_SESSIONS_BACKUP_DEBOUNCE_MS,
) {}
schedule(json: string) {
this.pendingJson = json;
if (this.timer) clearTimeout(this.timer);
this.timer = setTimeout(() => {
this.timer = null;
const json = this.pendingJson;
this.pendingJson = null;
if (json != null) this.write(json);
}, this.debounceMs);
}
flushNow() {
if (this.timer) {
clearTimeout(this.timer);
this.timer = null;
}
const json = this.pendingJson;
this.pendingJson = null;
if (json != null) this.write(json);
}
reset() {
if (this.timer) clearTimeout(this.timer);
this.timer = null;
this.pendingJson = null;
}
}
+171 -1
View File
@@ -16,6 +16,7 @@ import {
latestAISessionsSnapshot,
setLatestAIActiveSessionMapSnapshot,
setLatestAISessionsSnapshot,
rebindActiveSessionMap,
} from './aiStateSnapshots';
test('orphan cleanup keeps durable Catty output while explicit deletion removes it', async () => {
@@ -90,7 +91,9 @@ test('orphan cleanup preserves resumable privacy maps until explicit chat deleti
const previousWindow = globalThis.window;
const previousSessions = latestAISessionsSnapshot;
const previousActiveSessionMap = latestAIActiveSessionMapSnapshot;
t.mock.method(localStorageAdapter, 'read', () => null);
t.mock.method(localStorageAdapter, 'write', () => true);
t.mock.method(localStorageAdapter, 'writeString', () => true);
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
@@ -122,8 +125,24 @@ test('orphan cleanup preserves resumable privacy maps until explicit chat deleti
try {
cleanupOrphanedAISessions(new Set(['open', 'resumed-into']));
assert.equal(latestAISessionsSnapshot, sessions);
// Issue #4015 R2: the dropped chat is marked dangling but its record and
// its active pointer stay intact so /chats can offer a rebind.
const persistedSessions = latestAISessionsSnapshot;
assert.ok(persistedSessions);
assert.equal(
persistedSessions.find((session) => session.id === 'privacy-orphan')?.scopeDangling,
true,
);
assert.equal(
persistedSessions.find((session) => session.id === 'privacy-live')?.scopeDangling,
undefined,
);
assert.equal(
persistedSessions.find((session) => session.id === 'privacy-resumed')?.scopeDangling,
undefined,
);
assert.deepEqual(latestAIActiveSessionMapSnapshot, {
'terminal:closed': 'privacy-orphan',
'terminal:open': 'privacy-live',
'terminal:resumed-into': 'privacy-resumed',
});
@@ -158,6 +177,95 @@ test('orphan cleanup preserves resumable privacy maps until explicit chat deleti
}
});
test('orphan cleanup marks dangling chats instead of stripping them, and clears the flag when the target returns', (t) => {
const previousSessions = latestAISessionsSnapshot;
const previousActiveSessionMap = latestAIActiveSessionMapSnapshot;
const read = t.mock.method(localStorageAdapter, 'read', () => null);
t.mock.method(localStorageAdapter, 'write', () => true);
const writeString = t.mock.method(localStorageAdapter, 'writeString', () => true);
const previousWindow = globalThis.window;
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: { dispatchEvent: () => true, netcatty: {} },
});
const dangling = {
...makeSession('dangling-chat', 1, []),
scope: { type: 'terminal' as const, targetId: 'closed-ws' },
};
const live = {
...makeSession('live-chat', 2, []),
scope: { type: 'terminal' as const, targetId: 'open' },
};
setLatestAISessionsSnapshot([dangling, live]);
setLatestAIActiveSessionMapSnapshot({
'terminal:closed-ws': 'dangling-chat',
'terminal:open': 'live-chat',
});
try {
cleanupOrphanedAISessions(new Set(['open']));
let persisted = latestAISessionsSnapshot;
assert.ok(persisted);
assert.equal(persisted.find((s) => s.id === 'dangling-chat')?.scopeDangling, true);
assert.equal(persisted.find((s) => s.id === 'live-chat')?.scopeDangling, undefined);
// The active pointer survives the dangling marking.
assert.deepEqual(latestAIActiveSessionMapSnapshot, {
'terminal:closed-ws': 'dangling-chat',
'terminal:open': 'live-chat',
});
assert.ok(writeString.mock.callCount() > 0);
// The scope target becomes live again → the dangling flag clears.
read.mock.mockImplementation(() => null);
cleanupOrphanedAISessions(new Set(['open', 'closed-ws']));
persisted = latestAISessionsSnapshot;
assert.ok(persisted);
assert.equal(persisted.find((s) => s.id === 'dangling-chat')?.scopeDangling, undefined);
} finally {
setLatestAISessionsSnapshot(previousSessions ?? []);
setLatestAIActiveSessionMapSnapshot(previousActiveSessionMap ?? {});
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: previousWindow,
});
}
});
test('orphan cleanup with an empty live-target set marks all scoped chats dangling', (t) => {
const previousSessions = latestAISessionsSnapshot;
const previousActiveSessionMap = latestAIActiveSessionMapSnapshot;
t.mock.method(localStorageAdapter, 'read', () => null);
t.mock.method(localStorageAdapter, 'write', () => true);
t.mock.method(localStorageAdapter, 'writeString', () => true);
const previousWindow = globalThis.window;
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: { dispatchEvent: () => true, netcatty: {} },
});
const orphan = {
...makeSession('orphan-chat', 1, []),
scope: { type: 'terminal' as const, targetId: 'gone' },
};
setLatestAISessionsSnapshot([orphan]);
setLatestAIActiveSessionMapSnapshot({ 'terminal:gone': 'orphan-chat' });
try {
// Empty is a valid observed live set (nothing mounted), not "unknown".
cleanupOrphanedAISessions(new Set());
const persisted = latestAISessionsSnapshot;
assert.ok(persisted);
assert.equal(persisted.find((s) => s.id === 'orphan-chat')?.scopeDangling, true);
} finally {
setLatestAISessionsSnapshot(previousSessions ?? []);
setLatestAIActiveSessionMapSnapshot(previousActiveSessionMap ?? {});
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: previousWindow,
});
}
});
test('serializeSessionsForStorage strips oldest ciphertext before dropping visible sessions', async () => {
const { serializeSessionsForStorage } = await import('./aiStateSnapshots');
const ciphertext = 'gAAAA'.repeat(50000); // ~250 KB of ciphertext per message
@@ -817,3 +925,65 @@ for (const [budget, expectedCalls] of [[128 * 1024, 160], [0, 161]]) {
assert.equal(result.sessions.length, budget === 0 ? 1 : 4);
});
}
test('successful store writes schedule a throttled disk backup mirror', async (t) => {
const { writeSessionsForStorage, flushBackupMirror, __resetBackupMirrorForTests } =
await import('./aiStateSnapshots');
__resetBackupMirrorForTests();
const backupWrites: string[] = [];
const previousWindow = globalThis.window;
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
netcatty: {
aiSessionsBackupWrite: async (payload: { json: string }) => {
backupWrites.push(payload.json);
return { ok: true };
},
},
},
});
const sessions = freezeHistory(makePersistenceHistory(3, 10));
const writeString = t.mock.method(localStorageAdapter, 'writeString', () => true);
t.mock.method(localStorageAdapter, 'read', () => null);
try {
assert.equal(writeSessionsForStorage(sessions), true);
// Throttled: nothing has been written to disk yet.
assert.equal(backupWrites.length, 0);
flushBackupMirror();
assert.equal(backupWrites.length, 1);
// The mirror carries exactly the JSON that landed in localStorage.
assert.equal(backupWrites[0], writeString.mock.calls[0].arguments[1]);
// Failed store writes never schedule a mirror.
writeString.mock.mockImplementation(() => false);
assert.equal(writeSessionsForStorage(sessions), false);
flushBackupMirror();
assert.equal(backupWrites.length, 1);
} finally {
__resetBackupMirrorForTests();
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: previousWindow,
});
}
});
test('rebindActiveSessionMap drops stale pointers and sets the new scope', () => {
const cleaned = rebindActiveSessionMap(
{
'terminal:old-ws': 'chat-1',
'terminal:other': 'chat-2',
'workspace:keep': 'chat-1',
},
'chat-1',
'terminal:new-ws',
);
assert.deepEqual(cleaned, {
'terminal:other': 'chat-2',
'terminal:new-ws': 'chat-1',
});
// Idempotent when already bound to the target scope with no other pointers.
assert.deepEqual(
rebindActiveSessionMap({ 'terminal:new-ws': 'chat-1' }, 'chat-1', 'terminal:new-ws'),
{ 'terminal:new-ws': 'chat-1' },
);
});
+116 -44
View File
@@ -20,10 +20,11 @@ import {
} from './aiDraftState';
import {
pruneInactiveScopedSessions,
pruneInactiveScopedTransientState,
pruneInactiveScopedState,
} from './aiScopeCleanup';
import { emitAIStateChanged } from './aiStateEvents';
import { getAgentRuntime } from '../../infrastructure/ai/harness/globalAgentRuntime';
import { DebouncedBackupMirror, readLiveSessionsArray } from './aiSessionsBackup';
/** Typed accessor for the Electron IPC bridge exposed on `window.netcatty`. */
export interface AIBridge {
@@ -35,6 +36,11 @@ export interface AIBridge {
aiMcpSetCommandBlocklist?: (blocklist: string[]) => Promise<unknown> | unknown;
aiMcpSetCommandTimeout?: (timeout: number) => Promise<unknown> | unknown;
aiMcpSetMaxIterations?: (maxIterations: number) => Promise<unknown> | unknown;
/** Durability mirror: write one timestamped backup generation to disk. */
aiSessionsBackupWrite?: (payload: { json: string }) => Promise<{ ok: boolean }>;
aiSessionsBackupReadLatest?: () => Promise<{ ok: boolean; json?: string }>;
aiSessionsBackupExport?: (payload: { json: string }) => Promise<{ success: boolean; filePath?: string }>;
aiSessionsBackupImport?: () => Promise<{ success: boolean; json?: string }>;
}
export function getAIBridge() {
@@ -82,10 +88,13 @@ function isScopeKeyActive(scopeKey: string, activeTargetIds: Set<string>) {
return activeTargetIds.has(targetId);
}
/** Last live terminal/workspace target ids observed by orphan cleanup. */
export let latestAIActiveTargetIdsSnapshot: Set<string> = new Set();
export function cleanupOrphanedAISessions(activeTargetIds: Set<string>) {
latestAIActiveTargetIdsSnapshot = activeTargetIds;
const currentSessions = latestAISessionsSnapshot
?? localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS)
?? [];
?? readLiveSessionsArray(localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS));
// Sessions shown by a still-live scope must be protected from cleanup
// even when their own `scope.targetId` points at a closed terminal —
@@ -111,65 +120,62 @@ export function cleanupOrphanedAISessions(activeTargetIds: Set<string>) {
cleanupSdkAgentSessions(nextSessionCleanup.orphanedSessionIds);
}
if (nextSessionCleanup.sessions !== currentSessions) {
setLatestAISessionsSnapshot(nextSessionCleanup.sessions);
writeSessionsForStorage(nextSessionCleanup.sessions);
// Issue #4015 R2: a dropped chat is recoverable now, so mark it dangling
// instead of stripping its record or pointer. `/chats` lists dangling chats
// with a rebind affordance; the flag clears when the chat is rebound or its
// scope target becomes live again.
const orphanedIds = new Set(nextSessionCleanup.orphanedSessionIds);
let sessionsMarked = false;
const markedSessions = currentSessions.map((session) => {
if (orphanedIds.has(session.id)) {
if (session.scopeDangling) return session;
sessionsMarked = true;
return { ...session, scopeDangling: true };
}
if (
session.scopeDangling
&& (!session.scope.targetId || activeTargetIds.has(session.scope.targetId))
) {
sessionsMarked = true;
const { scopeDangling: _cleared, ...rest } = session;
return rest as AISession;
}
return session;
});
if (sessionsMarked) {
setLatestAISessionsSnapshot(markedSessions);
writeSessionsForStorage(markedSessions);
emitAIStateChanged(STORAGE_KEY_AI_SESSIONS);
}
const activeSessionIdMap = preCleanupActiveSessionMap;
let activeSessionMapChanged = false;
const nextActiveSessionIdMap = { ...activeSessionIdMap };
for (const scopeKey of Object.keys(activeSessionIdMap)) {
if (isScopeKeyActive(scopeKey, activeTargetIds)) continue;
delete nextActiveSessionIdMap[scopeKey];
activeSessionMapChanged = true;
}
if (activeSessionMapChanged) {
setLatestAIActiveSessionMapSnapshot(nextActiveSessionIdMap);
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, nextActiveSessionIdMap);
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
}
const currentActiveSessionIdMap = activeSessionMapChanged
? nextActiveSessionIdMap
: activeSessionIdMap;
// Issue #4015 R2: keep active-pointer entries for scopes whose target
// closed. Clearing them used to make dangling chats invisible (rank 0) and
// unrecoverable from the UI; the pointer is rewritten on rebind instead.
const currentDraftsByScope = latestAIDraftsByScopeSnapshot ?? {};
const currentPanelViewByScope = latestAIPanelViewByScopeSnapshot ?? {};
const prunedScopedTransientState = pruneInactiveScopedTransientState(
currentActiveSessionIdMap,
const prunedScopedState = pruneInactiveScopedState(
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) {
if (prunedScopedState.draftsByScope !== currentDraftsByScope) {
for (const scopeKey of Object.keys(currentDraftsByScope)) {
if (scopeKey in prunedScopedTransientState.draftsByScope) continue;
if (scopeKey in prunedScopedState.draftsByScope) continue;
bumpDraftMutationVersion(scopeKey);
bumpDraftUploadGeneration(scopeKey);
}
setLatestAIDraftsByScopeSnapshot(prunedScopedTransientState.draftsByScope);
setLatestAIDraftsByScopeSnapshot(prunedScopedState.draftsByScope);
emitAIStateChanged(AI_STATE_CHANGED_DRAFTS_BY_SCOPE);
}
if (prunedScopedTransientState.panelViewByScope !== currentPanelViewByScope) {
if (prunedScopedState.panelViewByScope !== currentPanelViewByScope) {
for (const scopeKey of Object.keys(currentPanelViewByScope)) {
if (scopeKey in prunedScopedTransientState.panelViewByScope) continue;
if (scopeKey in prunedScopedState.panelViewByScope) continue;
bumpDraftMutationVersion(scopeKey);
}
setLatestAIPanelViewByScopeSnapshot(prunedScopedTransientState.panelViewByScope);
setLatestAIPanelViewByScopeSnapshot(prunedScopedState.panelViewByScope);
emitAIStateChanged(AI_STATE_CHANGED_PANEL_VIEW_BY_SCOPE);
}
}
@@ -349,6 +355,10 @@ export function pruneSessionsForStorage(sessions: AISession[]): AISession[] {
});
}
export function prepareRestoredSessions(sessions: AISession[], syncEnabled: boolean): AISession[] {
return syncEnabled ? sessions : pruneSessionsForStorage(sessions);
}
/**
* Serialize sessions for localStorage under a byte budget, escalating pruning
* as needed. Returns the JSON to persist plus the (possibly) further-pruned
@@ -486,11 +496,47 @@ export function serializeSessionsForStorage(
* Returns true when the write succeeded; a false result means the payload
* could not be persisted even after escalation (it stays memory-only).
*/
// Durability mirror (issue #4015 follow-up): every successful store write
// also schedules a debounced disk backup so a wiped/recreated profile does
// not take the chat history with it. The exact JSON that landed in
// localStorage is mirrored (already byte-budgeted), throttled to at most one
// write per mutation batch.
const backupMirror = new DebouncedBackupMirror((json) => {
try {
void getAIBridge()?.aiSessionsBackupWrite?.({ json })?.catch(() => {});
} catch {
// Backup failures must never break chat persistence.
}
});
export function flushBackupMirror() {
try {
backupMirror.flushNow();
} catch {
// Unload and persistence must remain fail-safe.
}
}
export function __resetBackupMirrorForTests() {
backupMirror.reset();
}
export function scheduleBackupMirror(json: string) {
try {
backupMirror.schedule(json);
} catch {
// ignore
}
}
export function writeSessionsForStorage(sessions: AISession[]): boolean {
// Synced records are authoritative after restart: silently evicting a
// session or turn here would turn local retention into cloud deletion.
if (localStorageAdapter.read<{ enabled?: boolean }>(STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS)?.enabled) {
return localStorageAdapter.write(STORAGE_KEY_AI_SESSIONS, sessions);
const syncedOk = localStorageAdapter.write(STORAGE_KEY_AI_SESSIONS, sessions);
if (syncedOk) scheduleBackupMirror(JSON.stringify(sessions));
return syncedOk;
}
let previousLength = Number.POSITIVE_INFINITY;
for (const configuredBudget of [MAX_SESSIONS_JSON_BYTES, ...RETRY_SESSIONS_JSON_BYTES]) {
@@ -505,7 +551,10 @@ export function writeSessionsForStorage(sessions: AISession[]): boolean {
if (budget < 2) break;
const candidate = serializeSessionsForStorage(sessions, budget);
if (candidate.json.length >= previousLength) continue;
if (localStorageAdapter.writeString(STORAGE_KEY_AI_SESSIONS, candidate.json)) return true;
if (localStorageAdapter.writeString(STORAGE_KEY_AI_SESSIONS, candidate.json)) {
scheduleBackupMirror(candidate.json);
return true;
}
previousLength = candidate.json.length;
}
// Last resort: attempt the smallest representation this serializer can
@@ -516,6 +565,7 @@ export function writeSessionsForStorage(sessions: AISession[]): boolean {
minimalCandidate.json.length < previousLength
&& localStorageAdapter.writeString(STORAGE_KEY_AI_SESSIONS, minimalCandidate.json)
) {
scheduleBackupMirror(minimalCandidate.json);
return true;
}
console.warn(
@@ -531,6 +581,28 @@ export let latestAIPanelViewByScopeSnapshot: PanelViewByScope | null = null;
let latestAIDraftMutationVersionByScopeSnapshot: Record<string, number> = {};
let latestAIDraftUploadGenerationByScopeSnapshot: Record<string, number> = {};
/**
* Issue #4015 R2: rewrite the active-session map when rebinding a dangling
* chat. Drops every pointer that still names `sessionId` under a different
* scope key, then sets the new scope as active. Callers must persist and
* publish the returned map (do not rebuild from a stale snapshot).
*/
export function rebindActiveSessionMap(
activeSessionIdMap: Record<string, string | null>,
sessionId: string,
scopeKey: string,
): Record<string, string | null> {
let next = activeSessionIdMap;
for (const [key, id] of Object.entries(activeSessionIdMap)) {
if (id !== sessionId || key === scopeKey) continue;
if (next === activeSessionIdMap) next = { ...activeSessionIdMap };
delete next[key];
}
if (next[scopeKey] === sessionId) return next;
return { ...next, [scopeKey]: sessionId };
}
export function setLatestAISessionsSnapshot(sessions: AISession[]) {
latestAISessionsSnapshot = sessions;
}
+5
View File
@@ -33,6 +33,7 @@ const NAMED_KEYS = new Map<string, string>([
['tab', 'tab'],
]);
const PLUGIN_SHORTCUT_EDITABLE_SELECTOR = [
'[data-plugin-editing]',
'input',
'textarea',
'select',
@@ -44,6 +45,10 @@ const PLUGIN_SHORTCUT_EDITABLE_SELECTOR = [
'.monaco-menu-container',
].join(', ');
export function isPluginViewEditingTarget(node: EventTarget | null): boolean {
return Boolean((node as Element | null)?.closest?.('[data-plugin-editing]'));
}
function hasEditableShortcutAncestor(node: unknown): boolean {
const closest = (node as { closest?: (selector: string) => unknown } | null)?.closest;
return typeof closest === 'function'
@@ -0,0 +1,67 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { JSDOM } from 'jsdom';
test('React 19 changes are staged as safe data before any host rendering', async () => {
const dom = new JSDOM('<!doctype html><div id="remote"></div>', { url: 'http://localhost', pretendToBeVisual: true });
const saved = new Map<string, PropertyDescriptor | undefined>();
for (const key of ['window', 'document', 'navigator', 'HTMLElement', 'Element', 'Node', 'MutationObserver', 'Event', 'MouseEvent']) {
saved.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value: key === 'window' ? dom.window : dom.window[key as keyof typeof dom.window] });
}
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { configurable: true, writable: true, value: true });
const { default: React, act, useState } = await import('react');
const { createRoot } = await import('react-dom/client');
const { RemoteMutationObserver } = await import('@remote-dom/core/elements');
const { createPluginRemoteTree } = await import('./pluginRemoteTree');
const staged = createPluginRemoteTree();
const snapshots: ReturnType<typeof staged.snapshot>[] = [];
const observer = new RemoteMutationObserver({
mutate(records) { snapshots.push(staged.apply(JSON.parse(JSON.stringify(records)))); },
call() { throw new Error('Host method calls are unavailable'); },
});
const container = document.getElementById('remote')!;
observer.observe(container);
const root = createRoot(container);
function Example() {
const [count, setCount] = useState(0);
return <section className="card" style={{ display: 'grid', gap: '12px' }}><h1>Hello</h1><button type="button" onClick={() => setCount((value) => value + 1)}>Count {count}</button></section>;
}
try {
await act(async () => { root.render(<Example />); await new Promise((resolve) => setTimeout(resolve, 10)); });
const first = snapshots.at(-1)!;
const section = first.children[0]; assert.equal(section.type, 1);
if (section.type !== 1) throw new Error('Expected section');
assert.equal(section.attributes.class, 'card');
assert.match(section.attributes.style, /display:\s*grid/);
await act(async () => { container.querySelector('button')!.click(); await new Promise((resolve) => setTimeout(resolve, 10)); });
const updated = JSON.stringify(snapshots.at(-1));
assert.match(updated, /Count /); assert.match(updated, /"data":"1"/);
assert.equal(staged.isFailed(), false);
assert.throws(() => staged.apply([[3, section.id, 'onclick', 'attack()']]));
assert.deepEqual(staged.snapshot(), { children: [] });
assert.equal(staged.isFailed(), true);
assert.throws(() => staged.apply([]));
} finally {
observer.disconnect(); await act(async () => { root.unmount(); });
for (const [key, value] of saved) { if (value) Object.defineProperty(globalThis, key, value); else Reflect.deleteProperty(globalThis, key); }
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT'); dom.window.close();
}
});
test('removed attributes do not keep checkboxes, menus or old inline styles active', async () => {
const { createPluginRemoteTree } = await import('./pluginRemoteTree');
const tree = createPluginRemoteTree();
tree.apply([[0, '~', { id: 'control', type: 1, element: 'input', attributes: { type: 'checkbox', checked: '', style: 'color:red' }, children: [] }, 0]]);
const updated = tree.apply([[3, 'control', 'checked', null], [3, 'control', 'style', null]]);
assert.deepEqual(updated.children[0], { id: 'control', type: 1, element: 'input', attributes: { type: 'checkbox' }, children: [] });
});
test('inserting a child into an existing void element fails the view', async () => {
const { createPluginRemoteTree } = await import('./pluginRemoteTree');
const tree = createPluginRemoteTree();
tree.apply([[0, '~', { id: 'input', type: 1, element: 'input', attributes: {}, children: [] }, 0]]);
assert.throws(() => tree.apply([[0, 'input', { id: 'child', type: 3, data: 'invalid' }, 0]]), /Void view elements/);
assert.equal(tree.isFailed(), true);
assert.deepEqual(tree.snapshot(), { children: [] });
});
+41
View File
@@ -0,0 +1,41 @@
import { RemoteReceiver } from '@remote-dom/core/receivers';
import { validatePluginMutationBatch, validatePluginRemoteTree } from '@/domain/pluginRemoteTreePolicy.cjs';
type WireNode = { id: string; type: 3 | 8; data: string } | {
id: string; type: 1; element: string; attributes: Record<string, string>; children: WireNode[];
};
type WireTree = { children: WireNode[] };
/** Data-only staging area: untrusted changes are never applied directly to the host DOM. */
export function createPluginRemoteTree() {
const receiver = new RemoteReceiver();
let current: WireTree = { children: [] };
let failed = false;
const project = (node: unknown): WireNode => {
const value = node as { id: string; type: number; data?: string; element?: string; attributes?: Record<string, string>; children?: unknown[] };
if (value.type === 3 || value.type === 8) return { id: value.id, type: value.type, data: value.data ?? '' };
return { id: value.id, type: 1, element: value.element ?? '', attributes: Object.fromEntries(Object.entries(value.attributes ?? {}).filter(([, attribute]) => attribute !== null)), children: (value.children ?? []).map(project) };
};
return {
apply(input: unknown): WireTree {
if (failed) throw new Error('Plugin view has been closed after an invalid update');
try {
validatePluginMutationBatch(input);
// Ordinary DOM attribute mutations do not specify a channel. RemoteReceiver
// defaults those to properties, so explicitly mark them as attributes.
const batch = (input as unknown[][]).map((record) => record[0] === 3 && record.length === 4 ? [...record, 2] : record);
receiver.connection.mutate(batch as unknown as Parameters<typeof receiver.connection.mutate>[0]);
const next = { children: receiver.root.children.map(project) };
validatePluginRemoteTree(next);
current = next;
return next;
} catch (error) {
failed = true;
current = { children: [] };
throw error;
}
},
snapshot(): WireTree { return current; },
isFailed(): boolean { return failed; },
};
}
@@ -0,0 +1,91 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createPluginTerminalSelectionSource } from './usePluginTerminalSelection';
import { readPluginTerminalSelection, registerTerminalSelectionSource } from '../../infrastructure/plugins/terminalSelectionRegistry';
import type { Terminal } from '@xterm/xterm';
function fixture() {
let reads = 0;
const term = { cols: 80, element: { isConnected: true },
getSelectionPosition: () => ({ start: { x: 0, y: 0 }, end: { x: 3, y: 0 } }),
getSelection() { reads++; return 'raw'; },
buffer: { active: { getLine: () => ({ length: 3, translateToString: () => 'normalized' }) } },
} as unknown as Terminal;
const state = { term, sessionId: 'a', bootEpoch: 1, visible: true, isFocusedPane: true, connected: true, sensitive: false, normalize: false, label: 'Synthetic A' };
const source = createPluginTerminalSelectionSource(() => state, 'renderer');
return { term, state, source, get reads() { return reads; } };
}
test('registered reader honors explicit false normalization and reads only on Preview', () => {
const f = fixture();
const cleanup = registerTerminalSelectionSource('a', f.source);
try {
assert.equal(f.reads, 0);
assert.equal(readPluginTerminalSelection('a', 'renderer:1'), 'raw');
assert.equal(f.reads, 1);
f.state.normalize = true;
assert.equal(readPluginTerminalSelection('a', 'renderer:1'), 'normalized');
} finally { cleanup(); }
});
test('separate terminal references, hidden/attached renderer, reconnect and empty selection have no fallback', () => {
const a = fixture(), b = fixture(); b.state.sessionId = 'b';
const cleanupA = registerTerminalSelectionSource('a', a.source), cleanupB = registerTerminalSelectionSource('b', b.source);
try {
assert.throws(() => readPluginTerminalSelection('wrong', 'renderer:1'), /no longer/);
a.term.getSelection = () => '';
assert.throws(() => readPluginTerminalSelection('a', 'renderer:1'), /between 1/);
assert.equal(b.reads, 0);
a.state.bootEpoch++;
assert.throws(() => readPluginTerminalSelection('a', 'renderer:1'), /no longer/);
for (const field of ['visible', 'isFocusedPane', 'connected'] as const) {
a.state[field] = false;
assert.equal(a.source.target(), null);
a.state[field] = true;
}
a.state.sensitive = true;
assert.equal(a.source.target(), null);
a.state.sensitive = false;
Object.defineProperty(a.term.element, 'isConnected', { value: false });
assert.equal(a.source.target(), null);
} finally { cleanupA(); cleanupB(); }
assert.throws(() => readPluginTerminalSelection('b', 'renderer:1'), /no longer/);
});
test('oversized ranges refuse to materialize selection and UTF-8 bytes are bounded', () => {
const f = fixture(), cleanup = registerTerminalSelectionSource('a', f.source);
try {
f.term.getSelectionPosition = () => ({ start: { x: 0, y: 0 }, end: { x: 1, y: 100 } });
assert.throws(() => readPluginTerminalSelection('a', 'renderer:1'), /bounded/);
assert.equal(f.reads, 0);
f.term.getSelectionPosition = () => ({ start: { x: 0, y: 0 }, end: { x: 1, y: 0 } });
f.term.getSelection = () => '\u20ac'.repeat(1366);
assert.throws(() => readPluginTerminalSelection('a', 'renderer:1'), /4096 bytes/);
} finally { cleanup(); }
});
test('real pane snapshots give sharing ownership to solo, Focus mode, and only the selected split pane', async () => {
const { getTerminalPaneRenderSnapshot, parseTerminalPaneRenderSnapshot, resolveTerminalPaneInputProps } = await import('../../components/terminalPaneVisibility');
const workspaceById = new Map<string, import('../../types').Workspace>([
['split', { id: 'split', title: 'Split', viewMode: 'split', focusedSessionId: 'split-a', root: { id: 'split-root', type: 'split', direction: 'vertical', children: [{ id: 'a', type: 'pane', sessionId: 'split-a' }, { id: 'b', type: 'pane', sessionId: 'split-b' }] } }],
['focus', { id: 'focus', title: 'Focus', viewMode: 'focus', focusedSessionId: 'focus-a', root: { id: 'focus-root', type: 'split', direction: 'vertical', children: [{ id: 'a', type: 'pane', sessionId: 'focus-a' }, { id: 'b', type: 'pane', sessionId: 'focus-b' }] } }],
]);
const cases = [
{ name: 'solo', activeTabId: 'solo', sessionId: 'solo', eligible: true },
{ name: 'inactive solo', activeTabId: 'solo', sessionId: 'other', eligible: false },
{ name: 'Focus mode', activeTabId: 'focus', sessionId: 'focus-a', sessionWorkspaceId: 'focus', eligible: true },
{ name: 'other Focus pane', activeTabId: 'focus', sessionId: 'focus-b', sessionWorkspaceId: 'focus', eligible: false },
{ name: 'selected split', activeTabId: 'split', sessionId: 'split-a', sessionWorkspaceId: 'split', eligible: true },
{ name: 'other visible split', activeTabId: 'split', sessionId: 'split-b', sessionWorkspaceId: 'split', eligible: false },
{ name: 'inactive workspace', activeTabId: 'solo', sessionId: 'split-a', sessionWorkspaceId: 'split', eligible: false },
{ name: 'hidden layer', activeTabId: 'solo', sessionId: 'solo', isTerminalLayerVisible: false, eligible: false },
];
for (const item of cases) {
const snapshot = getTerminalPaneRenderSnapshot({ ...item, workspaceById, isTerminalLayerVisible: item.isTerminalLayerVisible ?? true });
const inputProps = resolveTerminalPaneInputProps(parseTerminalPaneRenderSnapshot(snapshot));
const f = fixture();
const state = { ...f.state, sessionId: item.sessionId, visible: inputProps.isVisible,
isFocusedPane: inputProps.isFocusedPane };
const source = createPluginTerminalSelectionSource(() => state, 'derived-pane');
assert.equal(source.target() !== null, item.eligible, `${item.name}: ${snapshot}`);
if (item.name === 'solo' || item.name === 'Focus mode') assert.equal(inputProps.isFocused, false, 'do not redefine the legacy focus bit');
}
});
+2 -133
View File
@@ -1,111 +1,7 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
PluginViewLifecycleController,
consumeClosedPluginViewInstance,
reconcilePluginViewTabCatalog,
markPluginViewOpenTokensClosed,
reconcileClosedPluginView,
rememberClosedPluginViewInstance,
resolvePluginViewSnapshotSelection,
shouldReconcilePluginViewTabCatalog,
withdrawPluginViewTab,
type HostedPluginViewState,
} from './pluginViewLifecycle.ts';
import { PluginViewTabStore } from './pluginViewTabStore.ts';
function view(id: string, tabId?: string): HostedPluginViewState {
return {
id,
viewId: `view.${id}`,
scopeId: 'window:main',
retainContextWhenHidden: false,
...(tabId ? { tabId } : {}),
};
}
test('host close events clear the active renderer instance and identify its native tab', () => {
const current = view('active', 'plugin-view:publisher.plugin:view.active');
const result = reconcileClosedPluginView({
current,
retained: new Map([['retained', view('retained')]]),
instanceId: 'active',
});
assert.equal(result.current, null);
assert.equal(result.matchedCurrent, true);
assert.equal(result.closedTabId, current.tabId);
assert.equal(result.retained.size, 1);
});
test('host close events remove retained instances without dismissing another active view', () => {
const current = view('active');
const retainedTab = view('retained-closed', 'plugin-view:publisher.plugin:view.retained');
const result = reconcileClosedPluginView({
current,
retained: new Map([
['closed', retainedTab],
['kept', view('retained-kept')],
]),
instanceId: 'retained-closed',
});
assert.equal(result.current, current);
assert.equal(result.matchedCurrent, false);
assert.equal(result.matchedRetained, true);
assert.deepEqual([...result.retained.keys()], ['kept']);
assert.equal(result.closedTabId, retainedTab.tabId);
});
test('explicit native-tab close destroys active and retained instances instead of retaining them', () => {
const tabId = 'plugin-view:publisher.plugin:view.shared';
const result = withdrawPluginViewTab({
current: view('active', tabId),
retained: new Map([
['same-tab', view('retained-same', tabId)],
['other-tab', view('retained-other', 'plugin-view:publisher.plugin:view.other')],
]),
tabId,
});
assert.equal(result.current, null);
assert.equal(result.matchedCurrent, true);
assert.equal(result.matchedRetained, true);
assert.deepEqual(result.instanceIds, ['active', 'retained-same']);
assert.deepEqual([...result.retained.keys()], ['other-tab']);
});
test('an early host close tombstone is consumed when the open response arrives later', () => {
const tombstones = new Set<string>();
rememberClosedPluginViewInstance(tombstones, 'instance-early');
assert.equal(consumeClosedPluginViewInstance(tombstones, 'instance-early'), true);
assert.equal(consumeClosedPluginViewInstance(tombstones, 'instance-early'), false);
for (let index = 0; index < 300; index += 1) {
rememberClosedPluginViewInstance(tombstones, `instance-${index}`);
}
assert.equal(tombstones.size, 256);
assert.equal(tombstones.has('instance-0'), false);
});
test('explicit close marks only in-flight opens owned by the closed surface', () => {
const first = Symbol('first');
const second = Symbol('second');
const explicitlyClosed = new Set<symbol>();
const opening = new Map<string, Set<symbol>>([
['window:main\0view.first', new Set([first])],
['window:main\0view.second', new Set([second])],
]);
assert.equal(markPluginViewOpenTokensClosed(
opening,
explicitlyClosed,
'window:main\0view.first',
), 1);
assert.deepEqual([...explicitlyClosed], [first]);
assert.equal(markPluginViewOpenTokensClosed(opening, explicitlyClosed, null), 0);
});
import { reconcilePluginViewTabCatalog, resolvePluginViewSnapshotSelection, shouldReconcilePluginViewTabCatalog } from './pluginViewLifecycle';
import { PluginViewTabStore } from './pluginViewTabStore';
test('locale-only snapshot refresh keeps the owned view alive without weakening context fail-closed behavior', () => {
const previous = {
@@ -184,30 +80,3 @@ test('active-tab context refresh cannot withdraw the plugin tab that triggered i
assert.equal(store.getTabs()[0]?.title, 'Localized View');
assert.equal(activeTabId, tab.id);
});
test('lifecycle controller owns retained views, open tokens, tombstones, and teardown', () => {
const controller = new PluginViewLifecycleController<HostedPluginViewState>();
const tabId = 'plugin-view:publisher.plugin:view.shared';
const active = view('active', tabId);
const retained = view('retained');
controller.setCurrent(active);
controller.retain('window:main\0view.retained', retained);
const token = controller.beginOpen({
viewKey: 'window:main\0view.active',
tabId,
});
const closed = controller.handleTabClose(tabId);
assert.deepEqual(closed.instanceIds, [active.id]);
assert.equal(controller.shouldCloseOpen(token), true);
controller.finishOpen({ token, viewKey: 'window:main\0view.active', tabId });
assert.equal(controller.shouldCloseOpen(token), false);
const early = controller.handleHostClose('instance-before-open-response');
assert.equal(early.matchedCurrent, false);
assert.equal(early.matchedRetained, false);
assert.equal(controller.consumeHostClose('instance-before-open-response'), true);
assert.deepEqual(controller.drain().map((candidate) => candidate.id), [retained.id]);
assert.equal(controller.getCurrent(), null);
});
-246
View File
@@ -1,11 +1,3 @@
export interface HostedPluginViewState {
id: string;
viewId: string;
scopeId: string;
retainContextWhenHidden: boolean;
tabId?: string;
}
export interface PluginViewSnapshotSelection<T> {
requestViewId: string;
contextKey: string;
@@ -81,241 +73,3 @@ export function reconcilePluginViewTabCatalog({
store.refreshMetadata(entries);
return true;
}
export function reconcileClosedPluginView<T extends HostedPluginViewState>({
current,
retained,
instanceId,
}: {
current: T | null;
retained: ReadonlyMap<string, T>;
instanceId: string;
}): {
current: T | null;
retained: Map<string, T>;
matchedCurrent: boolean;
matchedRetained: boolean;
closedTabId?: string;
} {
const matchedCurrent = current?.id === instanceId;
let matchedRetained = false;
let retainedTabId: string | undefined;
const nextRetained = new Map<string, T>();
for (const [key, view] of retained) {
if (view.id === instanceId) {
matchedRetained = true;
retainedTabId = view.tabId;
}
else nextRetained.set(key, view);
}
return {
current: matchedCurrent ? null : current,
retained: nextRetained,
matchedCurrent,
matchedRetained,
...((matchedCurrent ? current?.tabId : retainedTabId)
? { closedTabId: matchedCurrent ? current?.tabId : retainedTabId }
: {}),
};
}
export function withdrawPluginViewTab<T extends HostedPluginViewState>({
current,
retained,
tabId,
}: {
current: T | null;
retained: ReadonlyMap<string, T>;
tabId: string;
}): {
current: T | null;
retained: Map<string, T>;
instanceIds: string[];
matchedCurrent: boolean;
matchedRetained: boolean;
} {
const matchedCurrent = current?.tabId === tabId;
let matchedRetained = false;
const instanceIds: string[] = [];
if (matchedCurrent && current) instanceIds.push(current.id);
const nextRetained = new Map<string, T>();
for (const [key, view] of retained) {
if (view.tabId === tabId) {
matchedRetained = true;
instanceIds.push(view.id);
} else {
nextRetained.set(key, view);
}
}
return {
current: matchedCurrent ? null : current,
retained: nextRetained,
instanceIds,
matchedCurrent,
matchedRetained,
};
}
export function rememberClosedPluginViewInstance(
tombstones: Set<string>,
instanceId: string,
limit = 256,
): void {
if (tombstones.size >= limit) {
const oldest = tombstones.values().next().value;
if (typeof oldest === 'string') tombstones.delete(oldest);
}
tombstones.add(instanceId);
}
export function consumeClosedPluginViewInstance(tombstones: Set<string>, instanceId: string): boolean {
return tombstones.delete(instanceId);
}
export function markPluginViewOpenTokensClosed(
openingTokens: ReadonlyMap<string, ReadonlySet<symbol>>,
explicitlyClosedTokens: Set<symbol>,
ownerKey: string | null | undefined,
): number {
if (!ownerKey) return 0;
const tokens = openingTokens.get(ownerKey);
if (!tokens) return 0;
for (const token of tokens) explicitlyClosedTokens.add(token);
return tokens.size;
}
export class PluginViewLifecycleController<T extends HostedPluginViewState = HostedPluginViewState> {
private current: T | null = null;
private retained = new Map<string, T>();
private readonly closedInstanceIds = new Set<string>();
private readonly openingTokensByTab = new Map<string, Set<symbol>>();
private readonly openingTokensByView = new Map<string, Set<symbol>>();
private readonly explicitlyClosedOpenTokens = new Set<symbol>();
getCurrent(): T | null {
return this.current;
}
setCurrent(view: T | null): void {
this.current = view;
}
takeCurrent(): T | null {
const current = this.current;
this.current = null;
return current;
}
retain(key: string, view: T): void {
this.retained.set(key, view);
}
takeRetained(key: string): T | null {
const retained = this.retained.get(key) ?? null;
if (retained) this.retained.delete(key);
return retained;
}
removeRetained(key: string): void {
this.retained.delete(key);
}
removeRetainedWhere(predicate: (view: T) => boolean): T[] {
const removed: T[] = [];
for (const [key, view] of this.retained) {
if (!predicate(view)) continue;
this.retained.delete(key);
removed.push(view);
}
return removed;
}
handleHostClose(instanceId: string) {
const next = reconcileClosedPluginView({
current: this.current,
retained: this.retained,
instanceId,
});
this.current = next.current;
this.retained = next.retained;
if (!next.matchedCurrent && !next.matchedRetained) {
rememberClosedPluginViewInstance(this.closedInstanceIds, instanceId);
}
return next;
}
handleTabClose(tabId: string) {
const next = withdrawPluginViewTab({
current: this.current,
retained: this.retained,
tabId,
});
this.current = next.current;
this.retained = next.retained;
markPluginViewOpenTokensClosed(
this.openingTokensByTab,
this.explicitlyClosedOpenTokens,
tabId,
);
return next;
}
markViewClosed(viewKey: string | null | undefined): number {
return markPluginViewOpenTokensClosed(
this.openingTokensByView,
this.explicitlyClosedOpenTokens,
viewKey,
);
}
beginOpen({
viewKey,
tabId,
label,
}: {
viewKey: string;
tabId?: string;
label?: string;
}): symbol {
const token = Symbol(label ?? tabId ?? viewKey);
const viewTokens = this.openingTokensByView.get(viewKey) ?? new Set<symbol>();
viewTokens.add(token);
this.openingTokensByView.set(viewKey, viewTokens);
if (tabId) {
const tabTokens = this.openingTokensByTab.get(tabId) ?? new Set<symbol>();
tabTokens.add(token);
this.openingTokensByTab.set(tabId, tabTokens);
}
return token;
}
finishOpen({ token, viewKey, tabId }: { token: symbol; viewKey: string; tabId?: string }): void {
this.explicitlyClosedOpenTokens.delete(token);
const viewTokens = this.openingTokensByView.get(viewKey);
viewTokens?.delete(token);
if (viewTokens?.size === 0) this.openingTokensByView.delete(viewKey);
if (!tabId) return;
const tabTokens = this.openingTokensByTab.get(tabId);
tabTokens?.delete(token);
if (tabTokens?.size === 0) this.openingTokensByTab.delete(tabId);
}
shouldCloseOpen(token: symbol): boolean {
return this.explicitlyClosedOpenTokens.has(token);
}
consumeHostClose(instanceId: string): boolean {
return consumeClosedPluginViewInstance(this.closedInstanceIds, instanceId);
}
drain(): T[] {
const values = [this.current, ...this.retained.values()].filter((view): view is T => Boolean(view));
this.current = null;
this.retained.clear();
this.closedInstanceIds.clear();
this.openingTokensByTab.clear();
this.openingTokensByView.clear();
this.explicitlyClosedOpenTokens.clear();
return values;
}
}
@@ -0,0 +1,41 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import type { HostDrawnViewMessage, HostDrawnViewOpenRequest } from '@netcatty/plugin-contract';
type Update = Extract<HostDrawnViewMessage, { kind: 'host-ui.update' }> & { viewId: string };
import { PluginViewSessions } from './pluginViewSession';
function fixture() {
let updates: (data: Update) => void = () => {}; let closed: (data: NetcattyPluginViewClosedEvent) => void = () => {};
const opened: HostDrawnViewOpenRequest[] = []; const closes: string[] = []; const events: unknown[] = [];
const bridge = { openHostDrawnPluginView: async (request: HostDrawnViewOpenRequest) => { opened.push(request); return { instanceId: request.instanceId }; },
closeHostDrawnPluginView: async (id: string) => { closes.push(id); },
onHostDrawnPluginViewUpdate: (listener: typeof updates) => { updates = listener; return () => {}; },
onPluginViewClosed: (listener: typeof closed) => { closed = listener; return () => {}; },
sendHostDrawnPluginViewEvent: async (id: string, event: unknown) => { events.push({ id, event }); } };
return { bridge, opened, closes, events, update: (data: Update) => updates(data), close: (data: { instanceId: string }) => closed({ ...data, viewId: request.viewId, pluginId: 'com.example', reason: 'test' }) };
}
const request = { viewId: 'com.example.view', scopeId: 'window:one', context: {} };
test('a hidden view resumes its existing state and plugin instance', async () => {
const f = fixture(); const store = new PluginViewSessions();
const first = store.acquire('view', f.bridge, request); const id = f.opened[0].instanceId;
f.update({ kind: 'host-ui.update', viewId: request.viewId, instanceId: id, sequence: 1, css: '', mutations: [[0, '~', { id: 'text', type: 3, data: 'kept' }, 0]] });
first.release(); const next = store.acquire('view', f.bridge, request);
assert.equal(next, first); assert.equal(f.opened.length, 1); assert.deepEqual(next.getSnapshot().tree.children[0], { id: 'text', type: 3, data: 'kept' });
next.release(); store.dispose(); await Promise.resolve(); assert.equal(new Set(f.closes).size, 1);
});
test('host invalidation clears retained content and a reopen creates a new instance', () => {
const f = fixture(); const store = new PluginViewSessions(); const old = store.acquire('view', f.bridge, request);
f.close({ instanceId: f.opened[0].instanceId }); assert.equal(old.getSnapshot().error, true); assert.deepEqual(old.getSnapshot().tree.children, []);
const next = store.acquire('view', f.bridge, request); assert.notEqual(old, next); store.dispose();
});
test('unacknowledged input is not overwritten by an older plugin update', () => {
const f = fixture(); const store = new PluginViewSessions(); const view = store.acquire('view', f.bridge, request);
view.send({ type: 'input', nodeId: 'field', value: 'latest' }); assert.equal(view.getSnapshot().settled, false);
const base = { kind: 'host-ui.update' as const, viewId: request.viewId, instanceId: f.opened[0].instanceId, css: '', mutations: [] };
f.update({ ...base, sequence: 1, acknowledgedEvent: 0 }); assert.equal(view.getSnapshot().settled, false);
f.update({ ...base, sequence: 2, acknowledgedEvent: 1 }); assert.equal(view.getSnapshot().settled, true); store.dispose();
});
test('idle instances are evicted while active instances are never silently replaced', () => {
const f = fixture(); const store = new PluginViewSessions(1); const first = store.acquire('one', f.bridge, request);
assert.throws(() => store.acquire('two', f.bridge, request), /Too many/); first.release();
const second = store.acquire('two', f.bridge, request); assert.notEqual(second, first); assert.equal(f.closes.length, 1); store.dispose();
});
+78
View File
@@ -0,0 +1,78 @@
import { createPluginRemoteTree } from './pluginRemoteTree';
import { validatePluginStylesheet } from '@/domain/pluginViewStylePolicy.cjs';
import type { HostDrawnViewEvent } from '@netcatty/plugin-contract';
type Bridge = Pick<NonNullable<ReturnType<typeof import('../../infrastructure/services/netcattyBridge').netcattyBridge.get>>, 'openHostDrawnPluginView' | 'closeHostDrawnPluginView' | 'onHostDrawnPluginViewUpdate' | 'onPluginViewClosed' | 'sendHostDrawnPluginViewEvent' | 'getPluginViewResource'>;
export type PluginViewSnapshot = { tree: ReturnType<ReturnType<typeof createPluginRemoteTree>['snapshot']>; css: string; loading: boolean; error: boolean; settled: boolean };
const empty = (): PluginViewSnapshot => ({ tree: { children: [] }, css: '', loading: true, error: false, settled: true });
/** Owns a window's bounded, retained UI instances independently of React mounts. */
export class PluginViewSessions {
private sessions = new Map<string, ReturnType<PluginViewSessions['create']>>();
constructor(private readonly capacity = 16, private readonly idleMs = 300_000) {}
private create(key: string, bridge: Bridge, request: Omit<import('@netcatty/plugin-contract').HostDrawnViewOpenRequest, 'instanceId'>) {
const id = crypto.randomUUID();
const tree = createPluginRemoteTree();
let state = empty(); let sequence = 1; let eventId = 0; let disposed = false; let users = 0;
let timer: ReturnType<typeof setTimeout> | undefined;
const listeners = new Set<() => void>();
const publish = (next: PluginViewSnapshot) => { state = next; for (const listener of listeners) listener(); };
const cleanup: Array<() => void> = [];
const dispose = (failed = false) => {
if (disposed) return;
disposed = true; clearTimeout(timer); cleanup.forEach((stop) => stop());
this.sessions.delete(key);
publish({ ...empty(), loading: false, error: failed });
void bridge.closeHostDrawnPluginView?.(id).catch(() => {});
};
if (!bridge.openHostDrawnPluginView || !bridge.onHostDrawnPluginViewUpdate || !bridge.onPluginViewClosed) throw new Error('Plugin views unavailable');
cleanup.push(bridge.onHostDrawnPluginViewUpdate((update) => {
if (disposed || update.kind !== 'host-ui.update' || update.instanceId !== id || update.viewId !== request.viewId) return;
try {
if (update.sequence !== sequence++) throw new Error('Invalid plugin update order');
publish({ tree: tree.apply(update.mutations), css: validatePluginStylesheet(update.css), loading: false, error: false, settled: (update.acknowledgedEvent ?? 0) >= eventId });
} catch { dispose(true); }
}));
cleanup.push(bridge.onPluginViewClosed((event) => { if (event.instanceId === id) dispose(true); }));
void bridge.openHostDrawnPluginView({ ...request, instanceId: id }).then(() => {
if (disposed) void bridge.closeHostDrawnPluginView?.(id).catch(() => {});
}).catch(() => dispose(true));
return {
viewId: request.viewId,
scope: request.scopeId,
image: async (path: string) => {
if (disposed || !bridge.getPluginViewResource) throw new Error('Plugin image unavailable');
const result = await bridge.getPluginViewResource({ instanceId: id, path });
if (disposed) throw new Error('Plugin view closed');
return result.url;
},
get idle() { return users === 0; },
getSnapshot: () => state,
subscribe(listener: () => void) { listeners.add(listener); return () => { listeners.delete(listener); }; },
acquire: () => { clearTimeout(timer); users++; },
release: () => { if (--users === 0 && !disposed) timer = setTimeout(() => dispose(), this.idleMs); },
send: (event: HostDrawnViewEvent) => {
if (disposed || users === 0) return;
publish({ ...state, settled: false });
void bridge.sendHostDrawnPluginViewEvent?.(id, { ...event, eventId: ++eventId }).catch(() => dispose(true));
},
dispose,
};
}
acquire(key: string, bridge: Bridge, request: Omit<import('@netcatty/plugin-contract').HostDrawnViewOpenRequest, 'instanceId'>) {
let session = this.sessions.get(key);
if (!session) {
while (this.sessions.size >= this.capacity) {
const candidate = [...this.sessions.values()].find((item) => item.idle);
if (!candidate) throw new Error('Too many open plugin views');
candidate.dispose();
}
session = this.create(key, bridge, request); this.sessions.set(key, session);
}
session.acquire(); return session;
}
closeView(viewId: string) { for (const session of [...this.sessions.values()]) if (session.viewId === viewId) session.dispose(); }
closeScope(scope: string) { for (const session of [...this.sessions.values()]) if (session.scope === scope) session.dispose(); }
dispose() { for (const session of [...this.sessions.values()]) session.dispose(); }
}
export const pluginViewSessions = new PluginViewSessions();
+7
View File
@@ -49,6 +49,7 @@ import {
STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN,
STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN_TAB,
STORAGE_KEY_WINDOW_OPACITY,
STORAGE_KEY_WINDOW_BACKGROUND_BLUR,
STORAGE_KEY_APP_ICON_VARIANT,
STORAGE_KEY_HTTP_NETWORK_PROXY,
STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE,
@@ -99,6 +100,7 @@ interface UseSettingsIpcSyncParams {
setIsHotkeyRecordingState: Dispatch<SetStateAction<boolean>>;
setGlobalHotkeyEnabled: Dispatch<SetStateAction<boolean>>;
setWindowOpacity: (raw: unknown) => void;
setWindowBackgroundBlur: Dispatch<SetStateAction<boolean>>;
setAppIconVariant: Dispatch<SetStateAction<AppIconVariant>>;
setAutoUpdateEnabled: Dispatch<SetStateAction<boolean>>;
setAutoLaunchEnabled: Dispatch<SetStateAction<boolean>>;
@@ -148,6 +150,7 @@ export function useSettingsIpcSync({
setIsHotkeyRecordingState,
setGlobalHotkeyEnabled,
setWindowOpacity,
setWindowBackgroundBlur,
setAppIconVariant,
setAutoUpdateEnabled,
setAutoLaunchEnabled,
@@ -279,6 +282,9 @@ export function useSettingsIpcSync({
if (key === STORAGE_KEY_WINDOW_OPACITY) {
setWindowOpacity(value);
}
if (key === STORAGE_KEY_WINDOW_BACKGROUND_BLUR && typeof value === 'boolean') {
setWindowBackgroundBlur((prev) => (prev === value ? prev : value));
}
if (key === STORAGE_KEY_APP_ICON_VARIANT) {
const nextVariant = resolveAppIconVariant(value);
setAppIconVariant((prev) => (prev === nextVariant ? prev : nextVariant));
@@ -371,6 +377,7 @@ export function useSettingsIpcSync({
setFollowAppTerminalThemeState,
setGlobalHotkeyEnabled,
setWindowOpacity,
setWindowBackgroundBlur,
setAppIconVariant,
setHotkeyScheme,
setIsHotkeyRecordingState,
+14 -4
View File
@@ -53,6 +53,7 @@ import {
STORAGE_KEY_UI_LANGUAGE,
STORAGE_KEY_WORKSPACE_FOCUS_STYLE,
STORAGE_KEY_WINDOW_OPACITY,
STORAGE_KEY_WINDOW_BACKGROUND_BLUR,
STORAGE_KEY_APP_ICON_VARIANT,
} from '../../infrastructure/config/storageKeys';
import { resolveAppIconVariant, type AppIconVariant } from '../../domain/appIconVariant';
@@ -116,6 +117,7 @@ interface UseSettingsStorageSyncParams {
globalHotkeyEnabled: boolean;
autoUpdateEnabled: boolean;
windowOpacity: number;
windowBackgroundBlur: boolean;
appIconVariant: AppIconVariant;
setTheme: Dispatch<SetStateAction<'dark' | 'light' | 'system'>>;
setLightUiThemeId: Dispatch<SetStateAction<string>>;
@@ -166,6 +168,7 @@ interface UseSettingsStorageSyncParams {
setExplorerContextMenuEnabledState: (enabled: boolean) => void;
setGlobalHotkeyEnabled: Dispatch<SetStateAction<boolean>>;
setWindowOpacity: (raw: unknown) => void;
setWindowBackgroundBlur: Dispatch<SetStateAction<boolean>>;
setAppIconVariant: Dispatch<SetStateAction<AppIconVariant>>;
setAutoUpdateEnabled: Dispatch<SetStateAction<boolean>>;
setWorkspaceFocusStyleState: Dispatch<SetStateAction<'dim' | 'border'>>;
@@ -184,7 +187,7 @@ export function useSettingsStorageSync({
sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, localShellSidePanelAutoOpen, localShellSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd, startupLanding,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, windowBackgroundBlur, appIconVariant,
setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode, applyIncomingCustomAccent,
setCustomCSS, setUiFontFamilyId, setHotkeyScheme, setUiLanguage,
setTerminalThemeId, setTerminalThemeDarkId, setTerminalThemeLightId,
@@ -193,7 +196,7 @@ export function useSettingsStorageSync({
setSftpUseCompressedUpload, setSftpSkipUnchanged, setSftpAutoOpenSidebar, setSftpFollowTerminalCwd, setSftpDefaultViewMode,
setShowRecentHostsState, setHostClickBehaviorState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setTerminalSidePanelAutoOpenState, setTerminalSidePanelAutoOpenTabState, setLocalShellSidePanelAutoOpenState, setLocalShellSidePanelAutoOpenTabState, setShellOnlyTabNumberShortcutsState, setShowTabNumberBadgesState, setDisableTerminalFontZoomState, setRestorePreviousSessionState, setRestoreTerminalCwdState, setStartupLandingState,
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled, setSshDeepLinkEnabledState, setJmsDeepLinkEnabledState, setExplorerContextMenuEnabledState,
setGlobalHotkeyEnabled, setWindowOpacity, setAppIconVariant, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
setGlobalHotkeyEnabled, setWindowOpacity, setWindowBackgroundBlur, setAppIconVariant, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
setSftpTransferConcurrencyState, setSshTransportIdleTtlMsState,
applyIncomingCustomKeyBindings, mergeIncomingTerminalSettings,
}: UseSettingsStorageSyncParams) {
@@ -208,7 +211,7 @@ export function useSettingsStorageSync({
sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, localShellSidePanelAutoOpen, localShellSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd, startupLanding,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, windowBackgroundBlur, appIconVariant,
});
settingsSnapshotRef.current = {
theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, customAccentVersion,
@@ -218,7 +221,7 @@ export function useSettingsStorageSync({
sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, localShellSidePanelAutoOpen, localShellSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd, startupLanding,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, windowBackgroundBlur, appIconVariant,
};
// Listen for storage changes from other windows (cross-window sync)
@@ -523,6 +526,12 @@ export function useSettingsStorageSync({
if (e.key === STORAGE_KEY_WINDOW_OPACITY && e.newValue !== null) {
setWindowOpacity(e.newValue);
}
if (e.key === STORAGE_KEY_WINDOW_BACKGROUND_BLUR && e.newValue !== null) {
const newValue = e.newValue === 'true';
if (newValue !== s.windowBackgroundBlur) {
setWindowBackgroundBlur(newValue);
}
}
if (e.key === STORAGE_KEY_APP_ICON_VARIANT && e.newValue !== null) {
const newValue = resolveAppIconVariant(e.newValue);
if (newValue !== s.appIconVariant) {
@@ -566,6 +575,7 @@ export function useSettingsStorageSync({
setGlobalHotkeyEnabled,
setHostClickBehaviorState,
setWindowOpacity,
setWindowBackgroundBlur,
setAppIconVariant,
setHotkeyScheme,
setLightUiThemeId,
@@ -7,6 +7,7 @@ import {
STORAGE_KEY_GLOBAL_HOTKEY_ENABLED,
STORAGE_KEY_TOGGLE_WINDOW_HOTKEY,
STORAGE_KEY_WINDOW_OPACITY,
STORAGE_KEY_WINDOW_BACKGROUND_BLUR,
STORAGE_KEY_APP_ICON_VARIANT,
STORAGE_KEY_HTTP_NETWORK_PROXY,
} from '../../infrastructure/config/storageKeys';
@@ -17,6 +18,8 @@ import {
} from '../../domain/httpNetworkProxy';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
import { isMacPlatform } from '../../lib/utils';
import { isWindowBackgroundBlurActive } from '../../domain/windowBackgroundBlur';
import {
parseWindowOpacityRecord,
serializeWindowOpacityRecord,
@@ -47,6 +50,7 @@ interface UseSystemSettingsEffectsParams {
autoLaunchEnabled: boolean;
windowOpacityRecord: WindowOpacityRecord;
windowOpacityMutationSourceRef: MutableRefObject<WindowOpacityMutationSource>;
windowBackgroundBlur: boolean;
appIconVariant: AppIconVariant;
autoUpdateEnabled: boolean;
httpNetworkProxy: HttpNetworkProxySettings;
@@ -68,6 +72,7 @@ export function useSystemSettingsEffects({
autoLaunchEnabled,
windowOpacityRecord,
windowOpacityMutationSourceRef,
windowBackgroundBlur,
appIconVariant,
autoUpdateEnabled,
httpNetworkProxy,
@@ -300,6 +305,49 @@ export function useSystemSettingsEffects({
persistMountedRef,
]);
// Window chrome also applies in detached sessions, independently of the
// OS-level effects disabled there (tray, global shortcuts, autolaunch, ...).
// Only the windows that own settings persistence drive the shared native
// state; detached/editor windows consume it (see applySharedWindowSetting).
useEffect(() => {
if (!enabled) return;
localStorageAdapter.writeBoolean(STORAGE_KEY_WINDOW_BACKGROUND_BLUR, windowBackgroundBlur);
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_WINDOW_BACKGROUND_BLUR, windowBackgroundBlur);
}, [
enabled,
windowBackgroundBlur,
notifySettingsChanged,
persistMountedRef,
]);
const windowBackgroundBlurActive = isMacPlatform() && isWindowBackgroundBlurActive(
windowBackgroundBlur,
windowOpacityRecord.opacity,
);
// Applies in every app-content window, including detached sessions: the
// transparent backing only looks right when the renderer class matches the
// native vibrancy the main process put on that window.
useEffect(() => {
if (typeof document === 'undefined') return;
document.documentElement.classList.toggle('window-background-blur', windowBackgroundBlurActive);
document.documentElement.style.setProperty('--window-content-opacity', String(windowOpacityRecord.opacity));
return () => {
document.documentElement.classList.remove('window-background-blur');
document.documentElement.style.removeProperty('--window-content-opacity');
};
}, [windowBackgroundBlurActive, windowOpacityRecord.opacity]);
// setWindowBackgroundBlur is a main-process handler that retargets every
// app-content window, so a single owner must drive it.
useEffect(() => {
if (!enabled) return;
netcattyBridge.get()?.setWindowBackgroundBlur?.(windowBackgroundBlur).catch((err) => {
console.warn('[WindowBackgroundBlur] Failed to apply macOS vibrancy:', err);
});
}, [enabled, windowBackgroundBlur]);
// Persist and sync app icon variant
useEffect(() => {
if (!enabled) return;
@@ -0,0 +1,139 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import React from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { JSDOM } from 'jsdom';
import { STORAGE_KEY_WINDOW_BACKGROUND_BLUR } from '../../infrastructure/config/storageKeys';
import { DEFAULT_HTTP_NETWORK_PROXY } from '../../domain/httpNetworkProxy';
import { useSystemSettingsEffects } from './systemSettingsEffects';
type Harness = {
dom: JSDOM;
params: Parameters<typeof useSystemSettingsEffects>[0];
bridgeRequests: boolean[];
blurNotifications: unknown[];
restore: () => void;
};
function setUp(enabled: boolean, windowBackgroundBlur: boolean): Harness {
const dom = new JSDOM('<html><body><div id="root"></div></body></html>', { url: 'http://localhost' });
Object.defineProperty(dom.window.navigator, 'platform', { value: 'MacIntel' });
const previousGlobals = new Map<string, PropertyDescriptor | undefined>();
const replacements = {
window: dom.window,
document: dom.window.document,
navigator: dom.window.navigator,
localStorage: dom.window.localStorage,
IS_REACT_ACT_ENVIRONMENT: true,
};
for (const [key, value] of Object.entries(replacements)) {
previousGlobals.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, { value, configurable: true });
}
const bridgeRequests: boolean[] = [];
const blurNotifications: unknown[] = [];
Object.assign(dom.window, {
netcatty: {
setWindowBackgroundBlur: async (value: boolean) => { bridgeRequests.push(value); },
},
});
const noop = () => {};
const params: Parameters<typeof useSystemSettingsEffects>[0] = {
enabled,
toggleWindowHotkey: '',
globalHotkeyEnabled: false,
closeToTray: false,
showTrayIcon: false,
autoLaunchEnabled: false,
windowOpacityRecord: { opacity: 0.7, preferredOpacity: 0.7, version: 1 },
windowOpacityMutationSourceRef: { current: 'local' },
windowBackgroundBlur,
appIconVariant: 'original',
autoUpdateEnabled: false,
httpNetworkProxy: DEFAULT_HTTP_NETWORK_PROXY,
persistMountedRef: { current: true },
setHotkeyRegistrationError: noop,
setAutoUpdateEnabled: noop,
setAppIconVariant: noop,
setAutoLaunchEnabled: noop,
setAutoLaunchSupported: noop,
notifySettingsChanged: (key, value) => {
if (key === STORAGE_KEY_WINDOW_BACKGROUND_BLUR) blurNotifications.push(value);
},
};
return {
dom,
params,
bridgeRequests,
blurNotifications,
restore: () => {
for (const [key, descriptor] of previousGlobals) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else Reflect.deleteProperty(globalThis, key);
}
dom.window.close();
},
};
}
const Probe = (props: Parameters<typeof useSystemSettingsEffects>[0]) => {
useSystemSettingsEffects(props);
return null;
};
test('opacity changes and remounts preserve the chosen blur preference', async () => {
const harness = setUp(true, true);
const { dom, params, bridgeRequests, blurNotifications, restore } = harness;
let renderer: ReactTestRenderer | undefined;
try {
await act(async () => { renderer = create(React.createElement(Probe, params)); });
assert.equal(document.documentElement.classList.contains('window-background-blur'), true);
const opaqueParams = { ...params, windowOpacityRecord: { ...params.windowOpacityRecord, opacity: 1, version: 2 } };
await act(async () => { renderer!.update(React.createElement(Probe, opaqueParams)); });
assert.equal(document.documentElement.classList.contains('window-background-blur'), false);
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_WINDOW_BACKGROUND_BLUR), 'true');
assert.deepEqual(bridgeRequests, [true]);
assert.deepEqual(blurNotifications, [true]);
await act(async () => { renderer!.unmount(); });
await act(async () => { renderer = create(React.createElement(Probe, opaqueParams)); });
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_WINDOW_BACKGROUND_BLUR), 'true');
await act(async () => { renderer!.update(React.createElement(Probe, params)); });
assert.equal(document.documentElement.classList.contains('window-background-blur'), true);
await act(async () => { renderer!.update(React.createElement(Probe, { ...params, windowBackgroundBlur: false })); });
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_WINDOW_BACKGROUND_BLUR), 'false');
assert.equal(document.documentElement.classList.contains('window-background-blur'), false);
assert.deepEqual(bridgeRequests, [true, true, false]);
} finally {
await act(async () => { renderer?.unmount(); });
restore();
}
});
test('detached session windows only apply the blur class, never own the setting', async () => {
// Peer session windows run with enableSystemEffects off. They must still
// paint the transparent backing, but persistence, cross-window broadcast and
// the (all-windows) native vibrancy IPC stay with the settings owners.
const harness = setUp(false, true);
const { dom, params, bridgeRequests, blurNotifications, restore } = harness;
let renderer: ReactTestRenderer | undefined;
try {
await act(async () => { renderer = create(React.createElement(Probe, params)); });
assert.equal(document.documentElement.classList.contains('window-background-blur'), true);
assert.equal(document.documentElement.style.getPropertyValue('--window-content-opacity'), '0.7');
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_WINDOW_BACKGROUND_BLUR), null);
assert.deepEqual(bridgeRequests, []);
assert.deepEqual(blurNotifications, []);
await act(async () => {
renderer!.update(React.createElement(Probe, { ...params, windowBackgroundBlur: false }));
});
assert.equal(document.documentElement.classList.contains('window-background-blur'), false);
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_WINDOW_BACKGROUND_BLUR), null);
assert.deepEqual(bridgeRequests, []);
assert.deepEqual(blurNotifications, []);
} finally {
await act(async () => { renderer?.unmount(); });
restore();
}
});
@@ -0,0 +1,44 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { JSDOM } from 'jsdom';
test('plugin tools stay in the toolbar and retain placement across catalog changes and remount', async () => {
const dom = new JSDOM('<!doctype html><div id="root"></div>', { url: 'http://localhost' });
const previous = new Map<string, PropertyDescriptor | undefined>();
for (const key of ['window', 'document', 'navigator', 'localStorage', 'StorageEvent', 'CustomEvent', 'Event']) {
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value: key === 'window' ? dom.window : dom.window[key as keyof typeof dom.window] });
}
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { configurable: true, writable: true, value: true });
const { default: React, act } = await import('react');
const { createRoot } = await import('react-dom/client');
const { useTerminalSidePanelTabOrder, TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER } = await import('./terminalSidePanelTabs');
const { STORAGE_KEY_TERMINAL_SIDE_PANEL_TAB_LAYOUT } = await import('../../infrastructure/config/storageKeys');
const plugin = 'plugin-view:com.example.sidebar.page' as const;
dom.window.localStorage.setItem(STORAGE_KEY_TERMINAL_SIDE_PANEL_TAB_LAYOUT, JSON.stringify({ order: TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER, placement: {} }));
let model: ReturnType<typeof useTerminalSidePanelTabOrder>;
let tools: typeof plugin[] = [];
function Fixture() { model = useTerminalSidePanelTabOrder(tools); return <div />; }
const root = createRoot(document.getElementById('root')!);
const render = async () => { await act(async () => { root.render(<Fixture />); }); };
try {
await render();
tools = [plugin]; await render();
assert.equal(model!.sidePanelTabOrder[1], plugin, 'new plugin is a visible toolbar item');
assert.ok(model!.partition([...TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER, plugin]).shown.includes(plugin));
await act(async () => { model!.setPlacement(plugin, 'collapse', [...TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER, plugin]); });
tools = []; await render();
assert.ok(!model!.partition(TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER).collapsed.includes(plugin));
tools = [plugin]; await render();
assert.ok(model!.partition([...TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER, plugin]).collapsed.includes(plugin));
await act(async () => { root.unmount(); });
const nextRoot = createRoot(document.getElementById('root')!);
tools = []; await act(async () => { nextRoot.render(<Fixture />); });
tools = [plugin]; await act(async () => { nextRoot.render(<Fixture />); });
assert.ok(model!.partition([...TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER, plugin]).collapsed.includes(plugin), 'cold catalog loading preserves preference');
await act(async () => { nextRoot.unmount(); });
} finally {
for (const [key, descriptor] of previous) { if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key); }
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT'); dom.window.close();
}
});
+40 -4
View File
@@ -1,4 +1,4 @@
import { useCallback, useMemo } from 'react';
import { useCallback, useEffect, useMemo, useRef } from 'react';
import {
normalizeToolbarItemLayout,
@@ -13,7 +13,7 @@ import {
} from '../../infrastructure/config/storageKeys';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
import { useToolbarItemLayout } from './useToolbarItemLayout';
import type { BuiltinSidePanelTool, SidePanelTool } from '../../domain/sidePanelLayout';
import { isPluginSidePanelTool, type BuiltinSidePanelTool, type SidePanelTool } from '../../domain/sidePanelLayout';
export type TerminalSidePanelTabId = SidePanelTool;
@@ -101,7 +101,7 @@ function migrateSidePanelTabLayoutIfNeeded(): void {
migrateSidePanelTabLayoutIfNeeded();
export function useTerminalSidePanelTabOrder(): {
export function useTerminalSidePanelTabOrder(pluginTools: readonly SidePanelTool[] = []): {
sidePanelTabOrder: TerminalSidePanelTabId[];
setSidePanelTabOrder: (order: TerminalSidePanelTabId[]) => void;
layout: ToolbarItemLayout;
@@ -111,6 +111,23 @@ export function useTerminalSidePanelTabOrder(): {
resetLayout: () => void;
partition: ReturnType<typeof useToolbarItemLayout>['partition'];
} {
// Keep preferences for temporarily unavailable/disabled plugins, including
// across a cold start before the contribution catalog finishes loading.
const knownPluginTools = useRef<Set<string> | null>(null);
if (!knownPluginTools.current) {
let stored: unknown;
try { stored = localStorageAdapter.read(STORAGE_KEY_TERMINAL_SIDE_PANEL_TAB_LAYOUT); } catch { stored = null; }
const order = stored && typeof stored === 'object' && !Array.isArray(stored)
? (stored as { order?: unknown }).order : [];
knownPluginTools.current = new Set(Array.isArray(order)
? order.filter((id): id is string => typeof id === 'string' && isPluginSidePanelTool(id)) : []);
}
for (const tool of pluginTools) if (isPluginSidePanelTool(tool)) knownPluginTools.current.add(tool);
const pluginKey = [...knownPluginTools.current].join('\0');
const defaults = useMemo<ToolbarItemLayoutDefaults>(() => {
const plugins = pluginKey ? pluginKey.split('\0') : [];
return { order: [TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER[0], ...plugins, ...TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER.slice(1)] };
}, [pluginKey]);
const {
layout,
setPlacement,
@@ -121,9 +138,28 @@ export function useTerminalSidePanelTabOrder(): {
partition,
} = useToolbarItemLayout(
STORAGE_KEY_TERMINAL_SIDE_PANEL_TAB_LAYOUT,
TERMINAL_SIDE_PANEL_TAB_LAYOUT_DEFAULTS,
defaults,
);
// Newly installed plugins get a visible slot near the start even when an
// older saved layout already exists. Existing choices always keep their order.
const activePluginKey = pluginTools.filter(isPluginSidePanelTool).join('\0');
useEffect(() => {
const active = activePluginKey ? activePluginKey.split('\0') : [];
if (!active.length || active.some((id) => !layout.order.includes(id))) return;
let stored: unknown;
try { stored = localStorageAdapter.read(STORAGE_KEY_TERMINAL_SIDE_PANEL_TAB_LAYOUT); } catch { return; }
const storedOrder = stored && typeof stored === 'object' && !Array.isArray(stored)
? (stored as { order?: unknown }).order : null;
const saved = new Set(Array.isArray(storedOrder) ? storedOrder : []);
const fresh = active.filter((id) => !saved.has(id));
if (!fresh.length) return;
const rest = layout.order.filter((id) => !fresh.includes(id));
const index = Math.max(0, rest.indexOf('sftp') + 1);
const next = [...rest.slice(0, index), ...fresh, ...rest.slice(index)];
if (next.some((id, i) => id !== layout.order[i])) setOrder(next);
}, [activePluginKey, layout.order, setOrder]);
const sidePanelTabOrder = layout.order as TerminalSidePanelTabId[];
const setSidePanelTabOrder = useCallback(
+182 -2
View File
@@ -20,6 +20,7 @@ import {
STORAGE_KEY_AI_AGENT_THINKING_MAP,
STORAGE_KEY_AI_WEB_SEARCH,
STORAGE_KEY_AI_QUICK_MESSAGES,
STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS,
} from '../../infrastructure/config/storageKeys';
import type { AIQuickMessage } from '../../infrastructure/ai/quickMessages';
import { sanitizeQuickMessages } from '../../infrastructure/ai/quickMessages';
@@ -63,12 +64,17 @@ import {
bumpDraftUploadGeneration,
cleanupDeletedAIChatSessions,
cleanupOrphanedAISessions,
latestAIActiveTargetIdsSnapshot,
getAIBridge,
flushBackupMirror,
getDraftUploadGeneration,
latestAIActiveSessionMapSnapshot,
latestAIDraftsByScopeSnapshot,
latestAIPanelViewByScopeSnapshot,
latestAISessionsSnapshot,
prepareRestoredSessions,
scheduleBackupMirror,
rebindActiveSessionMap,
writeSessionsForStorage,
setLatestAIActiveSessionMapSnapshot,
setLatestAIDraftsByScopeSnapshot,
@@ -77,6 +83,12 @@ import {
type DraftsByScope,
type PanelViewByScope,
} from './aiStateSnapshots';
import {
isMissingOrCorruptSessionsStore,
mergeImportedSessions,
parseBackupSessions,
readLiveSessionsArray,
} from './aiSessionsBackup';
import { AI_STATE_CHANGED_EVENT, emitAIStateChanged } from './aiStateEvents';
import {
persistCommandBlocklistSetting,
@@ -168,9 +180,76 @@ export function useAIState() {
// ── Sessions ──
const [sessions, setSessionsRaw] = useState<AISession[]>(() =>
latestAISessionsSnapshot
?? localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS)
?? []
?? readLiveSessionsArray(localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS))
);
// An explicit empty array is authoritative, even if the backup is stale.
// Only a missing or corrupt store can be restored from disk.
useEffect(() => {
window.addEventListener('beforeunload', flushBackupMirror);
return () => window.removeEventListener('beforeunload', flushBackupMirror);
}, []);
useEffect(() => {
const liveRaw = localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS);
if (!isMissingOrCorruptSessionsStore(liveRaw)) {
if (readLiveSessionsArray(liveRaw).length > 0 && liveRaw != null) scheduleBackupMirror(liveRaw);
return;
}
if (latestAISessionsSnapshot && latestAISessionsSnapshot.length > 0) return;
const bridge = getAIBridge();
if (!bridge?.aiSessionsBackupReadLatest) return;
let cancelled = false;
void (async () => {
try {
const liveRaw = localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS);
if (!isMissingOrCorruptSessionsStore(liveRaw)) return;
const backup = await bridge.aiSessionsBackupReadLatest?.();
if (cancelled || !backup?.ok) return;
// Re-check after the async gap: a session may have been created
// while the backup was being read; never restore over live state.
// Cover both persisted storage and in-memory snapshot/React state —
// createSession updates the snapshot before the debounced disk write.
if (!isMissingOrCorruptSessionsStore(localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS))) {
return;
}
if ((latestAISessionsSnapshot?.length ?? 0) > 0) return;
const syncEnabled = !!localStorageAdapter.read<{ enabled?: boolean }>(STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS)?.enabled;
const restored = prepareRestoredSessions(parseBackupSessions(backup.json) ?? [], syncEnabled);
if (restored.length === 0) return;
// Apply restore synchronously (not inside a setState updater). A
// deferred updater would leave didRestore false and skip dangling
// marking even when the records later land in React state.
if ((latestAISessionsSnapshot?.length ?? 0) > 0) return;
if (sessionsRef.current.length > 0) return;
if (!isMissingOrCorruptSessionsStore(localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS))) {
return;
}
setLatestAISessionsSnapshot(restored);
writeSessionsForStorage(restored);
emitAIStateChanged(STORAGE_KEY_AI_SESSIONS);
// Restore does not change live terminals/workspaces, so the orphan-
// cleanup effect will not run. Reuse the last known live target set
// (empty is valid: nothing live) so chats bound to missing scopes
// become dangling and /chats can offer /rebind.
cleanupOrphanedAISessions(latestAIActiveTargetIdsSnapshot);
const nextSessions = latestAISessionsSnapshot ?? restored;
sessionsRef.current = nextSessions;
setSessionsRaw(nextSessions);
setActiveSessionIdMapRaw(
latestAIActiveSessionMapSnapshot
?? localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP)
?? {},
);
console.info(
`[AIState] Restored ${restored.length} AI session(s) from the disk backup.`,
);
} catch {
// Backup restore must never break startup.
}
})();
return () => {
cancelled = true;
};
}, []);
// Ref that always holds the latest sessions for use inside debounced callbacks
const sessionsRef = useRef(sessions);
useEffect(() => {
@@ -866,6 +945,101 @@ export function useAIState() {
});
}, [persistSessions]);
// Issue #4015 R2: rebind a dangling chat to the panel's current scope.
// Rewrites `scope`, clears the dangling marker and makes the chat the
// active session of its new scope so it shows in the scoped history again.
const rebindSessionScope = useCallback((
sessionId: string,
scope: { type: 'terminal' | 'workspace'; targetId: string; hostIds?: string[] },
) => {
const scopeKey = `${scope.type}:${scope.targetId}`;
setSessionsRaw(prev => {
const next = prev.map(s => {
if (s.id !== sessionId) return s;
const { scopeDangling: _cleared, ...rest } = s;
// Replace scope entirely (including hostIds) so history matching follows
// the destination terminal/workspace rather than the chat's old host.
const nextScope: AISession['scope'] = {
type: scope.type,
targetId: scope.targetId,
};
if (scope.hostIds && scope.hostIds.length > 0) {
nextScope.hostIds = [...scope.hostIds];
}
return {
...rest,
scope: nextScope,
updatedAt: Date.now(),
} as AISession;
});
setLatestAISessionsSnapshot(next);
persistSessions(next);
return next;
});
// Drop stale pointers that still reference this chat under its previous
// scope key, then restore the active pointer for the new scope.
// Compute + persist inside the updater so a deferred React setState still
// writes the cleaned map (outer capture would stay null and skip persist).
setActiveSessionIdMapRaw(prev => {
const next = rebindActiveSessionMap(prev, sessionId, scopeKey);
setLatestAIActiveSessionMapSnapshot(next);
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, next);
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
return next;
});
// Open the rebound chat in this panel; active-map alone loses to an
// explicit draft/session panelView that still points elsewhere.
setPanelViewByScope((prev) => setSessionView(prev, scopeKey, sessionId));
}, [persistSessions, setPanelViewByScope]);
// Manual durability escape hatch (issue #4015 follow-up): export the
// current store to a user-chosen JSON file, or import one back in.
// Import is an explicit action, so it may overwrite the live store.
const exportSessionsBackup = useCallback(async (): Promise<string | null> => {
const bridge = getAIBridge();
if (!bridge?.aiSessionsBackupExport) return null;
const result = await bridge.aiSessionsBackupExport({
json: JSON.stringify(sessionsRef.current),
});
return result?.success ? (result.filePath ?? '') : null;
}, []);
const importSessionsBackup = useCallback(async (): Promise<number> => {
const bridge = getAIBridge();
if (!bridge?.aiSessionsBackupImport) return 0;
const result = await bridge.aiSessionsBackupImport();
if (!result?.success || !result.json) return 0;
const imported = parseBackupSessions(result.json);
if (!imported || imported.length === 0) return 0;
// Merge synchronously against the live ref so orphan cleanup sees the
// imported chats even if React would defer a setState updater.
const syncEnabled = !!localStorageAdapter.read<{ enabled?: boolean }>(STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS)?.enabled;
const next = prepareRestoredSessions(mergeImportedSessions(sessionsRef.current, imported), syncEnabled);
if (!writeSessionsForStorage(next)) return 0;
const persisted = readLiveSessionsArray(localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS));
const importedIds = new Set(imported.map(session => session.id));
sessionsRef.current = persisted;
setLatestAISessionsSnapshot(persisted);
setSessionsRaw(persisted);
emitAIStateChanged(STORAGE_KEY_AI_SESSIONS);
// Import does not change live terminals/workspaces, so the orphan-cleanup
// effect will not run. Reuse the last known live target set (empty is
// valid: nothing live) to mark chats bound to missing scopes as dangling
// so /chats can offer /rebind.
cleanupOrphanedAISessions(latestAIActiveTargetIdsSnapshot);
const nextSessions =
latestAISessionsSnapshot
?? persisted;
sessionsRef.current = nextSessions;
setSessionsRaw(nextSessions);
setActiveSessionIdMapRaw(
latestAIActiveSessionMapSnapshot
?? localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP)
?? {},
);
return persisted.filter(session => importedIds.has(session.id)).length;
}, []);
const updateSessionExternalSessionId = useCallback((sessionId: string, externalSessionId: string | undefined) => {
setSessionsRaw(prev => {
const next = prev.map(s => (
@@ -1370,6 +1544,9 @@ export function useAIState() {
deleteSession,
deleteSessionsByTarget,
updateSessionTitle,
rebindSessionScope,
exportSessionsBackup,
importSessionsBackup,
updateSessionExternalSessionId,
addMessageToSession,
updateLastMessage,
@@ -1432,6 +1609,9 @@ export function useAIState() {
deleteSession,
deleteSessionsByTarget,
updateSessionTitle,
rebindSessionScope,
exportSessionsBackup,
importSessionsBackup,
updateSessionExternalSessionId,
addMessageToSession,
updateLastMessage,
-119
View File
@@ -1,119 +0,0 @@
import { useEffect, useRef, useState } from 'react';
import { PluginViewLifecycleController, type HostedPluginViewState } from './pluginViewLifecycle';
import { resolvePluginViewWindowScope } from './pluginViewScopes';
import { isPluginViewOccluded } from './pluginViewOcclusion';
import type { UsePluginContributionsResult } from './usePluginContributions';
type ViewAdapter = Pick<UsePluginContributionsResult, 'openView' | 'closeView' | 'focusView' | 'setViewBounds' | 'setViewVisibility' | 'onViewClosed' | 'onViewFocused'>;
/** Host-owned mounting only. No plugin router, DOM, session or vault data API. */
export function useEmbeddedPluginView({ viewId, version, slot, active, context, adapter, subscribeHidden, unavailable, focused, focusOnReveal = false }: {
viewId: string;
version: string;
slot: string;
active: boolean;
context: Record<string, unknown>;
adapter: ViewAdapter;
subscribeHidden(listener: () => void): () => void;
unavailable(): void;
focused?(): void;
focusOnReveal?: boolean;
}) {
const mountRef = useRef<HTMLDivElement>(null);
const [loading, setLoading] = useState(true);
const adapterRef = useRef(adapter);
const unavailableRef = useRef(unavailable);
const focusedRef = useRef(focused);
const revealFocusRef = useRef(focusOnReveal);
const requestFocusRef = useRef<() => void>(() => {});
revealFocusRef.current = focusOnReveal;
focusedRef.current = focused;
adapterRef.current = adapter;
unavailableRef.current = unavailable;
useEffect(() => {
const mount = mountRef.current;
if (!active || !mount) return;
let cancelled = false;
const lifecycle = new PluginViewLifecycleController<HostedPluginViewState>();
const instanceId = crypto.randomUUID();
const scopeId = resolvePluginViewWindowScope({ pathname: window.location.pathname,
search: window.location.search, hash: `${window.location.hash}|plugin-slot:${slot}` });
const { openView, closeView, focusView, setViewBounds, setViewVisibility, onViewClosed, onViewFocused } = adapterRef.current;
const close = () => {
if (cancelled) return;
cancelled = true;
lifecycle.markViewClosed(viewId);
void closeView(instanceId).catch(() => {});
};
const failCurrent = () => {
if (cancelled) return;
close();
unavailableRef.current();
};
const unsubscribe = onViewClosed((event) => {
lifecycle.handleHostClose(event.instanceId);
if (event.instanceId === instanceId && !cancelled) {
close();
unavailableRef.current();
}
});
const stopOnHide = subscribeHidden(close);
const stopOnFocus = onViewFocused((event) => {
if (!cancelled && event.instanceId === instanceId) focusedRef.current?.();
});
const bounds = () => {
const rect = mount.getBoundingClientRect();
return { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.max(1, Math.round(rect.width)), height: Math.max(1, Math.round(rect.height)) };
};
let opened = false;
let lastBounds = '';
let visible = false;
const focus = () => {
if (!cancelled && opened && visible && revealFocusRef.current && !isPluginViewOccluded(mount)) void focusView(instanceId).catch(() => {});
};
requestFocusRef.current = focus;
const updateVisibility = () => {
if (!opened || cancelled) return;
const rect = mount.getBoundingClientRect();
const next = rect.width > 0 && rect.height > 0 && !isPluginViewOccluded(mount);
if (visible === next) return;
visible = next;
void setViewVisibility(instanceId, next).then(() => { if (next) focus(); }).catch(failCurrent);
};
const resize = () => {
if (opened && !cancelled) {
const next = bounds();
const key = JSON.stringify(next);
if (key !== lastBounds) { lastBounds = key; void setViewBounds(instanceId, next).catch(failCurrent); }
}
updateVisibility();
};
const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(resize);
observer?.observe(mount);
const mutations = new MutationObserver(resize);
mutations.observe(document.body, { childList: true, subtree: true, attributes: true,
attributeFilter: ['class', 'style', 'hidden', 'inert', 'role', 'aria-modal', 'aria-hidden', 'data-state', 'data-app-lock-overlay', 'data-radix-popper-content-wrapper', 'data-native-view-occluder'] });
window.addEventListener('resize', resize);
document.addEventListener('scroll', resize, true);
setLoading(true);
const opening = lifecycle.beginOpen({ viewKey: viewId });
void openView({ viewId, instanceId, scopeId, bounds: bounds(), context }).then(async (result) => {
if (cancelled || lifecycle.shouldCloseOpen(opening) || lifecycle.consumeHostClose(result.instanceId)) {
await closeView(result.instanceId);
return;
}
opened = true;
lifecycle.setCurrent({ id: result.instanceId, viewId, scopeId, retainContextWhenHidden: false });
resize();
setLoading(false);
}).catch(() => {
if (!cancelled) { close(); unavailableRef.current(); }
}).finally(() => lifecycle.finishOpen({ token: opening, viewKey: viewId }));
return () => {
close(); unsubscribe(); stopOnHide(); stopOnFocus(); observer?.disconnect(); mutations.disconnect();
window.removeEventListener('resize', resize); document.removeEventListener('scroll', resize, true);
};
}, [active, context, slot, subscribeHidden, version, viewId]);
useEffect(() => { if (focusOnReveal) requestFocusRef.current(); }, [focusOnReveal]);
return { mountRef, loading };
}
@@ -0,0 +1,42 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
import { resolvePluginViewWindowScope } from './pluginViewScopes';
import { pluginViewSessions, type PluginViewSnapshot } from './pluginViewSession';
import type { HostDrawnViewEvent } from '@netcatty/plugin-contract';
const initial = (): PluginViewSnapshot => ({ tree: { children: [] }, css: '', loading: true, error: false, settled: true });
export function useHostDrawnPluginView({ viewId, version, slot, active, context, subscribeHidden, unavailable }: {
viewId: string; version: string; slot: string; active: boolean; context: Record<string, unknown>;
subscribeHidden(listener: () => void): () => void; unavailable(): void;
}) {
const contextKey = JSON.stringify(context);
const key = JSON.stringify([viewId, version, slot, contextKey]);
const [state, setState] = useState<{ key: string; snapshot: PluginViewSnapshot }>({ key: '', snapshot: initial() });
const [hidden, setHidden] = useState(false);
const unavailableRef = useRef(unavailable); unavailableRef.current = unavailable;
const sessionRef = useRef<{ key: string; image(path: string): Promise<string>; send(event: HostDrawnViewEvent): void } | null>(null);
useEffect(() => {
if (!active) return;
const bridge = netcattyBridge.get();
if (!bridge) { unavailableRef.current(); return; }
const scopeId = resolvePluginViewWindowScope({ pathname: window.location.pathname, search: window.location.search, hash: `${window.location.hash}|plugin-slot:${slot}` });
try {
const session = pluginViewSessions.acquire(JSON.stringify([scopeId, key]), bridge, { viewId, scopeId, context: JSON.parse(contextKey) });
const update = () => { const snapshot = session.getSnapshot(); setState({ key, snapshot }); if (snapshot.error) unavailableRef.current(); };
const stop = session.subscribe(update); update(); setHidden(false);
sessionRef.current = { key, send: session.send, image: session.image };
const stopHidden = subscribeHidden(() => { sessionRef.current = null; setHidden(true); });
return () => { sessionRef.current = null; stopHidden(); stop(); session.release(); };
} catch { unavailableRef.current(); }
}, [active, contextKey, key, slot, subscribeHidden, viewId]);
const loadImage = useCallback((path: string) => {
const session = sessionRef.current;
return active && session?.key === key ? session.image(path) : Promise.reject(new Error('Plugin view unavailable'));
}, [active, key]);
const sendEvent = useCallback((event: HostDrawnViewEvent) => {
const session = sessionRef.current; if (active && session?.key === key) session.send(event);
}, [active, key]);
// A newly selected plugin must never receive values from the previous tree,
// even during the render before effect cleanup and the next activation.
return { ...(state.key === key ? state.snapshot : initial()), visible: active && !hidden, loadImage, sendEvent };
}
+17
View File
@@ -0,0 +1,17 @@
import { useState } from "react";
import { buildMonitorDiskListLayout, type MonitorDiskListStats } from "../../domain/monitorDisks";
/** Volatile per-host disclosure preference; capacity alerts always force it open. */
export function useMonitorDiskGroups() {
const [expandedHosts, setExpandedHosts] = useState<ReadonlySet<string>>(() => new Set());
return {
layoutFor: (hostId: string, stats?: MonitorDiskListStats | null) =>
buildMonitorDiskListLayout(stats, expandedHosts.has(hostId)),
toggle: (hostId: string) => setExpandedHosts(previous => {
const next = new Set(previous);
if (next.has(hostId)) next.delete(hostId);
else next.add(hostId);
return next;
}),
};
}
@@ -0,0 +1,85 @@
import { useEffect, useRef, useState } from 'react';
import type { TerminalSession, Workspace } from '../../domain/models';
import { getNotesSnapshot } from './notesStore';
import { useActiveTabId } from './activeTabStore';
import { getAppAppLockRuntime } from './appRuntimeBridge';
import { createTrustedPluginAIHostRuntime } from '../../infrastructure/ai/plugin/trustedHostRuntime';
import { getConfiguredPluginModel, prepareConfiguredPluginModel } from '../../infrastructure/ai/plugin/configuredModel';
import { buildSelectedPluginSources } from '../../infrastructure/ai/plugin/selectedSources';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
export function usePluginAIContextHost({ sessions, workspaces }: { sessions: TerminalSession[]; workspaces: Workspace[] }) {
const activeTabId = useActiveTabId();
const latest = useRef({ activeTabId, sessions, workspaces });
latest.current = { activeTabId, sessions, workspaces };
const [choice, setChoice] = useState<{ confirm: (id: string) => void; cancel: () => void } | null>(null);
const [noteId, setNoteId] = useState('');
const [noteChoices, setNoteChoices] = useState<Array<{ id: string; title: string }>>([]);
useEffect(() => {
const api = netcattyBridge.get();
if (!api?.onPluginAIHostRequest || !api.setPluginAIHostReady || !api.respondPluginAIHost) return;
const bridge = {
onPluginAIHostRequest: api.onPluginAIHostRequest.bind(api),
setPluginAIHostReady: api.setPluginAIHostReady.bind(api),
respondPluginAIHost: api.respondPluginAIHost.bind(api),
};
let selectedNoteId: string | null = null;
let mounted = true;
let chooser: { runtimeId: string; cancel: () => void } | null = null;
const requests = new Map<string, { runtimeId: string; controller: AbortController }>();
const runtime = createTrustedPluginAIHostRuntime({
getModel: getConfiguredPluginModel,
prepareModel: prepareConfiguredPluginModel,
isActive: () => mounted && document.visibilityState === 'visible' && getAppAppLockRuntime()?.locked === false,
chooseSelected: (signal, runtimeId) => new Promise((resolve, reject) => {
if (chooser) { reject(Error('A host selection is already pending')); return; }
const finish = (id: string | null) => {
if (chooser?.cancel !== cancel) return;
chooser = null; signal.removeEventListener('abort', cancel); setChoice(null);
if (id === null || signal.aborted) { reject(Error('Selection cancelled')); return; }
selectedNoteId = id || null;
resolve(buildSelectedPluginSources(() => ({ ...latest.current,
note: getNotesSnapshot().notes.find((note) => note.id === selectedNoteId) ?? null })));
};
const cancel = () => finish(null);
chooser = { runtimeId, cancel };
signal.addEventListener('abort', cancel, { once: true });
setNoteId('');
// This inventory stays inside trusted host UI. The plugin receives
// summaries only for the Note chosen here and current selected scopes.
setNoteChoices(getNotesSnapshot().notes.map((note) => ({ id: note.id, title: note.title })));
setChoice({ confirm: (id) => finish(id), cancel });
if (signal.aborted) cancel();
}),
});
const unsubscribe = bridge.onPluginAIHostRequest((request) => {
if (request.kind === 'request.cancel') {
requests.get(String(request.payload.requestId))?.controller.abort();
return;
}
if (request.kind === 'runtime.release') {
for (const pending of requests.values()) if (pending.runtimeId === request.runtimeId) pending.controller.abort();
if (chooser?.runtimeId === request.runtimeId) chooser.cancel();
runtime.releaseRuntime(request.runtimeId);
return;
}
if (requests.has(request.requestId) || requests.size >= 64) return;
const controller = new AbortController();
requests.set(request.requestId, { runtimeId: request.runtimeId, controller });
void runtime.handle(request.kind, request.payload, request.runtimeId, controller.signal).then(
(result) => bridge.respondPluginAIHost({ requestId: request.requestId, actionToken: request.actionToken, result }),
() => bridge.respondPluginAIHost({ requestId: request.requestId, actionToken: request.actionToken, error: true }),
).catch(() => {}).finally(() => requests.delete(request.requestId));
});
const pageHide = () => { runtime.shutdown(); for (const pending of requests.values()) pending.controller.abort(); };
window.addEventListener('pagehide', pageHide);
void bridge.setPluginAIHostReady(true).catch(() => {});
return () => {
mounted = false; chooser?.cancel(); unsubscribe(); window.removeEventListener('pagehide', pageHide);
for (const pending of requests.values()) pending.controller.abort();
requests.clear(); runtime.shutdown();
void bridge.setPluginAIHostReady(false).catch(() => {});
};
}, []);
return { choice, noteId, setNoteId, noteChoices };
}
+93
View File
@@ -0,0 +1,93 @@
import { useEffect, useRef, useState } from 'react';
import type { TerminalSession } from '../../domain/models';
import type { PluginAgentHostTerminal } from '@netcatty/plugin-contract';
import { createTrustedPluginAgentRuntime } from '../../infrastructure/ai/pluginAgent/trustedAgentRuntime';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
import { getAppAppLockRuntime } from './appRuntimeBridge';
import { LOCAL_STORAGE_ADAPTER_CHANGED_EVENT } from '../../infrastructure/persistence/localStorageAdapter';
import { STORAGE_KEY_AI_PROVIDERS, STORAGE_KEY_AI_ACTIVE_PROVIDER, STORAGE_KEY_AI_ACTIVE_MODEL, STORAGE_KEY_AI_AGENT_MODEL_MAP,
STORAGE_KEY_AI_AGENT_PROVIDER_MAP, STORAGE_KEY_AI_EXTERNAL_AGENTS } from '../../infrastructure/config/storageKeys';
type Choice = { items: PluginAgentHostTerminal[]; confirm: (ids: string[]) => void; cancel: () => void };
// All inventory remains in trusted application UI. Only confirmed metadata is
// passed to the main-owned worker bindings; focus never determines a target.
export function usePluginAgentHost(sessions: TerminalSession[]) {
const latest = useRef(sessions); latest.current = sessions;
const [choice, setChoice] = useState<Choice | null>(null);
useEffect(() => {
const api = netcattyBridge.get();
if (!api?.onPluginAgentHostRequest || !api.setPluginAgentHostReady || !api.respondPluginAgentHost) return;
const bridge = { subscribe: api.onPluginAgentHostRequest.bind(api), ready: api.setPluginAgentHostReady.bind(api), respond: api.respondPluginAgentHost.bind(api) };
let mounted = true;
let chooser: { runtimeId: string; cancel: () => void } | null = null;
const requests = new Map<string, { runtimeId: string; controller: AbortController }>();
const runtime = createTrustedPluginAgentRuntime({
isActive: () => mounted && document.visibilityState === 'visible' && getAppAppLockRuntime()?.locked === false,
chooseTargets: (signal, runtimeId) => new Promise((resolve, reject) => {
if (chooser) { reject(Error('A target selection is already pending')); return; }
const items = latest.current.filter((item) => item.status === 'connected').map((item) => ({
sessionId: item.id, hostId: item.hostId, hostname: (item.hostname || '').slice(0, 253),
label: (item.customName || item.hostLabel || item.id).slice(0, 160),
...(item.protocol ? { protocol: item.protocol } : {}), ...(item.username ? { username: item.username.slice(0, 160) } : {}), connected: true,
}));
const finish = (ids: string[] | null) => {
if (chooser?.cancel !== cancel) return;
chooser = null; signal.removeEventListener('abort', cancel); setChoice(null);
if (ids === null || signal.aborted || ids.length > 32 || new Set(ids).size !== ids.length) { reject(Error('Target selection cancelled')); return; }
const selected = ids.map((id) => items.find((item) => item.sessionId === id));
if (selected.some((item) => !item || !latest.current.some((session) => session.id === item.sessionId && session.status === 'connected'))) {
reject(Error('Selected terminal is no longer connected')); return;
}
resolve(selected as PluginAgentHostTerminal[]);
};
const cancel = () => finish(null);
chooser = { runtimeId, cancel }; signal.addEventListener('abort', cancel, { once: true });
setChoice({ items, confirm: finish, cancel });
if (signal.aborted) cancel();
}),
});
const unsubscribe = bridge.subscribe((request) => {
if (request.kind === 'request.cancel') {
const pending = requests.get(request.payload.requestId);
if (pending?.runtimeId === request.runtimeId) pending.controller.abort();
runtime.cancelRequest(request.payload.requestId, request.runtimeId); return;
}
if (request.kind === 'runtime.release') {
for (const item of requests.values()) if (item.runtimeId === request.runtimeId) item.controller.abort();
if (chooser?.runtimeId === request.runtimeId) chooser.cancel();
runtime.releaseRuntime(request.runtimeId); return;
}
if (request.kind === 'agent.close') { runtime.releaseChat(request.payload.chatSessionId, request.runtimeId); return; }
if (requests.has(request.requestId) || requests.size >= 64) return;
const controller = new AbortController(); requests.set(request.requestId, { runtimeId: request.runtimeId, controller });
void runtime.handle(request, controller.signal).then(
(result) => bridge.respond({ requestId: request.requestId, actionToken: request.actionToken, result }).catch(() =>
bridge.respond({ requestId: request.requestId, actionToken: request.actionToken, error: true })),
() => bridge.respond({ requestId: request.requestId, actionToken: request.actionToken, error: true }),
).catch(() => {}).finally(() => requests.delete(request.requestId));
});
const pageHide = () => { runtime.shutdown(); for (const item of requests.values()) item.controller.abort(); };
const configurationKeys = new Set([STORAGE_KEY_AI_PROVIDERS, STORAGE_KEY_AI_ACTIVE_PROVIDER, STORAGE_KEY_AI_ACTIVE_MODEL,
STORAGE_KEY_AI_AGENT_MODEL_MAP, STORAGE_KEY_AI_AGENT_PROVIDER_MAP, STORAGE_KEY_AI_EXTERNAL_AGENTS]);
const configurationChanged = (event: Event) => {
const key = event.type === 'storage' ? (event as StorageEvent).key : (event as CustomEvent<{ key: string }>).detail?.key;
if (key === null || (key && configurationKeys.has(key))) {
runtime.invalidateConfiguration();
// Invalidate main-owned backend leases too, even with no event read in
// flight. Re-advertising availability does not renew existing handles.
void bridge.ready(false).then(() => { if (mounted) return bridge.ready(true); }).catch(() => {});
}
};
window.addEventListener('pagehide', pageHide);
window.addEventListener('storage', configurationChanged); window.addEventListener(LOCAL_STORAGE_ADAPTER_CHANGED_EVENT, configurationChanged);
void bridge.ready(true).catch(() => {});
return () => {
mounted = false; chooser?.cancel(); unsubscribe(); window.removeEventListener('pagehide', pageHide);
window.removeEventListener('storage', configurationChanged); window.removeEventListener(LOCAL_STORAGE_ADAPTER_CHANGED_EVENT, configurationChanged);
for (const item of requests.values()) item.controller.abort(); requests.clear(); runtime.shutdown();
void bridge.ready(false).catch(() => {});
};
}, []);
return { choice };
}
@@ -0,0 +1,59 @@
import { useEffect, useMemo, useState, useSyncExternalStore } from 'react';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
// All visible rows share one IPC subscription. No catalog fetch or polling here.
const generations = new Map<string, number>();
const listeners = new Set<() => void>();
let unsubscribe: (() => void) | undefined;
function subscribe(listener: () => void) {
listeners.add(listener);
if (listeners.size === 1) unsubscribe = netcattyBridge.get()?.onPluginCatalogChanged?.(({ sourceId }) => {
generations.set(sourceId, (generations.get(sourceId) ?? 0) + 1);
for (const notify of listeners) notify();
});
return () => {
listeners.delete(listener);
if (!listeners.size) { unsubscribe?.(); unsubscribe = undefined; generations.clear(); }
};
}
export function usePluginAuthorIdentity({ entry, plugin, sourceId = 'official' }: {
entry?: NetcattyPluginCatalogEntry; plugin?: NetcattyInstalledPlugin; sourceId?: string;
}) {
const [element, setElement] = useState<HTMLElement | null>(null);
const [loaded, setLoaded] = useState<{ key: string; value: NetcattyPluginAuthorPresentation } | null>(null);
const pluginId = plugin?.id;
const entryId = entry?.id;
const selectionToken = entry?.selectionToken;
const request = useMemo<NetcattyPluginAuthorRequest | null>(() => pluginId
? { kind: 'installed', pluginId }
: entryId && selectionToken ? { kind: 'catalog', pluginId: entryId, selectionToken, sourceId } : null,
[pluginId, entryId, selectionToken, sourceId]);
const identitySource = plugin?.source?.id ?? sourceId;
const generation = useSyncExternalStore(subscribe, () => generations.get(identitySource) ?? 0, () => 0);
const key = JSON.stringify([request, plugin?.activeVersion, plugin?.archiveSha256, plugin?.source?.id, plugin?.runtime?.quarantinedAt, generation]);
const installed = Boolean(plugin);
const version = plugin?.activeVersion;
const archiveSha256 = plugin?.archiveSha256 ?? null;
const installedSourceId = plugin?.source?.id ?? 'local';
useEffect(() => {
if (!element || !request) return;
let active = true;
let started = false;
const load = () => {
if (started) return;
started = true;
void netcattyBridge.get()?.getPluginAuthorPresentation?.(request).then((value) => {
if (!active || !value) return;
if (installed && (value.version !== version || value.archiveSha256 !== archiveSha256 || value.sourceId !== installedSourceId)) return;
setLoaded({ key, value });
}).catch(() => { /* The visible author placeholder remains usable offline. */ });
};
const observer = typeof IntersectionObserver === 'undefined' ? null : new IntersectionObserver((entries) => {
if (entries.some((entry) => entry.isIntersecting)) { load(); observer?.disconnect(); }
}, { rootMargin: '64px' });
if (observer) observer.observe(element); else load();
return () => { active = false; observer?.disconnect(); };
}, [element, request, key, installed, version, archiveSha256, installedSourceId]);
return { ref: setElement, identity: loaded?.key === key ? loaded.value : null };
}
+11
View File
@@ -10,6 +10,7 @@ export function usePluginCatalog(selectedId: string | null, sourceId = 'official
const [listAttempt, setListAttempt] = useState(0);
const [readmeState, setReadmeState] = useState<ReadmeState | null>(null);
const [readmeAttempt, setReadmeAttempt] = useState(0);
const [imageState, setImageState] = useState<{ id: string; token?: string; sourceId: string; images: Record<string, string> } | null>(null);
const [installError, setInstallError] = useState<{ id: string; message: string } | null>(null);
const [busy, setBusy] = useState(false);
const [sourceBinding, setSourceBinding] = useState<NetcattyPluginSource | null>(null);
@@ -51,9 +52,19 @@ export function usePluginCatalog(selectedId: string | null, sourceId = 'official
})();
return () => { active = false; };
}, [selectedId, selectionToken, readmeAttempt, sourceId]);
useEffect(() => {
let active = true;
if (selectedId && selectionToken) {
void netcattyBridge.get()?.getPluginCatalogReadmeImages?.(selectedId, selectionToken, sourceId)
.then((images) => { if (active) setImageState({ id: selectedId, token: selectionToken, sourceId, images }); })
.catch(() => { if (active) setImageState(null); });
}
return () => { active = false; };
}, [selectedId, selectionToken, sourceId, readmeAttempt]);
const currentReadme = readmeState?.id === selectedId && readmeState.token === selectionToken && readmeState.attempt === readmeAttempt ? readmeState : null;
return {
entries, listStatus, listError, source, sourceBinding,
readmeImages: imageState?.id === selectedId && imageState.token === selectionToken && imageState.sourceId === sourceId ? imageState.images : {},
readme: currentReadme?.status === 'ready' ? currentReadme.text : null,
readmeStatus: currentReadme?.status ?? (selectedId ? 'loading' : 'idle'),
readmeError: currentReadme?.error ?? null,
@@ -86,14 +86,8 @@ export interface UsePluginContributionsResult {
updateSetting(pluginId: string, settingId: string, value: unknown, scopeId?: string): Promise<{ restartRequired: boolean }>;
resetSetting(pluginId: string, settingId: string, scopeId?: string): Promise<{ restartRequired: boolean }>;
selectSettingPath(kind: 'file' | 'directory', title: string, defaultPath?: string): Promise<string | null>;
openView(payload: NetcattyPluginViewOpenRequest): Promise<{ instanceId: string }>;
closeView(instanceId: string): Promise<void>;
focusView(instanceId: string): Promise<void>;
setViewBounds(instanceId: string, bounds: { x: number; y: number; width: number; height: number }): Promise<void>;
setViewVisibility(instanceId: string, visible: boolean): Promise<void>;
setEnvironment(environment: NetcattyPluginEnvironment): Promise<void>;
onViewClosed(callback: (event: NetcattyPluginViewClosedEvent) => void): () => void;
onViewFocused(callback: (event: import('@netcatty/plugin-contract').PluginViewFocusEvent) => void): () => void;
}
export function usePluginContributions(
@@ -189,32 +183,6 @@ export function usePluginContributions(
return picker(title, defaultPath);
}, [bridge]);
const openView = useCallback(async (payload: NetcattyPluginViewOpenRequest) => {
if (!bridge?.openPluginView) throw new Error('Plugin views are unavailable');
return bridge.openPluginView(payload);
}, [bridge]);
const closeView = useCallback(async (instanceId: string) => {
if (!bridge?.closePluginView) return;
await bridge.closePluginView(instanceId);
}, [bridge]);
const focusView = useCallback(async (instanceId: string) => {
await bridge?.focusPluginView?.({ instanceId });
}, [bridge]);
const setViewBounds = useCallback(async (
instanceId: string,
bounds: { x: number; y: number; width: number; height: number },
) => {
if (!bridge?.setPluginViewBounds) return;
await bridge.setPluginViewBounds(instanceId, bounds);
}, [bridge]);
const setViewVisibility = useCallback(async (instanceId: string, visible: boolean) => {
if (!bridge?.setPluginViewVisibility) return;
await bridge.setPluginViewVisibility(instanceId, visible);
}, [bridge]);
const setEnvironment = useCallback(async (environment: NetcattyPluginEnvironment) => {
if (!bridge?.setPluginEnvironment) return;
await bridge.setPluginEnvironment(environment);
@@ -223,10 +191,6 @@ export function usePluginContributions(
const onViewClosed = useCallback((callback: (event: NetcattyPluginViewClosedEvent) => void) => (
bridge?.onPluginViewClosed?.(callback) ?? (() => {})
), [bridge]);
const onViewFocused = useCallback((callback: (event: import('@netcatty/plugin-contract').PluginViewFocusEvent) => void) => (
bridge?.onPluginViewFocused?.(callback) ?? (() => {})
), [bridge]);
const currentLoadState = resolvePluginContributionLoadState({
currentQueryKey: queryKey,
loadedQueryKey: loadedSnapshot.queryKey,
@@ -245,13 +209,7 @@ export function usePluginContributions(
updateSetting,
resetSetting,
selectSettingPath,
openView,
closeView,
focusView,
setViewBounds,
setViewVisibility,
setEnvironment,
onViewClosed,
onViewFocused,
};
}
+8 -3
View File
@@ -112,7 +112,7 @@ export function usePluginHostActions() {
const entry = current.current;
if (!entry || entry.prompt.requestId !== prompt.requestId || entry.prompt.request.kind !== prompt.request.kind) throw new Error('Host review is unavailable');
entry.prompt = prompt;
setReview({ ...entry });
if (!(prompt.request.kind === 'vault.requestConnect' && prompt.permissionMode === 'allow')) setReview({ ...entry });
void execute(entry);
return;
}
@@ -126,7 +126,12 @@ export function usePluginHostActions() {
scriptsRun: host ? pluginHostRunsSavedScripts(host, vault.groupConfigs, vault.snippets) : false,
claimed: false,
};
current.current = entry; setReview(entry); setSelectedIds([]); setBusy(false); setError(false); responding.current = false;
current.current = entry; setSelectedIds([]); setBusy(false); setError(false); responding.current = false;
if (prompt.request.kind === 'vault.requestConnect' && prompt.permissionMode === 'allow') {
setReview(null);
void bridge.respondPluginInteraction?.({ requestId: prompt.requestId, actionToken: prompt.actionToken,
action: 'submit', scriptsRun: entry.scriptsRun }).catch(() => void fail(entry));
} else setReview(entry);
} catch {
void bridge.respondPluginInteraction?.({ requestId: prompt.requestId, actionToken: prompt.actionToken, action: 'fail' }).catch(() => {});
}
@@ -137,7 +142,7 @@ export function usePluginHostActions() {
unsubscribe();
if (entry && !entry.claimed) void bridge.respondPluginInteraction?.({ requestId: entry.prompt.requestId, actionToken: entry.prompt.actionToken, action: 'cancel' }).catch(() => {});
};
}, [execute, getVault]);
}, [execute, fail, getVault]);
const respond = useCallback(async (action: 'cancel' | 'submit', draft?: PluginHostDraftFields) => {
const entry = current.current;
+15 -2
View File
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import type { HostInteractionPrompt, HostInteractionRequest, HostInteractionResponse } from '@netcatty/plugin-contract';
import { readPluginTerminalSelection } from '../../infrastructure/plugins/terminalSelectionRegistry';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
export function usePluginHostInteractions() {
@@ -19,8 +20,17 @@ export function usePluginHostInteractions() {
current.current = null; setPrompt(null);
} else {
if (event.request.kind.startsWith('vault.')) return;
current.current = event as UiPrompt; setPrompt(event as UiPrompt);
setValue(event.request.kind === 'input' ? event.request.initialValue ?? '' : '');
current.current = event as UiPrompt;
if (event.request.kind === 'terminal.selection.read' && event.phase === 'request') {
setPrompt(null);
try {
const selection = readPluginTerminalSelection(event.terminalSessionId ?? '', event.terminalRendererId ?? '');
void bridge.respondPluginInteraction?.({ requestId: event.requestId, actionToken: event.actionToken, action: 'preview', value: selection }).catch(() => {
void bridge.respondPluginInteraction?.({ requestId: event.requestId, actionToken: event.actionToken, action: 'cancel' }).catch(() => {});
});
} catch { void bridge.respondPluginInteraction?.({ requestId: event.requestId, actionToken: event.actionToken, action: 'cancel' }).catch(() => {}); }
} else setPrompt(event as UiPrompt);
setValue(event.request.kind === 'input' ? event.request.initialValue ?? '' : event.request.kind === 'terminal.input.propose' ? event.request.text : '');
}
pending.current = false; setBusy(false); setError(false);
});
@@ -38,8 +48,11 @@ export function usePluginHostInteractions() {
try {
const bridge = netcattyBridge.require();
if (!bridge.respondPluginInteraction) throw new Error('Host interactions unavailable');
const selection = action === 'preview' && active.request.kind === 'terminal.selection.read'
? readPluginTerminalSelection(active.terminalSessionId ?? '', active.terminalRendererId ?? '') : undefined;
const accepted = await bridge.respondPluginInteraction({
requestId: active.requestId, actionToken: active.actionToken, action,
...(selection === undefined ? {} : { value: selection }),
...(action === 'submit' && (active.request.kind === 'input' || active.request.kind === 'quickPick') ? { value } : {}),
});
if (!accepted && current.current?.actionToken === active.actionToken) {
+49
View File
@@ -0,0 +1,49 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
export function usePluginLogs() {
const [status, setStatus] = useState<NetcattyPluginLogStatus | null>(null);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const pending = useRef(false);
const mounted = useRef(true);
const run = useCallback(async (action: 'status' | 'configure' | 'open' | 'export' | 'clear', settings?: { saveToDisk: boolean; verbose: boolean }) => {
if (pending.current) return;
pending.current = true;
setBusy(true);
setError(null);
try {
const bridge = netcattyBridge.require();
const result = await (action === 'configure' ? bridge.configurePluginLogs?.(settings!)
: action === 'open' ? bridge.openPluginLogFolder?.()
: action === 'export' ? bridge.exportPluginLogs?.()
: action === 'clear' ? bridge.clearPluginLogs?.() : bridge.getPluginLogStatus?.());
if (!result) throw new Error('Local plugin logging is unavailable');
if (mounted.current) setStatus(result);
} catch (cause) {
let message = cause instanceof Error ? cause.message : String(cause);
if (action === 'configure') {
// Configuration fails closed before disk I/O. Show actual host state,
// even when persisting a setting failed, rather than keeping stale ON.
try {
const actual = await netcattyBridge.require().getPluginLogStatus?.();
if (!actual) throw new Error('Local plugin log status is unavailable');
if (mounted.current) setStatus(actual);
} catch (refreshError) {
if (mounted.current) setStatus(null);
message += `\n${refreshError instanceof Error ? refreshError.message : String(refreshError)}`;
}
}
if (mounted.current) setError(message);
} finally {
pending.current = false;
if (mounted.current) setBusy(false);
}
}, []);
useEffect(() => {
mounted.current = true;
void run('status');
return () => { mounted.current = false; };
}, [run]);
return { status, error, busy, run };
}
+8 -5
View File
@@ -10,7 +10,8 @@ export function usePluginManager() {
const [error, setError] = useState<string | null>(null);
const generation = useRef(0);
const mutation = useRef(false);
const refresh = useCallback(async () => {
const refresh = useCallback(async (options?: { clearError?: boolean }) => {
if (options?.clearError) setError(null);
const current = ++generation.current;
try {
const bridge = netcattyBridge.get();
@@ -37,18 +38,20 @@ export function usePluginManager() {
const refreshGeneration = generation;
return () => { refreshGeneration.current++; unsubscribe?.(); window.removeEventListener('netcatty:plugin-packages-changed', update); };
}, [refresh]);
const run = useCallback(async (action: (bridge: NetcattyBridge) => Promise<unknown>) => {
const run = useCallback(async (action: (bridge: NetcattyBridge) => Promise<unknown>, throwOnError = false) => {
if (mutation.current) return;
mutation.current = true;
setBusy(true);
setError(null);
let failure: Error | null = null;
try { await action(netcattyBridge.require()); }
catch (cause) { setError(cause instanceof Error ? cause.message : String(cause)); }
catch (cause) { failure = cause instanceof Error ? cause : new Error(String(cause)); setError(failure.message); }
finally {
try { await refresh(); } catch (cause) { setError(String(cause)); }
try { await refresh(); } catch (cause) { failure ??= cause instanceof Error ? cause : new Error(String(cause)); setError(failure.message); }
mutation.current = false;
setBusy(false);
}
if (throwOnError && failure) throw failure;
}, [refresh]);
return {
plugins, available, statusKnown, busy, error, credentialStorageAvailable, refresh,
@@ -69,6 +72,6 @@ export function usePluginManager() {
if (!bridge.resetPluginSource) throw new Error('Private source reset unavailable');
await bridge.resetPluginSource(id, source.id);
}
}),
}, true),
};
}
@@ -0,0 +1,31 @@
import { useEffect, useRef, useState } from 'react';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
export function usePluginPermissionPrompt() {
const [prompts, setPrompts] = useState<NetcattyPluginPermissionPrompt[]>([]);
const [busy, setBusy] = useState(false);
const active = useRef<NetcattyPluginPermissionPrompt | null>(null);
const prompt = prompts[0];
active.current = prompt ?? null;
useEffect(() => {
const bridge = netcattyBridge.get();
if (!bridge?.onPluginPermission || !bridge.setPluginPermissionReady) return;
const unsubscribe = bridge.onPluginPermission((event) => {
setPrompts((previous) => 'closed' in event ? previous.filter((item) => item.requestId !== event.requestId)
: [...previous.filter((item) => item.requestId !== event.requestId), event]);
setBusy(false);
});
void bridge.setPluginPermissionReady(true).catch(() => {});
return () => { unsubscribe(); void bridge.setPluginPermissionReady?.(false).catch(() => {}); };
}, []);
const respond = async (decision: 'allow' | 'deny' | 'cancel') => {
if (!prompt || busy || active.current !== prompt) return;
setBusy(true);
try {
const accepted = await netcattyBridge.require().respondPluginPermission?.({ requestId: prompt.requestId, actionToken: prompt.actionToken, decision });
if (!accepted) setPrompts((previous) => previous.filter((item) => item.requestId !== prompt.requestId));
} catch { setPrompts((previous) => previous.filter((item) => item.requestId !== prompt.requestId)); }
finally { setBusy(false); }
};
return { prompt, busy, respond };
}
@@ -0,0 +1,22 @@
import { useEffect, useMemo, useState } from 'react';
import { useI18n } from '../i18n/I18nProvider';
import { usePluginContributions } from './usePluginContributions';
import { resolvePluginSettingScopeSelection, usePluginSettingScopeCatalog } from './usePluginSettingScopeCatalog';
export function usePluginSettingsModel(pluginId: string) {
const { locale } = useI18n();
const catalog = usePluginSettingScopeCatalog();
const [scopeIds, setScopeIds] = useState<Partial<Record<NetcattyPluginSettingScopeKind, string>>>({});
useEffect(() => {
setScopeIds(current => resolvePluginSettingScopeSelection(catalog, current));
}, [catalog]);
const query = useMemo<NetcattyPluginContributionQuery>(() => ({
locale, context: { 'netcatty.surface': 'settings' }, scopeIds,
}), [locale, scopeIds]);
const contributions = usePluginContributions(query);
const plugin = contributions.snapshot.plugins.find(item => item.id === pluginId);
const settings = plugin?.settings.filter(setting => setting.visible) ?? [];
const contextualScopes = [...new Set(settings.map(setting => setting.scope)
.filter((scope): scope is NetcattyPluginSettingScopeKind => scope !== 'application'))];
return { ...contributions, plugin, settings, catalog, scopeIds, setScopeIds, contextualScopes };
}
@@ -0,0 +1,50 @@
import { useEffect, useRef } from 'react';
import type { Terminal } from '@xterm/xterm';
import { getTerminalSelectionForClipboard } from '../../components/terminal/normalizeTerminalSelection';
import { publishTerminalSelectionTarget, registerTerminalSelectionSource } from '../../infrastructure/plugins/terminalSelectionRegistry';
type SelectionState = {
sessionId: string | null;
bootEpoch: number;
term: Terminal | null;
visible: boolean;
isFocusedPane: boolean | undefined;
connected: boolean;
sensitive: boolean;
normalize: boolean;
label: string;
};
// No retained snapshots, PTY adapter, recorder, or AI context reader enters here.
export function createPluginTerminalSelectionSource(readState: () => SelectionState, instanceId: string) {
const target = () => {
const state = readState();
// Undefined means solo/Focus mode; only a visible selected split pane may use true.
if (state.visible !== true || state.isFocusedPane === false || state.connected !== true || state.sensitive !== false
|| !state.sessionId || !state.term || state.term.element?.isConnected !== true) return null;
return { sessionId: state.sessionId, rendererId: `${instanceId}:${state.bootEpoch}`, label: state.label.slice(0, 160) || 'Terminal' };
};
return {
target,
read() {
if (!target()) throw new Error('The selected terminal is unavailable');
const { term, normalize } = readState();
const range = term!.getSelectionPosition();
// Refuse a large range before asking xterm to materialize its text.
if (!range || Math.abs(range.end.y - range.start.y) * term!.cols + Math.abs(range.end.x - range.start.x) > 4096) {
throw new Error('Select a bounded terminal range first');
}
return getTerminalSelectionForClipboard(term!, normalize);
},
};
}
export function usePluginTerminalSelection(key: string, readState: () => SelectionState, lifecycle: string) {
const latest = useRef(readState);
latest.current = readState;
const instance = useRef<string | null>(null);
if (instance.current === null) instance.current = crypto.randomUUID();
useEffect(() => registerTerminalSelectionSource(key, createPluginTerminalSelectionSource(() => latest.current(), instance.current!)), [key]);
// The target getter reads current refs. Publication revokes old renderer/boot identities.
useEffect(() => { publishTerminalSelectionTarget(); }, [lifecycle]);
}
+52
View File
@@ -0,0 +1,52 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { checkPluginUpdates, type PluginUpdateCheckResult } from '../../infrastructure/services/pluginUpdateCheck';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
/** Manual catalog checks only; no scheduler, install or permission mutation. */
export function usePluginUpdateCheck(plugins: readonly NetcattyInstalledPlugin[]) {
const [result, setResult] = useState<PluginUpdateCheckResult | null>(null);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const pending = useRef(false);
const mounted = useRef(false);
useEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []);
const generation = useRef(0);
const identityKey = useMemo(() => JSON.stringify(plugins.map((plugin) => [
plugin.id, plugin.activeVersion, plugin.archiveSha256, plugin.source?.id,
plugin.source?.kind, plugin.source?.origin,
])), [plugins]);
useEffect(() => {
generation.current++;
setResult(null);
setError(null);
const requests = generation;
return () => { requests.current++; };
}, [identityKey]);
useEffect(() => {
const invalidate = () => {
generation.current++;
setResult(null);
setError(null);
};
const unsubscribe = netcattyBridge.get()?.onPluginContributionsChanged?.(invalidate);
window.addEventListener('netcatty:plugin-packages-changed', invalidate);
return () => { unsubscribe?.(); window.removeEventListener('netcatty:plugin-packages-changed', invalidate); };
}, []);
const check = useCallback(async () => {
if (pending.current) return;
pending.current = true;
const current = generation.current;
setBusy(true); setError(null); setResult(null);
try {
const value = await checkPluginUpdates(netcattyBridge.require());
if (mounted.current && current === generation.current) setResult(value);
} catch (cause) {
if (mounted.current && current === generation.current) setError(cause instanceof Error ? cause.message : String(cause));
} finally {
pending.current = false;
// This component may still be mounted with a refreshed package list.
if (mounted.current) setBusy(false);
}
}, []);
return { result, error, busy, check };
}
+9 -197
View File
@@ -14,17 +14,11 @@ import {
usePluginViewTabs,
} from './pluginViewTabStore';
import {
PluginViewLifecycleController,
reconcilePluginViewTabCatalog,
resolvePluginViewSnapshotSelection,
type HostedPluginViewState,
type PluginViewSnapshotSelection,
} from './pluginViewLifecycle';
import {
canRetainPluginViewInScope,
resolvePluginRetainedViewKey,
resolvePluginViewWindowScope,
} from './pluginViewScopes';
import { pluginViewSessions } from './pluginViewSession';
import { usePluginContributions } from './usePluginContributions';
export const OPEN_PLUGIN_VIEW_EVENT = 'netcatty:open-plugin-view';
@@ -69,17 +63,8 @@ export function usePluginViewLifecycle({
const {
snapshot,
executeCommand,
openView,
closeView,
setViewBounds,
setViewVisibility,
setEnvironment,
onViewClosed,
} = contributions;
const [instance, setInstance] = useState<HostedPluginViewState | null>(null);
const lifecycleRef = useRef(new PluginViewLifecycleController<HostedPluginViewState>());
const closeViewRef = useRef(closeView);
const mountRef = useRef<HTMLDivElement>(null);
const resolvedActiveView = useMemo(() => viewContributions.snapshot.plugins
.flatMap((plugin) => plugin.views.map((view) => ({ plugin, view })))
.find(({ view }) => view.id === effectiveRequested?.viewId && view.visible && !['vault-page', 'terminal-sidebar'].includes(view.location)) ?? null,
@@ -92,16 +77,6 @@ export function usePluginViewLifecycle({
requestedViewId: effectiveRequested?.viewId,
contextKey: viewQueryContextKey,
});
const activeViewId = activeView?.view.id;
const viewScopeId = typeof window === 'undefined'
? 'window:server'
: resolvePluginViewWindowScope(window.location);
const retainedViewKey = activeViewId
? resolvePluginRetainedViewKey(activeViewId, viewScopeId)
: null;
useEffect(() => { closeViewRef.current = closeView; }, [closeView]);
useEffect(() => {
if (viewContributions.loading) return;
stableActiveViewRef.current = resolvedActiveView && effectiveRequested?.viewId
@@ -113,26 +88,6 @@ export function usePluginViewLifecycle({
: null;
}, [effectiveRequested?.viewId, resolvedActiveView, viewContributions.loading, viewQueryContextKey]);
useEffect(() => onViewClosed((event) => {
const next = lifecycleRef.current.handleHostClose(event.instanceId);
if (next.matchedCurrent) {
setInstance(null);
setRequested(null);
}
if (next.closedTabId) pluginViewTabStore.close(next.closedTabId);
}), [onViewClosed]);
useEffect(() => pluginViewTabStore.onDidClose(({ tab }) => {
const next = lifecycleRef.current.handleTabClose(tab.id);
if (next.matchedCurrent) {
setInstance(null);
setRequested(null);
}
for (const instanceId of next.instanceIds) {
void closeViewRef.current(instanceId).catch(() => {});
}
}), []);
useEffect(() => {
const bindings = snapshot.plugins.flatMap((plugin) => plugin.keybindings)
.filter((binding) => binding.enabled)
@@ -181,153 +136,16 @@ export function usePluginViewLifecycle({
setRequested(null);
}, [activeView, requested]);
const hideOrClose = useCallback(async (current: HostedPluginViewState) => {
if (!current.retainContextWhenHidden) {
await closeView(current.id);
return;
}
const key = resolvePluginRetainedViewKey(current.viewId, current.scopeId);
lifecycleRef.current.retain(key, current);
try {
await setViewVisibility(current.id, false);
} catch {
lifecycleRef.current.removeRetained(key);
await closeView(current.id);
}
}, [closeView, setViewVisibility]);
useEffect(() => {
const stop = pluginViewTabStore.onDidClose(({ tab }) => pluginViewSessions.closeView(tab.viewId));
return () => { stop(); };
}, []);
const close = useCallback(async () => {
lifecycleRef.current.markViewClosed(retainedViewKey);
const current = lifecycleRef.current.takeCurrent();
setInstance(null);
setRequested(null);
if (current) await closeView(current.id);
}, [closeView, retainedViewKey]);
useEffect(() => {
const removed = lifecycleRef.current.removeRetainedWhere(
(retained) => !canRetainPluginViewInScope(retained.scopeId, viewScopeId),
);
for (const retained of removed) void closeView(retained.id).catch(() => {});
}, [closeView, viewScopeId]);
useEffect(() => {
if (!instance) return;
if (effectiveRequested?.viewId === instance.viewId
&& activeViewId === instance.viewId
&& instance.scopeId === viewScopeId) return;
lifecycleRef.current.takeCurrent();
setInstance(null);
if (canRetainPluginViewInScope(instance.scopeId, viewScopeId)) {
void hideOrClose(instance);
} else {
void closeView(instance.id).catch(() => {});
}
}, [activeViewId, closeView, effectiveRequested?.viewId, hideOrClose, instance, viewScopeId]);
useEffect(() => {
if (!activeViewId || !retainedViewKey || !mountRef.current || instance) return;
let cancelled = false;
const openingTabId = activePluginTab?.id;
const openingToken = lifecycleRef.current.beginOpen({
viewKey: retainedViewKey,
tabId: openingTabId,
label: activePluginTab?.id ?? activeViewId,
});
const bounds = mountRef.current.getBoundingClientRect();
const nextBounds = {
x: Math.round(bounds.x),
y: Math.round(bounds.y),
width: Math.max(1, Math.round(bounds.width)),
height: Math.max(1, Math.round(bounds.height)),
};
void (async () => {
let opened = lifecycleRef.current.takeRetained(retainedViewKey);
if (opened) {
try {
await setViewBounds(opened.id, nextBounds);
await setViewVisibility(opened.id, true);
} catch {
try { await closeView(opened.id); } catch { /* best-effort cleanup after restore failure */ }
opened = null;
}
}
if (!opened) {
const result = await openView({
viewId: activeViewId,
scopeId: viewScopeId,
bounds: nextBounds,
context: effectiveRequested?.context,
});
opened = {
id: result.instanceId,
viewId: activeViewId,
scopeId: viewScopeId,
retainContextWhenHidden: activeView.view.retainContextWhenHidden === true,
...(activePluginTab ? { tabId: activePluginTab.id } : {}),
};
}
if (lifecycleRef.current.consumeHostClose(opened.id)) {
throw new Error('Plugin view closed while its open response was in flight');
}
if (lifecycleRef.current.shouldCloseOpen(openingToken)) {
await closeView(opened.id);
return;
}
if (cancelled) {
await hideOrClose(opened);
return;
}
lifecycleRef.current.setCurrent(opened);
setInstance(opened);
})().catch(() => {
if (cancelled) return;
if (activePluginTab) pluginViewTabStore.close(activePluginTab.id);
else setRequested(null);
}).finally(() => {
lifecycleRef.current.finishOpen({
token: openingToken,
viewKey: retainedViewKey,
tabId: openingTabId,
});
});
return () => { cancelled = true; };
}, [
activePluginTab,
activeView?.view.retainContextWhenHidden,
activeViewId,
closeView,
effectiveRequested?.context,
hideOrClose,
instance,
openView,
retainedViewKey,
setViewBounds,
setViewVisibility,
viewScopeId,
]);
useEffect(() => {
if (!instance || !mountRef.current) return;
const update = () => {
const bounds = mountRef.current?.getBoundingClientRect();
if (!bounds) return;
void setViewBounds(instance.id, {
x: Math.round(bounds.x),
y: Math.round(bounds.y),
width: Math.max(1, Math.round(bounds.width)),
height: Math.max(1, Math.round(bounds.height)),
});
};
const observer = new ResizeObserver(update);
observer.observe(mountRef.current);
window.addEventListener('resize', update);
update();
return () => {
observer.disconnect();
window.removeEventListener('resize', update);
};
}, [instance, setViewBounds]);
if (activeView) pluginViewSessions.closeView(activeView.view.id);
if (activePluginTab) pluginViewTabStore.close(activePluginTab.id);
}, [activePluginTab, activeView]);
useEffect(() => {
if (!contributions.available) return;
@@ -364,16 +182,10 @@ export function usePluginViewLifecycle({
};
}, [contributions.available, locale, setEnvironment, suppliedThemeTokens, theme]);
useEffect(() => () => {
const views = lifecycleRef.current.drain();
const ids = new Set(views.map((view) => view.id));
for (const id of ids) void closeViewRef.current(id);
}, []);
return {
activeView,
close,
effectiveRequested,
mountRef,
viewQueryContext,
};
}
+2
View File
@@ -2,6 +2,8 @@ import { useCallback, useEffect, useRef, useState } from 'react';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
export interface DiskInfo {
diskReadSpeed?: number | null; // Mounted block-device I/O, bytes/sec
diskWriteSpeed?: number | null;
capacityKey?: string; // Filesystem or shared-pool identity for deduplication
filesystemType?: string; // Filesystem type reported by df (for network/FUSE filtering)
mountPoint: string;
+58 -24
View File
@@ -24,6 +24,7 @@ reorderWorkspaceFocusSessionOrder,
SplitDirection,
SplitHint,
updateWorkspaceSplitSizes,
invertSplitPosition,
} from '../../domain/workspace';
import { clearSessionFontSizeOverride as clearSessionFontSizeOverrideFields } from '../../domain/terminalAppearance';
import {
@@ -795,12 +796,68 @@ export const useSessionState = ({
return workspace.id;
}, [setActiveTabId]);
const addSessionToWorkspace = useCallback((
workspaceId: string,
sessionId: string,
hint: SplitHint
) => {
if (!hint) return;
setSessions(prevSessions => {
const session = prevSessions.find(s => s.id === sessionId);
if (!session || session.workspaceId) return prevSessions;
setWorkspaces(prevWorkspaces => {
const targetWorkspace = prevWorkspaces.find(w => w.id === workspaceId);
if (!targetWorkspace) return prevWorkspaces;
return insertWorkspacePaneIfMissing(prevWorkspaces, workspaceId, sessionId, hint);
});
setActiveTabId(workspaceId);
return prevSessions.map(s => s.id === sessionId ? { ...s, workspaceId } : s);
});
}, [setActiveTabId]);
// Merge two tabs into one workspace. When one of them already belongs to a
// workspace, that workspace (and its id) is reused instead of dissolving
// and recreating: the id is what workspace-scoped AI chats bind to, so
// regenerating it used to strand every conversation anchored to it
// (issue #4015 R3). Only two genuinely standalone tabs create a new id.
const createWorkspaceFromSessions = useCallback((
baseSessionId: string,
joiningSessionId: string,
hint: SplitHint
) => {
if (!hint || baseSessionId === joiningSessionId) return;
const baseSession = sessionsRef.current.find((s) => s.id === baseSessionId);
const joiningSession = sessionsRef.current.find((s) => s.id === joiningSessionId);
const anchorWorkspaceId = baseSession?.workspaceId ?? joiningSession?.workspaceId;
if (anchorWorkspaceId) {
const anchorSessionId = baseSession?.workspaceId ? baseSessionId : joiningSessionId;
const orphanSessionId = anchorSessionId === baseSessionId ? joiningSessionId : baseSessionId;
const orphanSession = sessionsRef.current.find((s) => s.id === orphanSessionId);
if (!orphanSession || orphanSession.workspaceId) return;
// Split next to the anchor's existing pane: the raw drop hint targets
// the orphan's pane, which is not part of this workspace tree yet.
// When the dragged (joining) pane already owns the workspace, the orphan
// is what gets inserted — invert the drop position so the previewed
// side still matches (hint describes where the dragged pane should sit).
const joinOwnsWorkspace = Boolean(joiningSession?.workspaceId) && !baseSession?.workspaceId;
const insertHint = {
...hint,
targetSessionId: anchorSessionId,
position: joinOwnsWorkspace ? invertSplitPosition(hint.position) : hint.position,
};
addSessionToWorkspace(
anchorWorkspaceId,
orphanSessionId,
insertHint,
);
// The orphan's standalone tab slot is replaced by the workspace tab.
setTabOrder((prevTabOrder) => prevTabOrder.filter((id) => id !== orphanSessionId));
return;
}
// Name the merged workspace after both tabs' connection labels (e.g.
// "01/02") so the window title and the rename dialog show the merged
// identity instead of the generic "Workspace". Falls back to the default
@@ -855,30 +912,7 @@ export const useSessionState = ({
return s;
});
});
}, [setActiveTabId]);
const addSessionToWorkspace = useCallback((
workspaceId: string,
sessionId: string,
hint: SplitHint
) => {
if (!hint) return;
setSessions(prevSessions => {
const session = prevSessions.find(s => s.id === sessionId);
if (!session || session.workspaceId) return prevSessions;
setWorkspaces(prevWorkspaces => {
const targetWorkspace = prevWorkspaces.find(w => w.id === workspaceId);
if (!targetWorkspace) return prevWorkspaces;
return insertWorkspacePaneIfMissing(prevWorkspaces, workspaceId, sessionId, hint);
});
setActiveTabId(workspaceId);
return prevSessions.map(s => s.id === sessionId ? { ...s, workspaceId } : s);
});
}, [setActiveTabId]);
}, [addSessionToWorkspace, setActiveTabId]);
// Add a host into an existing workspace by creating a new session for
// that host and appending it as the last pane at the workspace root.
@@ -0,0 +1,147 @@
import assert from "node:assert/strict";
import test from "node:test";
import React from "react";
import { act, create, type ReactTestRenderer } from "react-test-renderer";
import { collectSessionIds } from "../../domain/workspace.ts";
import { useSessionState } from "./useSessionState.ts";
type SessionState = ReturnType<typeof useSessionState>;
test("merging a workspace tab with a standalone tab reuses the workspace id (issue #4015 R3)", async (t) => {
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
const eventTarget = new EventTarget() as EventTarget & Record<string, unknown>;
Object.assign(eventTarget, {
setTimeout,
clearTimeout,
});
Object.defineProperty(globalThis, "window", {
configurable: true,
value: eventTarget,
});
t.after(() => {
if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow);
else Reflect.deleteProperty(globalThis, "window");
});
const actEnvironment = globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean;
};
const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
t.after(() => {
actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
});
let state: SessionState | null = null;
let renderer: ReactTestRenderer | null = null;
const current = (): SessionState => {
assert.ok(state);
return state;
};
function Probe() {
state = useSessionState({ persistSessionRestore: false });
return null;
}
await act(async () => {
renderer = create(React.createElement(Probe));
});
let baseId = "";
let memberId = "";
let standaloneId = "";
await act(async () => {
baseId = current().createLocalTerminal({ shellName: "01" });
memberId = current().createLocalTerminal({ shellName: "02" });
standaloneId = current().createLocalTerminal({ shellName: "03" });
});
await act(async () => {
current().createWorkspaceFromSessions(baseId, memberId, {
direction: "vertical",
position: "right",
targetSessionId: baseId,
});
});
const workspaceId = current().workspaces[0]?.id;
assert.ok(workspaceId);
assert.equal(current().sessions.find((s) => s.id === standaloneId)?.workspaceId ?? "", "");
// Dropping the standalone tab onto a member of the workspace must join the
// existing workspace — regenerating the id would strand AI chats bound to it.
await act(async () => {
current().createWorkspaceFromSessions(standaloneId, memberId, {
direction: "vertical",
position: "right",
targetSessionId: standaloneId,
});
});
assert.equal(current().workspaces.length, 1);
assert.equal(current().workspaces[0]?.id, workspaceId);
assert.equal(
current().sessions.find((s) => s.id === standaloneId)?.workspaceId,
workspaceId,
);
const memberIds = collectSessionIds(current().workspaces[0]!.root);
assert.ok(memberIds.includes(standaloneId));
assert.ok(memberIds.includes(memberId));
// The standalone tab slot is replaced by the workspace tab.
assert.ok(!current().orderedTabs.includes(standaloneId));
// Dropping a workspace member onto a standalone tab must insert the
// standalone on the opposite side of the drop hint (hint describes where
// the dragged member should appear relative to the standalone).
let orientationStandaloneId = "";
let orientationMemberId = "";
await act(async () => {
orientationStandaloneId = current().createLocalTerminal({ shellName: "orient-standalone" });
});
// memberId is already in workspaceId; drop member onto the new standalone with position "right"
// (preview: member to the right of standalone ⇒ standalone should end up LEFT of member).
await act(async () => {
current().createWorkspaceFromSessions(orientationStandaloneId, memberId, {
direction: "vertical",
position: "right",
targetSessionId: orientationStandaloneId,
});
});
{
const root = current().workspaces.find((ws) => ws.id === workspaceId)?.root;
assert.ok(root);
const ordered = collectSessionIds(root!);
const standaloneIdx = ordered.indexOf(orientationStandaloneId);
const memberIdx = ordered.indexOf(memberId);
assert.ok(standaloneIdx >= 0 && memberIdx >= 0);
assert.ok(
standaloneIdx < memberIdx,
`expected standalone left of member after inverted right-drop, got order ${ordered.join(",")}`,
);
}
// Two genuinely standalone tabs still create a fresh workspace.
let firstStandaloneId = "";
let secondStandaloneId = "";
await act(async () => {
firstStandaloneId = current().createLocalTerminal({ shellName: "04" });
secondStandaloneId = current().createLocalTerminal({ shellName: "05" });
});
await act(async () => {
current().createWorkspaceFromSessions(firstStandaloneId, secondStandaloneId, {
direction: "vertical",
position: "right",
targetSessionId: firstStandaloneId,
});
});
assert.equal(current().workspaces.length, 2);
const freshWorkspace = current().workspaces.find((candidate) => candidate.id !== workspaceId);
assert.ok(freshWorkspace);
assert.notEqual(freshWorkspace.id, workspaceId);
await act(async () => {
renderer?.unmount();
});
});
+18 -4
View File
@@ -74,6 +74,7 @@ import {
STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE,
STORAGE_KEY_GLOBAL_HOTKEY_ENABLED,
STORAGE_KEY_WINDOW_OPACITY,
STORAGE_KEY_WINDOW_BACKGROUND_BLUR,
STORAGE_KEY_APP_ICON_VARIANT,
STORAGE_KEY_AUTO_UPDATE_ENABLED,
STORAGE_KEY_WORKSPACE_FOCUS_STYLE,
@@ -645,6 +646,9 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
windowOpacityMutationSourceRef.current = 'local';
setWindowOpacityRecord(toggleWindowOpacityRecord);
}, []);
const [windowBackgroundBlur, setWindowBackgroundBlur] = useState<boolean>(() => {
return readStoredString(STORAGE_KEY_WINDOW_BACKGROUND_BLUR) === 'true';
});
const [appIconVariant, setAppIconVariantState] = useState<AppIconVariant>(() => {
const stored = readStoredString(STORAGE_KEY_APP_ICON_VARIANT);
return resolveAppIconVariant(stored ?? DEFAULT_APP_ICON_VARIANT);
@@ -1276,6 +1280,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
setIsHotkeyRecordingState,
setGlobalHotkeyEnabled,
setWindowOpacity: applyIncomingWindowOpacity,
setWindowBackgroundBlur,
setAppIconVariant,
setAutoUpdateEnabled,
setAutoLaunchEnabled,
@@ -1298,12 +1303,18 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
setSshTransportIdleTtlMsState,
});
// Detached sessions share opacity and the shortcut configuration needed to
// operate it. Keep general settings listeners and OS effects disabled there.
// Detached sessions share window chrome (opacity, background blur) and the
// shortcut configuration needed to operate it. Keep general settings
// listeners and OS effects disabled there.
useEffect(() => {
if (enableSettingsSync) return;
const applySharedWindowSetting = (key: string, value: unknown) => {
if (key === STORAGE_KEY_WINDOW_OPACITY) applyIncomingWindowOpacity(value);
if (key === STORAGE_KEY_WINDOW_BACKGROUND_BLUR) {
// Storage events deliver the raw string; IPC delivers a boolean.
const incoming = typeof value === 'boolean' ? value : value === 'true';
setWindowBackgroundBlur(incoming);
}
if (key === STORAGE_KEY_HOTKEY_SCHEME && (value === 'disabled' || value === 'mac' || value === 'pc')) {
setHotkeyScheme(value);
}
@@ -1377,7 +1388,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, localShellSidePanelAutoOpen, localShellSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd, startupLanding,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, windowBackgroundBlur, appIconVariant,
setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode,
applyIncomingCustomAccent,
setCustomCSS, setUiFontFamilyId, setHotkeyScheme, setUiLanguage,
@@ -1387,7 +1398,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
setSftpUseCompressedUpload, setSftpSkipUnchanged, setSftpAutoOpenSidebar, setSftpFollowTerminalCwd, setSftpDefaultViewMode,
setShowRecentHostsState, setHostClickBehaviorState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setTerminalSidePanelAutoOpenState, setTerminalSidePanelAutoOpenTabState, setLocalShellSidePanelAutoOpenState, setLocalShellSidePanelAutoOpenTabState, setShellOnlyTabNumberShortcutsState, setShowTabNumberBadgesState, setDisableTerminalFontZoomState, setRestorePreviousSessionState, setRestoreTerminalCwdState, setStartupLandingState,
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled, setSshDeepLinkEnabledState: applyIncomingSshDeepLinkEnabled, setJmsDeepLinkEnabledState: applyIncomingJmsDeepLinkEnabled, setExplorerContextMenuEnabledState: applyIncomingExplorerContextMenuEnabled,
setGlobalHotkeyEnabled, setWindowOpacity: applyIncomingWindowOpacity, setAppIconVariant, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
setGlobalHotkeyEnabled, setWindowOpacity: applyIncomingWindowOpacity, setWindowBackgroundBlur, setAppIconVariant, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
setSftpTransferConcurrencyState, setSshTransportIdleTtlMsState,
applyIncomingCustomKeyBindings, mergeIncomingTerminalSettings,
});
@@ -1896,6 +1907,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
autoLaunchEnabled,
windowOpacityRecord,
windowOpacityMutationSourceRef,
windowBackgroundBlur,
appIconVariant,
autoUpdateEnabled,
httpNetworkProxy,
@@ -2276,6 +2288,8 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
windowOpacity,
setWindowOpacity,
toggleWindowOpacity,
windowBackgroundBlur,
setWindowBackgroundBlur,
appIconVariant,
setAppIconVariant,
rehydrateAllFromStorage,
+1
View File
@@ -335,6 +335,7 @@ export const useTerminalBackend = () => {
contextViewportSnapshot: string;
contextScrollbackSnapshot: string;
alternateScreen: boolean;
perCharacterInput?: boolean;
kittyKeyboardModeState?: NetcattyKittyKeyboardModeState;
kittyKeyboardProtocolEnabled?: boolean;
},
@@ -0,0 +1,81 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import React from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { useToolAccessGuidanceState, type ToolAccessGuidanceState } from './useToolAccessGuidanceState';
import { AI_STATE_CHANGED_EVENT } from './aiStateEvents';
import { STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED } from '../../infrastructure/config/storageKeys';
import type { AIToolIntegrationMode } from '../../infrastructure/ai/types';
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((done) => { resolve = done; });
return { promise, resolve };
}
async function harness() {
const requests: ReturnType<typeof deferred<unknown>>[] = [];
const events = new EventTarget();
const values = new Map([[STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED, 'true']]);
const previous = new Map<string, PropertyDescriptor | undefined>();
const globals = {
IS_REACT_ACT_ENVIRONMENT: true,
localStorage: { getItem: (key: string) => values.get(key) ?? null, setItem: (key: string, value: string) => values.set(key, value) },
window: {
netcatty: {
externalMcpGetStatus: () => { const request = deferred<unknown>(); requests.push(request); return request.promise; },
aiSkillsCliGetInvocation: async () => ({ ok: true, skillPath: '/fixture/skills/SKILL.md', commandPrefix: 'fixture-cli' }),
},
addEventListener: events.addEventListener.bind(events), removeEventListener: events.removeEventListener.bind(events),
dispatchEvent: events.dispatchEvent.bind(events), setTimeout, clearTimeout,
},
};
for (const [key, value] of Object.entries(globals)) {
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, { value, configurable: true, writable: true });
}
let result!: ToolAccessGuidanceState;
function Harness({ mode }: { mode: AIToolIntegrationMode }) { result = useToolAccessGuidanceState(mode); return null; }
let renderer!: ReactTestRenderer;
await act(async () => { renderer = create(<Harness mode="mcp" />); });
return {
requests, events, values, result: () => result,
skills: async () => { await act(async () => renderer.update(<Harness mode="skills" />)); },
close: async () => {
await act(async () => renderer.unmount());
for (const [key, value] of previous) { if (value) Object.defineProperty(globalThis, key, value); else Reflect.deleteProperty(globalThis, key); }
},
};
}
const status = (path: string) => ({ ok: true, enabled: true, state: 'running', launcherPath: path, discoveryPath: `${path}.json` });
test('older MCP guidance response cannot overwrite a newer enable-state refresh', async () => {
const h = await harness();
try {
assert.equal(h.requests.length, 1);
await act(async () => { h.events.dispatchEvent(new CustomEvent(AI_STATE_CHANGED_EVENT, { detail: { key: STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED } })); });
assert.equal(h.requests.length, 2);
await act(async () => h.requests[1].resolve(status('/fixture/new')));
assert.equal(h.result().mcpLauncherPath, '/fixture/new');
await act(async () => h.requests[0].resolve(status('/fixture/old')));
assert.equal(h.result().mcpLauncherPath, '/fixture/new');
} finally { await h.close(); }
});
test('switching to Skills ignores pending MCP results and preserves resolved Skills guidance', async () => {
const h = await harness();
try {
await h.skills();
await act(async () => h.requests[0].resolve(status('/fixture/stale')));
assert.equal(h.result().mcpLauncherPath, null);
assert.equal(h.result().skillPath, '/fixture/skills/SKILL.md');
assert.equal(h.result().commandPrefix, 'fixture-cli');
} finally { await h.close(); }
});
test('MCP guidance completion after unmount has no visible state or retry work', async () => {
const h = await harness();
const initial = h.result();
await h.close();
await act(async () => h.requests[0].resolve(status('/fixture/stale')));
assert.equal(h.result(), initial);
});
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import type { AIToolIntegrationMode } from '../../infrastructure/ai/types';
import { STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED } from '../../infrastructure/config/storageKeys';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
@@ -38,14 +38,15 @@ export type ToolAccessGuidanceState = {
* runtime is still starting or after an idle shutdown).
*/
export function useToolAccessGuidanceState(mode: AIToolIntegrationMode): ToolAccessGuidanceState {
const requestGeneration = useRef(0);
const [skillPath, setSkillPath] = useState<string | null>(null);
const [commandPrefix, setCommandPrefix] = useState('');
const [mcpLauncherPath, setMcpLauncherPath] = useState<string | null>(null);
const [mcpDiscoveryPath, setMcpDiscoveryPath] = useState<string | null>(null);
const refreshMcpStatus = useCallback(async (): Promise<string | null | undefined> => {
const refreshMcpStatus = useCallback(async (isCurrent: () => boolean): Promise<string | null | undefined> => {
const status = await netcattyBridge.get()?.externalMcpGetStatus?.();
if (!status?.ok) return undefined;
if (!isCurrent() || !status?.ok) return undefined;
// Do not trust `enabled` alone: the main process flips `enabled` before
// host credentials are resolved and the discovery file is written, and it
// keeps reporting the configured discovery/launcher paths while starting.
@@ -93,9 +94,11 @@ export function useToolAccessGuidanceState(mode: AIToolIntegrationMode): ToolAcc
const refetch = async () => {
if (cancelled) return;
const generation = ++requestGeneration.current;
const isCurrent = () => !cancelled && generation === requestGeneration.current;
const storedEnabled = readExternalMcpStoredEnabled();
const readyLauncherPath = await refreshMcpStatus().catch(() => undefined);
if (cancelled) return;
const readyLauncherPath = await refreshMcpStatus(isCurrent).catch(() => undefined);
if (!isCurrent()) return;
// The persisted switch is off but the status check failed: drop any
// cached launcher/discovery paths. The stored credentials were revoked
// by the disable, and falling through to the comparison below would see
@@ -63,6 +63,9 @@ export function useToolbarItemLayout(
const [layout, setLayout] = useState<ToolbarItemLayout>(() => readLayout(storageKey, defaults));
useEffect(() => {
// Defaults can include dynamically discovered items; re-read without writing
// so temporary catalog changes never erase stored toolbar preferences.
setLayout(readLayout(storageKey, defaults));
const sync = (event: Event) => {
if (event instanceof StorageEvent && event.key !== storageKey) return;
if (event instanceof CustomEvent && event.detail?.key !== storageKey) return;
@@ -0,0 +1,108 @@
import assert from "node:assert/strict";
import test from "node:test";
import React from "react";
import { act, create, type ReactTestRenderer } from "react-test-renderer";
import { JSDOM } from "jsdom";
import { installBrowserLockFixture } from "../testSupport/browserLockFixture";
import type { Host } from "../../domain/models";
import { STORAGE_KEY_HOSTS } from "../../infrastructure/config/storageKeys";
// Exercise actual vault save/load/JSON import and sync boundaries, including
// the asynchronous encrypted-field adapter (without a native safeStorage key).
test("host input choices survive save, restart, duplicate, backup and cloud sync", async () => {
const dom = new JSDOM("", { url: "http://localhost" });
const globals = ["window", "localStorage", "CustomEvent", "IS_REACT_ACT_ENVIRONMENT", "navigator"];
const previous = new Map(globals.map(key => [key, Object.getOwnPropertyDescriptor(globalThis, key)]));
Object.defineProperties(globalThis, {
window: { value: dom.window, configurable: true },
localStorage: { value: dom.window.localStorage, configurable: true },
CustomEvent: { value: dom.window.CustomEvent, configurable: true },
IS_REACT_ACT_ENVIRONMENT: { value: true, configurable: true },
});
installBrowserLockFixture();
const { useVaultState } = await import("./useVaultState");
const { buildSyncPayload, applySyncPayload, buildLocalVaultPayload, applyLocalVaultPayload } = await import("../syncPayload");
const { buildVaultHostFromDraft } = await import("../../domain/vaultHostCreate");
const legacy: Host = { id: "legacy", label: "Legacy", hostname: "localhost", username: "agent", protocol: "ssh", tags: [], os: "linux" };
dom.window.localStorage.setItem(STORAGE_KEY_HOSTS, JSON.stringify([legacy]));
let state: ReturnType<typeof useVaultState>;
let renderer: ReactTestRenderer | undefined;
const Probe = () => { state = useVaultState({ syncConnectionLogTerminalData: false }); return null; };
const mount = async () => {
await act(async () => { renderer = create(React.createElement(Probe)); });
for (let attempt = 0; !state!.isInitialized && attempt < 50; attempt++) {
await act(async () => { await new Promise(resolve => setTimeout(resolve, 10)); });
}
assert.equal(state!.isInitialized, true);
await act(async () => { await state!.readPersistedHosts(); });
};
const choices = () => state!.hosts.map(host => [host.id, host.sshInputCompatibility]);
try {
await mount();
assert.deepEqual(choices(), [["legacy", true]]);
const created = buildVaultHostFromDraft({ hostname: "new.example.test", username: "agent" });
if (created.ok === false) throw Error(created.error);
assert.equal(created.ok, true);
const standard = { ...created.host, id: "standard" };
await act(async () => { await state!.updateHosts([...state!.hosts, standard]); });
assert.deepEqual(choices(), [["legacy", true], ["standard", false]]);
const duplicate = { ...state!.hosts[0], id: "copy", label: "Legacy copy" };
await act(async () => { await state!.updateHosts([...state!.hosts, duplicate]); });
await act(async () => { renderer!.unmount(); });
await mount();
assert.deepEqual(choices(), [["legacy", true], ["standard", false], ["copy", true]]);
// A later deliberate false must survive migration, including another boot.
await act(async () => { await state!.updateHosts(state!.hosts.map(host => host.id === "legacy" ? { ...host, sshInputCompatibility: false } : host)); });
const expected = [["legacy", false], ["standard", false], ["copy", true]];
const exported = JSON.stringify(state!.exportData());
await act(async () => { await state!.updateHosts([]); await state!.importDataFromString(exported); });
assert.deepEqual(choices(), expected);
// Use the real sync payload builder and apply importer, not JSON alone.
const cloud = JSON.parse(JSON.stringify(buildSyncPayload({ ...state!.exportData(), identities: state!.identities, knownHosts: state!.knownHosts })));
await act(async () => { await state!.updateHosts([]); await applySyncPayload(cloud, { importVaultData: state!.importDataFromString }); });
assert.deepEqual(choices(), expected);
const backup = JSON.parse(JSON.stringify(buildLocalVaultPayload({ ...state!.exportData(), identities: state!.identities, knownHosts: state!.knownHosts })));
await act(async () => { await state!.updateHosts([]); await applyLocalVaultPayload(backup, { importVaultData: state!.importDataFromString }); });
assert.deepEqual(choices(), expected);
await act(async () => { renderer!.unmount(); });
await mount();
assert.deepEqual(choices(), expected);
assert.deepEqual((await state!.readPersistedHosts()).map(host => [host.id, host.sshInputCompatibility]), expected);
// Older cloud snapshots enter through the same per-record migration.
cloud.hosts = [{ ...legacy, id: "cloud-legacy" }];
await act(async () => { await applySyncPayload(cloud, { importVaultData: state!.importDataFromString }); });
assert.deepEqual(choices(), [["cloud-legacy", true]]);
await act(async () => { await state!.updateHosts(state!.hosts.map(host => ({ ...host, sshInputCompatibility: false }))); });
await act(async () => { renderer!.unmount(); });
await mount();
assert.deepEqual(choices(), [["cloud-legacy", false]]);
// These drafts enter HostDetailsPanel as initialData, then the real vault
// save/normalization path. They must not be mistaken for legacy records.
const { buildSshDeepLinkHostDraft, parseSshDeepLink } = await import("../../domain/sshDeepLink");
const { normalizePluginImporterRecords } = await import("../../domain/pluginImporter");
const linkDraft = buildSshDeepLinkHostDraft(parseSshDeepLink("ssh://agent@new.test")!, { id: "new-link", now: 1 });
const imported = normalizePluginImporterRecords([undefined, false, true].map(choice => ({
type: "draft" as const, draft: { kind: "host" as const, value: {
hostname: `import-${choice}.test`, protocol: "ssh",
...(choice === undefined ? {} : { sshInputCompatibility: choice }),
} },
})));
assert.deepEqual(imported.errors, []);
const explicitCopy = { ...imported.hosts[2], id: "copy-explicit-input" };
const drafts = [linkDraft, ...imported.hosts, explicitCopy];
const draftChoices = drafts.map(host => [host.id, host.sshInputCompatibility]);
assert.deepEqual(drafts.map(host => host.sshInputCompatibility), [false, false, false, true, true]);
await act(async () => { await state!.updateHosts([...state!.hosts, ...drafts]); });
await act(async () => { renderer!.unmount(); });
await mount();
assert.deepEqual(choices(), [["cloud-legacy", false], ...draftChoices]);
} finally {
await act(async () => { renderer?.unmount(); });
dom.window.close();
for (const key of globals) {
const descriptor = previous.get(key);
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else Reflect.deleteProperty(globalThis, key);
}
}
});
@@ -0,0 +1,63 @@
import assert from "node:assert/strict";
import test from "node:test";
import React from "react";
import { act, create, type ReactTestRenderer } from "react-test-renderer";
import { JSDOM } from "jsdom";
import { installBrowserLockFixture } from "../testSupport/browserLockFixture";
import { STORAGE_KEY_SHELL_HISTORY } from "../../infrastructure/config/storageKeys";
test("shell history append yields the input turn and persists merged entries", async () => {
const dom = new JSDOM("", { url: "http://localhost" });
const globals = ["window", "localStorage", "CustomEvent", "IS_REACT_ACT_ENVIRONMENT", "navigator"];
const previous = new Map(globals.map(key => [key, Object.getOwnPropertyDescriptor(globalThis, key)]));
Object.defineProperties(globalThis, {
window: { value: dom.window, configurable: true },
localStorage: { value: dom.window.localStorage, configurable: true },
CustomEvent: { value: dom.window.CustomEvent, configurable: true },
IS_REACT_ACT_ENVIRONMENT: { value: true, configurable: true },
});
installBrowserLockFixture();
const { useVaultState } = await import("./useVaultState");
let state: ReturnType<typeof useVaultState>;
let renderer: ReactTestRenderer | undefined;
const Probe = () => { state = useVaultState({ syncConnectionLogTerminalData: false }); return null; };
try {
await act(async () => { renderer = create(React.createElement(Probe)); });
for (let attempt = 0; !state!.isInitialized && attempt < 50; attempt++) {
await act(async () => { await new Promise(resolve => setTimeout(resolve, 10)); });
}
assert.equal(state!.isInitialized, true);
const entry = { command: " echo hello ", hostId: "local", hostLabel: "Local", sessionId: "one" };
act(() => {
state!.addShellHistoryEntry(entry);
state!.addShellHistoryEntry({ ...entry, sessionId: "two" });
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_SHELL_HISTORY), null);
assert.equal(state!.shellHistory.length, 0);
});
await act(async () => { await new Promise(resolve => setTimeout(resolve, 20)); });
const saved = JSON.parse(dom.window.localStorage.getItem(STORAGE_KEY_SHELL_HISTORY)!);
assert.equal(saved.length, 1);
assert.equal(saved[0].command, "echo hello");
assert.equal(saved[0].sessionId, "two");
assert.deepEqual(state!.shellHistory, saved);
for (const clear of [() => state!.clearShellHistory(), () => state!.clearAllCommandHistory()]) {
act(() => {
state!.addShellHistoryEntry(entry);
state!.addShellHistoryEntry({ ...entry, command: "another command" });
clear();
});
await act(async () => { await new Promise(resolve => setTimeout(resolve, 20)); });
assert.deepEqual(state!.shellHistory, []);
assert.deepEqual(JSON.parse(dom.window.localStorage.getItem(STORAGE_KEY_SHELL_HISTORY)!), []);
}
} finally {
await act(async () => { renderer?.unmount(); });
dom.window.close();
for (const key of globals) {
const descriptor = previous.get(key);
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else Reflect.deleteProperty(globalThis, key);
}
}
});
+16 -8
View File
@@ -317,6 +317,7 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
const [noteGroups, setNoteGroups] = useState<string[]>([]);
const [knownHosts, setKnownHosts] = useState<KnownHost[]>([]);
const [shellHistory, setShellHistory] = useState<ShellHistoryEntry[]>([]);
const shellHistoryGenerationRef = useRef(0);
const [connectionLogs, setConnectionLogs] = useState<ConnectionLog[]>([]);
const [managedSources, setManagedSources] = useState<ManagedSource[]>([]);
const [groupConfigs, setGroupConfigs] = useState<GroupConfig[]>([]);
@@ -1445,19 +1446,25 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
const addShellHistoryEntry = useCallback(
(entry: Omit<ShellHistoryEntry, "id" | "timestamp">) => {
setShellHistory((prev) => {
const updated = mergeGlobalHistoryOnAppend(prev, entry);
if (updated === prev) return prev;
// Persist immediately so crash between commit and layout effect cannot drop history.
// Store republish is also done in useLayoutEffect for load/storage-event paths.
localStorageAdapter.write(STORAGE_KEY_SHELL_HISTORY, updated);
return updated;
});
const generation = shellHistoryGenerationRef.current;
// Yield the input turn before history rendering and synchronous storage work.
// Persist in the next task to keep the crash-loss window small.
setTimeout(() => {
if (generation !== shellHistoryGenerationRef.current) return;
setShellHistory((prev) => {
if (generation !== shellHistoryGenerationRef.current) return prev;
const updated = mergeGlobalHistoryOnAppend(prev, entry);
if (updated === prev) return prev;
localStorageAdapter.write(STORAGE_KEY_SHELL_HISTORY, updated);
return updated;
});
}, 0);
},
[],
);
const clearShellHistory = useCallback(() => {
shellHistoryGenerationRef.current += 1;
setShellHistory([]);
localStorageAdapter.write(STORAGE_KEY_SHELL_HISTORY, []);
publishShellHistorySnapshot([]);
@@ -1582,6 +1589,7 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
group: "",
tags: [],
protocol: "ssh",
sshInputCompatibility: false,
order: getNextVaultOrder(hosts),
};
+42 -2
View File
@@ -208,13 +208,15 @@ test('replacement stops active turns and waits for finalizers before persisting
globalThis.localStorage.setItem = (key, value) => { if (key === keys.STORAGE_KEY_AI_SESSIONS) order.push('persist'); original(key, value); };
const incoming = { ...session(), updatedAt: 3, messages: [...session().messages,
{ id: 'remote', role: 'user' as const, content: 'Remote', timestamp: 3 }] };
await applySyncPayload(remotePayload(remove ? [] : [incoming]), { importVaultData: () => undefined });
await applyLocalVaultPayload(remotePayload(remove ? [] : [incoming]), { importVaultData: () => undefined }, {
prepareConvergentRestore: async () => async () => {},
});
assert.ok(order.indexOf('cancel') < order.indexOf('settled'));
assert.ok(order.indexOf('settled') < order.indexOf('persist'));
assert.ok(order.indexOf('persist') < order.indexOf('cleanup'));
assert.equal(hook!.abortControllersRef.current.has('chat'), false);
if (remove) assert.deepEqual(getAISessionsSnapshot().sessions, []);
else assert.ok(getAISessionsSnapshot().sessions[0].messages.some(message => message.content === 'Settled'));
else assert.equal(getAISessionsSnapshot().sessions[0].messages.at(-1)?.content, 'Remote');
wait.mock.restore();
globalThis.localStorage.setItem = original;
await act(async () => { hook!.setStreamingForScope('chat', false); });
@@ -226,6 +228,44 @@ test('replacement stops active turns and waits for finalizers before persisting
}
});
test('cloud sync preserves an active trimmed conversation and its native thread', async () => {
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
let hook: UseAIChatStreamingReturn;
function Harness() {
hook = useAIChatStreaming({ maxIterations: 1, addMessageToSession: () => {}, updateLastMessage: () => {}, updateMessageById: () => {} });
return null;
}
let renderer: ReactTestRenderer;
await act(async () => { renderer = create(React.createElement(Harness)); });
const calls: string[] = [];
const controller = new AbortController();
const messages = Array.from({ length: 501 }, (_, i) => ({ id: `m-${i}`, role: 'user' as const, content: `Message ${i}`, timestamp: i }));
const remote = { ...session(), agentId: 'codex', messages };
// Appending the 501st message trims the oldest message locally.
const local = { ...remote, messages: messages.slice(-500), externalSessionId: 'native-thread' };
save([local]);
hook!.abortControllersRef.current.set('chat', controller);
await act(async () => { hook!.setStreamingForScope('chat', true); });
Object.assign(window, { netcatty: {
aiCattyCancelExec: async () => { calls.push('cancel'); },
aiSdkAgentCancel: async () => { calls.push('sdk-cancel'); return { ok: true }; },
aiSdkAgentCleanup: async () => { calls.push('cleanup'); return { ok: true }; },
} });
try {
for (const incoming of [[remote], []]) {
await applySyncPayload(remotePayload(incoming), { importVaultData: () => undefined });
assert.equal(controller.signal.aborted, false);
assert.deepEqual(calls, []);
assert.deepEqual(getAISessionsSnapshot().sessions, [local]);
assert.equal(JSON.parse(storage.get(keys.STORAGE_KEY_AI_SESSIONS)!)[0].externalSessionId, 'native-thread');
}
} finally {
hook!.abortControllersRef.current.delete('chat');
await act(async () => { hook!.setStreamingForScope('chat', false); renderer!.unmount(); });
delete (window as unknown as { netcatty?: unknown }).netcatty;
}
});
test('local restore replaces later sessions and later turns with the backup snapshot', async () => {
const backedUp = session();
const later = { ...session(), updatedAt: 3, messages: [...session().messages,
+8 -3
View File
@@ -1275,6 +1275,8 @@ export async function buildLocalVaultPayloadAsync(
/** Merge downloaded chat data through the existing state and persistence boundary. */
async function applyAiSessionData(sessions: NonNullable<SyncPayload['aiSessions']>, includeLocalOnlyData: boolean): Promise<void> {
const { isAIChatSessionStreaming, stopAIChatSessionBeforeReplacement } = await import('./state/useAIChatStreaming');
const preserveActive = (id: string) => !includeLocalOnlyData && isAIChatSessionStreaming(id);
const incoming = includeLocalOnlyData
? sessions
: sessions.map(stripAiSessionDeviceState);
@@ -1282,26 +1284,29 @@ async function applyAiSessionData(sessions: NonNullable<SyncPayload['aiSessions'
const incomingById = new Map(incoming.map((session) => [session.id, session]));
const portableMessages = (value: typeof incoming[number]) => stripAiSessionPayloadForSync([value])[0].messages;
const affectedIds = collectLocalAiSessions().filter((local) => {
if (preserveActive(local.id)) return false;
const next = incomingById.get(local.id);
if (!next) return true;
const candidate = includeLocalOnlyData ? next : mergeAiSessionLists([local], [next])[0];
return JSON.stringify(portableMessages(local)) !== JSON.stringify(portableMessages(candidate));
}).map((session) => session.id);
if (affectedIds.length > 0) {
const { stopAIChatSessionBeforeReplacement } = await import('./state/useAIChatStreaming');
await Promise.all(affectedIds.map(stopAIChatSessionBeforeReplacement));
}
// Stop finalizers can finish the last local message. Read it again before
// merging so the stopped turn cannot write into a replaced/deleted session.
const localSessions = collectLocalAiSessions();
const localById = new Map(localSessions.map((session) => [session.id, session]));
const removedIds = localSessions.filter((session) => !incomingIds.has(session.id)).map((session) => session.id);
const removedIds = localSessions.filter((session) => !incomingIds.has(session.id) && !preserveActive(session.id)).map((session) => session.id);
const resetSdkSessions: string[] = [];
// The sync merge has already decided which session IDs survive. Backups
// restore their exact transcript; cloud survivors still retain local turns.
const candidates = includeLocalOnlyData ? incoming
: mergeAiSessionLists(localSessions.filter((session) => incomingIds.has(session.id)), incoming);
const mergedSessions = candidates.map((session) => {
// Keep active cloud sessions intact until a later sync, including remote deletions.
const retainedActive = localSessions.filter((session) => preserveActive(session.id));
const mergedSessions = [...candidates.filter((session) => !preserveActive(session.id)), ...retainedActive].map((session) => {
if (preserveActive(session.id)) return session;
// Native SDK threads belong to this device. Reuse the local thread
// only while it already contains the entire merged transcript.
const local = localById.get(session.id);
+11
View File
@@ -1,3 +1,14 @@
!macro customInit
; #3946: external MCP hosts launch Netcatty.exe with ELECTRON_RUN_AS_NODE=1
; via netcatty-external-mcp.cmd. Those processes can outlive the host UI as
; orphans and keep $INSTDIR locked, so the in-app updater hangs (AppHangB1).
; Force-kill leftover Netcatty.exe before files are replaced. Do not pass /T:
; electron-updater spawns this installer as a child of the UI Netcatty.exe,
; and /T would terminate the installer itself. Ignore a missing process.
nsExec::ExecToLog "taskkill /F /IM Netcatty.exe"
Pop $0
!macroend
!macro customInstall
; Open a selected folder in a new Netcatty local terminal.
; Args go after `--` so Electron/Chromium does not swallow them. Use
+32
View File
@@ -40,3 +40,35 @@ test('terminal side panel tools share one primary toolbar height', () => {
assert.match(source, /TERMINAL_SIDE_PANEL_INNER_HEADER_CLASS/);
}
});
test('/rename and /chats panel commands stay out of the agent message path', () => {
const sidePanel = preparingSource;
// Panel implements the commands with updateSessionTitle + a note state —
// never addMessageToSession, so the commands cannot leak into chat history.
assert.match(sidePanel, /handleRenameSession = useCallback/);
assert.match(sidePanel, /handleListChats = useCallback/);
assert.match(sidePanel, /updateSessionTitle\(sessionId, title\)/);
assert.match(sidePanel, /setPanelCommandNote\(formatAiChatsListingNote\(entries, t\)\)/);
const renameHandler = sidePanel.slice(
sidePanel.indexOf('handleRenameSession = useCallback'),
sidePanel.indexOf('const handleRenameSlashCommand'),
);
assert.doesNotMatch(renameHandler, /addMessageToSession/);
const chatsHandler = sidePanel.slice(
sidePanel.indexOf('handleListChats = useCallback'),
sidePanel.indexOf('const handleSend'),
);
assert.doesNotMatch(chatsHandler, /addMessageToSession/);
// Wiring reaches AIChatPanelContent.
assert.match(sidePanel, /onRenameActiveChat=\{handleRenameSlashCommand\}/);
assert.match(sidePanel, /onListChats=\{handleListChats\}/);
});
test('AIChatPanelContent renders the panel command note and wires rename/list props', () => {
assert.match(panelSource, /panelCommandNote \? \(/);
assert.match(panelSource, /role="status"/);
assert.match(panelSource, /onDismissPanelCommandNote/);
assert.match(panelSource, /onRenameSession=\{onRenameActiveChat\}/);
assert.match(panelSource, /onListChats=\{onListChats\}/);
assert.match(panelSource, /onRename=\{onRenameSession\}/);
});
+43
View File
@@ -57,6 +57,20 @@ interface AIChatPanelContentProps {
setInputValue: (value: string) => void;
handleSend: () => void;
handleCompact: () => void;
/** Rename a chat by id (history drawer edit affordance). Panel action, never agent content. */
onRenameSession?: (sessionId: string, title: string) => void;
/** `/rename <title>` for the current chat. Panel action, never agent content. */
onRenameActiveChat?: (title: string) => void;
/** `/chats`: list the chats visible to this panel. Panel action, never agent content. */
onListChats?: () => void;
/** `/rebind <n>`: rebind a dangling chat to this panel's scope. Panel action, never agent content. */
onRebindChat?: (arg: string) => void;
/** `/chats` listing output, rendered as a system-style note above the composer. */
panelCommandNote?: string | null;
onDismissPanelCommandNote?: () => void;
/** Manual durability escape hatch: export/import the whole session store. */
onExportSessions?: () => void;
onImportSessions?: () => void;
/** Undo last turn is offered at a safe boundary (never mid-tool / mid-compaction). */
canUndoLastTurn?: boolean;
handleUndoLastTurn?: () => void;
@@ -142,6 +156,14 @@ export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
setInputValue,
handleSend,
handleCompact,
onRenameSession,
onRenameActiveChat,
onListChats,
onRebindChat,
panelCommandNote,
onDismissPanelCommandNote,
onExportSessions,
onImportSessions,
canUndoLastTurn = false,
handleUndoLastTurn,
canCompact = false,
@@ -309,6 +331,9 @@ export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
activeSessionId={activeSessionId}
onSelect={sending ? () => undefined : handleSelectSession}
onDelete={handleDeleteSession}
onRename={onRenameSession}
onExportSessions={onExportSessions}
onImportSessions={onImportSessions}
onClose={() => setShowHistory(false)}
/>
</React.Profiler>
@@ -369,6 +394,21 @@ export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
{!hideInput && (
<React.Profiler {...getAIPanelProfilerProps('AIChatPanel.Input')}>
<div>
{panelCommandNote ? (
<div role="status" className="mx-3 mb-1.5 rounded-md border border-border/50 bg-muted/30 px-2.5 py-1.5 text-[11px] leading-4 text-muted-foreground">
<span className="block max-h-48 overflow-y-auto whitespace-pre-wrap break-words">{panelCommandNote}</span>
{onDismissPanelCommandNote && (
<button
type="button"
aria-label={t('common.close')}
onClick={onDismissPanelCommandNote}
className="mt-1 text-[11px] text-muted-foreground/60 underline-offset-2 transition-colors hover:text-foreground hover:underline cursor-pointer"
>
{t('common.close')}
</button>
)}
</div>
) : null}
{steerWarning ? (
<div role="status" className="mx-3 mb-1.5 rounded-md border border-amber-500/25 bg-amber-500/10 px-2.5 py-1.5 text-[11px] leading-4 text-amber-600 dark:text-amber-400">
{steerWarning}
@@ -396,6 +436,9 @@ export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
onChange={setInputValue}
onSend={handleSend}
onCompact={handleCompact}
onRenameSession={onRenameActiveChat}
onListChats={onListChats}
onRebindChat={onRebindChat}
canCompact={canCompact}
onSteer={handleSteer}
onStop={handleStop}
+113 -8
View File
@@ -1,5 +1,5 @@
import React, { useEffect, useId, useRef, useState } from 'react';
import { Search, Trash2, X } from 'lucide-react';
import { Download, Pencil, Search, Trash2, Upload, X } from 'lucide-react';
import type { AISession } from '../infrastructure/ai/types';
import { useI18n } from '../application/i18n/I18nProvider';
import { cn } from '../lib/utils';
@@ -19,6 +19,12 @@ interface SessionHistoryDrawerProps {
activeSessionId: string | null;
onSelect: (sessionId: string) => void;
onDelete: (e: React.MouseEvent, sessionId: string) => void;
/** Rename a chat inline (issue #4015 R5). Empty titles are ignored. */
onRename?: (sessionId: string, title: string) => void;
/** Manual durability escape hatch: export the whole store to a JSON file. */
onExportSessions?: () => void;
/** Import sessions from a JSON export; merges by id, newer wins. */
onImportSessions?: () => void;
onClose: () => void;
}
@@ -30,11 +36,17 @@ export const SessionHistoryDrawer: React.FC<SessionHistoryDrawerProps> = ({
activeSessionId,
onSelect,
onDelete,
onRename,
onExportSessions,
onImportSessions,
onClose,
}) => {
const { t } = useI18n();
const [renderCount, setRenderCount] = useState(SESSION_RENDER_BATCH);
const [searchQuery, setSearchQuery] = useState('');
const [renamingSessionId, setRenamingSessionId] = useState<string | null>(null);
const [renameDraft, setRenameDraft] = useState('');
const renameInputRef = useRef<HTMLInputElement>(null);
const searchHintId = useId();
const searchInputRef = useRef<HTMLInputElement>(null);
const { filteredSessions, isSearching } = useSessionHistorySearch(
@@ -52,16 +64,69 @@ export const SessionHistoryDrawer: React.FC<SessionHistoryDrawerProps> = ({
const displayedSessions = filteredSessions.slice(0, renderCount);
const hiddenSessionCount = Math.max(0, filteredSessions.length - renderCount);
useEffect(() => {
if (renamingSessionId) renameInputRef.current?.focus();
}, [renamingSessionId]);
const beginRename = (sessionId: string, currentTitle: string) => {
setRenamingSessionId(sessionId);
setRenameDraft(currentTitle);
};
const commitRename = () => {
if (renamingSessionId && renameDraft.trim()) {
onRename?.(renamingSessionId, renameDraft.trim());
}
setRenamingSessionId(null);
setRenameDraft('');
};
return (
<div className="flex-1 flex flex-col min-h-0">
<div className="px-4 py-2.5 flex items-center justify-between shrink-0 border-b border-border/30">
<span className="text-[13px] font-medium text-foreground/80">{t('ai.chat.allSessions')}</span>
<button
onClick={onClose}
className="text-[12px] text-muted-foreground/60 hover:text-muted-foreground transition-colors cursor-pointer"
>
<X size={14} />
</button>
<div className="flex items-center gap-2">
{onImportSessions && (
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={(e) => {
e.stopPropagation();
onImportSessions();
}}
className="text-[12px] text-muted-foreground/60 hover:text-muted-foreground transition-colors cursor-pointer"
aria-label={t('ai.chat.importSessions')}
>
<Upload size={14} />
</button>
</TooltipTrigger>
<TooltipContent>{t('ai.chat.importSessions')}</TooltipContent>
</Tooltip>
)}
{onExportSessions && (
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={(e) => {
e.stopPropagation();
onExportSessions();
}}
className="text-[12px] text-muted-foreground/60 hover:text-muted-foreground transition-colors cursor-pointer"
aria-label={t('ai.chat.exportSessions')}
>
<Download size={14} />
</button>
</TooltipTrigger>
<TooltipContent>{t('ai.chat.exportSessions')}</TooltipContent>
</Tooltip>
)}
<button
onClick={onClose}
className="text-[12px] text-muted-foreground/60 hover:text-muted-foreground transition-colors cursor-pointer"
>
<X size={14} />
</button>
</div>
</div>
<div className="px-3 py-2 shrink-0 border-b border-border/30">
<div className="relative">
@@ -146,12 +211,52 @@ export const SessionHistoryDrawer: React.FC<SessionHistoryDrawerProps> = ({
)}
>
<span className={SESSION_HISTORY_ROW_CLASSNAMES.title}>
{session.title || t('ai.chat.untitled')}
{renamingSessionId === session.id ? (
<Input
ref={renameInputRef}
type="text"
value={renameDraft}
aria-label={t('ai.chat.renameChat')}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => {
e.stopPropagation();
if (e.key === 'Enter') {
e.preventDefault();
commitRename();
} else if (e.key === 'Escape') {
e.preventDefault();
setRenamingSessionId(null);
setRenameDraft('');
}
}}
onChange={(e) => setRenameDraft(e.target.value)}
onBlur={commitRename}
className="h-6 text-[12px]"
/>
) : (
session.title || t('ai.chat.untitled')
)}
</span>
<div className={SESSION_HISTORY_ROW_CLASSNAMES.meta}>
<span className={SESSION_HISTORY_ROW_CLASSNAMES.time}>
{timeStr}
</span>
{onRename && renamingSessionId !== session.id && (
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={(e) => {
e.stopPropagation();
beginRename(session.id, session.title || '');
}}
className={SESSION_HISTORY_ROW_CLASSNAMES.deleteButton}
>
<Pencil size={12} />
</button>
</TooltipTrigger>
<TooltipContent>{t('ai.chat.renameChat')}</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<button
+137
View File
@@ -64,6 +64,7 @@ import {
type DefaultTargetSessionHint,
} from '../application/state/useAIChatStreaming';
import { getScopedHistorySessions } from './ai/scopedHistorySessions';
import { buildAiChatsListingEntries, canRebindChat, formatAiChatsListingNote, resolveRebindTargetFromArg, type AiChatsListingEntry } from './ai/aiChatsListing';
import { resolveInheritedAIActiveSessionId } from '../domain/aiWorkspaceScopeInherit';
import { resolveUndoLastTurnBoundary } from '../domain/aiUndoLastTurn';
import { aiSessionIdSetEqual, exactScopeAISessionsEqual } from '../domain/aiSessionsForScope';
@@ -279,6 +280,9 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
undoLastTurnInSession,
deleteSession,
updateSessionTitle,
rebindSessionScope,
exportSessionsBackup,
importSessionsBackup,
updateSessionExternalSessionId,
addMessageToSession,
updateLastMessage,
@@ -312,7 +316,9 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
scopeHostIds,
scopeLabel,
focusedSessionId,
workspaces = [],
terminalSessions = [],
liveScopeTargetIds,
resolveExecutorContext,
isVisible = true,
notes = [],
@@ -327,6 +333,11 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
const scopeKey = `${scopeType}:${scopeTargetId ?? ''}`;
const [showHistory, setShowHistory] = useState(false);
// Inline system-style note above the composer (e.g. `/chats` listing).
// Panel-command output, never agent content.
const [panelCommandNote, setPanelCommandNote] = useState<string | null>(null);
// Entries of the last `/chats` listing; `/rebind <n>` resolves against them.
const lastChatsListingRef = useRef<AiChatsListingEntry[]>([]);
const [isSending, setIsSending] = useState(false);
// Undo-last-turn is still
// running. Unlike the send flow this is not reflected in `isSending`, so it
@@ -484,6 +495,119 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
const activeSessionRef = useRef(activeSession);
activeSessionRef.current = activeSession;
/** Rename a chat (slash command + history drawer affordance). Panel action: nothing is sent to the agent. */
const handleRenameSession = useCallback((sessionId: string, rawTitle: string) => {
const title = rawTitle.trim();
if (!title) {
toast.warning(t('ai.chat.renameUsage'));
return;
}
if (!sessionId) {
toast.warning(t('ai.chat.renameNoActive'));
return;
}
updateSessionTitle(sessionId, title);
toast.success(t('ai.chat.renameDone').replace('{title}', title));
}, [t, updateSessionTitle]);
const handleRenameSlashCommand = useCallback((title: string) => {
handleRenameSession(activeSessionRef.current?.id ?? null, title);
}, [handleRenameSession]);
const handleListChats = useCallback(() => {
// Prefer the app-wide live target set so chats bound to other still-open
// terminals are not mislabelled dangling / offered for /rebind.
const liveTerminalIds = liveScopeTargetIds
?? (scopeType === 'workspace' && workspaceMemberTerminalIds
? workspaceMemberTerminalIds
: new Set(scopeTargetId ? [scopeTargetId] : []));
// Dangling chats no longer match any live scope (rank 0), so they must be
// appended explicitly for the listing — that is where they become visible
// and rebindable again (issue #4015 R2).
const listedSessionIds = new Set(historySessions.map((session) => session.id));
const danglingSessions = sessions.filter((session) => session.scopeDangling && !listedSessionIds.has(session.id));
const entries = buildAiChatsListingEntries({
sessions: [...historySessions, ...danglingSessions],
activeSessionId,
externalAgents,
workspaces,
liveTerminalIds,
currentScopeLabel: scopeLabel,
scopeTargetId,
t,
});
lastChatsListingRef.current = entries;
setPanelCommandNote(formatAiChatsListingNote(entries, t));
}, [activeSessionId, externalAgents, historySessions, liveScopeTargetIds, scopeLabel, scopeTargetId, scopeType, sessions, t, workspaces, workspaceMemberTerminalIds]);
// `/rebind <n>`: rebind the nth dangling chat from the last /chats listing
// to this panel's current scope. Panel action — no agent round-trip.
const handleRebindChat = useCallback((arg: string) => {
if (!scopeTargetId) {
toast.warning(t('ai.chat.rebindUnavailable'));
return;
}
const target = resolveRebindTargetFromArg(arg, lastChatsListingRef.current);
if (!target) {
toast.warning(t('ai.chat.rebindUsage'));
return;
}
const session = sessions.find((item) => item.id === target.sessionId);
const liveTargets = new Set(liveScopeTargetIds ?? workspaceMemberTerminalIds ?? []);
for (const workspace of workspaces) liveTargets.add(workspace.id);
liveTargets.add(scopeTargetId);
if (canRebindChat(session, liveTargets)) {
rebindSessionScope?.(target.sessionId, { type: scopeType, targetId: scopeTargetId, hostIds: scopeHostIds });
toast.success(t('ai.chat.rebindDone').replace('{title}', target.title));
} else {
toast.warning(t('ai.chat.rebindUsage'));
}
// Do not rebuild /chats from this render's stale sessions (still dangling).
// Drop the resolved entry from the stored listing so a repeat /rebind <n>
// cannot target it; the next explicit /chats refreshes from live state.
const remaining = lastChatsListingRef.current.filter(
(entry) => entry.sessionId !== target.sessionId,
);
let danglingCounter = 0;
for (const entry of remaining) {
if (!entry.isDangling) {
entry.rebindNumber = undefined;
continue;
}
danglingCounter += 1;
entry.rebindNumber = danglingCounter;
}
lastChatsListingRef.current = remaining;
setPanelCommandNote(formatAiChatsListingNote(remaining, t));
}, [liveScopeTargetIds, rebindSessionScope, scopeHostIds, scopeTargetId, scopeType, sessions, t, workspaces, workspaceMemberTerminalIds]);
// Manual durability actions (history drawer header). Export writes the
// whole store to a user-chosen JSON file; import merges by id.
const handleExportSessions = useCallback(() => {
void exportSessionsBackup?.().then((filePath) => {
if (filePath == null) {
toast.warning(t('ai.chat.exportUnavailable'));
return;
}
toast.success(
filePath
? t('ai.chat.exportDone').replace('{path}', filePath)
: t('ai.chat.exportDoneNoPath'),
);
});
}, [exportSessionsBackup, t]);
const handleImportSessions = useCallback(() => {
void importSessionsBackup?.().then((count) => {
if (count <= 0) {
toast.warning(t('ai.chat.importNone'));
return;
}
toast.success(t('ai.chat.importDone').replace('{n}', String(count)));
});
}, [importSessionsBackup, t]);
const defaultTargetSession = useMemo<DefaultTargetSessionHint | undefined>(() => {
const connectedSessions = terminalSessions.filter((session) => session.connected !== false);
@@ -2065,6 +2189,14 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
setInputValue={setInputValue}
handleSend={handleSend}
handleCompact={handleCompact}
onRenameSession={handleRenameSession}
onRenameActiveChat={handleRenameSlashCommand}
onListChats={handleListChats}
onRebindChat={handleRebindChat}
panelCommandNote={panelCommandNote}
onDismissPanelCommandNote={() => setPanelCommandNote(null)}
onExportSessions={handleExportSessions}
onImportSessions={handleImportSessions}
canUndoLastTurn={canUndoLastTurn}
handleUndoLastTurn={undoLastTurnInSession ? handleUndoLastTurn : undefined}
handleSteer={handleSteer}
@@ -2143,6 +2275,9 @@ const AI_CHAT_SIDE_PANEL_AI_STATE_KEYS = [
'createSession',
'deleteSession',
'updateSessionTitle',
'rebindSessionScope',
'exportSessionsBackup',
'importSessionsBackup',
'updateSessionExternalSessionId',
'addMessageToSession',
'updateLastMessage',
@@ -2193,6 +2328,8 @@ export function aiChatSidePanelPropsAreEqual(
if ((prev.isVisible ?? true) !== (next.isVisible ?? true)) return false;
if (prev.scopeHostIds !== next.scopeHostIds) return false;
if (prev.terminalSessions !== next.terminalSessions) return false;
if (prev.liveScopeTargetIds !== next.liveScopeTargetIds) return false;
if (prev.workspaces !== next.workspaces) return false;
if (prev.resolveExecutorContext !== next.resolveExecutorContext) return false;
if (prev.notes !== next.notes) return false;
if (prev.hosts !== next.hosts) return false;
+10
View File
@@ -44,6 +44,12 @@ export interface AIChatSidePanelProps {
) => Promise<{ sessionId: string; restored: { text: string; attachments: import('../infrastructure/ai/types').UploadedFile[]; selectedUserSkillSlugs: string[] } } | null>;
deleteSession: (sessionId: string, scopeKey?: string) => void;
updateSessionTitle: (sessionId: string, title: string) => void;
/** Issue #4015 R2: rebind a dangling chat to this panel's scope (panel action, never agent content). */
rebindSessionScope?: (sessionId: string, scope: { type: 'terminal' | 'workspace'; targetId: string; hostIds?: string[] }) => void;
/** Manual durability escape hatch: export the store to a JSON file (returns the path, '' when fixed-path). */
exportSessionsBackup?: () => Promise<string | null>;
/** Import sessions from a JSON export; merges by id. Returns the imported count. */
importSessionsBackup?: () => Promise<number>;
updateSessionExternalSessionId: (sessionId: string, externalSessionId: string | undefined) => void;
addMessageToSession: (sessionId: string, message: ChatMessage) => void;
updateLastMessage: (
@@ -103,6 +109,10 @@ export interface AIChatSidePanelProps {
scopeLabel?: string;
/** Workspace focused pane; preferred when inheriting AI chat after merge. */
focusedSessionId?: string;
/** Live workspaces, used to resolve workspace titles for `/chats`. */
workspaces?: Array<{ id: string; title: string }>;
/** All currently live terminal (and workspace) scope target ids. */
liveScopeTargetIds?: Set<string>;
// Terminal session context (from parent)
terminalSessions?: Array<{
@@ -503,6 +503,19 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
enabled={!!form.showLineTimestamps}
onToggle={() => update("showLineTimestamps", !form.showLineTimestamps)}
/>
{(form.protocol === undefined || form.protocol === "ssh") && !form.moshEnabled && !form.etEnabled && (
<>
<ToggleRow
label={t("hostDetails.sshInputCompatibility")}
hint={t("hostDetails.sshInputCompatibility.desc")}
enabled={form.sshInputCompatibility ?? true}
onToggle={() => update("sshInputCompatibility", !(form.sshInputCompatibility ?? true))}
/>
<p className="text-xs text-muted-foreground">
{t("hostDetails.sshInputCompatibility.reconnect")}
</p>
</>
)}
<HostDetailsSettingRow label={t("hostDetails.backspaceBehavior")}>
<Select
value={form.backspaceBehavior ?? "default"}
+1
View File
@@ -182,6 +182,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
os: "linux",
authMethod: undefined,
authPolicyVersion: 1,
sshInputCompatibility: false,
charset: groupDefaults?.charset ? undefined : "UTF-8",
distroMode: "auto",
createdAt: Date.now(),
@@ -0,0 +1,138 @@
import assert from "node:assert/strict";
import test from "node:test";
import React from "react";
import type { Host, Identity } from "../types";
import { handleKeyboardInteractiveSubmitImpl } from "../application/app/AppHandlers";
import { createTerminalSessionStarters } from "./terminal/runtime/createTerminalSessionStarters";
import { createPromptLineBreakState } from "./terminal/runtime/promptLineBreak";
import { createDomRenderer, dispatchDomEvent, installDomEnvironment } from "./test-support/renderReactDom";
const noop = () => {};
test("KI remember input preserves the reconnect account and leaves shared hosts unchanged", async (t) => {
const dom = installDomEnvironment();
const globals = {
MutationObserver: dom.window.MutationObserver,
NodeFilter: dom.window.NodeFilter,
HTMLInputElement: dom.window.HTMLInputElement,
localStorage: dom.window.localStorage,
};
const previous = new Map(Object.keys(globals).map((key) => [key, Object.getOwnPropertyDescriptor(globalThis, key)]));
for (const [key, value] of Object.entries(globals)) Object.defineProperty(globalThis, key, { configurable: true, value });
const { KeyboardInteractiveModal } = await import("./KeyboardInteractiveModal");
const { I18nProvider } = await import("../application/i18n/I18nProvider");
const renderer = await createDomRenderer(dom.document);
try {
for (const scenario of [
{ name: "differing usernames", hostUsername: "old-account", identityUsername: "fixtureki", remember: true },
{ name: "blank host username", hostUsername: "", identityUsername: "fixtureki", remember: true },
{ name: "blank identity username", hostUsername: "fixtureki", identityUsername: " ", remember: true },
{ name: "no identity", hostUsername: "fixtureki", identityUsername: undefined, remember: true },
{ name: "remember unchecked", hostUsername: "old-account", identityUsername: "fixtureki", remember: false },
{ name: "secondary password", hostUsername: "old-account", identityUsername: "fixtureki", remember: true, allowSavePassword: false },
]) {
await t.test(scenario.name, async () => {
const identity: Identity = {
id: "shared", label: "Fixture", username: scenario.identityUsername ?? "",
authMethod: "password", password: "fixture-old", created: 0,
};
const identities = scenario.identityUsername === undefined ? [] : [identity];
const originalIdentity = { ...identity };
const host: Host = {
id: "changed", label: "Fixture", hostname: "127.0.0.1", username: scenario.hostUsername,
tags: [], os: "linux", protocol: "ssh", authMethod: "password",
password: "fixture-host-old", savePassword: false,
...(identities.length ? { identityId: identity.id } : {}),
};
const otherHost = { ...host, id: "unchanged", label: "Other fixture" };
let hosts = [host, otherHost];
let updates = 0;
const request = {
requestId: `ki-${scenario.name}`, sessionId: "fixture-session", hostname: host.hostname,
name: "Password expiry", instructions: "", prompts: [{ prompt: "New password:", echo: false }],
allowSavePassword: scenario.allowSavePassword ?? true,
};
let queue = [request];
const responses: unknown[][] = [];
await renderer.render(
<I18nProvider locale="en">
<KeyboardInteractiveModal request={request} onCancel={() => false}
onSubmit={(requestId, values, savePassword) => handleKeyboardInteractiveSubmitImpl(() => ({
hosts, identities, keyboardInteractiveQueue: queue,
sessions: [{ id: "fixture-session", hostId: host.id, hostname: host.hostname }],
netcattyBridge: { get: () => ({ respondKeyboardInteractive: (...args: unknown[]) => {
responses.push(args);
return { success: true };
} }) },
setKeyboardInteractiveQueue: (updater: (items: typeof queue) => typeof queue) => { queue = updater(queue); },
updateHosts: (next: Host[]) => { updates += 1; hosts = next; },
t: (key: string) => key, toast: { error: (message: string) => assert.fail(message) },
}), requestId, values, savePassword)} />
</I18nProvider>,
);
const input = dom.document.querySelector<HTMLInputElement>("#ki-prompt-0");
assert.ok(input);
Object.getOwnPropertyDescriptor(dom.window.HTMLInputElement.prototype, "value")!.set!.call(input, "fixture-new");
await dispatchDomEvent(input, new dom.window.Event("input", { bubbles: true }));
const checkbox = dom.document.querySelector<HTMLInputElement>('input[type="checkbox"]');
if (scenario.allowSavePassword === false) assert.equal(checkbox, null);
else {
assert.ok(checkbox);
assert.equal(checkbox.checked, false);
if (scenario.remember) await dispatchDomEvent(checkbox, new dom.window.MouseEvent("click", { bubbles: true }));
}
const submit = [...dom.document.querySelectorAll("button")].find((button) => button.textContent === "Submit");
assert.ok(submit);
await dispatchDomEvent(submit, new dom.window.MouseEvent("click", { bubbles: true }));
assert.deepEqual(responses, [[request.requestId, ["fixture-new"], false]]);
assert.deepEqual(queue, []);
const saved = scenario.remember && scenario.allowSavePassword !== false;
assert.equal(updates, saved ? 1 : 0);
assert.equal(hosts[0].identityId, saved && identities.length ? "" : host.identityId);
assert.equal(hosts[0].password, saved ? "fixture-new" : host.password);
assert.equal(hosts[0].savePassword, saved);
assert.equal(hosts[1], otherHost);
assert.deepEqual(identity, originalIdentity);
// Exercise the reconnect launcher that builds the actual SSH bridge request.
for (const [index, candidate] of hosts.entries()) {
let started: { username: string; password?: string } | undefined;
const ctx = {
host: candidate, identities, keys: [], knownHosts: [], resolvedChainHosts: [],
sessionId: `reconnect-${index}`, terminalSettings: {},
sessionRef: { current: null }, hasConnectedRef: { current: true },
hasRunStartupCommandRef: { current: true }, disposeDataRef: { current: null },
disposeExitRef: { current: null }, fitAddonRef: { current: null },
serializeAddonRef: { current: null }, pendingAuthRef: { current: null },
promptLineBreakStateRef: { current: createPromptLineBreakState() }, sudoAutofillRef: { current: null },
updateStatus: noop, setStatus: noop, setError: noop, setNeedsAuth: noop,
setAuthRetryMessage: noop, setAuthPassword: noop, setProgressLogs: noop,
setProgressValue: noop, setChainProgress: noop,
terminalBackend: {
backendAvailable: () => true,
startSSHSession: async (options: { username: string; password?: string }) => { started = options; return `reconnect-${index}`; },
onSessionData: () => noop, onSessionExit: () => noop, onChainProgress: () => noop,
writeToSession: noop, resizeSession: noop,
},
};
await createTerminalSessionStarters(ctx as never).startSSH({
cols: 80, rows: 24, write: noop, writeln: noop, scrollToBottom: noop,
} as never);
assert.ok(started);
assert.equal(started.username, scenario.identityUsername?.trim() || scenario.hostUsername);
assert.equal(started.password, saved && index === 0 ? "fixture-new" : identities.length ? "fixture-old" : "fixture-host-old");
}
// A fresh request resets the checkbox and submitting state for the next case.
await renderer.render(<I18nProvider locale="en"><KeyboardInteractiveModal request={null} onSubmit={() => false} onCancel={() => false} /></I18nProvider>);
});
}
} finally {
await renderer.unmount();
await new Promise((resolve) => setTimeout(resolve, 0));
for (const [key, descriptor] of previous) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else Reflect.deleteProperty(globalThis, key);
}
dom.cleanup();
}
});
+222
View File
@@ -0,0 +1,222 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { JSDOM } from 'jsdom';
import { createRequire } from 'node:module';
import { EventEmitter, once } from 'node:events';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const require = createRequire(import.meta.url);
test('cold plugin host reaches the local fixture through the actual main provider allowlist without the AI sidebar', { timeout: 20_000 }, async (t) => {
// JSDOM + the in-process runtime prove app wiring; they are not native sandbox
// evidence. All state is synthetic, with no real provider, key, Note or host.
const rootPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'netcatty-ai-ui-'));
const oldTmp = process.env.TMPDIR; process.env.TMPDIR = fixtureRoot;
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', { pretendToBeVisual: true, url: 'http://localhost' });
const previous = new Map<string, PropertyDescriptor | undefined>();
const install = (key: string, value: unknown) => {
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
};
for (const key of ['window', 'document', 'navigator', 'HTMLElement', 'HTMLInputElement', 'HTMLSelectElement', 'Element', 'Node', 'NodeFilter', 'MutationObserver', 'CustomEvent', 'Event', 'KeyboardEvent', 'MouseEvent', 'DocumentFragment']) {
install(key, key === 'window' ? dom.window : dom.window[key as keyof typeof dom.window]);
}
install('localStorage', dom.window.localStorage);
install('getComputedStyle', dom.window.getComputedStyle.bind(dom.window));
install('requestAnimationFrame', dom.window.requestAnimationFrame.bind(dom.window));
install('cancelAnimationFrame', dom.window.cancelAnimationFrame.bind(dom.window));
install('IS_REACT_ACT_ENVIRONMENT', true);
const { default: React, act } = await import('react');
const { createRoot } = await import('react-dom/client');
const { PluginAIContextHost } = await import('./plugins/PluginAIContextHost');
const { I18nProvider } = await import('../application/i18n/I18nProvider');
const { publishNotesSnapshot, getNotesSnapshot } = await import('../application/state/notesStore');
const { registerAppAppLockRuntime, getAppAppLockRuntime } = await import('../application/state/appRuntimeBridge');
const { STORAGE_KEY_AI_PROVIDERS, STORAGE_KEY_AI_ACTIVE_PROVIDER, STORAGE_KEY_AI_ACTIVE_MODEL } = await import('../infrastructure/config/storageKeys');
const { createAssistantProviderFixture } = await import('../scripts/plugin-assistant-provider-fixture.mjs');
const { createPluginHostService } = require('../electron/plugins/hostService.cjs');
const { inProcessBrowserRuntime } = require('../electron/plugins/fixtures/inProcessBrowserRuntime.cjs');
const { createPreloadApi } = require('../electron/preload/api.cjs');
const aiBridge = require('../electron/bridges/aiBridge.cjs');
const { registerProviderHandlers } = require('../electron/bridges/aiBridge/providerHandlers.cjs');
const { server, counts } = createAssistantProviderFixture({ delayMs: 25 });
const oldNotes = getNotesSnapshot(); const oldLock = getAppAppLockRuntime();
let service: ReturnType<typeof createPluginHostService> | undefined;
let panel: JSDOM | undefined;
const root = createRoot(document.getElementById('root')!);
let locked = false; let contentAllowed = false;
const lockListeners = new Set<(state: { locked: boolean }) => void>();
const reviews: Array<Record<string, unknown>> = [];
const trustedPayloads: unknown[] = [];
const streamResults: Array<{ ok: boolean; statusCode?: number; allowlistRejected: boolean }> = [];
const providerSyncs: Array<{ count: number; selectedPresent: boolean }> = [];
const sdkErrorNames: string[] = [];
let diagnosticPhase = 'setup';
const originalConsoleError = console.error;
// node:sqlite was imported above. Node 22 delivers its ExperimentalWarning
// asynchronously through console.error. Let module warnings reach normal
// stderr before observing SDK/renderer errors; do not suppress that warning.
await new Promise<void>((resolve) => setImmediate(resolve));
// The expected pre-fix SDK error contains request details. Keep diagnostics
// categorical even on the failing-before path; never print payloads/headers.
console.error = (value: unknown) => {
let category = 'unknown';
if (typeof value === 'string' && /not wrapped in act/.test(value)) category = 'react-act-warning';
else if (typeof value === 'string' && /act.*environment|environment.*act/.test(value)) category = 'react-act-environment';
else if (typeof value === 'string' && /ExperimentalWarning: SQLite is an experimental feature/.test(value)) category = 'node-sqlite-experimental';
else if (typeof value === 'string') category = 'other-string';
else if (value && typeof value === 'object') {
const name = (value as { name?: unknown }).name;
if (typeof name === 'string' && ['AbortError', 'AI_APICallError', 'AI_LoadAPIKeyError', 'TypeError', 'Error'].includes(name)) category = name;
}
sdkErrorNames.push(`${diagnosticPhase}:${category}`);
};
t.after(() => { console.error = originalConsoleError; });
try {
server.listen(0, '127.0.0.1'); await once(server, 'listening');
const address = server.address(); assert.ok(address && typeof address !== 'string');
const endpoint = `http://127.0.0.1:${address.port}/v1`;
const ipcHandlers = new Map<string, (...args: unknown[]) => unknown>();
const ipcRenderer = new EventEmitter() as EventEmitter & { invoke: (channel: string, payload: unknown) => Promise<unknown> };
const sender = Object.assign(new EventEmitter(), { id: 1, isDestroyed: () => false, send: (channel: string, payload: unknown) => {
if (channel === 'netcatty:plugins:ai-host-request') trustedPayloads.push(structuredClone(payload));
ipcRenderer.emit(channel, {}, structuredClone(payload));
} });
const mainWindow = Object.assign(new EventEmitter(), { webContents: sender, isDestroyed: () => false, isVisible: () => true, isFocused: () => true });
const providerContext = {
ipcMain: { handle: (channel: string, handler: (...args: unknown[]) => unknown) => ipcHandlers.set(channel, handler) },
providerConfigs: [] as Array<{ id: string; baseURL?: string; apiKey?: string }>, webSearchApiHost: null,
validateSender: (event: { sender: unknown }) => event.sender === sender,
validateSenderOrSettings: (event: { sender: unknown }) => event.sender === sender,
// Only credential injection is synthetic. Admission, sync and the HTTP
// transport are the production implementations, not a direct-fetch seam.
injectApiKeyIntoRequest: (url: string, headers: Record<string, string>, providerId: string) => {
const config = providerContext.providerConfigs.find((item) => item.id === providerId);
return { url, headers: Object.fromEntries(Object.entries(headers).map(([key, value]) =>
[key, config?.apiKey ? value.replace('__IPC_SECURED__', config.apiKey) : value])) };
},
shouldSkipTLSVerify: () => false,
streamRequest: aiBridge._streamRequestForTests,
};
registerProviderHandlers(providerContext);
assert.equal(providerContext.providerConfigs.length, 0);
ipcRenderer.invoke = async (channel, payload) => {
if (channel === 'netcatty:ai:chat:stream') {
const request = payload as { url: string; headers: Record<string, string>; body: string; requestId: string };
assert.equal(new URL(request.url).origin, new URL(endpoint).origin);
assert.doesNotMatch(JSON.stringify(request), /fixture-only/);
}
if (channel === 'netcatty:ai:chat:cancel') return aiBridge.cancelPluginAIStreamsForSender(sender) > 0;
const handler = ipcHandlers.get(channel);
assert.ok(handler, `Unexpected trusted IPC: ${channel}`);
const result = await handler({ sender }, structuredClone(payload));
if (channel === 'netcatty:ai:sync-providers') providerSyncs.push({ count: providerContext.providerConfigs.length,
selectedPresent: providerContext.providerConfigs.some((item) => item.id === 'qa-provider') });
if (channel === 'netcatty:ai:chat:stream') {
const status = result as { ok: boolean; statusCode?: number; error?: string };
streamResults.push({ ok: status.ok, statusCode: status.statusCode, allowlistRejected: status.error === 'URL host is not in the allowed list' });
}
return result;
};
const api = createPreloadApi({ ipcRenderer, webUtils: {} });
Object.defineProperty(dom.window, 'netcatty', { value: api });
const syntheticNote = { id: 'synthetic-note', title: 'Synthetic retry plan', content: 'Synthetic retry plan: add a retry button.', createdAt: 1, updatedAt: 1 };
publishNotesSnapshot({ notes: [syntheticNote], noteGroups: [] });
registerAppAppLockRuntime({ locked: false } as NonNullable<ReturnType<typeof getAppAppLockRuntime>>);
dom.window.localStorage.setItem(STORAGE_KEY_AI_PROVIDERS, JSON.stringify([
{ id: 'qa-provider', name: 'QA fixture', providerId: 'custom', enabled: true, baseURL: endpoint, defaultModel: 'deterministic-fixture', apiKey: 'fixture-only' },
{ id: 'qa-secondary', name: 'Synthetic secondary', providerId: 'custom', enabled: true, baseURL: 'https://secondary.invalid/v1', defaultModel: 'synthetic', apiKey: 'secondary-fixture-marker' },
]));
dom.window.localStorage.setItem(STORAGE_KEY_AI_ACTIVE_PROVIDER, 'qa-provider');
dom.window.localStorage.setItem(STORAGE_KEY_AI_ACTIVE_MODEL, 'deterministic-fixture');
service = createPluginHostService({
app: { getAppPath: () => rootPath, getPath: () => fixtureRoot, getVersion: () => '0.0.0' }, env: {},
getInteractionWindow: () => mainWindow, isAppLocked: () => locked,
subscribeAppLock: (listener: (state: { locked: boolean }) => void) => { lockListeners.add(listener); return () => lockListeners.delete(listener); },
electron: { ipcMain: { handle: (name: string, handler: (...args: unknown[]) => unknown) => ipcHandlers.set(name, handler), removeHandler: (name: string) => ipcHandlers.delete(name) },
dialog: { showMessageBox: async (_window: unknown, options: Record<string, unknown>) => { reviews.push(options); return { response: 1 }; } } },
requestPermissionDecision: async (request: { requestId: string; permission: string; relatedPermissions?: Array<{ permission: string }> }) => (request.permission === 'context.selected.read' || request.relatedPermissions?.some((item) => item.permission === 'context.selected.read')) && !contentAllowed
? { requestId: request.requestId, decision: 'deny' }
: { requestId: request.requestId, decision: 'allow', scope: request.permission === 'context.selected.read' ? 'once' : 'application' },
});
service.runtimeSupervisor.runtimeFactories.browser = inProcessBrowserRuntime;
await service.manager.initialize();
const staging = path.join(fixtureRoot, 'package'); fs.mkdirSync(path.join(staging, 'dist'), { recursive: true });
const sample = path.join(rootPath, 'examples/plugins/assistant');
for (const file of ['netcatty.plugin.json', 'README.md']) fs.copyFileSync(path.join(sample, file), path.join(staging, file));
fs.copyFileSync(path.join(sample, 'dist/host.js'), path.join(staging, 'dist/host.js'));
const { buildPluginPackage } = await import('@netcatty/plugin-cli');
const archive = path.join(fixtureRoot, 'assistant.ncpkg'); await buildPluginPackage(staging, archive);
service.database.setPermissionMode('com.netcatty.assistant', 'context.selected.read', 'ask');
await service.manager.install(archive, { enable: true });
await act(async () => root.render(<I18nProvider locale="en"><PluginAIContextHost sessions={[]} workspaces={[]} /></I18nProvider>));
const waitFor = async (condition: () => boolean) => {
const deadline = Date.now() + 5000;
while (!condition()) {
if (Date.now() >= deadline) throw Error('Connected host UI state timed out');
await act(async () => { await new Promise((resolve) => setTimeout(resolve, 5)); });
}
};
panel = new JSDOM(fs.readFileSync(path.join(sample, 'view.html'), 'utf8'), { runScripts: 'outside-only' });
Object.defineProperty(panel.window, 'pluginContext', { value: { commands: {
executeCommand: async (command: string, args: unknown) => structuredClone(await service!.contributionService.executeCommand(command, structuredClone(args))),
}, views: { onDidReceiveMessage: () => ({ dispose() {} }) }
} });
diagnosticPhase = 'initial-selection';
await act(async () => panel!.window.eval(fs.readFileSync(path.join(sample, 'panel.js'), 'utf8').replace('export function mount', 'function mount') + '\nwindow.disposePanel = mount(document.body, window.pluginContext, "test", "test");'));
await waitFor(() => document.getElementById('plugin-ai-note') !== null);
const noteChoice = document.getElementById('plugin-ai-note') as HTMLSelectElement;
assert.equal(noteChoice.options.length, 2);
await act(async () => { noteChoice.value = syntheticNote.id; noteChoice.dispatchEvent(new Event('change', { bubbles: true })); });
await act(async () => { Array.from(document.querySelectorAll('button')).find((button) => button.textContent === 'Share selected summaries')!.click(); });
const element = (id: string) => panel!.window.document.getElementById(id)!;
await waitFor(() => element('status').textContent === 'Ready');
assert.match(element('model').textContent!, /QA fixture/);
const reference = element('context') as HTMLSelectElement;
assert.equal(reference.value, ''); assert.equal(reference.options.length, 2);
assert.notEqual(reference.options[1].value, syntheticNote.id); reference.value = reference.options[1].value;
const send = () => element('compose').dispatchEvent(new panel!.window.Event('submit', { cancelable: true }));
diagnosticPhase = 'deny-content';
send(); await waitFor(() => element('status').textContent!.startsWith('Request ended'));
assert.equal(counts.requests, 0); assert.equal(reviews.length, 0);
contentAllowed = true;
diagnosticPhase = 'complete-stream';
send(); await waitFor(() => counts.completed === 1 || streamResults.some((result) => !result.ok));
assert.deepEqual(streamResults.at(-1), { ok: true, statusCode: 200, allowlistRejected: false },
'Cold plugin startup must synchronize host providers before the main allowlist admits transport');
assert.ok(providerSyncs.some((sync) => sync.selectedPresent && sync.count === 2), 'Preserve other host-configured providers in the shared registry');
await waitFor(() => element('status').textContent === 'Complete');
assert.equal(reviews.length, 0, 'content policy must not open a second native sharing review');
assert.equal(counts.completed, 1); assert.match(element('response').textContent!, /Synthetic plan/); assert.match(element('usage').textContent!, /12 input tokens/);
diagnosticPhase = 'stop-stream';
send(); await waitFor(() => element('response').textContent!.startsWith('Synthetic plan: '));
element('stop').dispatchEvent(new panel.window.Event('click'));
await waitFor(() => element('status').textContent === 'Stopped' && counts.aborted === 1);
const stoppedText = element('response').textContent; await new Promise((resolve) => setTimeout(resolve, 50)); assert.equal(element('response').textContent, stoppedText);
diagnosticPhase = 'lock-stream';
send(); await waitFor(() => element('response').textContent!.startsWith('Synthetic plan: '));
locked = true; registerAppAppLockRuntime({ locked: true } as NonNullable<ReturnType<typeof getAppAppLockRuntime>>);
for (const listener of lockListeners) listener({ locked: true });
await waitFor(() => counts.aborted === 2 && service!.aiContextService.broker.turns.size === 0);
assert.equal(aiBridge._getActiveStreamCountForTests(), 0);
assert.doesNotMatch(JSON.stringify(trustedPayloads), /fixture-only|secondary-fixture-marker|apiKey|privateOwner|permissionMode|mcp/);
assert.deepEqual(sdkErrorNames, []);
} finally {
console.error = originalConsoleError;
// JSDOM.close does not deliver the browser pagehide lifecycle. Invalidate
// the sample's pending callbacks before teardown closes its document.
if (panel) { panel.window.disposePanel(); panel.window.close(); }
await act(async () => root.unmount());
await service?.manager.shutdown(); aiBridge.cleanup();
publishNotesSnapshot(oldNotes); registerAppAppLockRuntime(oldLock);
server.closeAllConnections(); await new Promise<void>((resolve) => server.close(() => resolve()));
dom.window.close();
for (const [key, value] of previous) { if (value) Object.defineProperty(globalThis, key, value); else Reflect.deleteProperty(globalThis, key); }
if (oldTmp === undefined) delete process.env.TMPDIR; else process.env.TMPDIR = oldTmp;
fs.rmSync(fixtureRoot, { recursive: true, force: true });
}
});
+193
View File
@@ -0,0 +1,193 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { JSDOM } from 'jsdom';
import { createRequire } from 'node:module';
import { EventEmitter, once } from 'node:events';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const require = createRequire(import.meta.url);
test('installed global Chat UI reuses actual Catty runtime, configured HTTP transport and selected worker dispatch', { timeout: 30_000 }, async () => {
// This Node/JSDOM harness proves installed app wiring, not a native sandbox or
// real model/Claude acceptance. No real shell, remote host or credentials.
const rootPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'netcatty-global-agent-ui-'));
const oldTmp = process.env.TMPDIR; process.env.TMPDIR = directory;
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', { pretendToBeVisual: true, url: 'http://localhost' });
const previous = new Map<string, PropertyDescriptor | undefined>();
const install = (key: string, value: unknown) => { previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key)); Object.defineProperty(globalThis, key, { configurable: true, writable: true, value }); };
for (const key of ['window', 'document', 'navigator', 'HTMLElement', 'HTMLInputElement', 'HTMLSelectElement', 'Element', 'Node', 'NodeFilter', 'MutationObserver', 'CustomEvent', 'Event', 'KeyboardEvent', 'MouseEvent', 'DocumentFragment']) install(key, key === 'window' ? dom.window : dom.window[key as keyof typeof dom.window]);
install('localStorage', dom.window.localStorage); install('getComputedStyle', dom.window.getComputedStyle.bind(dom.window));
install('requestAnimationFrame', dom.window.requestAnimationFrame.bind(dom.window)); install('cancelAnimationFrame', dom.window.cancelAnimationFrame.bind(dom.window)); install('IS_REACT_ACT_ENVIRONMENT', true);
const { default: React, act } = await import('react'); const { createRoot } = await import('react-dom/client');
const { PluginAgentHost } = await import('./plugins/PluginAgentHost');
const { I18nProvider } = await import('../application/i18n/I18nProvider');
const { registerAppAppLockRuntime, getAppAppLockRuntime } = await import('../application/state/appRuntimeBridge');
const keys = await import('../infrastructure/config/storageKeys');
const { createGlobalAgentProviderFixture } = await import('../scripts/plugin-global-agent-provider-fixture.mjs');
const { createPluginHostService } = require('../electron/plugins/hostService.cjs');
const { inProcessBrowserRuntime } = require('../electron/plugins/fixtures/inProcessBrowserRuntime.cjs');
const { createPreloadApi } = require('../electron/preload/api.cjs');
const { registerProviderHandlers } = require('../electron/bridges/aiBridge/providerHandlers.cjs');
const { registerCattyExecHandlers } = require('../electron/bridges/aiBridge/cattyExecHandlers.cjs');
const { registerAgentProcessHandlers } = require('../electron/bridges/aiBridge/agentProcessHandlers.cjs');
const { registerPrivacyHandlers } = require('../electron/bridges/aiBridge/privacyHandlers.cjs');
const aiBridge = require('../electron/bridges/aiBridge.cjs'); const mcp = require('../electron/bridges/mcpServerBridge.cjs');
const privacy = require('../electron/shared/aiPrivacyService.cjs');
const { server, counts } = createGlobalAgentProviderFixture({ delayMs: 30 });
const oldLock = getAppAppLockRuntime(); const root = createRoot(document.getElementById('root')!);
let service: ReturnType<typeof createPluginHostService> | undefined; let panel: JSDOM | undefined;
const errors: string[] = []; const originalError = console.error;
let phase = 'setup';
// Flush the known Node sqlite import warning before observing safe categories.
await new Promise<void>((resolve) => setImmediate(resolve));
console.error = (value: unknown) => { errors.push(`${phase}:${typeof value === 'string' && value === '[Plugin Agent] Turn unavailable' ? 'DELEGATED_TURN_UNAVAILABLE' : typeof value === 'string' && /not wrapped in act/.test(value) ? 'REACT_ACT_WARNING' : 'SDK_OR_RENDERER_ERROR'}`); };
try {
privacy.configure({ enabled: false }); server.listen(0, '127.0.0.1'); await once(server, 'listening');
const address = server.address(); assert.ok(address && typeof address !== 'string'); const endpoint = `http://127.0.0.1:${address.port}/v1`;
const handlers = new Map<string, (...args: unknown[]) => unknown>();
const ipcMain = { handle: (name: string, handler: (...args: unknown[]) => unknown) => handlers.set(name, handler), removeHandler: (name: string) => handlers.delete(name) };
const ipcRenderer = new EventEmitter() as EventEmitter & { invoke: (channel: string, payload: unknown) => Promise<unknown> };
const trustedReplies: unknown[] = []; const pluginReplies: unknown[] = []; const workerCalls: string[] = [];
const sender = Object.assign(new EventEmitter(), { id: 7, isDestroyed: () => false, send: (channel: string, payload: unknown) => ipcRenderer.emit(channel, {}, structuredClone(payload)) });
const mainWindow = Object.assign(new EventEmitter(), { webContents: sender, isDestroyed: () => false, isVisible: () => true, isFocused: () => true });
const owner = (id: string) => id === 'synthetic-a' ? 7 : id === 'synthetic-b' ? 8 : null;
const worker = { getSessionOwnerWebContentsId: owner, getPluginSessionBinding: (id: string, actual: number) => actual === owner(id) ? { epoch: 'fixture-worker', generation: id === 'synthetic-a' ? 1 : 2 } : null,
send: () => {}, request: async (method: string, payload: { sessionId: string }, options?: { beforeExecute?: () => void }) => {
options?.beforeExecute?.(); if (method === 'netcatty:ai:exec') { workerCalls.push(payload.sessionId); return { ok: true, stdout: 'Synthetic worker output', stderr: '', exitCode: 0 }; } return [];
} };
mcp.init({ sessions: new Map(), terminalWorkerManager: worker, cliDiscoveryFilePath: path.join(directory, 'discovery.json') }); mcp.setPermissionMode('observer');
const common = { ipcMain, validateSender: (event: { sender: unknown }) => event.sender === sender,
validateSenderOrSettings: (event: { sender: unknown }) => event.sender === sender, mcpServerBridge: mcp, aiPrivacyService: privacy };
const providerContext = { ...common, require: createRequire(path.join(rootPath, 'electron/bridges/aiBridge.cjs')),
providerConfigs: [] as Array<{ id: string; baseURL?: string; apiKey?: string }>, webSearchApiHost: null,
injectApiKeyIntoRequest: (url: string, headers: Record<string, string>, providerId: string) => {
const provider = providerContext.providerConfigs.find((entry) => entry.id === providerId);
return { url, headers: Object.fromEntries(Object.entries(headers).map(([key, value]) => [key, value.replace('__IPC_SECURED__', provider?.apiKey ?? '')])) };
}, shouldSkipTLSVerify: () => false, streamRequest: aiBridge._streamRequestForTests,
// The production stream function owns this map. Turn release below uses
// its actual main lease; direct SDK abort still calls the ordinary route.
activeStreams: new Map() };
registerProviderHandlers(providerContext); registerCattyExecHandlers(common); registerAgentProcessHandlers(common); registerPrivacyHandlers(common);
ipcRenderer.invoke = async (channel, payload) => {
if (channel === 'netcatty:ai:chat:stream') {
const request = payload as { requestId: string; url: string }; assert.equal(new URL(request.url).origin, new URL(endpoint).origin);
assert.match(request.requestId, /^agent-model_/);
}
const handler = handlers.get(channel); assert.ok(handler, `Unexpected trusted IPC: ${channel}`);
const result = await handler({ sender }, structuredClone(payload));
if (channel === 'netcatty:plugins:agent-host-response') trustedReplies.push(payload);
return result;
};
const api = createPreloadApi({ ipcRenderer, webUtils: {} }); Object.defineProperty(dom.window, 'netcatty', { value: api });
registerAppAppLockRuntime({ locked: false } as NonNullable<ReturnType<typeof getAppAppLockRuntime>>);
dom.window.localStorage.setItem(keys.STORAGE_KEY_AI_PROVIDERS, JSON.stringify([{ id: 'fixture-provider', name: 'Synthetic fixture', providerId: 'custom', enabled: true,
baseURL: endpoint, defaultModel: 'global-agent-fixture', apiKey: 'FIXTURE_ONLY_API_MARKER' }]));
dom.window.localStorage.setItem(keys.STORAGE_KEY_AI_ACTIVE_PROVIDER, 'fixture-provider');
service = createPluginHostService({ app: { getAppPath: () => rootPath, getPath: () => directory, getVersion: () => '0.0.0' }, env: {},
electron: { ipcMain }, getInteractionWindow: () => mainWindow, isAppLocked: () => false, getTerminalWorkerManager: () => worker });
service.runtimeSupervisor.runtimeFactories.browser = inProcessBrowserRuntime; await service.manager.initialize();
const sample = path.join(rootPath, 'examples/plugins/global-agent'); const staging = path.join(directory, 'package'); fs.mkdirSync(path.join(staging, 'dist'), { recursive: true });
for (const file of ['netcatty.plugin.json', 'README.md']) fs.copyFileSync(path.join(sample, file), path.join(staging, file));
fs.copyFileSync(path.join(sample, 'dist/host.js'), path.join(staging, 'dist/host.js'));
const { buildPluginPackage } = await import('@netcatty/plugin-cli'); const archive = path.join(directory, 'global-agent.ncpkg'); await buildPluginPackage(staging, archive);
await service.manager.install(archive, { enable: true });
await act(async () => root.render(<I18nProvider locale="en"><PluginAgentHost sessions={[
{ id: 'synthetic-a', hostId: 'host-a', hostLabel: 'Workspace A fixture', hostname: 'a.invalid', username: 'fixture', status: 'connected' },
{ id: 'synthetic-b', hostId: 'host-b', hostLabel: 'Workspace B fixture', hostname: 'b.invalid', username: 'fixture', status: 'connected' },
]} /></I18nProvider>));
const waitFor = async (condition: () => boolean) => { const until = Date.now() + 8_000; while (!condition()) {
if (Date.now() >= until) throw Error(`Global Agent UI fixture timed out; accepted=${counts.requests}, rejected=${counts.rejected}, effects=${workerCalls.length}, status=${panel?.window.document.getElementById('status')?.textContent}`);
await act(async () => { await new Promise((resolve) => setTimeout(resolve, 5)); });
} };
let viewOpen = true;
const mountPanel = async () => {
panel = new JSDOM(fs.readFileSync(path.join(sample, 'view.html'), 'utf8'), { runScripts: 'outside-only' });
Object.defineProperty(panel.window, 'pluginContext', { value: { commands: { executeCommand: async (name: string, params: unknown) => {
// PluginViewHost.close removes sender authority before WebContents
// closes. A pagehide RPC cannot be relied on to reach this relay.
if (!viewOpen) throw Error('Untrusted plugin view sender');
const result = structuredClone(await service!.contributionService.executeCommand(name, structuredClone(params))); pluginReplies.push(result); return result;
} }, views: { onDidReceiveMessage: () => ({ dispose() {} }) } } });
await act(async () => panel!.window.eval(fs.readFileSync(path.join(sample, 'panel.js'), 'utf8').replace('export function mount', 'function mount') + '\nwindow.disposePanel = mount(document.body, window.pluginContext, "test", "test");'));
};
await mountPanel();
const element = (id: string) => panel!.window.document.getElementById(id)!;
await waitFor(() => element('status').textContent!.startsWith('Choose terminals'));
await act(async () => element('targets').click()); await waitFor(() => document.querySelectorAll('input[type="checkbox"]').length === 2);
await act(async () => { for (const checkbox of document.querySelectorAll<HTMLInputElement>('input[type="checkbox"]')) checkbox.click(); });
await act(async () => { [...document.querySelectorAll('button')].find((button) => button.textContent === 'Use selected terminals')!.click(); });
await waitFor(() => element('status').textContent!.startsWith('Targets selected'));
await act(async () => element('open').click()); await waitFor(() => element('status').textContent!.startsWith('Ready'));
let prompt = element('prompt') as HTMLTextAreaElement; prompt.value = 'Synthetic selected-target task';
const send = () => element('compose').dispatchEvent(new panel!.window.Event('submit', { cancelable: true }));
phase = 'complete'; await act(async () => { send(); }); await waitFor(() => element('status').textContent === 'Complete');
assert.deepEqual(workerCalls, ['synthetic-b']); assert.match(element('transcript').textContent!, /Synthetic global Agent result/);
assert.equal(mcp.getPermissionMode(), 'observer'); assert.equal(counts.requests, 2); assert.equal(counts.rejected, 0);
assert.match(element('usage').textContent!, /input tokens/);
phase = 'stop'; prompt.value = 'fixture:wait'; await act(async () => { send(); });
await waitFor(() => element('transcript').textContent!.includes('Synthetic waiting output'));
await act(async () => element('stop').click()); await waitFor(() => element('status').textContent === 'Stopped' && counts.aborted === 1);
const stopped = element('transcript').textContent; await new Promise((resolve) => setTimeout(resolve, 75)); assert.equal(element('transcript').textContent, stopped);
phase = 'modal-reopen-history';
const reference = pluginReplies.find((value) => value && typeof value === 'object' && 'conversationRef' in value && 'chatHandle' in value) as { conversationRef: string };
const beforeReopen = counts.requests;
viewOpen = false;
panel!.window.disposePanel(); panel!.window.close();
viewOpen = true; await mountPanel();
await waitFor(() => element('status').textContent!.startsWith('Choose terminals'));
(element('conversation') as HTMLSelectElement).value = reference.conversationRef;
await act(async () => element('conversation').dispatchEvent(new panel!.window.Event('change')));
await act(async () => element('targets').click()); await waitFor(() => document.querySelectorAll('input[type="checkbox"]').length === 2);
await act(async () => { [...document.querySelectorAll('button')].find((button) => button.textContent === 'Use selected terminals')!.click(); });
await waitFor(() => element('status').textContent!.startsWith('Targets selected'));
await act(async () => element('open').click());
await waitFor(() => /^(Ready|Conversation unavailable)/.test(element('status').textContent!));
assert.match(element('status').textContent!, /^Ready/, 'ordinary modal reopen must release the prior exact chat even if pagehide cleanup never reaches the plugin');
assert.match(element('transcript').textContent!, /Synthetic global Agent result/);
assert.equal(service.agentService.chats.size, 1); assert.equal(counts.requests, beforeReopen, 'history reopen does not invoke a model');
prompt = element('prompt') as HTMLTextAreaElement; prompt.value = 'Synthetic continuation';
phase = 'deny'; const before = counts.requests; const plugin = service.database.getActivePlugin('com.netcatty.globalagent');
service.permissionEngine.setMode(plugin, 'ai.agent.actions', 'deny', service.permissionEngine.getPolicyRevision(plugin));
await act(async () => { send(); }); await waitFor(() => element('status').textContent!.startsWith('Request ended'));
assert.equal(counts.requests, before); assert.deepEqual(workerCalls, ['synthetic-b']);
phase = 'restart-history';
await service.manager.setEnabled('com.netcatty.globalagent', false);
await service.manager.setEnabled('com.netcatty.globalagent', true);
await act(async () => element('refresh').click()); await waitFor(() => element('status').textContent!.startsWith('Choose terminals'));
const conversation = element('conversation') as HTMLSelectElement; conversation.value = reference.conversationRef;
await act(async () => conversation.dispatchEvent(new panel!.window.Event('change')));
await act(async () => element('targets').click()); await waitFor(() => document.querySelectorAll('input[type="checkbox"]').length === 2);
await act(async () => { [...document.querySelectorAll('button')].find((button) => button.textContent === 'Use selected terminals')!.click(); });
await waitFor(() => element('status').textContent!.startsWith('Targets selected'));
await act(async () => element('open').click()); await waitFor(() => element('status').textContent!.startsWith('Ready'));
assert.match(element('transcript').textContent!, /Synthetic global Agent result/, 'owned core history survives runtime restart');
prompt.value = 'Synthetic model-only continuation'; await act(async () => { send(); });
await waitFor(() => element('status').textContent === 'Complete');
assert.equal(counts.requests, before + 1, 'Actions Deny allows a tool-free configured model turn after reopening');
assert.deepEqual(workerCalls, ['synthetic-b']);
phase = 'backend-revision';
const latestChat = [...pluginReplies].reverse().find((value) => value && typeof value === 'object' && 'chatHandle' in value) as { chatHandle: string };
const generation = service.agentService.host.generation; const requestsBeforeChange = counts.requests;
const { LOCAL_STORAGE_ADAPTER_CHANGED_EVENT } = await import('../infrastructure/persistence/localStorageAdapter');
dom.window.localStorage.setItem(keys.STORAGE_KEY_AI_ACTIVE_MODEL, 'changed-synthetic-model');
await act(async () => window.dispatchEvent(new CustomEvent(LOCAL_STORAGE_ADAPTER_CHANGED_EVENT, { detail: { key: keys.STORAGE_KEY_AI_ACTIVE_MODEL } })));
await waitFor(() => service!.agentService.host.generation > generation && service!.agentService.chats.size === 0);
await assert.rejects(service.contributionService.executeCommand('com.netcatty.globalagent.history', { chatHandle: latestChat.chatHandle }));
assert.equal(counts.requests, requestsBeforeChange, 'configuration invalidation never retries model inference');
assert.doesNotMatch(JSON.stringify(pluginReplies), /FIXTURE_ONLY_API_MARKER|apiKey|externalSessionId|plugin-agent:|opaque-owner|mcpServers|permissionMode/);
assert.doesNotMatch(JSON.stringify(trustedReplies), /FIXTURE_ONLY_API_MARKER|apiKey/);
assert.deepEqual(errors, []);
} finally {
console.error = originalError;
if (panel) { panel.window.disposePanel(); panel.window.close(); }
await act(async () => root.unmount()); await service?.manager.shutdown();
mcp.setPermissionMode('confirm'); mcp.cleanup(); aiBridge.cleanup(); privacy.configure({ enabled: false }); registerAppAppLockRuntime(oldLock);
server.closeAllConnections(); await new Promise<void>((resolve) => server.close(() => resolve())); dom.window.close();
for (const [key, descriptor] of previous) { if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key); }
if (oldTmp === undefined) delete process.env.TMPDIR; else process.env.TMPDIR = oldTmp;
fs.rmSync(directory, { recursive: true, force: true });
}
});
@@ -43,11 +43,11 @@ test('installed sample entrypoint uses real SDK, source authority, signed host d
const sender = Object.assign(new EventEmitter(), { id: 77, isDestroyed: () => false, send(_channel: string, event: HostInteractionEvent) { if (!('closed' in event)) prompts.push(event); for (const listener of listeners) listener(event); } });
const owner = Object.assign(new EventEmitter(), { webContents: sender, isFocused: () => true, isVisible: () => true, isDestroyed: () => false });
let locked = false;
let onLock: ((state: { locked: boolean }) => void) | undefined;
const lockListeners = new Set<(state: { locked: boolean }) => void>();
const service = createPluginHostService({
app: { getAppPath: () => process.cwd(), getPath: () => directory, getVersion: () => '0.0.0' }, electron: {}, env: {},
getInteractionWindow: () => owner, isAppLocked: () => locked,
subscribeAppLock: (callback: typeof onLock) => { onLock = callback; return () => {}; },
subscribeAppLock: (callback: (state: { locked: boolean }) => void) => { lockListeners.add(callback); return () => lockListeners.delete(callback); },
requestPermissionDecision: async (request: { requestId: string; permission: string }) => ({ requestId: request.requestId, decision: 'allow', scope: ['vault.metadata', 'vault.write', 'ui.interact'].includes(request.permission) ? 'once' : 'application' }),
});
let requestClock = Date.now();
@@ -109,6 +109,7 @@ test('installed sample entrypoint uses real SDK, source authority, signed host d
const archive = path.join(directory, 'fixture.ncpkg');
await buildPluginPackage(packageRoot, archive);
await service.manager.install(archive, { enable: true });
service.database.setPermissionMode('com.netcatty.host-actions', 'ui.interact', 'ask');
service.interactionService.setReady(sender, true);
await act(async () => root.render(<I18nProvider locale="en"><PluginHostActionHost /></I18nProvider>));
@@ -180,7 +181,7 @@ test('installed sample entrypoint uses real SDK, source authority, signed host d
await t.test('Vault lock cancels a pending connection', async () => {
const run = await begin('connect'); await chooseGroup();
await waitFor(() => button('Connect') !== undefined);
await act(async () => { locked = true; publish(); onLock?.({ locked: true }); await settle(); });
await act(async () => { locked = true; publish(); for (const listener of lockListeners) listener({ locked: true }); await settle(); });
await assert.rejects(run.promise, /cancelled/); assert.equal(connections.length, 1);
locked = false; publish();
});
@@ -192,8 +193,8 @@ test('installed sample entrypoint uses real SDK, source authority, signed host d
const run = await begin('edit'); await chooseGroup();
await waitFor(() => button('Save Host') !== undefined); await click(button('Save Host'));
await waitFor(() => preparing);
await act(async () => { service.permissionEngine.revokeApplication('com.netcatty.host-actions', 'vault.write'); release(); await settle(); });
await assert.rejects(run.promise, /revoked|cancelled/); assert.equal(saves, 1); beforeCommit = undefined;
await act(async () => { service.permissionEngine.setMode(service.database.getActivePlugin('com.netcatty.host-actions'), 'vault.write', 'deny'); release(); await settle(); });
await assert.rejects(run.promise, /revoked|cancelled/); assert.equal(saves, 1); beforeCommit = undefined; service.permissionEngine.setMode(service.database.getActivePlugin('com.netcatty.host-actions'), 'vault.write', 'allow');
});
await t.test('source replacement during review cannot use the old action authority', async () => {
@@ -272,13 +273,44 @@ test('installed sample entrypoint uses real SDK, source authority, signed host d
}
});
await t.test('Allow connects through the selected target without a confirmation dialog; Deny blocks every Host action', async () => {
const id = 'com.netcatty.host-actions';
const plugin = service.database.getActivePlugin(id);
const set = (permission: string, mode: string) => service.permissionEngine.setMode(plugin, permission, mode);
set('ui.interact', 'allow');
const allowed = await begin('connect'); await chooseGroup();
await act(async () => { await allowed.promise; await settle(); });
assert.equal(document.querySelector('[role="dialog"]'), null);
assert.equal(connections.length, 2);
for (const [permission, command] of [['vault.metadata', 'select'], ['vault.write', 'create']] as const) {
set(permission, 'deny'); requestClock += 60_001;
await assert.rejects(service.contributionService.executeCommand(`${id}.${command}`), /disabled/);
assert.equal(document.querySelector('[role="dialog"]'), null);
set(permission, 'allow');
}
set('ui.interact', 'deny');
const denied = await begin('connect'); await chooseGroup();
await assert.rejects(denied.promise, /disabled/);
assert.equal(connections.length, 2); assert.equal(saves, 1);
set('ui.interact', 'ask'); set('vault.write', 'ask');
const draft = await begin('create');
assert.equal(document.querySelectorAll('[role="dialog"]').length, 1);
assert.ok(button('Save Host')); await click(button('Cancel'));
assert.deepEqual(await draft.promise, { cancelled: true });
set('vault.write', 'allow'); set('vault.metadata', 'ask');
const selection = await begin('select'); await chooseGroup();
assert.ok('hosts' in await selection.promise);
assert.equal(document.querySelector('[role="dialog"]'), null);
set('vault.metadata', 'allow');
});
await t.test('runtime removal rejects old signed replies', async () => {
const run = await begin('create');
const old = prompts.at(-1)!;
await act(async () => { await service.manager.setEnabled('com.netcatty.host-actions', false); await settle(); });
await assert.rejects(run.promise);
assert.equal(await service.interactionService.respond(sender, { requestId: old.requestId, actionToken: old.actionToken, action: 'submit', draft: { hostname: 'late.invalid' } }), false);
assert.equal(saves, 1); assert.equal(connections.length, 1);
assert.equal(saves, 1); assert.equal(connections.length, 2);
});
} finally {
await act(async () => { root.unmount(); await settle(); });
+61 -6
View File
@@ -3,7 +3,7 @@ import test from 'node:test';
import { EventEmitter } from 'node:events';
import { JSDOM } from 'jsdom';
test('host dialog drives choice/input/copy and the two-phase clipboard share using real service action tokens', async () => {
test('host dialog drives choice/input/copy and a single scoped clipboard share using real service action tokens', async () => {
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', { pretendToBeVisual: true, url: 'http://localhost' });
const previous = new Map<string, PropertyDescriptor | undefined>();
const install = (key: string, value: unknown) => {
@@ -22,25 +22,34 @@ test('host dialog drives choice/input/copy and the two-phase clipboard share usi
const { I18nProvider } = await import('../application/i18n/I18nProvider');
const { PluginHostInteractionHost } = await import('./plugins/PluginHostInteractionHost');
const { PluginHostInteractionService, assertInteractionRequest } = await import('../electron/plugins/hostInteractionService.cjs');
const { getTerminalPaneRenderSnapshot, parseTerminalPaneRenderSnapshot, resolveTerminalPaneInputProps } = await import('./terminalPaneVisibility');
const { createPluginTerminalSelectionSource } = await import('../application/state/usePluginTerminalSelection');
const { registerTerminalSelectionSource } = await import('../infrastructure/plugins/terminalSelectionRegistry');
const { privateOwner, LOCAL_SOURCE } = await import('../electron/plugins/sourceIdentity.cjs');
let selectionReads = 0;
let selectionText = 'synthetic terminal selection\nsecond line';
let listener: ((event: unknown) => void) | null = null;
let reads = 0;
const writes: string[] = [];
const audits: unknown[] = [];
const sender = Object.assign(new EventEmitter(), { id: 7, isDestroyed: () => false, send(_channel: string, payload: unknown) { listener?.(payload); } });
const owner = Object.assign(new EventEmitter(), { webContents: sender, isFocused: () => true, isVisible: () => true, isDestroyed: () => false });
const terminalManager = { getPluginSessionBinding: (id: string, owner: number) => id === 'terminal-a' && owner === 7 ? { epoch: 'worker-a', generation: 1 } : null };
const service = new PluginHostInteractionService({
getWindow: () => owner, isLocked: () => false,
permissionEngine: { onDidRevoke() { return { dispose() {} }; } },
database: { recordSecurityAudit(...args: unknown[]) { audits.push(args); } },
getTerminalWorkerManager: () => terminalManager,
database: { privateOwner: (id: string) => privateOwner(LOCAL_SOURCE, id), publicPluginId: () => 'com.example.synthetic', recordSecurityAudit(...args: unknown[]) { audits.push(args); } },
quotaManager: { chargeBytes() {} },
clipboard: { readText() { reads++; return 'synthetic private text'; }, writeText(value: string) { writes.push(value); } },
});
Object.assign(dom.window, { netcatty: {
async setPluginTerminalTarget(target: unknown) { return service.setTerminalTarget(sender, target); },
onPluginInteraction(callback: (event: unknown) => void) { listener = callback; return () => { listener = null; }; },
async setPluginInteractionReady(ready: boolean) { return service.setReady(sender, ready); },
async respondPluginInteraction(response: unknown) { return service.respond(sender, response); },
} });
const context = { pluginId: 'com.example.synthetic', pluginVersion: '1.0.0', runtimeId: 'runtime-synthetic', signal: new AbortController().signal, async assertActive() {} };
const context = { pluginId: 'com.example.synthetic', pluginVersion: '1.0.0', runtimeId: 'runtime-synthetic', manifest: { features: { required: ['terminal.selection-sharing', 'terminal.input-drafts'] } }, signal: new AbortController().signal, async assertActive() {} };
const root = createRoot(document.getElementById('root')!);
const settle = () => new Promise((resolve) => setTimeout(resolve, 20));
const button = (text: string) => Array.from(document.querySelectorAll<HTMLButtonElement>('button')).find((item) => item.textContent === text)!;
@@ -71,14 +80,60 @@ test('host dialog drives choice/input/copy and the two-phase clipboard share usi
assert.deepEqual(await copy.promise, { cancelled: false, value: true });
assert.deepEqual(writes, ['synthetic output']);
const share = await begin({ kind: 'clipboard.read', title: 'Share clipboard' });
assert.equal(reads, 0);
assert.equal(document.querySelector('textarea'), null);
await click(button('Preview clipboard'));
assert.equal(reads, 1);
assert.equal(document.querySelector('textarea')?.value, 'synthetic private text');
await click(button('Share with plugin'));
assert.deepEqual(await share.promise, { cancelled: false, value: 'synthetic private text' });
assert.equal(JSON.stringify(audits).includes('synthetic private text'), false);
const terminalElement = document.createElement('div'); document.body.append(terminalElement);
const terminal = { cols: 80, element: terminalElement,
getSelectionPosition: () => ({ start: { x: 0, y: 0 }, end: { x: 10, y: 0 } }),
getSelection() { selectionReads++; return selectionText; },
} as unknown as import('@xterm/xterm').Terminal;
const inputProps = resolveTerminalPaneInputProps(parseTerminalPaneRenderSnapshot(getTerminalPaneRenderSnapshot({
activeTabId: 'terminal-a', sessionId: 'terminal-a', workspaceById: new Map(), isTerminalLayerVisible: true,
})));
assert.equal(inputProps.isFocused, false);
const selectionState = { term: terminal, sessionId: 'terminal-a', bootEpoch: 1, visible: inputProps.isVisible, isFocusedPane: inputProps.isFocusedPane, connected: true, sensitive: false, normalize: false, label: 'Synthetic selected terminal' };
const cleanupSelection = registerTerminalSelectionSource('terminal-a', createPluginTerminalSelectionSource(() => selectionState, 'renderer-a'));
try {
const noShare = await begin({ kind: 'terminal.selection.read', title: 'Synthetic terminal share' });
assert.match(document.querySelector('[role="dialog"]')?.textContent ?? '', /passwords, tokens/);
assert.match(document.querySelector('[role="dialog"]')?.textContent ?? '', /Synthetic selected terminal/);
await click(button('Cancel'));
assert.deepEqual(await noShare.promise, { cancelled: true });
assert.equal(selectionReads, 1);
const selected = await begin({ kind: 'terminal.selection.read', title: 'Synthetic terminal share' });
assert.equal(selectionReads, 2);
assert.equal(document.querySelector('textarea')?.value, selectionText);
selectionText = 'changed after preview';
await click(button('Share with plugin'));
const result = await selected.promise as { cancelled: false; value: { text: string; handle: string } };
assert.equal(result.value.text, 'synthetic terminal selection\nsecond line');
assert.equal(selectionReads, 2);
const draft = await begin({ kind: 'terminal.input.propose', handle: result.value.handle, title: 'Synthetic editable review', text: 'first\nsecond\r\u0003\u001b[31m\u0000' });
assert.match(document.querySelector('[role="dialog"]')?.textContent ?? '', /sends no terminal input or Enter/);
const textarea = document.querySelector('textarea')!;
assert.equal(textarea.readOnly, false);
await act(async () => {
Object.getOwnPropertyDescriptor(dom.window.HTMLTextAreaElement.prototype, 'value')!.set!.call(textarea, 'edited synthetic draft\nnext line');
textarea.dispatchEvent(new dom.window.Event('input', { bubbles: true }));
textarea.dispatchEvent(new dom.window.KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
await settle();
});
assert.equal(textarea.value, 'edited synthetic draft\nnext line');
assert.equal(service.pending.size, 1, 'Enter in the editable review does not submit or dispatch');
assert.match(document.querySelector('pre')?.textContent ?? '', /edited synthetic draft/);
assert.deepEqual(writes, ['synthetic output']);
await click(button('Close draft'));
assert.deepEqual(await draft.promise, { cancelled: false, value: true });
assert.deepEqual(writes, ['synthetic output']);
selectionState.visible = false;
const hidden = await begin({ kind: 'terminal.selection.read', title: 'Hidden terminal' });
assert.deepEqual(await hidden.promise, { cancelled: true });
assert.equal(selectionReads, 2);
assert.equal(document.querySelector('[role="dialog"]'), null);
} finally { cleanupSelection(); terminalElement.remove(); }
const cancelled = await begin({ kind: 'clipboard.write', title: 'Cancelled copy', text: 'must not copy' });
await click(button('Cancel'));
assert.deepEqual(await cancelled.promise, { cancelled: true });
+142
View File
@@ -0,0 +1,142 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { EventEmitter } from 'node:events';
import { JSDOM } from 'jsdom';
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
// Test-only observer preserves the security-surface visibility assertions.
function observePluginHostSecurityVisibility(mount: HTMLElement, publish: (visible: boolean) => void): () => void {
const document = mount.ownerDocument;
let visible = true;
const update = () => {
const blocked = [...document.querySelectorAll('[data-host-security-surface]')].some((surface) => {
const style = document.defaultView!.getComputedStyle(surface);
return style.display !== 'none' && style.visibility !== 'hidden'
&& !surface.closest('[hidden], [inert], [aria-hidden="true"]');
});
if (visible === !blocked) return;
visible = !blocked;
publish(visible);
};
const observer = new document.defaultView!.MutationObserver(update);
observer.observe(document.body, { childList: true, subtree: true, attributes: true,
attributeFilter: ['class', 'style', 'hidden', 'inert', 'aria-hidden', 'data-host-security-surface'] });
update();
return () => observer.disconnect();
}
test('registered Ask uses one in-app dialog; policy controls apply Allow and Deny with no hidden prompt', async () => {
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', { pretendToBeVisual: true, url: 'http://localhost' });
const previous = new Map<string, PropertyDescriptor | undefined>();
const install = (key: string, value: unknown) => {
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
};
for (const key of ['window', 'document', 'navigator', 'HTMLElement', 'HTMLInputElement', 'Element', 'Node', 'NodeFilter', 'MutationObserver', 'CustomEvent', 'Event', 'KeyboardEvent', 'MouseEvent', 'DocumentFragment']) {
install(key, key === 'window' ? dom.window : dom.window[key as keyof typeof dom.window]);
}
install('getComputedStyle', dom.window.getComputedStyle.bind(dom.window));
install('requestAnimationFrame', dom.window.requestAnimationFrame.bind(dom.window));
install('cancelAnimationFrame', dom.window.cancelAnimationFrame.bind(dom.window));
install('IS_REACT_ACT_ENVIRONMENT', true);
dom.window.HTMLElement.prototype.scrollIntoView = () => {};
const { default: React, act } = await import('react');
const { createRoot } = await import('react-dom/client');
const { I18nProvider } = await import('../application/i18n/I18nProvider');
const { PluginPermissionHost } = await import('./plugins/PluginPermissionHost');
const { PluginPermissionPanel } = await import('./settings/tabs/PluginPermissionPanel');
const { PluginPermissionDecisionHost } = require('../electron/plugins/permissionDecisionHost.cjs');
const { PluginPermissionEngine } = require('../electron/plugins/permissionEngine.cjs');
const { PluginHostRpcRegistry } = require('../electron/plugins/hostRpcRegistry.cjs');
const listeners = new Set<(event: unknown) => void>();
const sender = Object.assign(new EventEmitter(), { isDestroyed: () => false, send: (_channel: string, event: unknown) => { for (const listener of listeners) listener(event); } });
const owner = Object.assign(new EventEmitter(), { webContents: sender, isDestroyed: () => false, isFocused: () => true, isVisible: () => true });
const host = new PluginPermissionDecisionHost({ getWindow: () => owner, isLocked: () => false });
let mode = 'allow', effects = 0;
const plugin = { id: 'com.example.permission-ui', activeVersion: '1.0.0', manifest: { id: 'com.example.permission-ui', publisher: 'example', permissions: { optional: [{ permission: 'network', resources: ['https://example.com'] }] } } };
const engine = new PluginPermissionEngine({ database: { getPermissionMode: () => mode, privateOwner: () => 'synthetic-source-owner',
setPermissionMode: (_id: string, _permission: string, value: string) => { mode = value; }, recordSecurityAudit() {}, deleteAllPermissionGrants() {}, listPermissionGrants: () => [] },
requestDecision: (...args: unknown[]) => host.request(...args) });
const registry = new PluginHostRpcRegistry(); registry.use(engine.createMiddleware());
registry.registerRequest('network.fixture', async () => { effects++; return 'synthetic result'; }, { authorization: { permission: 'network', resources: ['https://example.com'] } });
const route = registry.createRoutes({ pluginId: plugin.id, pluginVersion: '1.0.0', runtimeId: 'runtime-ui', runtimeKind: 'browser', manifest: plugin.manifest }).requestHandlers['network.fixture'];
Object.assign(dom.window, { netcatty: {
onPluginPermission(callback: (event: unknown) => void) { listeners.add(callback); return () => listeners.delete(callback); },
setPluginPermissionReady: async (ready: boolean) => host.setReady(sender, ready),
respondPluginPermission: async (response: unknown) => host.respond(sender, response),
getPluginPermissions: async () => ({ grants: [], audit: [], policies: engine.describePolicies(plugin), policyRevision: engine.getPolicyRevision(plugin) }),
setPluginPermissionMode: async (_id: string, permission: string, value: string, revision: string) => {
assert.equal(revision, engine.getPolicyRevision(plugin)); engine.setMode(plugin, permission, value); return true;
},
} });
const root = createRoot(document.getElementById('root')!);
const nativeMount = document.createElement('div'); document.body.append(nativeMount);
const nativeVisibility: boolean[] = [];
const stopVisibility = observePluginHostSecurityVisibility(nativeMount, (visible) => nativeVisibility.push(visible));
const settle = () => new Promise((resolve) => setTimeout(resolve, 20));
const button = (text: string) => Array.from(document.querySelectorAll<HTMLButtonElement>('button')).find((element) => element.textContent === text)!;
const choose = async (text: string) => {
await act(async () => { document.querySelector('[role="combobox"]')!.dispatchEvent(new dom.window.KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })); await settle(); });
const option = Array.from(document.querySelectorAll<HTMLElement>('[role="option"]')).find((element) => element.textContent === text)!;
assert.ok(option);
await act(async () => { option.click(); await settle(); });
};
try {
await act(async () => root.render(<I18nProvider locale="en"><PluginPermissionPanel pluginId={plugin.id} onChanged={async () => {}} /><PluginPermissionHost /></I18nProvider>));
await route({}, {}); assert.equal(effects, 1); assert.equal(document.querySelector('[role="dialog"]'), null);
await choose('Ask'); assert.equal(mode, 'ask');
let operation!: Promise<string>;
await act(async () => { operation = route({}, {}); await settle(); });
assert.equal(document.querySelectorAll('[role="dialog"]').length, 1);
assert.ok(document.querySelector('[role="dialog"][data-host-security-surface]'));
assert.equal(nativeVisibility.at(-1), false, 'the existing native-view lifecycle must yield to the trusted Ask');
assert.match(document.querySelector('[role="dialog"]')!.textContent!, /https:\/\/example.com/);
assert.equal(effects, 1);
await act(async () => { button('Allow this operation').click(); await operation; await settle(); });
assert.equal(effects, 2); assert.equal(document.querySelector('[role="dialog"]'), null);
await new Promise<void>((resolve) => dom.window.queueMicrotask(resolve));
assert.equal(nativeVisibility.at(-1), false, 'visible permission settings remain a security surface after Ask closes');
await choose('Deny'); assert.equal(mode, 'deny');
await assert.rejects(route({}, {}), /disabled/); assert.equal(effects, 2); assert.equal(document.querySelector('[role="dialog"]'), null);
await choose('Allow'); assert.equal(mode, 'allow');
await route({}, {}); assert.equal(effects, 3); assert.equal(document.querySelector('[role="dialog"]'), null);
// Model the settings editor closing before interacting with a native view.
document.querySelector('section[data-host-security-surface]')!.setAttribute('hidden', '');
await new Promise<void>((resolve) => dom.window.queueMicrotask(resolve));
assert.equal(nativeVisibility.at(-1), true);
let displayed!: Promise<unknown>;
const opaque = 'selected:0cfc16d6-3448-4333-82ef-dd4ffbddfdab:v1';
await act(async () => {
displayed = host.request({ requestId: 'host-display-fixture', pluginId: plugin.id, permission: 'ai.invoke', resources: ['*'], reason: 'Host model invocation',
relatedPermissions: [{ permission: 'context.selected.read', resources: [opaque] }],
displayContext: { modelLabel: 'QA fixture · deterministic-fixture', selections: [{ title: 'Synthetic retry plan', scope: 'note', version: 'bf304994-039a-4884-a707-3a1a40a5d3a9' }] },
}); await settle();
});
const text = document.querySelector('[role="dialog"]')!.textContent!;
assert.equal(nativeVisibility.at(-1), false, 'Ask alone must hide the attached plugin view');
assert.match(text, /QA fixture · deterministic-fixture/); assert.match(text, /Synthetic retry plan/);
assert.doesNotMatch(text, /selected:|0cfc16d6|bf304994/);
await act(async () => { button('Deny').click(); await displayed; await settle(); });
await new Promise<void>((resolve) => dom.window.queueMicrotask(resolve));
assert.equal(nativeVisibility.at(-1), true, 'dismissing Ask restores the existing view without closing it');
for (const selections of [[], [{ title: 'Synthetic terminal B', scope: 'session', version: 'opaque-worker-revision' }]]) {
await act(async () => {
displayed = host.request({ requestId: 'agent-empty-args', pluginId: plugin.id, permission: 'ai.agent.actions', resources: ['*'],
displayContext: { modelLabel: 'Synthetic configured Catty', action: { capabilityId: 'session.environment', text: '{}', label: 'PLUGIN_SUPPLIED_OPERATION_NAME' }, selections },
}); await settle();
});
const dialog = document.querySelector('[role="dialog"]')!;
assert.match(dialog.textContent!, /Operation:.*get_environment/);
assert.match(dialog.textContent!, /session\.environment/);
assert.equal(dialog.querySelector('pre')!.textContent, '{}');
if (selections.length) assert.match(dialog.textContent!, /Targets:.*Synthetic terminal B/);
else assert.match(dialog.textContent!, /Targets:.*No targets selected/);
assert.doesNotMatch(dialog.textContent!, /opaque-worker-revision|PLUGIN_SUPPLIED_OPERATION_NAME/);
await act(async () => { button('Deny').click(); await displayed; await settle(); });
}
} finally {
await act(async () => root.unmount()); stopVisibility(); host.shutdown(); engine.shutdown(); dom.window.close();
for (const [key, descriptor] of previous) { if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key); }
}
});
@@ -0,0 +1,115 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { EventEmitter } from 'node:events';
import { createRequire } from 'node:module';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { JSDOM } from 'jsdom';
const require = createRequire(import.meta.url);
test('registered selected-summary Ask is one host chooser; Allow still selects and Deny releases nothing', { timeout: 45_000 }, async () => {
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', { pretendToBeVisual: true, url: 'http://localhost' });
const previous = new Map<string, PropertyDescriptor | undefined>();
const install = (key: string, value: unknown) => {
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
};
for (const key of ['window', 'document', 'navigator', 'HTMLElement', 'HTMLInputElement', 'HTMLSelectElement', 'Element', 'Node', 'NodeFilter', 'MutationObserver', 'CustomEvent', 'Event', 'KeyboardEvent', 'MouseEvent']) install(key, key === 'window' ? dom.window : dom.window[key as keyof typeof dom.window]);
install('getComputedStyle', dom.window.getComputedStyle.bind(dom.window));
install('requestAnimationFrame', dom.window.requestAnimationFrame.bind(dom.window));
install('cancelAnimationFrame', dom.window.cancelAnimationFrame.bind(dom.window));
install('IS_REACT_ACT_ENVIRONMENT', true);
const { default: React, act } = await import('react');
const { createRoot } = await import('react-dom/client');
const { I18nProvider } = await import('../application/i18n/I18nProvider');
const { PluginAIContextHost } = await import('./plugins/PluginAIContextHost');
const { PluginPermissionHost } = await import('./plugins/PluginPermissionHost');
const { publishNotesSnapshot, getNotesSnapshot } = await import('../application/state/notesStore');
const { registerAppAppLockRuntime, getAppAppLockRuntime } = await import('../application/state/appRuntimeBridge');
const { createPluginHostService } = require('../electron/plugins/hostService.cjs');
const { inProcessBrowserRuntime } = require('../electron/plugins/fixtures/inProcessBrowserRuntime.cjs');
const { registerPluginBridge } = require('../electron/plugins/pluginBridge.cjs');
const { createPreloadApi } = require('../electron/preload/api.cjs');
const fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'netcatty-selected-policy-'));
const oldNotes = getNotesSnapshot(); const oldLock = getAppAppLockRuntime();
const handlers = new Map<string, (...args: unknown[]) => unknown>();
const ipcMain = Object.assign(new EventEmitter(), { handle: (channel: string, handler: (...args: unknown[]) => unknown) => handlers.set(channel, handler), removeHandler: (channel: string) => handlers.delete(channel) });
const ipcRenderer = Object.assign(new EventEmitter(), { invoke: async (channel: string, payload: unknown) => {
const handler = handlers.get(channel); assert.ok(handler, channel); return handler({ sender }, structuredClone(payload));
} });
const prompts: Array<{ channel: string; kind?: string; closed?: boolean }> = [];
const sender = Object.assign(new EventEmitter(), { id: 42, isDestroyed: () => false, send: (channel: string, payload: { kind?: string; closed?: boolean }) => {
prompts.push({ channel, kind: payload.kind, closed: payload.closed }); ipcRenderer.emit(channel, {}, structuredClone(payload));
} });
const owner = Object.assign(new EventEmitter(), { webContents: sender, isFocused: () => true, isVisible: () => true, isDestroyed: () => false });
const service = createPluginHostService({ app: { getAppPath: () => process.cwd(), getPath: () => fixtureRoot, getVersion: () => '0.0.0' },
electron: { ipcMain }, env: {}, getInteractionWindow: () => owner, isAppLocked: () => false });
service.runtimeSupervisor.runtimeFactories.browser = inProcessBrowserRuntime;
registerPluginBridge(ipcMain, { ...service, isTrustedSender: (event: { sender: unknown }) => event.sender === sender });
Object.assign(dom.window, { netcatty: createPreloadApi({ ipcRenderer, webUtils: {} }) });
const note = { id: 'synthetic-selected-note', title: 'Synthetic selection', content: 'BODY MUST NOT BE SHARED', createdAt: 1, updatedAt: 1 };
publishNotesSnapshot({ notes: [note], noteGroups: [] }); registerAppAppLockRuntime({ locked: false } as never);
const root = createRoot(document.getElementById('root')!);
const settle = () => new Promise((resolve) => setTimeout(resolve, 20));
const waitForDialog = async () => {
const deadline = Date.now() + 5_000;
while (!document.querySelector('[role="dialog"]')) {
assert.ok(Date.now() < deadline, 'registered route did not present a host interaction');
await act(async () => { await settle(); });
}
};
const button = (label: string) => Array.from(document.querySelectorAll<HTMLButtonElement>('button')).find((element) => element.textContent === label)!;
const begin = () => {
const result = service.contributionService.executeCommand('com.netcatty.assistant.contexts') as Promise<Array<{ handle: string; version: string; title: string }>>;
void result.catch(() => {});
return result;
};
try {
await service.manager.initialize();
const stage = path.join(fixtureRoot, 'package'); fs.mkdirSync(path.join(stage, 'dist'), { recursive: true });
const sample = path.resolve('examples/plugins/assistant');
for (const file of ['netcatty.plugin.json', 'README.md']) fs.copyFileSync(path.join(sample, file), path.join(stage, file));
fs.copyFileSync(path.join(sample, 'dist/host.js'), path.join(stage, 'dist/host.js'));
const { buildPluginPackage } = await import('@netcatty/plugin-cli');
const archive = path.join(fixtureRoot, 'assistant.ncpkg'); await buildPluginPackage(stage, archive); await service.manager.install(archive, { enable: true });
await act(async () => root.render(<I18nProvider locale="en"><PluginPermissionHost /><PluginAIContextHost sessions={[]} workspaces={[]} /></I18nProvider>));
const plugin = service.database.getActivePlugin('com.netcatty.assistant');
for (const mode of ['ask', 'allow']) {
service.permissionEngine.setMode(plugin, 'context.selected.list', mode);
let result!: ReturnType<typeof begin>;
await act(async () => { result = begin(); await settle(); });
await waitForDialog();
assert.ok(document.getElementById('plugin-ai-note'), 'the first interaction must be the real host chooser');
assert.equal(document.querySelectorAll('[role="dialog"]').length, 1);
assert.equal(prompts.filter((prompt) => prompt.channel === 'netcatty:plugins:permission-prompt' && !prompt.closed).length, 0, 'no capability dialog before the chooser');
await act(async () => {
const select = document.getElementById('plugin-ai-note') as HTMLSelectElement; select.value = note.id; select.dispatchEvent(new dom.window.Event('change', { bubbles: true })); await settle();
});
await act(async () => { button('Share selected summaries').click(); await settle(); });
const summaries = await result;
assert.ok(summaries.some((item) => item.title === note.title));
assert.doesNotMatch(JSON.stringify(summaries), /BODY MUST NOT BE SHARED/);
assert.equal(document.querySelector('[role="dialog"]'), null);
}
service.permissionEngine.setMode(plugin, 'context.selected.list', 'deny');
const count = prompts.filter((prompt) => prompt.kind === 'context.select').length;
await assert.rejects(service.contributionService.executeCommand('com.netcatty.assistant.contexts'), /disabled/);
assert.equal(prompts.filter((prompt) => prompt.kind === 'context.select').length, count); assert.equal(document.querySelector('[role="dialog"]'), null);
service.permissionEngine.setMode(plugin, 'context.selected.list', 'ask');
let cancelled!: ReturnType<typeof begin>;
await act(async () => { cancelled = begin(); await settle(); });
await waitForDialog();
await act(async () => { button('Cancel').click(); await settle(); });
await assert.rejects(cancelled); assert.equal(service.aiContextService.selected.handles.size, 0);
let revoked!: ReturnType<typeof begin>;
await act(async () => { revoked = begin(); await settle(); });
await waitForDialog();
await act(async () => { service.permissionEngine.setMode(plugin, 'context.selected.list', 'deny'); await settle(); });
await assert.rejects(revoked); assert.equal(document.querySelector('[role="dialog"]'), null); assert.equal(service.aiContextService.selected.handles.size, 0);
} finally {
await act(async () => root.unmount()); await service.manager.shutdown();
publishNotesSnapshot(oldNotes); registerAppAppLockRuntime(oldLock); dom.window.close(); fs.rmSync(fixtureRoot, { recursive: true, force: true });
for (const [key, descriptor] of previous) { if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key); }
}
});
+21 -35
View File
@@ -14,11 +14,12 @@ test('terminal extension pages use actual split panes, focus and session ownersh
install('PointerEvent', dom.window.MouseEvent);
install('getComputedStyle', dom.window.getComputedStyle.bind(dom.window));
install('requestAnimationFrame', dom.window.requestAnimationFrame.bind(dom.window)); install('cancelAnimationFrame', dom.window.cancelAnimationFrame.bind(dom.window)); install('IS_REACT_ACT_ENVIRONMENT', true);
let rectX = 200;
const rectX = 200;
dom.window.HTMLElement.prototype.getBoundingClientRect = () => new dom.window.DOMRect(rectX, 100, 400, 300);
const { default: React, act, useCallback, useRef, useState } = await import('react');
const { createRoot } = await import('react-dom/client');
const { I18nProvider } = await import('../application/i18n/I18nProvider');
const { TooltipProvider } = await import('./ui/tooltip');
const { activeTabStore } = await import('../application/state/activeTabStore');
const { sidePanelLiveStore, SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT } = await import('../application/state/sidePanelLiveStore');
const { useTerminalSidePanelLayoutState } = await import('../application/state/useTerminalSidePanelLayoutState');
@@ -30,22 +31,17 @@ test('terminal extension pages use actual split panes, focus and session ownersh
type Tool = import('../domain/sidePanelLayout').SidePanelTool;
let selectedSession = 'synthetic-session-a';
const getSession = () => selectedSession;
const a = { id: 'com.example.sidebar.first', title: 'First page', location: 'terminal-sidebar', entry: 'page.html', visible: true };
const a = { id: 'com.example.sidebar.first', title: 'First page', location: 'terminal-sidebar', visible: true };
const b = { ...a, id: 'com.example.sidebar.second', title: 'Second page' };
let snapshot = { locale: 'en', plugins: [{ id: 'com.example.sidebar', displayName: 'Synthetic plugin', version: '1.0.0', commands: [], keybindings: [], menus: [], settings: [], views: [a, b] }] };
const changed = new Set<() => void>(), focusListeners = new Set<(event: { instanceId: string }) => void>();
const opens: NetcattyPluginViewOpenRequest[] = [], closes: string[] = [], focus: string[] = [], bounds: Array<{ instanceId: string; x: number }> = [];
const visible = new Map<string, boolean>();
const changed = new Set<() => void>();
const opens: NetcattyPluginViewOpenRequest[] = [];
Object.assign(dom.window, { netcatty: {
async getPluginRuntimeStatus() { return { available: true }; }, async getPluginContributions() { return snapshot; },
onPluginContributionsChanged(fn: () => void) { changed.add(fn); return () => { changed.delete(fn); }; },
onPluginViewClosed() { return () => {}; },
onPluginViewFocused(fn: (event: { instanceId: string }) => void) { focusListeners.add(fn); return () => { focusListeners.delete(fn); }; },
async openPluginView(payload: NetcattyPluginViewOpenRequest) { opens.push(payload); return { instanceId: payload.instanceId! }; },
async closePluginView(id: string) { closes.push(id); },
async setPluginViewBounds(id: string, rect: { x: number }) { bounds.push({ instanceId: id, x: rect.x }); },
async setPluginViewVisibility(id: string, value: boolean) { visible.set(id, value); },
async focusPluginView(event: { instanceId: string }) { focus.push(event.instanceId); for (const fn of focusListeners) fn(event); },
onPluginViewClosed() { return () => {}; }, onHostDrawnPluginViewUpdate() { return () => {}; },
async openHostDrawnPluginView(payload: NetcattyPluginViewOpenRequest) { opens.push(payload); return { instanceId: payload.instanceId! }; },
async closeHostDrawnPluginView() {},
} });
let split!: () => void;
function Fixture() {
@@ -75,54 +71,44 @@ test('terminal extension pages use actual split panes, focus and session ownersh
const page = model.pages.find((item) => item.tool === pane.tool); const target = hosts.get(pane.tool) ?? parking.current;
if (!page || !target) return null;
return <PersistentSidePanelPortal key={`${pane.id}:${model.sessionId}`} portalKey={`plugin-${pane.id}`} target={target}>
<PluginTerminalSidebarPageHost page={page} tabId={model.tabId!} sessionId={model.sessionId} getSession={getSession} adapter={model.contributions}
focusOnReveal={layout?.focusedPaneId === pane.id} focused={() => focusPane(pane.id)} unavailable={() => closePane(pane.id)} />
<PluginTerminalSidebarPageHost page={page} tabId={model.tabId!} sessionId={model.sessionId} getSession={getSession} showHeader={panes.length === 1} focused={() => focusPane(pane.id)} unavailable={() => closePane(pane.id)} />
</PersistentSidePanelPortal>;
})}</>;
}
const root = createRoot(document.getElementById('root')!); const settle = () => new Promise((resolve) => setTimeout(resolve, 40));
try {
activeTabStore.setActiveTabId('workspace-synthetic'); sidePanelLiveStore.update({ ...SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT, focusedSessionId: selectedSession });
await act(async () => { root.render(<I18nProvider locale="en"><Fixture /></I18nProvider>); await settle(); });
await act(async () => { document.querySelector<HTMLButtonElement>('button[aria-label="Extension pages"]')!.click(); await settle(); });
const firstButton = [...document.querySelectorAll('button')].find((button) => button.textContent?.includes('First page'))!;
await act(async () => { root.render(<I18nProvider locale="en"><TooltipProvider><Fixture /></TooltipProvider></I18nProvider>); await settle(); });
assert.equal(document.querySelector('button[aria-label="Extension pages"]'), null, 'pages do not require opening a grouped menu');
assert.ok(document.querySelector('button[aria-label="Second page — Synthetic plugin"]'), 'each contributed page has a direct toolbar button');
const firstButton = document.querySelector<HTMLButtonElement>('button[aria-label="First page — Synthetic plugin"]')!;
await act(async () => { firstButton.click(); await settle(); });
assert.equal(document.querySelectorAll('[data-section="terminal-side-panel-pane"]').length, 1);
assert.ok(document.querySelector('[data-pane-tool] [data-plugin-view-slot="terminal-sidebar"]'));
assert.equal(document.querySelectorAll('[data-plugin-terminal-sidebar] header').length, 1);
assert.equal(opens.length, 1); assert.deepEqual(opens[0].context, { 'netcatty.surface': 'terminal-sidebar' });
await act(async () => { split(); await settle(); });
assert.equal(document.querySelectorAll('[data-section="terminal-side-panel-pane"]').length, 2);
assert.equal(document.querySelectorAll('[data-plugin-terminal-sidebar] header').length, 0, 'split pane headers already identify both pages');
assert.equal(opens.length, 2);
const firstId = opens.find((item) => item.viewId === a.id)!.instanceId!;
await act(async () => { for (const fn of focusListeners) fn({ instanceId: firstId }); await settle(); });
await act(async () => { document.querySelector<HTMLElement>(`[data-pane-tool="plugin-view:${a.id}"] [data-plugin-view-slot]`)!.dispatchEvent(new dom.window.FocusEvent('focusin', { bubbles: true })); await settle(); });
assert.equal(document.querySelector(`[data-pane-tool="plugin-view:${a.id}"]`)?.getAttribute('data-focused'), 'true');
const dialog = document.createElement('div'); dialog.setAttribute('role', 'dialog'); dialog.setAttribute('aria-modal', 'true');
await act(async () => { document.body.append(dialog); await settle(); });
assert.ok(opens.every((item) => visible.get(item.instanceId!) === false));
focus.length = 0;
await act(async () => { dialog.remove(); await settle(); });
assert.ok(focus.length > 0); assert.ok(focus.every((id) => id === firstId), 'restoring panes focuses only the host-selected pane');
const old = opens.map((item) => item.instanceId!);
await act(async () => { selectedSession = 'synthetic-session-b'; sidePanelLiveStore.update({ ...SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT, focusedSessionId: selectedSession });
assert.ok(old.every((id) => closes.includes(id)), 'workspace focus cancels old native owners synchronously'); await settle(); });
await act(async () => { selectedSession = 'synthetic-session-b'; sidePanelLiveStore.update({ ...SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT, focusedSessionId: selectedSession }); await settle(); });
assert.equal(opens.length, 4);
assert.notEqual(opens[0].scopeId, opens[2].scopeId);
assert.ok(opens.every((item) => JSON.stringify(item.context) === JSON.stringify({ 'netcatty.surface': 'terminal-sidebar' })));
await act(async () => { rectX = 260; snapshot = { ...snapshot, plugins: [{ ...snapshot.plugins[0], views: [{ ...a, visible: false }, b] }] }; for (const fn of changed) fn(); await settle(); });
await act(async () => { snapshot = { ...snapshot, plugins: [{ ...snapshot.plugins[0], views: [{ ...a, visible: false }, b] }] }; for (const fn of changed) fn(); await settle(); });
assert.equal(document.querySelectorAll('[data-section="terminal-side-panel-pane"]').length, 1);
assert.equal(document.querySelector('[data-pane-tool]')?.getAttribute('data-pane-tool'), `plugin-view:${b.id}`);
assert.ok(bounds.some((item) => item.x === 260), 'portal relocation updates native geometry');
const last = opens.at(-1)!.instanceId!;
await act(async () => { activeTabStore.setActiveTabId('vault'); assert.ok(closes.includes(last)); await settle(); });
await act(async () => { activeTabStore.setActiveTabId('vault'); await settle(); });
assert.equal(document.querySelector('[data-plugin-terminal-sidebar]'), null);
await act(async () => { selectedSession = 'synthetic-local-terminal'; activeTabStore.setActiveTabId(selectedSession); await settle(); });
await act(async () => { document.querySelector<HTMLButtonElement>('button[aria-label="Extension pages"]')!.click(); await settle(); });
const localPage = [...document.querySelectorAll('button')].find((button) => button.textContent?.includes('Second page'))!;
const localPage = document.querySelector<HTMLButtonElement>('button[aria-label="Second page — Synthetic plugin"]')!;
await act(async () => { localPage.click(); await settle(); });
assert.ok(document.querySelector('[data-plugin-view-slot="terminal-sidebar"]'), 'standalone Local Terminal is a valid navigation owner');
assert.deepEqual(opens.at(-1)!.context, { 'netcatty.surface': 'terminal-sidebar' });
} finally {
await act(async () => { root.unmount(); await settle(); }); dom.window.close();
await act(async () => { root.unmount(); (await import('../application/state/pluginViewSession')).pluginViewSessions.dispose(); await settle(); }); dom.window.close();
for (const [key, descriptor] of previous) { if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key); }
}
});
+14 -37
View File
@@ -21,24 +21,19 @@ test('Vault navigation mounts a real content slot, yields to host dialogs, and w
const { activeTabStore } = await import('../application/state/activeTabStore');
const { requestOpenPluginView } = await import('../application/state/usePluginViewLifecycle');
const { I18nProvider } = await import('../application/i18n/I18nProvider');
const page = { id: 'com.example.page.main', title: 'Synthetic page', location: 'vault-page', entry: 'page.html', visible: true };
const page = { id: 'com.example.page.main', title: 'Synthetic page', location: 'vault-page', visible: true };
let snapshot = { locale: 'en', plugins: [{ id: 'com.example.page', version: '1.0.0', displayName: 'Synthetic plugin', commands: [], keybindings: [], menus: [], settings: [], views: [page] }] };
let changed: (() => void) | undefined;
const listeners = new Set<(event: NetcattyPluginViewClosedEvent) => void>();
const opens: NetcattyPluginViewOpenRequest[] = [], closes: string[] = [], visibility: boolean[] = [];
let defer: ((value: { instanceId: string }) => void) | undefined;
let deferOpen = false;
let delayLayout = false;
const rejectOldLayout: Array<(error: Error) => void> = [];
const layoutResult = () => delayLayout ? new Promise<void>((_resolve, reject) => { rejectOldLayout.push(reject); }) : Promise.resolve();
const opens: NetcattyPluginViewOpenRequest[] = [], closes: string[] = [];
Object.assign(dom.window, { netcatty: {
async getPluginRuntimeStatus() { return { available: true }; },
async getPluginContributions() { return snapshot; },
onPluginContributionsChanged(fn: () => void) { changed = fn; return () => { changed = undefined; }; },
onPluginViewClosed(fn: (event: NetcattyPluginViewClosedEvent) => void) { listeners.add(fn); return () => { listeners.delete(fn); }; },
async openPluginView(payload: NetcattyPluginViewOpenRequest) { opens.push(payload); return deferOpen ? new Promise<{ instanceId: string }>((resolve) => { defer = resolve; }) : { instanceId: payload.instanceId! }; },
async closePluginView(id: string) { closes.push(id); }, async setPluginViewBounds() { await layoutResult(); },
async setPluginViewVisibility(_id: string, visible: boolean) { visibility.push(visible); await layoutResult(); },
onHostDrawnPluginViewUpdate() { return () => {}; },
async openHostDrawnPluginView(payload: NetcattyPluginViewOpenRequest) { opens.push(payload); return { instanceId: payload.instanceId! }; },
async closeHostDrawnPluginView(id: string) { closes.push(id); },
} });
let selectSection!: (section: 'hosts' | `plugin-vault:${string}`) => void;
function Fixture() {
@@ -58,44 +53,26 @@ test('Vault navigation mounts a real content slot, yields to host dialogs, and w
assert.ok(document.querySelector('[data-section="vault-main"] [data-plugin-view-slot="vault-page"]'));
assert.equal(opens.length, 1);
assert.deepEqual(opens[0].context, { 'netcatty.surface': 'vault-page' }, 'no Vault/session data accompanies navigation');
assert.equal(visibility.at(-1), true);
const dialog = document.createElement('div'); dialog.setAttribute('role', 'dialog'); dialog.setAttribute('aria-modal', 'true');
await act(async () => { document.body.append(dialog); await settle(); });
assert.equal(visibility.at(-1), false, 'native content yields to host security/settings dialogs');
await act(async () => { dialog.remove(); await settle(); });
assert.equal(visibility.at(-1), true);
await act(async () => { activeTabStore.setActiveTabId('terminal:synthetic'); assert.ok(closes.includes(opens[0].instanceId!)); await settle(); });
await act(async () => { activeTabStore.setActiveTabId('terminal:synthetic'); await settle(); });
assert.equal(closes.length, 0, 'hiding keeps the logical view available');
await act(async () => { activeTabStore.setActiveTabId('vault'); await settle(); });
assert.equal(opens.length, 2);
assert.equal(opens.length, 1, 'returning to Vault reuses the same plugin state');
await act(async () => { snapshot = { ...snapshot, plugins: [] }; changed?.(); await settle(); });
await act(async () => { await settle(); });
assert.ok(closes.includes(opens[1].instanceId!));
// The main process owns immediate invalidation on disable/uninstall.
await act(async () => { for (const listener of listeners) listener({ instanceId: opens[0].instanceId!, viewId: page.id, pluginId: 'com.example.page', reason: 'plugin-disabled' }); });
assert.ok(closes.includes(opens[0].instanceId!));
assert.equal(document.querySelector('[data-plugin-vault-page]'), null);
assert.equal(document.activeElement?.textContent, 'Hosts');
await act(async () => { selectSection('plugin-vault:missing'); await settle(); });
assert.match(document.querySelector('main')!.textContent!, /Built-in hosts/);
await act(async () => { snapshot = { ...snapshot, plugins: [{ id: 'com.example.page', version: '1.0.0', displayName: 'Synthetic plugin', commands: [], keybindings: [], menus: [], settings: [], views: [page] }] }; changed?.(); await settle(); });
deferOpen = true;
await act(async () => { requestOpenPluginView({ viewId: page.id }); await settle(); });
assert.equal(opens.length, 3);
assert.equal(opens.length, 2);
await act(async () => { selectSection('hosts'); await settle(); });
assert.ok(closes.includes(opens[2].instanceId!), 'cancel reserved instance before its open response');
await act(async () => { defer!({ instanceId: opens[2].instanceId! }); await settle(); });
assert.equal(document.querySelector('[data-plugin-vault-page]'), null, 'late open cannot restore withdrawn page');
assert.ok(closes.filter((id) => id === opens[2].instanceId).length >= 2, 'late native response is also closed');
deferOpen = false;
const second = { ...page, id: 'com.example.page.second', title: 'Second page' };
await act(async () => { snapshot = { ...snapshot, plugins: [{ ...snapshot.plugins[0], views: [page, second] }] }; changed?.(); await settle(); });
delayLayout = true;
await clickPage();
assert.ok(rejectOldLayout.length >= 2, 'old page has both bounds and visibility IPC in flight');
await act(async () => { selectSection('hosts'); await settle(); });
delayLayout = false;
await act(async () => { document.querySelector<HTMLButtonElement>('button[aria-label="Second page — Synthetic plugin"]')!.click(); await settle(); });
await act(async () => { for (const reject of rejectOldLayout) reject(new Error('old page layout failed')); await settle(); });
assert.equal(document.querySelector('[data-plugin-vault-page]')?.getAttribute('aria-label'), 'Second page', 'old bounds/visibility failures cannot withdraw the newly selected page');
assert.equal(document.querySelector('[data-plugin-vault-page]'), null);
} finally {
await act(async () => { root.unmount(); await settle(); }); dom.window.close();
await act(async () => { root.unmount(); (await import('../application/state/pluginViewSession')).pluginViewSessions.dispose(); await settle(); }); dom.window.close();
for (const [key, descriptor] of previous) { if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key); }
}
});
@@ -0,0 +1,50 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import React from 'react';
import { I18nProvider } from '../application/i18n/I18nProvider';
import { createDomRenderer, dispatchDomEvent, flushEffects, installDomEnvironment } from './test-support/renderReactDom';
test('a rejected palette plugin command shows bounded existing toast feedback without raw diagnostics or retry', async () => {
const dom = installDomEnvironment();
const globals = { localStorage: dom.window.localStorage, Element: dom.window.Element, ResizeObserver: class { observe() {} disconnect() {} } };
const previous = new Map(Object.keys(globals).map((key) => [key, Object.getOwnPropertyDescriptor(globalThis, key)]));
for (const [key, value] of Object.entries(globals)) Object.defineProperty(globalThis, key, { configurable: true, value });
const { QuickSwitcher } = await import('./QuickSwitcher');
const { toast } = await import('./ui/toast');
const renderer = await createDomRenderer(dom.document);
const notices: string[] = [], calls: unknown[][] = [];
const error = toast.error;
toast.error = (message) => { notices.push(message); };
Object.defineProperty(dom.window, 'netcatty', { value: {
getPluginRuntimeStatus: async () => ({ available: true }),
getPluginContributions: async () => ({ locale: 'en', plugins: [{
id: 'com.example.selection', version: '1.0.0', displayName: 'Synthetic selection', description: '',
commands: [{ id: 'com.example.selection.share', title: 'Share selected terminal', enabled: true }],
menus: [{ id: 'menu', command: 'com.example.selection.share', title: 'Share selected terminal', location: 'commandPalette', visible: true, enabled: true }],
keybindings: [], settings: [], views: [],
}] }),
executePluginCommand: async (...args: unknown[]) => { calls.push(args); throw new Error('synthetic-private-diagnostic'); },
} });
let closes = 0;
try {
await renderer.render(<I18nProvider locale="en"><QuickSwitcher isOpen query="Share selected terminal" results={[]} sessions={[]} workspaces={[]}
onQueryChange={() => {}} onSelect={() => {}} onSelectTab={() => {}} onClose={() => { closes++; }} showSftpTab={false} /></I18nProvider>);
await flushEffects(); await flushEffects();
const button = [...dom.document.querySelectorAll('button')].find((node) => node.textContent?.includes('Share selected terminal'));
assert.ok(button);
await dispatchDomEvent(button, new dom.window.MouseEvent('click', { bubbles: true }));
await flushEffects();
assert.equal(calls.length, 1);
assert.equal(closes, 1);
assert.deepEqual(notices, ['The plugin command could not be completed. Check its required selection and permissions.']);
assert.equal(notices.join('').includes('synthetic-private-diagnostic'), false);
} finally {
toast.error = error;
await renderer.unmount();
await new Promise((resolve) => setTimeout(resolve, 0));
for (const [key, descriptor] of previous) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key);
}
dom.cleanup();
}
});
+2 -1
View File
@@ -21,6 +21,7 @@ import { buildQuickSwitcherShells, useDiscoveredShells, getShellIconPath, isMono
import { usePluginContributions } from "../application/state/usePluginContributions";
import { requestOpenPluginView } from "./plugins/PluginContributionHost";
import { PluginContributionIcon } from "./plugins/PluginContributionIcon";
import { toast } from "./ui/toast";
type QuickSwitcherItemBase = {
id: string;
@@ -507,7 +508,7 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
useAlternate && item.altCommand ? item.altCommand : item.commandId,
undefined,
{ 'netcatty.surface': 'commandPalette' },
).catch(() => {});
).catch(() => toast.error(t('plugins.command.failed')));
onClose();
}
break;
Loaded 100 of 648 files, more files were not shown because too many files have changed in this diff. Show more