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
陈奇 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
陈奇 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
陈奇 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
陈奇 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
陈奇 d33c0193cc test(plugin-ai): drain Node module warnings before observing SDK errors 2026-10-27 02:33:17 +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
482 changed files with 25000 additions and 4987 deletions

No files matched your search

+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 -1
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');
+3
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';
@@ -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)),
+2
View File
@@ -27,6 +27,7 @@ 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 {
@@ -858,6 +859,7 @@ function AppViewInner({ domains }: AppViewProps) {
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...",
};
+15 -2
View File
@@ -161,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...',
@@ -582,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',
@@ -1262,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.",
@@ -1371,6 +1380,9 @@ Highlight the focused split pane:
"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",
@@ -1389,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.',
@@ -1419,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",
+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...",
};
+15 -2
View File
@@ -161,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...',
@@ -582,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',
@@ -1261,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.",
@@ -1371,6 +1380,9 @@ Resalta el panel dividido enfocado:
"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",
@@ -1389,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.',
@@ -1419,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",
+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...",
};
+15 -2
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": "Импортированные пакеты отключены до проверки и включения.",
@@ -1236,6 +1245,9 @@ export const ruCoreMessages: Messages = {
"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",
@@ -1254,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': 'Включённые плагины не предоставляют настройки.',
@@ -1284,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": "Запись",
@@ -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 配置…",
};
+15 -2
View File
@@ -165,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': '检查中...',
@@ -586,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': '原始',
@@ -1177,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": "导入本地插件包以开始使用。",
@@ -1286,6 +1295,9 @@ Vaults 标签保持扁平,不使用这块背景。
"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": "管理来源",
@@ -1304,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': '当前没有已启用插件提供设置。',
@@ -1334,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": "写入",
+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 設定…",
};
+15 -2
View File
@@ -167,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': '檢查中...',
@@ -588,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': '原始',
@@ -1179,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": "導入的插件保持停用,請檢查後再啟用。",
@@ -1257,6 +1266,9 @@ Vaults 分頁維持扁平,不使用這塊背景。
"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": "管理来源",
@@ -1275,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} 個插件",
@@ -1337,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": "寫入",
+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;
}
@@ -1,36 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { JSDOM } from 'jsdom';
import { observePluginHostSecurityVisibility } from './pluginHostSecurityVisibility';
test('native modal yields to the host chooser, restores on dismissal, and never restores after cleanup', async () => {
const dom = new JSDOM('<body><div role="dialog" aria-modal="true"><div id="mount"></div></div></body>');
try {
const { document } = dom.window;
const calls: boolean[] = [];
const stop = observePluginHostSecurityVisibility(document.getElementById('mount')!, (visible) => calls.push(visible));
const flush = () => new Promise<void>((resolve) => dom.window.queueMicrotask(resolve));
assert.deepEqual(calls, []); // Its own modal does not hide the plugin.
const chooser = document.createElement('div');
chooser.dataset.hostSecuritySurface = '';
document.body.append(chooser);
await flush(); assert.deepEqual(calls, [false]);
chooser.remove(); await flush(); assert.deepEqual(calls, [false, true]);
document.body.append(chooser); await flush();
assert.deepEqual(calls, [false, true, false]);
stop(); chooser.remove(); await flush();
assert.deepEqual(calls, [false, true, false]);
} finally { dom.window.close(); }
});
test('hidden security surfaces do not occlude a native view', async () => {
const dom = new JSDOM('<body><div id="mount"></div><div hidden><div data-host-security-surface></div></div></body>');
try {
const calls: boolean[] = [];
const stop = observePluginHostSecurityVisibility(dom.window.document.getElementById('mount')!, (visible) => calls.push(visible));
assert.deepEqual(calls, []);
dom.window.document.querySelector('[hidden]')!.removeAttribute('hidden');
await new Promise<void>((resolve) => dom.window.queueMicrotask(resolve));
assert.deepEqual(calls, [false]); stop();
} finally { dom.window.close(); }
});
@@ -1,21 +0,0 @@
/** Native plugin views must yield while a host-owned security dialog is open. */
export 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();
// The view lifecycle owns close/retention. Cleanup must never reveal an old view.
return () => observer.disconnect();
}
+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; },
};
}
+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;
}),
};
}
+2 -1
View File
@@ -4,7 +4,7 @@ import { getNotesSnapshot } from './notesStore';
import { useActiveTabId } from './activeTabStore';
import { getAppAppLockRuntime } from './appRuntimeBridge';
import { createTrustedPluginAIHostRuntime } from '../../infrastructure/ai/plugin/trustedHostRuntime';
import { getConfiguredPluginModel } from '../../infrastructure/ai/plugin/configuredModel';
import { getConfiguredPluginModel, prepareConfiguredPluginModel } from '../../infrastructure/ai/plugin/configuredModel';
import { buildSelectedPluginSources } from '../../infrastructure/ai/plugin/selectedSources';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
@@ -29,6 +29,7 @@ export function usePluginAIContextHost({ sessions, workspaces }: { sessions: Ter
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; }
+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,
};
}
+6 -4
View File
@@ -38,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,
@@ -70,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,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 };
}
+9 -209
View File
@@ -14,19 +14,12 @@ 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';
import { observePluginHostSecurityVisibility } from './pluginHostSecurityVisibility';
export const OPEN_PLUGIN_VIEW_EVENT = 'netcatty:open-plugin-view';
@@ -70,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,
@@ -93,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
@@ -114,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)
@@ -182,164 +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;
let disposed = false;
const stop = observePluginHostSecurityVisibility(mountRef.current, (visible) => {
void setViewVisibility(instance.id, visible).catch(() => {
if (!disposed) void closeView(instance.id).catch(() => {});
});
});
return () => { disposed = true; stop(); };
}, [closeView, instance, setViewVisibility]);
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;
@@ -376,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,
@@ -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,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);
}
}
});
+15 -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([]);
+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);
+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<{
+77 -14
View File
@@ -10,7 +10,7 @@ import { fileURLToPath } from 'node:url';
const require = createRequire(import.meta.url);
test('default host adapter, real preload, mounted host chooser and installed assistant reach only the local fixture', { timeout: 20_000 }, async () => {
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)), '..');
@@ -42,6 +42,7 @@ test('default host adapter, real preload, mounted host chooser and installed ass
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;
@@ -51,6 +52,30 @@ test('default host adapter, real preload, mounted host chooser and installed ass
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');
@@ -62,25 +87,50 @@ test('default host adapter, real preload, mounted host chooser and installed ass
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/);
const status = await aiBridge._streamRequestForTests(request.url, { headers: request.headers, body: request.body }, { sender }, request.requestId, false);
return { ok: status.statusCode === 200, ...status };
}
if (channel === 'netcatty:ai:chat:cancel') return aiBridge.cancelPluginAIStreamsForSender(sender) > 0;
const handler = ipcHandlers.get(channel);
assert.ok(handler, `Unexpected trusted IPC: ${channel}`);
return handler({ sender }, structuredClone(payload));
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' }]));
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({
@@ -97,8 +147,8 @@ test('default host adapter, real preload, mounted host chooser and installed ass
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', 'panel.html', 'panel.css', 'panel.js', 'README.md']) fs.copyFileSync(path.join(sample, file), path.join(staging, file));
fs.copyFileSync(path.join(sample, 'src/index.js'), path.join(staging, 'dist/index.js'));
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');
@@ -111,12 +161,13 @@ test('default host adapter, real preload, mounted host chooser and installed ass
await act(async () => { await new Promise((resolve) => setTimeout(resolve, 5)); });
}
};
panel = new JSDOM(fs.readFileSync(path.join(sample, 'panel.html'), 'utf8'), { runScripts: 'outside-only' });
Object.defineProperty(panel.window, 'netcattyView', { value: {
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))),
onDidReceiveMessage: () => ({ dispose() {} }),
}, views: { onDidReceiveMessage: () => ({ dispose() {} }) }
} });
await act(async () => panel!.window.eval(fs.readFileSync(path.join(sample, 'panel.js'), 'utf8')));
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);
@@ -129,24 +180,36 @@ test('default host adapter, real preload, mounted host chooser and installed ass
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;
send(); await waitFor(() => element('status').textContent === 'Complete');
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|apiKey|privateOwner|permissionMode|mcp/);
assert.doesNotMatch(JSON.stringify(trustedPayloads), /fixture-only|secondary-fixture-marker|apiKey|privateOwner|permissionMode|mcp/);
assert.deepEqual(sdkErrorNames, []);
} finally {
panel?.window.close();
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);
+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 });
}
});
+36 -1
View File
@@ -5,6 +5,27 @@ 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>();
@@ -25,7 +46,6 @@ test('registered Ask uses one in-app dialog; policy controls apply Allow and Den
const { I18nProvider } = await import('../application/i18n/I18nProvider');
const { PluginPermissionHost } = await import('./plugins/PluginPermissionHost');
const { PluginPermissionPanel } = await import('./settings/tabs/PluginPermissionPanel');
const { observePluginHostSecurityVisibility } = await import('../application/state/pluginHostSecurityVisibility');
const { PluginPermissionDecisionHost } = require('../electron/plugins/permissionDecisionHost.cjs');
const { PluginPermissionEngine } = require('../electron/plugins/permissionEngine.cjs');
const { PluginHostRpcRegistry } = require('../electron/plugins/hostRpcRegistry.cjs');
@@ -100,6 +120,21 @@ test('registered Ask uses one in-app dialog; policy controls apply Allow and Den
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); }
@@ -69,8 +69,8 @@ test('registered selected-summary Ask is one host chooser; Allow still selects a
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', 'panel.html', 'panel.css', 'panel.js', 'README.md']) fs.copyFileSync(path.join(sample, file), path.join(stage, file));
fs.copyFileSync(path.join(sample, 'src/index.js'), path.join(stage, 'dist/index.js'));
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>));
+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); }
}
});
+2
View File
@@ -576,6 +576,8 @@ const SettingsPageContent: React.FC<{ settings: SettingsState; appLock?: AppLock
setShowHostTreeSidebar={settings.setShowHostTreeSidebar}
windowOpacity={settings.windowOpacity}
setWindowOpacity={settings.setWindowOpacity}
windowBackgroundBlur={settings.windowBackgroundBlur}
setWindowBackgroundBlur={settings.setWindowBackgroundBlur}
appIconVariant={settings.appIconVariant}
setAppIconVariant={settings.setAppIconVariant}
/>
+6 -3
View File
@@ -64,6 +64,8 @@ interface TextEditorModalProps {
onClose: () => void;
fileName: string;
initialContent: string;
/** Full path for language detection; the title still uses fileName. */
filePath?: string;
onSave: (content: string) => Promise<void>;
editorWordWrap: boolean;
onToggleWordWrap: () => void;
@@ -79,6 +81,7 @@ const TextEditorModal: React.FC<TextEditorModalProps> = ({
open,
onClose,
fileName,
filePath,
initialContent,
onSave,
editorWordWrap,
@@ -94,7 +97,7 @@ const TextEditorModal: React.FC<TextEditorModalProps> = ({
const [baselineContent, setBaselineContent] = useState(initialContent);
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [languageId, setLanguageId] = useState(() => getLanguageId(fileName));
const [languageId, setLanguageId] = useState(() => getLanguageId(filePath ?? fileName));
const contentRef = useRef(initialContent);
const baselineContentRef = useRef(initialContent);
const savingRef = useRef(false);
@@ -149,13 +152,13 @@ const TextEditorModal: React.FC<TextEditorModalProps> = ({
setBaselineContent(initialContent);
setSaveError(null);
setSaving(false);
setLanguageId(getLanguageId(fileName));
setLanguageId(getLanguageId(filePath ?? fileName));
contentRef.current = initialContent;
baselineContentRef.current = initialContent;
savingRef.current = false;
closePromptRef.current = null;
viewStateRef.current = null;
}, [initialContent, fileName]);
}, [initialContent, fileName, filePath]);
const saveContent = useCallback(async (contentToSave = contentRef.current): Promise<boolean> => {
return saveCoordinatorRef.current?.save(contentToSave) ?? false;
+58 -2
View File
@@ -4,7 +4,7 @@ import { readFileSync } from 'node:fs';
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import ChatInput from './ChatInput';
import ChatInput, { replaceSlashQueryWithCommand, replaceSlashQueryWithRename } from './ChatInput';
import {
CHAT_INPUT_MAX_HEIGHT,
CHAT_INPUT_MIN_HEIGHT,
@@ -217,9 +217,46 @@ test('renders the Catty context usage ring after the model chip', () => {
assert.match(html, /aria-valuenow="50"/);
});
test('selecting rename completes a partial slash query without sending', () => {
assert.equal(replaceSlashQueryWithRename('/ren', { start: 0, end: 4 }), '/rename ');
assert.equal(replaceSlashQueryWithRename('/', { start: 0, end: 1 }), '/rename ');
assert.equal(replaceSlashQueryWithRename('before /ren after', { start: 7, end: 11 }), 'before /rename after');
assert.equal(replaceSlashQueryWithRename('old text', null), '/rename ');
const source = readFileSync(new URL('./ChatInput.tsx', import.meta.url), 'utf8');
const branch = source.slice(source.indexOf("if (command === 'rename')"), source.indexOf("commitComposerText('');", source.indexOf("if (command === 'rename')")));
assert.match(branch, /commitComposerText\(replaceSlashQueryWithRename\(readComposerText\(\), slashRange\)\)/);
assert.match(branch, /closeAllMenus\(\);[\s\S]*?return;/);
assert.doesNotMatch(branch, /onSend/);
});
test('selecting rebind completes a partial slash query without sending', () => {
assert.equal(replaceSlashQueryWithCommand('/re', { start: 0, end: 3 }, 'rebind'), '/rebind ');
assert.equal(replaceSlashQueryWithCommand('/', { start: 0, end: 1 }, 'rebind'), '/rebind ');
assert.equal(replaceSlashQueryWithCommand('before /re after', { start: 7, end: 10 }, 'rebind'), 'before /rebind after');
assert.equal(replaceSlashQueryWithCommand('old text', null, 'rebind'), '/rebind ');
const source = readFileSync(new URL('./ChatInput.tsx', import.meta.url), 'utf8');
const branch = source.slice(source.indexOf("if (command === 'rebind')"), source.indexOf("if (command === 'rename')"));
assert.match(branch, /commitComposerText\(replaceSlashQueryWithCommand\(readComposerText\(\), slashRange, 'rebind'\)\)/);
assert.match(branch, /closeAllMenus\(\);[\s\S]*?return;/);
assert.doesNotMatch(branch, /onSend/);
});
test('slash picker system commands clear the local composer', () => {
const source = readFileSync(new URL('./ChatInput.tsx', import.meta.url), 'utf8');
assert.match(source, /if \(command === 'stop'\) onStop\?\.\(\);\s*commitComposerText\(''\);/s);
// /stop falls through to the shared clear; /rename, /chats and /rebind have
// their own branches above it (rename/rebind keep the composer so the user
// can type an argument).
assert.match(source, /if \(command === 'stop'\) onStop\?\.\(\);[\s\S]*?commitComposerText\(''\);/);
assert.match(source, /if \(command === 'chats'\) onListChats\?\.\(\);/);
assert.match(source, /if \(command === 'rename'\) \{[\s\S]*?closeAllMenus\(\);[\s\S]*?return;/);
assert.match(source, /if \(command === 'rebind'\) \{[\s\S]*?closeAllMenus\(\);[\s\S]*?return;/);
});
test('/rebind routes its argument to onRebindChat as a panel action', () => {
const source = readFileSync(new URL('./ChatInput.tsx', import.meta.url), 'utf8');
assert.match(source, /if \(systemCommand === 'rebind'\) \{[\s\S]*?onRebindChat\?\.\(getRebindSlashCommandArg\(submittedText\)\);/);
});
test('keeps thinking on a separate chip instead of mixing it into the model list', () => {
@@ -333,6 +370,25 @@ test('ChatInput wires /compact through getSystemSlashCommand and canCompact', ()
assert.match(source, /command\.slug !== 'compact' \|\| canCompact/);
});
test('ChatInput intercepts /rename and /chats as panel actions before onSend', () => {
const source = readFileSync(new URL('./ChatInput.tsx', import.meta.url), 'utf8');
// The interception happens in handleSubmit's system-command branch, which
// returns before onSend() — the commands never reach the agent.
assert.match(source, /systemCommand === 'rename'/);
assert.match(source, /systemCommand === 'chats'/);
assert.match(source, /onRenameSession\?\.\(getRenameSlashCommandTitle\(submittedText\)\)/);
assert.match(source, /onListChats\?\.\(\)/);
// Both commands clear the composer instead of falling through to onSend.
const renameBranchStart = source.indexOf("systemCommand === 'rename'");
const renameBranchEnd = source.indexOf('if (isStreaming && canSteer)', renameBranchStart);
const renameBranch = source.slice(renameBranchStart, renameBranchEnd);
assert.match(renameBranch, /commitComposerText\(''\)/);
assert.doesNotMatch(renameBranch, /onSend\(\)/);
// The slash picker registers the built-ins and keeps /rename text editable.
assert.match(source, /command === 'rename'/);
assert.match(source, /command === 'chats'/);
});
test('renders a Mention Note picker entry with a searchable note list', () => {
const source = readFileSync(new URL('./ChatInput.tsx', import.meta.url), 'utf8');
+55 -2
View File
@@ -33,6 +33,8 @@ import {
filterQuickMessages,
filterSystemSlashCommands,
filterUserSkillsForSlash,
getRebindSlashCommandArg,
getRenameSlashCommandTitle,
getSlashCommandItemKey,
getSystemSlashCommand,
SYSTEM_BUILTIN_SLASH_COMMANDS,
@@ -80,6 +82,19 @@ const MENU_VIEWPORT_GUTTER = 8;
const CONTEXT_USAGE_RING_RADIUS = 10;
const CONTEXT_USAGE_RING_CIRCUMFERENCE = 2 * Math.PI * CONTEXT_USAGE_RING_RADIUS;
export function replaceSlashQueryWithCommand(
text: string,
range: { start: number; end: number } | null,
command: string,
): string {
const replacement = `/${command} `;
return range ? `${text.slice(0, range.start)}${replacement}${text.slice(range.end)}` : replacement;
}
export function replaceSlashQueryWithRename(text: string, range: { start: number; end: number } | null): string {
return replaceSlashQueryWithCommand(text, range, 'rename');
}
function formatContextTokens(tokens: number): string {
if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}M`;
if (tokens >= 1_000) return `${(tokens / 1_000).toFixed(tokens >= 100_000 ? 0 : 1)}K`;
@@ -266,6 +281,12 @@ interface ChatInputProps {
onThinkingLevelChange?: (level: string) => void;
/** Hidden retained panels must not leave body-portaled menus open. */
parked?: boolean;
/** `/rename <title>`: rename the current chat (panel action, never sent to the agent). */
onRenameSession?: (title: string) => void;
/** `/chats`: list the chats visible to this panel (panel action, never sent to the agent). */
onListChats?: () => void;
/** `/rebind <n>`: rebind a dangling chat to this panel's scope (panel action, never sent to the agent). */
onRebindChat?: (arg: string) => void;
}
const ChatInput: React.FC<ChatInputProps> = ({
@@ -313,6 +334,9 @@ const ChatInput: React.FC<ChatInputProps> = ({
thinkingLevel,
onThinkingLevelChange,
parked = false,
onRenameSession,
onListChats,
onRebindChat,
}) => {
const { t } = useI18n();
const hasTerminalSelectionAttachment = files.some((file) => file.terminalSelection || file.vaultNoteId);
@@ -750,6 +774,20 @@ const ChatInput: React.FC<ChatInputProps> = ({
onCompact?.();
}
if (command === 'stop') onStop?.();
if (command === 'chats') onListChats?.();
if (command === 'rebind') {
// Replace a partial slash query (e.g. `/re`) with `/rebind ` so the
// user can finish the dangling-chat number; never leave `/re 1` to
// be sent to the agent.
commitComposerText(replaceSlashQueryWithCommand(readComposerText(), slashRange, 'rebind'));
closeAllMenus();
return;
}
if (command === 'rename') {
commitComposerText(replaceSlashQueryWithRename(readComposerText(), slashRange));
closeAllMenus();
return;
}
commitComposerText('');
closeAllMenus();
return;
@@ -759,7 +797,7 @@ const ChatInput: React.FC<ChatInputProps> = ({
return;
}
insertUserSkillToken(item.skill);
}, [canCompact, closeAllMenus, commitComposerText, insertQuickMessage, insertUserSkillToken, onCompact, onStop]);
}, [canCompact, closeAllMenus, commitComposerText, insertQuickMessage, insertUserSkillToken, onCompact, onListChats, onStop, readComposerText, slashRange]);
// Reset active highlight when a menu opens or when the *identity* of the
// visible items changes. Watching only `.length` misses cases where the
@@ -962,6 +1000,21 @@ const ChatInput: React.FC<ChatInputProps> = ({
commitComposerText('');
return;
}
if (systemCommand === 'rename') {
onRenameSession?.(getRenameSlashCommandTitle(submittedText));
commitComposerText('');
return;
}
if (systemCommand === 'chats') {
onListChats?.();
commitComposerText('');
return;
}
if (systemCommand === 'rebind') {
onRebindChat?.(getRebindSlashCommandArg(submittedText));
commitComposerText('');
return;
}
return;
}
if (isStreaming && canSteer) {
@@ -972,7 +1025,7 @@ const ChatInput: React.FC<ChatInputProps> = ({
// abort; the parent clears value only after the user message is accepted.
onSend();
},
[canCompact, canSteer, commitComposerText, isStreaming, onCompact, onSend, onSteer, onStop, readComposerText],
[canCompact, canSteer, commitComposerText, isStreaming, onCompact, onListChats, onRebindChat, onRenameSession, onSend, onSteer, onStop, readComposerText],
);
const status: PromptInputStatus = isStreaming ? 'streaming' : 'idle';
+186
View File
@@ -0,0 +1,186 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { AISession, ExternalAgentConfig } from '../../infrastructure/ai/types.ts';
import {
buildAiChatsListingEntries,
canRebindChat,
formatAiChatsListingNote,
resolveChatAgentLabel,
resolveRebindTargetFromArg,
truncateChatTitle,
} from './aiChatsListing.ts';
test('rebind revalidates cached entries against current sessions and live targets', () => {
const liveTargets = new Set(['ws-live', 'term-live']);
assert.equal(canRebindChat(undefined, liveTargets), false);
assert.equal(canRebindChat(createSession('restored', { scope: { type: 'workspace', targetId: 'ws-live' } }), liveTargets), false);
assert.equal(canRebindChat(createSession('restored-terminal', { scope: { type: 'terminal', targetId: 'term-live' } }), liveTargets), false);
assert.equal(canRebindChat(createSession('dangling', { scopeDangling: true, scope: { type: 'workspace', targetId: 'ws-old' } }), liveTargets), true);
assert.equal(canRebindChat(createSession('unbound', { scope: { type: 'terminal', targetId: 'term-old' } }), liveTargets), true);
});
function createSession(
id: string,
overrides: Partial<AISession> = {},
): AISession {
return {
id,
title: id,
agentId: 'catty',
scope: { type: 'workspace', targetId: 'ws-1' },
messages: [],
createdAt: 1_000,
updatedAt: 1_000,
...overrides,
};
}
const t = (key: string) => key;
const externalAgents: ExternalAgentConfig[] = [
{ id: 'claude', name: 'Claude Code' } as ExternalAgentConfig,
];
function buildEntries(sessions: AISession[], activeSessionId: string | null = 's-live') {
return buildAiChatsListingEntries({
sessions,
activeSessionId,
externalAgents,
workspaces: [{ id: 'ws-1', title: 'Web farm' }],
liveTerminalIds: new Set(['term-1']),
scopeTargetId: 'ws-1',
t,
});
}
test('/chats listing marks the live workspace chat, terminal chat and dangling chat', () => {
const now = Date.now();
const sessions = [
createSession('s-live', { scope: { type: 'workspace', targetId: 'ws-1' }, updatedAt: now }),
createSession('s-term', { scope: { type: 'terminal', targetId: 'term-1' }, agentId: 'claude', updatedAt: now }),
createSession('s-dangling', { scope: { type: 'workspace', targetId: 'ws-gone' }, updatedAt: now }),
];
const entries = buildEntries(sessions);
assert.equal(entries.length, 3);
assert.equal(entries[0].isCurrent, true);
assert.equal(entries[0].agentLabel, 'Catty Agent');
assert.equal(entries[0].scopeLabel, 'ai.chat.chatsScopeWorkspace: Web farm');
assert.equal(entries[0].isDangling, false);
assert.equal(entries[1].isCurrent, false);
assert.equal(entries[1].agentLabel, 'Claude Code');
assert.equal(entries[1].scopeLabel, 'ai.chat.chatsScopeTerminal');
assert.equal(entries[1].isDangling, false);
assert.equal(entries[2].isDangling, true);
assert.equal(entries[2].scopeLabel, 'ai.chat.chatsScopeDangling');
});
test('/chats listing numbers dangling entries for /rebind and leaves live ones unnumbered', () => {
const sessions = [
createSession('s-dangling-a', { scope: { type: 'workspace', targetId: 'ws-gone-a' } }),
createSession('s-live', { scope: { type: 'workspace', targetId: 'ws-1' } }),
createSession('s-dangling-b', { scope: { type: 'terminal', targetId: 'term-gone' } }),
createSession('s-dangling-c', { scope: { type: 'workspace', targetId: 'ws-gone-b' } }),
];
const entries = buildEntries(sessions, null);
assert.deepEqual(
entries.map((entry) => entry.rebindNumber ?? 0),
[1, 0, 2, 3],
);
});
test('/chats listing falls back to the panel scope label for the current workspace', () => {
const sessions = [
createSession('s-live', { scope: { type: 'workspace', targetId: 'ws-1' } }),
];
const entries = buildAiChatsListingEntries({
sessions,
activeSessionId: 's-live',
externalAgents,
workspaces: [],
liveTerminalIds: new Set<string>(),
currentScopeLabel: 'Web farm',
scopeTargetId: 'ws-1',
t,
});
assert.equal(entries[0].isDangling, false);
assert.equal(entries[0].scopeLabel, 'ai.chat.chatsScopeWorkspace: Web farm');
});
test('/chats note renders header, current marker and one line per chat', () => {
const sessions = [
createSession('s-live', { title: 'Fix deploy' }),
createSession('s-term', { title: 'Logs', scope: { type: 'terminal', targetId: 'term-1' }, agentId: 'claude' }),
createSession('s-dangling', { title: 'Old', scope: { type: 'workspace', targetId: 'ws-gone' } }),
];
const entries = buildEntries(sessions);
const note = formatAiChatsListingNote(entries, t);
const lines = note.split('\n');
assert.equal(lines[0], 'ai.chat.chatsHeader');
// Three chat lines plus the dangling rebind hint.
assert.equal(lines.length, 5);
assert.match(lines[1], /^• Fix deploy — Catty Agent · /);
assert.match(lines[2], /^ {2}Logs — Claude Code · /);
assert.match(lines[3], /ai\.chat\.chatsScopeDangling/);
assert.ok(!lines[3].startsWith('•'));
// The dangling line carries an inline rebind affordance, and the note
// explains it.
assert.match(lines[3], /\/rebind 1/);
assert.equal(lines[4], 'ai.chat.chatsRebindHint');
});
test('/chats note omits the rebind hint when nothing is dangling', () => {
const sessions = [
createSession('s-live', { title: 'Fix deploy' }),
createSession('s-term', { title: 'Logs', scope: { type: 'terminal', targetId: 'term-1' }, agentId: 'claude' }),
];
const entries = buildEntries(sessions);
const note = formatAiChatsListingNote(entries, t);
assert.ok(!note.includes('rebind'));
});
test('resolveRebindTargetFromArg picks only dangling entries by their number', () => {
const sessions = [
createSession('s-live', { title: 'Fix deploy' }),
createSession('s-dangling', { title: 'Old', scope: { type: 'workspace', targetId: 'ws-gone' } }),
createSession('s-dangling-2', { title: 'Older', scope: { type: 'terminal', targetId: 'term-gone' } }),
];
const entries = buildEntries(sessions);
const dangling = entries.find((entry) => entry.isDangling);
assert.ok(dangling);
assert.equal(resolveRebindTargetFromArg('1', entries)?.sessionId, 's-dangling');
assert.equal(resolveRebindTargetFromArg(' 2 ', entries)?.sessionId, entries.filter((entry) => entry.isDangling)[1].sessionId);
assert.equal(resolveRebindTargetFromArg('3', entries), null);
assert.equal(resolveRebindTargetFromArg('1foo', entries), null);
assert.equal(resolveRebindTargetFromArg('1.5', entries), null);
assert.equal(resolveRebindTargetFromArg('0', entries), null);
assert.equal(resolveRebindTargetFromArg('-1', entries), null);
assert.equal(resolveRebindTargetFromArg('', entries), null);
assert.equal(resolveRebindTargetFromArg('abc', entries), null);
// A live entry is not rebindable even if its position would match.
assert.equal(resolveRebindTargetFromArg('1', [entries[0]]), null);
});
test('/chats note reports an empty panel', () => {
assert.equal(formatAiChatsListingNote([], t), 'ai.chat.chatsEmpty');
});
test('truncateChatTitle clamps long titles and keeps short ones intact', () => {
assert.equal(truncateChatTitle('short'), 'short');
assert.equal(truncateChatTitle(' '), '');
const long = 'x'.repeat(80);
const truncated = truncateChatTitle(long);
assert.equal(truncated.length, 42);
assert.ok(truncated.endsWith('…'));
});
test('resolveChatAgentLabel resolves external agents and falls back to the raw id', () => {
assert.equal(resolveChatAgentLabel('catty', externalAgents), 'Catty Agent');
assert.equal(resolveChatAgentLabel('claude', externalAgents), 'Claude Code');
assert.equal(resolveChatAgentLabel('mystery', externalAgents), 'mystery');
});
+161
View File
@@ -0,0 +1,161 @@
import type { AISession, ExternalAgentConfig } from '../../infrastructure/ai/types';
import { formatRelativeTime } from '../AIChatSessionHistoryDrawer';
/**
* Pure builder for the `/chats` panel note. Kept separate from the component
* so a future rebind action (issue #4015 R2) can attach per-entry actions
* without reworking the text rendering.
*/
export interface AiChatsListingInput {
/** Rank-sorted sessions visible to the current panel scope. */
sessions: AISession[];
activeSessionId: string | null;
externalAgents: ExternalAgentConfig[];
/** Live workspaces, used to resolve workspace scope titles. */
workspaces: Array<{ id: string; title: string }>;
/** Live terminal session ids visible to this panel. */
liveTerminalIds: Iterable<string>;
/** Fallback scope label for the panel's own scope (e.g. current workspace title). */
currentScopeLabel?: string;
scopeTargetId?: string;
t: (key: string) => string;
}
export interface AiChatsListingEntry {
sessionId: string;
title: string;
agentLabel: string;
scopeLabel: string;
/** Scope target no longer resolves to a live workspace/terminal. */
isDangling: boolean;
/** 1-based number among dangling entries, used by `/rebind <n>`. */
rebindNumber?: number;
isCurrent: boolean;
relativeTime: string;
}
const TITLE_TRUNCATE = 42;
export function truncateChatTitle(title: string): string {
const trimmed = title.trim() || '';
if (trimmed.length <= TITLE_TRUNCATE) return trimmed;
return `${trimmed.slice(0, TITLE_TRUNCATE - 1)}…`;
}
export function resolveChatAgentLabel(
agentId: string,
externalAgents: ExternalAgentConfig[],
): string {
if (agentId === 'catty') return 'Catty Agent';
return externalAgents.find((agent) => agent.id === agentId)?.name ?? agentId;
}
export function buildAiChatsListingEntries(input: AiChatsListingInput): AiChatsListingEntry[] {
const {
sessions,
activeSessionId,
externalAgents,
workspaces,
liveTerminalIds,
scopeTargetId,
t,
} = input;
const workspaceTitles = new Map(workspaces.map((workspace) => [workspace.id, workspace.title]));
const liveTerminals = new Set(liveTerminalIds);
const entries: AiChatsListingEntry[] = sessions.map((session) => {
const isCurrent = session.id === activeSessionId;
let scopeLabel = '';
let isDangling = false;
if (session.scope.type === 'workspace') {
const title = workspaceTitles.get(session.scope.targetId ?? '');
if (title != null) {
scopeLabel = `${t('ai.chat.chatsScopeWorkspace')}: ${title}`;
} else {
// The panel's own scope may be labelled by the parent even when the
// workspace store has not been threaded through; treat the active
// scope target as live.
if (isCurrent && session.scope.targetId === scopeTargetId && input.currentScopeLabel) {
scopeLabel = `${t('ai.chat.chatsScopeWorkspace')}: ${input.currentScopeLabel}`;
} else {
isDangling = true;
scopeLabel = t('ai.chat.chatsScopeDangling');
}
}
} else if (session.scope.type === 'terminal') {
if (liveTerminals.has(session.scope.targetId ?? '')) {
scopeLabel = t('ai.chat.chatsScopeTerminal');
} else {
isDangling = true;
scopeLabel = t('ai.chat.chatsScopeDangling');
}
} else {
scopeLabel = t('ai.chat.chatsScopeGlobal');
}
return {
sessionId: session.id,
title: truncateChatTitle(session.title || t('ai.chat.untitled')),
agentLabel: resolveChatAgentLabel(session.agentId, externalAgents),
scopeLabel,
isDangling,
isCurrent,
relativeTime: formatRelativeTime(new Date(session.updatedAt), t),
};
});
// Number the dangling entries so the note can advertise `/rebind <n>`.
let danglingCounter = 0;
for (const entry of entries) {
if (!entry.isDangling) continue;
danglingCounter += 1;
entry.rebindNumber = danglingCounter;
}
return entries;
}
/**
* Resolve a `/rebind <n>` argument against a `/chats` listing. Only dangling
* entries are rebindable; a non-dangling target (or a bad index) yields null.
*/
export function resolveRebindTargetFromArg(
arg: string,
entries: AiChatsListingEntry[],
): AiChatsListingEntry | null {
const trimmed = arg.trim();
if (!/^\d+$/.test(trimmed)) return null;
const index = Number.parseInt(trimmed, 10);
if (!Number.isInteger(index) || index < 1) return null;
const dangling = entries.filter((entry) => entry.isDangling);
return dangling[index - 1] ?? null;
}
/** Revalidate a cached listing entry against the current session and live scopes. */
export function canRebindChat(session: AISession | undefined, liveScopeTargetIds: ReadonlySet<string>): boolean {
if (!session) return false;
return !!session.scopeDangling || !session.scope.targetId || !liveScopeTargetIds.has(session.scope.targetId);
}
export function formatAiChatsListingNote(
entries: AiChatsListingEntry[],
t: (key: string) => string,
): string {
if (entries.length === 0) {
return t('ai.chat.chatsEmpty');
}
let hasDangling = false;
const lines = entries.map((entry) => {
const marker = entry.isCurrent ? '•' : ' ';
const rebindHint = entry.isDangling && entry.rebindNumber != null
? ` · /rebind ${entry.rebindNumber}`
: '';
if (entry.isDangling) hasDangling = true;
return `${marker} ${entry.title} — ${entry.agentLabel} · ${entry.scopeLabel} · ${entry.relativeTime}${rebindHint}`;
});
if (hasDangling) {
lines.push(t('ai.chat.chatsRebindHint'));
}
return [t('ai.chat.chatsHeader'), ...lines].join('\n');
}
@@ -8,6 +8,7 @@ import {
getTextEditorContentStats,
isTextEditorCommandWEnabled,
isTextEditorReadOnly,
languageIdToMonaco,
TextEditorPromoteButton,
} from "./TextEditorPane.tsx";
import { TooltipProvider } from "../ui/tooltip.tsx";
@@ -16,6 +17,11 @@ import { DEFAULT_KEY_BINDINGS } from "../../domain/models/keyBindings.ts";
const wrap = (child: React.ReactElement) =>
React.createElement(TooltipProvider, null, child);
test("maps manual Nginx selection to the registered language", () => {
assert.equal(languageIdToMonaco('nginx'), 'nginx');
assert.equal(languageIdToMonaco('ini'), 'ini');
});
test("disables promoting a modal editor to a tab while a save is running", () => {
assert.equal(canPromoteTextEditor({ saving: true }), false);
assert.equal(canPromoteTextEditor({ saving: false }), true);
+4 -1
View File
@@ -30,6 +30,7 @@ import { useClipboardBackend } from '../../application/state/useClipboardBackend
import { isPrimaryModifierWBinding } from '../../application/state/windowCommandClose';
import { HotkeyScheme, KeyBinding, matchesKeyBinding } from '../../domain/models';
import { pasteForMonacoEditorCommand } from '../../infrastructure/monaco/monacoClipboardPaste';
import { registerNginxLanguage } from '../../infrastructure/monaco/nginxLanguage';
import { useNetcattyMonacoTheme } from '../../infrastructure/monaco/useNetcattyMonacoTheme';
import { getLanguageName, getSupportedLanguages } from '../../lib/sftpFileUtils';
import { Button } from '../ui/button';
@@ -37,7 +38,7 @@ import { Combobox } from '../ui/combobox';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
// Map our language IDs to Monaco language IDs
const languageIdToMonaco = (langId: string): string => {
export const languageIdToMonaco = (langId: string): string => {
const mapping: Record<string, string> = {
'javascript': 'javascript',
'typescript': 'typescript',
@@ -73,6 +74,7 @@ const languageIdToMonaco = (langId: string): string => {
'yaml': 'yaml',
'toml': 'ini',
'ini': 'ini',
'nginx': 'nginx',
'sql': 'sql',
'graphql': 'graphql',
'markdown': 'markdown',
@@ -512,6 +514,7 @@ const TextEditorPaneInner: React.FC<TextEditorPaneProps> = ({
language={monacoLanguage}
value={content}
onChange={handleEditorChange}
beforeMount={registerNginxLanguage}
onMount={handleEditorMount}
theme={customThemeName}
loading={
+26
View File
@@ -0,0 +1,26 @@
import React, { useState } from 'react';
import type { TerminalSession } from '../../domain/models';
import { usePluginAgentHost } from '../../application/state/usePluginAgentHost';
import { Button } from '../ui/button';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../ui/dialog';
export function PluginAgentHost({ sessions }: { sessions: TerminalSession[] }) {
const { choice } = usePluginAgentHost(sessions);
const [selected, setSelected] = useState<string[]>([]);
return <Dialog open={choice !== null} onOpenChange={(open) => { if (!open) choice?.cancel(); }}>
<DialogContent data-host-security-surface="" onOpenAutoFocus={() => setSelected([])}>
<DialogHeader><DialogTitle>Choose terminals for this Agent conversation</DialogTitle>
<DialogDescription>The conversation is independent of the active tab or workspace. Only the terminals selected here are eligible targets. Tool actions follow this plugin's Allow / Ask / Deny setting.</DialogDescription></DialogHeader>
<div className="max-h-72 space-y-2 overflow-y-auto">
{choice?.items.map((item) => <label key={item.sessionId} className="flex gap-2 text-sm">
<input type="checkbox" checked={selected.includes(item.sessionId)} disabled={selected.length >= 32 && !selected.includes(item.sessionId)}
onChange={(event) => setSelected((ids) => event.target.checked ? [...ids, item.sessionId] : ids.filter((id) => id !== item.sessionId))} />
<span>{item.label} <span className="text-muted-foreground">{item.hostname}</span></span>
</label>)}
{choice?.items.length === 0 && <p className="text-sm text-muted-foreground">No connected terminals. You can continue without targets.</p>}
</div>
<DialogFooter><Button variant="outline" onClick={() => choice?.cancel()}>Cancel</Button>
<Button onClick={() => choice?.confirm(selected)}>Use selected terminals</Button></DialogFooter>
</DialogContent>
</Dialog>;
}
+35
View File
@@ -0,0 +1,35 @@
import React, { useState } from 'react';
import { Globe, HardDrive, Store } from 'lucide-react';
import { usePluginAuthorIdentity } from '../../application/state/usePluginAuthorIdentity';
import { useI18n } from '../../application/i18n/I18nProvider';
import { Badge } from '../ui/badge';
export function PluginAuthorRow({ entry, plugin, sourceId, showSource = false }: {
entry?: NetcattyPluginCatalogEntry; plugin?: NetcattyInstalledPlugin; sourceId?: string; showSource?: boolean;
}) {
const { t } = useI18n();
const { ref, identity } = usePluginAuthorIdentity({ entry, plugin, sourceId });
const [failed, setFailed] = useState<string | null>(null);
const author = identity?.author ?? entry?.author ?? plugin?.manifest?.publisher ?? '';
const imageKey = JSON.stringify([entry?.id, entry?.selectionToken, plugin?.id, plugin?.activeVersion, plugin?.archiveSha256, sourceId, identity?.avatar]);
const avatar = identity?.avatar;
const showImage = avatar && avatar.length <= 700_000 && /^data:image\/png;base64,[A-Za-z0-9+/]+={0,2}$/.test(avatar) && failed !== imageKey;
const source = plugin?.source;
const sourceKey = source?.kind === 'third-party' ? 'thirdparty' : source?.kind === 'official' || source?.kind === 'local' ? source.kind : undefined;
const sourceLabel = t(sourceKey ? `settings.plugins.sources.short.${sourceKey}` : 'settings.plugins.landing.sourceUnknown');
const sourceDescription = sourceKey
? [t(`settings.plugins.sources.${sourceKey}`), source?.name, source?.origin].filter(Boolean).join(' · ')
: sourceLabel;
const SourceIcon = source?.kind === 'local' ? HardDrive : source?.kind === 'official' ? Store : Globe;
return <div ref={ref} className="flex min-w-0 items-center gap-3 overflow-hidden text-xs text-muted-foreground" data-plugin-author-row>
{(author || showImage || identity?.officialMaintained === true) && <span data-plugin-author className="inline-flex min-w-0 items-center gap-1.5 overflow-hidden">
{showImage && <img src={avatar} alt="" width={16} height={16} className="h-4 w-4 shrink-0 rounded-full object-cover" onError={() => setFailed(imageKey)} />}
{author && <span className="min-w-0 truncate" title={author}>{author}</span>}
{identity?.officialMaintained === true && <Badge className="shrink-0 whitespace-nowrap border-0 bg-amber-500/15 px-2 py-0.5 text-[10px] font-medium text-amber-800 hover:bg-amber-500/15 dark:bg-amber-400/15 dark:text-amber-300 dark:hover:bg-amber-400/15"
title={t('settings.plugins.author.officialMeaning')} data-plugin-official-maintainer>{t('settings.plugins.author.official')}</Badge>}
</span>}
{showSource && <span data-plugin-source className="inline-flex shrink-0 items-center gap-1 whitespace-nowrap" title={sourceDescription} aria-label={sourceDescription}>
<SourceIcon size={12} aria-hidden="true" />{sourceLabel}
</span>}
</div>;
}
+17 -5
View File
@@ -7,9 +7,12 @@ import {
} from '../../application/state/usePluginViewLifecycle';
import { Button } from '../ui/button';
import { PluginContributionIcon } from './PluginContributionIcon';
import { PluginHostDrawnPage } from './PluginHostDrawnPage';
export { requestOpenPluginView };
const subscribeNoop = () => () => {};
const DEFAULT_KEYBINDING_CONTEXT = Object.freeze({ 'netcatty.surface': 'keybinding' });
export function PluginContributionHost({
@@ -28,7 +31,7 @@ export function PluginContributionHost({
activeView,
close,
effectiveRequested,
mountRef,
viewQueryContext,
} = usePluginViewLifecycle({
locale,
theme,
@@ -38,18 +41,24 @@ export function PluginContributionHost({
if (!effectiveRequested || !activeView) return null;
const location = activeView.view.location;
const content = <PluginHostDrawnPage pluginId={activeView.plugin.id} viewId={activeView.view.id} version={activeView.plugin.version} slot={`view:${activeView.view.id}`} active context={viewQueryContext}
subscribeHidden={subscribeNoop} unavailable={() => void close()} loadingLabel={t('plugins.vault.loading')} />;
const containerClass = location === 'aside'
? 'absolute inset-y-0 right-0 z-40 w-[420px] border-l border-border bg-background shadow-2xl'
: location === 'tab'
? 'absolute inset-0 z-40 flex p-2 pt-0'
: location === 'panel'
? 'absolute inset-x-0 bottom-0 z-40 h-[42%] border-t border-border bg-background shadow-2xl'
: location === 'modal'
? 'fixed left-1/2 top-1/2 z-50 h-[70vh] w-[min(800px,85vw)] -translate-x-1/2 -translate-y-1/2 rounded-xl border border-border bg-background shadow-2xl'
? 'fixed left-1/2 top-1/2 z-40 h-[70vh] w-[min(800px,85vw)] -translate-x-1/2 -translate-y-1/2 rounded-xl border border-border bg-background shadow-2xl'
: 'absolute inset-0 z-40 bg-background';
if (location === 'tab') {
return (
<section className={`${containerClass} flex flex-col`} role="region" aria-label={activeView.view.title}>
<div ref={mountRef} className="min-h-0 flex-1" />
<section className={containerClass} role="region" aria-label={activeView.view.title}>
<div className="flex min-h-0 min-w-0 flex-1 overflow-hidden rounded-xl border border-border/60 bg-background shadow-sm">
{content}
</div>
</section>
);
}
@@ -58,6 +67,7 @@ export function PluginContributionHost({
<section
className={`${containerClass} flex flex-col`}
role={location === 'modal' ? 'dialog' : 'region'}
data-plugin-dialog={location === 'modal' ? 'true' : undefined}
aria-modal={location === 'modal' ? true : undefined}
aria-label={activeView.view.title}
>
@@ -80,7 +90,9 @@ export function PluginContributionHost({
<X size={14} />
</Button>
</header>
<div ref={mountRef} className="min-h-0 flex-1" />
<div className={location === 'modal' ? 'flex min-h-0 flex-1 p-3' : 'flex min-h-0 flex-1'}>
{content}
</div>
</section>
);
}
@@ -0,0 +1,15 @@
import { useHostDrawnPluginView } from '../../application/state/useHostDrawnPluginView';
import { PluginSettingsEntry } from './PluginSettingsEntry';
import { PluginRemoteSurface } from './PluginRemoteSurface';
export function PluginHostDrawnPage(props: Parameters<typeof useHostDrawnPluginView>[0] & { loadingLabel: string; pluginId: string }) {
const view = useHostDrawnPluginView(props);
if (!view.visible) return null;
if (view.loading) return <p data-plugin-view-slot={props.slot.split(':')[0]} role="status" className="p-4 text-sm text-muted-foreground">{props.loadingLabel}</p>;
return <div className="relative isolate h-full min-h-0 w-full flex-1" data-plugin-view-slot={props.slot.split(':')[0]}>
<PluginRemoteSurface key={`${props.viewId}:${props.version}:${props.slot}`} tree={view.tree} css={view.css} settled={view.settled} loadImage={view.loadImage} onEvent={view.sendEvent} />
<div className="absolute right-3 top-3 z-10" data-plugin-settings-entry="">
<PluginSettingsEntry key={`${props.pluginId}:${props.version}`} pluginId={props.pluginId} />
</div>
</div>;
}
+15 -3
View File
@@ -3,6 +3,7 @@ import { useI18n } from '../../application/i18n/I18nProvider';
import { describePluginPermission } from '@/domain/pluginPermissionDescriptions.cjs';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../ui/dialog';
import { Button } from '../ui/button';
import globalAgentToolSpecs from '../../infrastructure/ai/harness/generated/globalAgentToolSpecs.json';
/** Rendered only by the trusted app, never inside a plugin view. */
export function PluginPermissionHost() {
@@ -12,6 +13,9 @@ export function PluginPermissionHost() {
if (!prompt) return null;
const description = describePluginPermission(prompt.permission, resolvedLocale);
const resources = prompt.resources.filter((resource) => resource !== '*' && !resource.startsWith('selected:'));
const action = prompt.displayContext?.action;
const operation = action && (globalAgentToolSpecs.find((tool) => tool.capabilityId === action.capabilityId)?.toolName ?? action.capabilityId);
const selections = prompt.displayContext?.selections ?? [];
return <Dialog open onOpenChange={(open) => { if (!open) void respond('cancel'); }}>
<DialogContent data-host-security-surface="" className="max-h-[85vh] overflow-y-auto sm:max-w-[480px]">
<DialogHeader>
@@ -20,9 +24,17 @@ export function PluginPermissionHost() {
</DialogHeader>
<p className="text-sm">{description.description}</p>
{prompt.displayContext?.modelLabel && <p className="text-sm break-words">{zh ? '模型' : 'Model'}: {prompt.displayContext.modelLabel}</p>}
{(prompt.displayContext?.selections ?? []).map((item, index) => <p key={index} className="text-sm break-words">
{item.title} <span className="text-xs text-muted-foreground">· {item.scope}</span>
</p>)}
{action && <div className="space-y-1">
<p className="text-sm break-words">{zh ? '操作' : 'Operation'}: <code>{operation}</code>{operation !== action.capabilityId && <> ({action.capabilityId})</>}</p>
<p className="text-sm">{zh ? '请求内容(控制字符以转义形式显示)' : 'Requested action (control characters shown as escapes)'}</p>
<pre className="max-h-48 overflow-auto whitespace-pre-wrap break-words rounded border border-border p-2 text-xs">{action.text}</pre>
</div>}
{(selections.length > 0 || prompt.permission === 'ai.agent.actions') && <div className="space-y-1">
<p className="text-sm">{zh ? '目标' : 'Targets'}:{selections.length === 0 && (zh ? ' 未选择目标' : ' No targets selected')}</p>
{selections.map((item, index) => <p key={index} className="text-sm break-words">
{item.title} <span className="text-xs text-muted-foreground">· {item.scope}</span>
</p>)}
</div>}
{(prompt.relatedPermissions ?? []).filter((item, index, items) => items.findIndex((other) => other.permission === item.permission) === index).map((item, index) => <div key={index} className="space-y-1 text-sm">
<p>{describePluginPermission(item.permission, resolvedLocale).description}</p>
<p className="text-xs text-muted-foreground break-all">{item.resources.filter((resource) => !resource.startsWith('selected:')).join(', ')}</p>
@@ -0,0 +1,130 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { JSDOM } from 'jsdom';
test('plugin surface forwards its own input and contains malformed updates and render errors', async () => {
const dom = new JSDOM('<!doctype html><div id="root"></div><input id="host" value="private">', { url: 'http://localhost', pretendToBeVisual: true });
const saved = new Map<string, PropertyDescriptor | undefined>();
for (const key of ['window', 'document', 'navigator', 'HTMLElement', 'Element', 'Node', 'Event', 'MouseEvent', 'ShadowRoot']) {
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 });
let captured: ShadowRoot | undefined;
const nativeAttach = dom.window.Element.prototype.attachShadow;
dom.window.Element.prototype.attachShadow = function (options) { captured = nativeAttach.call(this, options) as unknown as ShadowRoot; return captured as unknown as globalThis.ShadowRoot; };
const { default: React, act } = await import('react');
const { createRoot } = await import('react-dom/client');
const { PluginRemoteSurface } = await import('./PluginRemoteSurface');
const events: unknown[] = [];
const tree = { children: [{ id: '1', type: 1, element: 'div', attributes: { class: 'card', style: 'display:grid;gap:8px' }, children: [
{ id: '2', type: 1, element: 'button', attributes: { type: 'button', 'data-netcatty-remote-node': 'forged' }, children: [{ id: '3', type: 3, data: '<b>Hello</b>' }] },
{ id: '4', type: 1, element: 'input', attributes: { type: 'text', value: '' }, children: [] },
] }] };
const root = createRoot(document.getElementById('root')!);
try {
await act(async () => { root.render(<PluginRemoteSurface tree={tree} css=".card{color:red}" onEvent={(event) => events.push(event)} />); });
assert.ok(captured); assert.equal(document.querySelector('[data-plugin-remote-surface]')?.querySelector('button'), null);
assert.equal(captured!.querySelector('button')?.textContent, '<b>Hello</b>');
assert.equal(captured!.querySelector('button')?.getAttribute('data-netcatty-remote-node'), '2');
await act(async () => { captured!.querySelector('button')!.click(); });
assert.deepEqual(events, [{ nodeId: '2', type: 'click' }]);
const input = captured!.querySelector('input')!; input.value = 'typed';
await act(async () => { input.dispatchEvent(new dom.window.Event('input', { bubbles: true })); });
assert.deepEqual(events.at(-1), { nodeId: '4', type: 'input', value: 'typed' });
await act(async () => { document.getElementById('host')!.dispatchEvent(new dom.window.Event('input', { bubbles: true })); });
assert.equal(events.length, 2);
await act(async () => { root.render(<PluginRemoteSurface tree={tree} css="@import 'https://bad.example/x';" onEvent={() => {}} />); });
assert.equal(document.querySelector('[role=alert]')?.textContent, 'Unable to display plugin view.');
let clicks = 0;
const renderView = async (nextTree: unknown) => act(async () => root.render(<>
<PluginRemoteSurface key="view" tree={nextTree} css="" onEvent={() => {}} />
<button id="canary" onClick={() => clicks++}>Host canary</button>
<PluginRemoteSurface key="sibling" tree={tree} css="" onEvent={() => {}} />
</>));
await renderView(tree);
await renderView({ children: [{ id: 'bad', type: 1, element: 'input', attributes: {}, children: [{ id: 'text', type: 3, data: 'invalid' }] }] });
assert.equal(document.querySelectorAll('[data-plugin-remote-surface]').length, 1);
assert.ok(document.querySelector('[role=alert]'));
await act(async () => document.getElementById('canary')!.click());
assert.equal(clicks, 1);
// Validated data can still hit a React rendering constraint. Contain that
// descendant throw too, independently of the policy rejection above.
const originalError = console.error;
try {
console.error = () => {};
await renderView({ children: [{ id: 'bad', type: 1, element: 'textarea', attributes: {}, children: [
{ id: 'a', type: 3, data: 'one' }, { id: 'b', type: 3, data: 'two' },
] }] });
} finally { console.error = originalError; }
assert.equal(document.querySelectorAll('[data-plugin-remote-surface]').length, 1);
assert.ok(document.querySelector('[role=alert]'));
await act(async () => document.getElementById('canary')!.click());
assert.equal(clicks, 2);
} finally {
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('closed-shadow editing keeps app close shortcuts in Vault, sidebar, tabs and popovers', async () => {
const dom = new JSDOM('<!doctype html><div id="root"></div>', { url: 'http://localhost', pretendToBeVisual: true });
const saved = new Map<string, PropertyDescriptor | undefined>();
for (const [key, value] of Object.entries({ window: dom.window, document: dom.window.document, navigator: dom.window.navigator,
HTMLElement: dom.window.HTMLElement, Element: dom.window.Element, Node: dom.window.Node, IS_REACT_ACT_ENVIRONMENT: true })) {
saved.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
}
dom.window.HTMLElement.prototype.getBoundingClientRect = () => ({ x: 20, y: 20, width: 240, height: 30, top: 20, left: 20, right: 260, bottom: 50, toJSON() {} });
Object.assign(dom.window, { ResizeObserver: class { observe() {} disconnect() {} } });
const shadows: ShadowRoot[] = [];
const nativeAttach = dom.window.Element.prototype.attachShadow;
dom.window.Element.prototype.attachShadow = function (options) {
const shadow = nativeAttach.call(this, options); shadows.push(shadow as unknown as ShadowRoot); return shadow;
};
const { act } = await import('react');
const { createRoot } = await import('react-dom/client');
const { PluginRemoteSurface } = await import('./PluginRemoteSurface');
const { handleGlobalHotkeyKeyDownImpl } = await import('../../application/app/AppHandlers');
const { isPluginViewEditingTarget, isPluginShortcutEditableEvent } = await import('../../application/state/pluginKeybindings');
const { matchesKeyBinding } = await import('../../domain/models');
const { DEFAULT_KEY_BINDINGS } = await import('../../domain/models/keyBindings');
const actions: string[] = [];
const capture = (event: KeyboardEvent) => handleGlobalHotkeyKeyDownImpl(() => ({
HOTKEY_DEBUG: false, closeTabKeyStr: 'Ctrl + W', hotkeyScheme: 'pc', keyBindings: DEFAULT_KEY_BINDINGS, matchesKeyBinding,
executeHotkeyAction: (action: string) => actions.push(action),
}), event);
dom.window.addEventListener('keydown', capture as unknown as EventListener, true);
const control = (id: string, element: string, attributes = {}) => ({ id, type: 1, element, attributes, children: [] });
const tree = { children: [control('input', 'input'), control('textarea', 'textarea'), control('anchor', 'button', { id: 'anchor' }),
{ ...control('popover', 'div', { 'data-plugin-popover': 'true', 'data-plugin-anchor': 'anchor', open: '' }), children: [control('pop-input', 'input'), control('pop-textarea', 'textarea')] },
] };
const root = createRoot(document.getElementById('root')!);
try {
for (const slot of ['vault-page', 'terminal-sidebar', 'plugin-tab']) {
shadows.length = 0;
await act(async () => root.render(<div key={slot} data-plugin-view-slot={slot}><PluginRemoteSurface tree={tree} css="" onEvent={() => {}} /></div>));
assert.equal(shadows.length, 2, 'includes the portaled popover surface');
for (const shadow of shadows) {
for (const element of shadow.querySelectorAll<HTMLInputElement | HTMLTextAreaElement>('input,textarea')) {
await act(async () => element.focus());
// Native menu IPC sees this same retargeted document.activeElement.
assert.equal(isPluginViewEditingTarget(document.activeElement), true, `${slot}: ${element.tagName}`);
let editable = false;
const observe = (event: Event) => { editable = isPluginShortcutEditableEvent(event); assert.equal(event.target, shadow.host); };
dom.window.addEventListener('keydown', observe, { once: true });
await act(async () => element.dispatchEvent(new dom.window.KeyboardEvent('keydown', { key: 'w', code: 'KeyW', ctrlKey: true, bubbles: true, composed: true, cancelable: true })));
assert.equal(editable, true);
assert.deepEqual(actions, []);
}
}
await act(async () => (shadows[0].querySelector('button') as HTMLButtonElement).focus());
assert.equal(isPluginViewEditingTarget(document.activeElement), false, 'focus marker clears when editing ends');
}
} finally {
dom.window.removeEventListener('keydown', capture as unknown as EventListener, true);
await act(async () => root.unmount()); dom.window.close();
for (const [key, value] of saved) { if (value) Object.defineProperty(globalThis, key, value); else Reflect.deleteProperty(globalThis, key); }
}
});
+120
View File
@@ -0,0 +1,120 @@
import React, { createContext, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { LazyLoadBoundary } from '../ui/lazy-load-boundary';
import { PluginViewPopover } from './PluginViewPopover';
import { createPortal } from 'react-dom';
import { validatePluginRemoteTree } from '@/domain/pluginRemoteTreePolicy.cjs';
import { validatePluginStylesheet } from '@/domain/pluginViewStylePolicy.cjs';
type RemoteNode = { id: string; type: 3; data: string } | { id: string; type: 8; data: string } | {
id: string; type: 1; element: string; attributes: Record<string, string>; children: RemoteNode[];
};
export type PluginSurfaceEvent = import('@netcatty/plugin-contract').HostDrawnViewEvent;
const ATTRIBUTE_NAMES: Record<string, string> = { class: 'className', for: 'htmlFor', readonly: 'readOnly', tabindex: 'tabIndex', maxlength: 'maxLength', minlength: 'minLength', colspan: 'colSpan', rowspan: 'rowSpan', autocomplete: 'autoComplete' };
const BOOLEAN_ATTRIBUTES = new Set(['checked', 'disabled', 'readonly', 'required', 'multiple', 'selected', 'open']);
const SurfaceContext = createContext<{ css: string; onEvent(event: PluginSurfaceEvent): void; depth: number }>({ css: '', onEvent: () => {}, depth: 0 });
const ResourceLoader = createContext<((path: string) => Promise<string>) | undefined>(undefined);
function RemoteElement({ node, settled }: { node: RemoteNode; settled: boolean }): React.ReactNode {
const attrs = node.type === 1 ? node.attributes : {};
const surface = useContext(SurfaceContext);
const loadImage = useContext(ResourceLoader);
const imagePath = node.type === 1 && node.element === 'img' ? attrs.src?.slice(6) : undefined;
const [imageUrl, setImageUrl] = useState<string | undefined>();
useEffect(() => {
let cancelled = false; setImageUrl(undefined);
if (imagePath && loadImage) void loadImage(imagePath).then((url) => { if (!cancelled) setImageUrl(url); }).catch(() => {});
return () => { cancelled = true; };
}, [imagePath, loadImage]);
const remoteSelection = JSON.stringify(node.type === 1 && node.element === 'select'
? node.children.filter((child) => child.type === 1 && child.element === 'option' && Object.hasOwn(child.attributes, 'selected')).map((child) => child.type === 1 ? child.attributes.value ?? '' : '') : []);
const [localSelection, setLocalSelection] = useState<string[]>(() => JSON.parse(remoteSelection));
useEffect(() => { if (settled) setLocalSelection(JSON.parse(remoteSelection)); }, [remoteSelection, settled]);
const remoteValue = attrs.value;
const remoteChecked = Object.hasOwn(attrs, 'checked');
const [localValue, setLocalValue] = useState(remoteValue ?? '');
const [localChecked, setLocalChecked] = useState(remoteChecked);
useEffect(() => { if (settled && remoteValue !== undefined) setLocalValue(remoteValue); }, [remoteValue, settled]);
useEffect(() => { if (settled) setLocalChecked(remoteChecked); }, [remoteChecked, settled]);
if (node.type === 3) return node.data;
if (node.type === 8) return null;
if (attrs['data-plugin-popover'] === 'true') return surface.depth < 1 ? <PluginViewPopover anchor={attrs['data-plugin-anchor'] ?? ''} open={Object.hasOwn(attrs, 'open')} dismiss={() => surface.onEvent({ nodeId: node.id, type: 'dismiss' })}><PluginRemoteSurface tree={{ children: node.children }} css={surface.css} onEvent={surface.onEvent} settled={settled} loadImage={loadImage} depth={surface.depth + 1} /></PluginViewPopover> : null;
const props: Record<string, unknown> = { 'data-netcatty-remote-node': node.id };
let inlineStyle: string | undefined;
for (const [name, value] of Object.entries(node.attributes)) {
if (name === 'src' || (node.element === 'option' && name === 'selected')) continue;
if (name === 'style') { inlineStyle = value; continue; }
if (name === 'data-netcatty-remote-node') continue;
props[ATTRIBUTE_NAMES[name] ?? name] = BOOLEAN_ATTRIBUTES.has(name) ? true : value;
}
// The policy validated this declaration list before this component rendered.
props.ref = (el: HTMLElement | null) => { if (el) { if (inlineStyle === undefined) el.removeAttribute('style'); else el.setAttribute('style', inlineStyle); } };
if (node.element === 'img') props.src = imageUrl;
if (node.element === 'form') props.onSubmit = (event: React.FormEvent) => event.preventDefault();
if (['input', 'select', 'textarea'].includes(node.element)) {
if (node.element === 'select' && Object.hasOwn(attrs, 'multiple')) props.value = localSelection;
else if (remoteValue !== undefined) props.value = localValue;
props.autoComplete = 'off';
if (node.element === 'input' && ['checkbox', 'radio'].includes(attrs.type ?? '')) props.checked = localChecked;
props.onChange = (event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
setLocalValue(event.currentTarget.value);
if (event.currentTarget instanceof event.currentTarget.ownerDocument.defaultView!.HTMLSelectElement && event.currentTarget.multiple) setLocalSelection([...event.currentTarget.selectedOptions].map((option) => option.value));
if (event.currentTarget instanceof event.currentTarget.ownerDocument.defaultView!.HTMLInputElement) setLocalChecked(event.currentTarget.checked);
};
}
return React.createElement(node.element, props, ...(node.element === 'textarea' && remoteValue !== undefined ? [] : node.children).map((child) => <RemoteElement key={child.id} node={child} settled={settled} />));
}
/** Host-owned DOM, kept inside a closed shadow root and a clipped stacking context. */
export function PluginRemoteSurface({ tree, css, onEvent, settled = true, loadImage, depth = 0 }: { depth?: number; tree: unknown; css: string; settled?: boolean; loadImage?(path: string): Promise<string>; onEvent(event: PluginSurfaceEvent): void }) {
const checked = useMemo(() => {
try {
validatePluginRemoteTree(tree);
return { tree: tree as { children: RemoteNode[] }, css: validatePluginStylesheet(css) };
} catch { return null; }
}, [tree, css]);
if (!checked) return <p role="alert" className="p-4 text-sm text-destructive">Unable to display plugin view.</p>;
return <LazyLoadBoundary name="Plugin view" fallback={<p role="alert" className="p-4 text-sm text-destructive">Unable to display plugin view.</p>}><SurfaceContext.Provider value={{ css: checked.css, onEvent, depth }}><ResourceLoader.Provider value={loadImage}><ValidatedPluginRemoteSurface tree={checked.tree} css={checked.css} onEvent={onEvent} settled={settled} /></ResourceLoader.Provider></SurfaceContext.Provider></LazyLoadBoundary>;
}
function ValidatedPluginRemoteSurface({ tree, css, onEvent, settled }: { settled: boolean; tree: { children: RemoteNode[] }; css: string; onEvent(event: PluginSurfaceEvent): void }) {
const hostRef = useRef<HTMLDivElement>(null);
const onEventRef = useRef(onEvent); onEventRef.current = onEvent;
const shadowRef = useRef<ShadowRoot | null>(null);
const [shadow, setShadow] = useState<ShadowRoot | null>(null);
useLayoutEffect(() => {
const host = hostRef.current;
if (!host) return;
// The same DOM node can be mounted twice in React Strict Mode.
const root = shadowRef.current ?? host.attachShadow({ mode: 'closed' });
shadowRef.current = root;
setShadow(root);
}, []);
useEffect(() => {
if (!shadow) return;
const listener = (event: Event) => {
const target = event.target;
const view = shadow.ownerDocument.defaultView;
if (!view || !(target instanceof view.HTMLElement) || !['click', 'input', 'change', 'keydown', 'keyup', 'focusin', 'focusout', 'compositionstart', 'compositionupdate', 'compositionend', 'submit', 'reset'].includes(event.type)) return;
// Closed-shadow events hide their editable target from window capture.
if (event.type === 'focusin') hostRef.current?.toggleAttribute('data-plugin-editing', target.matches('input, textarea, select, [contenteditable]'));
if (event.type === 'focusout') hostRef.current?.removeAttribute('data-plugin-editing');
if (event.type === 'submit') event.preventDefault();
const nodeId = target.dataset.netcattyRemoteNode;
if (!nodeId) return;
const value = target instanceof view.HTMLInputElement || target instanceof view.HTMLTextAreaElement || target instanceof view.HTMLSelectElement ? target.value : undefined;
const values = target instanceof view.HTMLSelectElement && target.multiple ? [...target.selectedOptions].map((option) => option.value) : undefined;
const checkedValue = target instanceof view.HTMLInputElement && ['checkbox', 'radio'].includes(target.type) ? target.checked : undefined;
if (value !== undefined && value.length > 8192) return;
const keyboard = event instanceof view.KeyboardEvent && event.key.length <= 64
? { key: event.key, ctrlKey: event.ctrlKey, metaKey: event.metaKey, altKey: event.altKey, shiftKey: event.shiftKey } : {};
onEventRef.current({ nodeId, type: event.type as PluginSurfaceEvent['type'], ...(value === undefined ? {} : { value }), ...(checkedValue === undefined ? {} : { checked: checkedValue }), ...keyboard, ...(values ? { values } : {}), ...(event instanceof view.CompositionEvent ? { data: event.data.slice(0, 8192) } : {}) });
};
for (const event of ['click', 'input', 'change', 'keydown', 'keyup', 'focusin', 'focusout', 'compositionstart', 'compositionupdate', 'compositionend', 'submit', 'reset']) shadow.addEventListener(event, listener, true);
return () => { for (const event of ['click', 'input', 'change', 'keydown', 'keyup', 'focusin', 'focusout', 'compositionstart', 'compositionupdate', 'compositionend', 'submit', 'reset']) shadow.removeEventListener(event, listener, true); };
}, [shadow]);
return <div className="h-full w-full min-h-0 min-w-0 overflow-hidden [contain:paint] isolate relative" data-plugin-remote-surface>
<div ref={hostRef} className="h-full min-h-0 overflow-auto [container-type:inline-size]" />
{shadow && createPortal(<><style>{css}</style>{tree.children.map((node) => <RemoteElement key={node.id} node={node} settled={settled} />)}</>, shadow)}
</div>;
}
@@ -0,0 +1,85 @@
import { Settings } from 'lucide-react';
import { useEffect, useRef, useState, type RefObject } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { usePluginContributions } from '../../application/state/usePluginContributions';
import { usePluginSettingsModel } from '../../application/state/usePluginSettingsModel';
import { useAvailableFonts } from '../../application/state/fontStore';
import { Button } from '../ui/button';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '../ui/dialog';
import { PluginSettingGroups } from './PluginSettingsForm';
export const pluginSettingsButtonClass = 'h-8 w-8 shrink-0 rounded-full border border-border/60 bg-background/95 shadow-sm hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2';
export function PluginSettingsDialog({ pluginId, onClose, restoreFocusTo }: {
pluginId: string;
onClose: () => void;
restoreFocusTo: RefObject<HTMLElement | null>;
}) {
const { t } = useI18n();
const model = usePluginSettingsModel(pluginId);
const fonts = useAvailableFonts();
useEffect(() => {
if (!model.loading && !model.error && !model.settings.length) onClose();
}, [model.loading, model.error, model.settings.length, onClose]);
return <Dialog open onOpenChange={open => { if (!open) onClose(); }}>
<DialogContent data-plugin-settings-dialog={pluginId}
className="app-no-drag flex max-h-[min(80dvh,760px)] w-[calc(100vw-2rem)] max-w-[720px] flex-col gap-0 overflow-hidden rounded-xl p-0"
overlayClassName="bg-black/40"
onEscapeKeyDown={event => event.stopPropagation()}
onCloseAutoFocus={event => {
const element = restoreFocusTo.current;
if (element?.isConnected && !element.matches(':disabled')) {
event.preventDefault(); element.focus({ preventScroll: true });
}
}}>
<DialogHeader className="shrink-0 border-b border-border/60 px-6 py-4 pr-14">
<DialogTitle className="flex min-w-0 items-center gap-2.5 text-base">
<Settings size={18} aria-hidden="true" className="shrink-0 text-muted-foreground" />
<span className="truncate">{model.plugin?.displayName ?? t('settings.plugins.configuration')}</span>
</DialogTitle>
</DialogHeader>
<div className="min-h-0 overflow-y-auto p-6" data-plugin-settings-form="">
{model.loading && <p role="status" className="text-sm text-muted-foreground">{t('settings.plugins.loading')}</p>}
{model.error && <p role="alert" className="text-sm text-destructive">{model.error.message}</p>}
{model.contextualScopes.length > 0 && <section className="mb-4 grid gap-3 sm:grid-cols-2" aria-label={t('settings.plugins.scopeTargets')}>
{model.contextualScopes.map(kind => <label key={kind} className="space-y-1 text-xs font-medium">
<span>{t('settings.plugins.scopeTarget').replace('{scope}', kind)}</span>
<select value={model.scopeIds[kind] ?? ''}
onChange={event => model.setScopeIds(current => ({ ...current, [kind]: event.target.value || undefined }))}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm font-normal">
{!model.catalog[kind].length && <option value="">{t('settings.plugins.noScopeTargets')}</option>}
{model.catalog[kind].map(entry => <option key={entry.id} value={entry.id}>{entry.label}</option>)}
</select>
</label>)}
</section>}
<div className="space-y-4">
<PluginSettingGroups pluginId={pluginId} settings={model.settings} updateSetting={model.updateSetting}
resetSetting={model.resetSetting} selectSettingPath={model.selectSettingPath} availableFonts={fonts} />
</div>
</div>
</DialogContent>
</Dialog>;
}
function AvailableSettingsEntry({ pluginId, name }: { pluginId: string; name: string }) {
const { t } = useI18n();
const [open, setOpen] = useState(false);
const opener = useRef<HTMLButtonElement>(null);
return <>
<div className="flex items-center gap-3">
<Button ref={opener} type="button" variant="ghost" size="icon" className={pluginSettingsButtonClass}
aria-label={t('settings.plugins.landing.settingsFor').replace('{name}', name)} title={t('common.settings')}
onClick={() => setOpen(true)}><Settings size={16} /></Button>
</div>
{open && <PluginSettingsDialog pluginId={pluginId} onClose={() => setOpen(false)} restoreFocusTo={opener} />}
</>;
}
export function PluginSettingsEntry({ pluginId }: { pluginId: string }) {
const { locale } = useI18n();
const contributions = usePluginContributions({ locale, context: { 'netcatty.surface': 'settings' } });
const plugin = contributions.snapshot.plugins.find(item => item.id === pluginId);
if (!plugin?.settings.some(setting => setting.visible)) return null;
// Removing or replacing the owning contribution also discards its open dialog.
return <AvailableSettingsEntry key={`${plugin.id}:${plugin.version}`} pluginId={plugin.id} name={plugin.displayName} />;
}
+275
View File
@@ -0,0 +1,275 @@
import React, { useEffect, useState } from 'react';
import { FolderOpen, RotateCcw } from 'lucide-react';
import { normalizePluginKeyboardEvent } from '../../application/state/pluginKeybindings';
import { usePluginContributions } from '../../application/state/usePluginContributions';
import { useI18n } from '../../application/i18n/I18nProvider';
import { useAvailableFonts } from '../../application/state/fontStore';
import { Button } from '../ui/button';
import { Input } from '../ui/input';
import { Toggle } from '../settings/settings-ui';
import { parsePluginStructuredSettingValue } from '../settings/tabs/pluginSettingValues';
import { PluginStructuredSettingEditor } from '../settings/tabs/PluginStructuredSettingEditor';
import { TerminalFontSelect } from '../settings/TerminalFontSelect';
export function PluginSettingField({
pluginId,
setting,
updateSetting,
resetSetting,
selectSettingPath,
availableFonts,
}: {
pluginId: string;
setting: NetcattyPluginSettingContribution;
updateSetting: ReturnType<typeof usePluginContributions>['updateSetting'];
resetSetting: ReturnType<typeof usePluginContributions>['resetSetting'];
selectSettingPath: ReturnType<typeof usePluginContributions>['selectSettingPath'];
availableFonts: ReturnType<typeof useAvailableFonts>;
}) {
const { t } = useI18n();
const [value, setValue] = useState<unknown>(setting.secret ? '' : setting.value ?? '');
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const itemLabel = (key: 'settings.plugins.moveItemUp' | 'settings.plugins.moveItemDown', index: number) => (
t(key).replace('{label}', setting.label).replace('{index}', String(index + 1))
);
useEffect(() => {
setValue(setting.secret ? '' : setting.value ?? '');
}, [setting.secret, setting.value]);
const save = async (nextValue: unknown) => {
setSaving(true);
setError(null);
try {
await updateSetting(pluginId, setting.id, nextValue, setting.scopeId ?? undefined);
if (setting.secret) setValue('');
} catch (cause) {
setError(cause instanceof Error ? cause.message : String(cause));
} finally {
setSaving(false);
}
};
const control = (() => {
if (setting.scopeId == null) {
return <div className="text-xs text-muted-foreground">{t('settings.plugins.scopeContext').replace('{scope}', setting.scope)}</div>;
}
if (setting.control === 'switch') {
return <Toggle ariaLabel={setting.label} checked={Boolean(value)} disabled={saving}
onChange={(next) => { setValue(next); void save(next); }} />;
}
if (setting.control === 'radio') {
return (
<fieldset className="space-y-2" disabled={saving}>
<legend className="sr-only">{setting.label}</legend>
{setting.options?.map((option) => (
<label key={option.value} className="flex items-start gap-2 text-sm">
<input
type="radio"
name={setting.id}
value={option.value}
checked={value === option.value}
onChange={() => { setValue(option.value); void save(option.value); }}
className="mt-0.5 accent-primary"
/>
<span>{option.label}{option.description && <span className="block text-xs text-muted-foreground">{option.description}</span>}</span>
</label>
))}
</fieldset>
);
}
if (setting.control === 'select') {
return (
<select
aria-label={setting.label}
value={String(value)}
disabled={saving}
onChange={(event) => { setValue(event.target.value); void save(event.target.value); }}
className="h-9 min-w-52 rounded-md border border-input bg-background px-3 text-sm"
>
{setting.options?.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
);
}
if (setting.control === 'multiselect') {
const selected = Array.isArray(value) ? value.map(String) : [];
return (
<select
aria-label={setting.label}
multiple
value={selected}
disabled={saving}
onChange={(event) => {
const next = [...event.currentTarget.selectedOptions].map((option) => option.value);
setValue(next);
void save(next);
}}
className="min-h-24 min-w-52 rounded-md border border-input bg-background px-3 py-2 text-sm"
>
{setting.options?.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
);
}
if (setting.control === 'textarea') {
return (
<textarea
aria-label={setting.label}
value={String(value)}
placeholder={setting.placeholder}
disabled={saving}
onChange={(event) => setValue(event.target.value)}
onBlur={(event) => void save(event.currentTarget.value)}
className="min-h-24 w-full max-w-xl rounded-md border border-input bg-background px-3 py-2 text-sm"
/>
);
}
if (setting.control === 'number' || setting.control === 'slider') {
return (
<Input
aria-label={setting.label}
type={setting.control === 'slider' ? 'range' : 'number'}
value={typeof value === 'number' ? value : Number(value) || 0}
min={setting.minimum}
max={setting.maximum}
step={setting.step}
disabled={saving}
onChange={(event) => setValue(Number(event.target.value))}
onBlur={(event) => void save(Number(event.currentTarget.value))}
className="max-w-sm"
/>
);
}
if (setting.control === 'list' || setting.control === 'table') {
return (
<PluginStructuredSettingEditor
setting={setting}
value={typeof value === 'string' ? parsePluginStructuredSettingValue(value) : value}
disabled={saving}
onChange={setValue}
onCommit={(next) => void save(next)}
labels={{
add: t('settings.plugins.addItem'),
remove: t('settings.plugins.removeItem'),
moveUp: (index) => itemLabel('settings.plugins.moveItemUp', index),
moveDown: (index) => itemLabel('settings.plugins.moveItemDown', index),
}}
/>
);
}
if (setting.control === 'font') {
return (
<TerminalFontSelect
value={String(value)}
fonts={availableFonts}
disabled={saving}
onChange={(next) => { setValue(next); void save(next); }}
className="w-full max-w-xl"
ariaLabel={setting.label}
/>
);
}
if (setting.control === 'file' || setting.control === 'directory') {
return (
<div className="flex max-w-xl gap-2">
<Input aria-label={setting.label} value={String(value)} readOnly disabled={saving} className="min-w-0 flex-1" />
<Button
type="button"
variant="outline"
disabled={saving}
onClick={() => void selectSettingPath(setting.control as 'file' | 'directory', setting.label, String(value || '')).then((selected) => {
if (!selected) return;
setValue(selected);
void save(selected);
}).catch((cause) => setError(cause instanceof Error ? cause.message : String(cause)))}
>
<FolderOpen size={14} className="mr-2" /> {t('settings.plugins.browse')}
</Button>
</div>
);
}
if (setting.control === 'keybinding') {
return (
<Input
aria-label={setting.label}
value={String(value)}
placeholder={setting.placeholder ?? t('settings.plugins.pressKeybinding')}
readOnly
disabled={saving}
onKeyDown={(event) => {
event.preventDefault();
const key = normalizePluginKeyboardEvent(event.nativeEvent);
if (!key) return;
setValue(key);
void save(key);
}}
className="max-w-xl"
/>
);
}
return (
<Input
aria-label={setting.label}
type={setting.secret || setting.control === 'password' ? 'password' : setting.control === 'color' ? 'color' : 'text'}
value={String(value)}
placeholder={setting.secret && setting.configured ? t('settings.plugins.configuredReplacement') : setting.placeholder}
disabled={saving}
onChange={(event) => setValue(event.target.value)}
onBlur={(event) => {
const next = event.currentTarget.value;
if (!setting.secret || next.length > 0) void save(next);
}}
className="max-w-xl"
/>
);
})();
const expanded = ['textarea', 'list', 'table', 'radio', 'multiselect'].includes(setting.control);
const reset = !setting.required && setting.scopeId != null && <Button
type="button" variant="ghost" size="icon" className="h-7 w-7 shrink-0 text-muted-foreground"
title={t('common.reset')} aria-label={t('common.reset')} disabled={saving}
onClick={() => {
setSaving(true); setError(null);
void resetSetting(pluginId, setting.id, setting.scopeId ?? undefined)
.catch((cause) => setError(cause instanceof Error ? cause.message : String(cause)))
.finally(() => setSaving(false));
}}><RotateCcw size={14} /></Button>;
return <div className="space-y-2 py-3" data-plugin-setting={setting.id}>
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="min-w-0 flex-1 basis-40">
<div className="text-sm font-medium">{setting.label}</div>
{setting.description && <p className="mt-1 max-w-2xl text-xs leading-relaxed text-muted-foreground">{setting.description}</p>}
{setting.restartRequired && <span className="text-xs text-amber-700 dark:text-amber-300">{t('settings.plugins.restartRequired')}</span>}
</div>
<div className="flex max-w-full items-center justify-end gap-2">
{!expanded && <div className={setting.control === 'switch' ? 'shrink-0' : 'w-56 max-w-full'}>{control}</div>}
{reset}
</div>
</div>
{expanded && control}
{(saving || (setting.secret && setting.configured) || error) && <div className="flex flex-wrap items-center gap-2">
{saving && <span role="status" className="text-xs text-muted-foreground">{t('settings.plugins.saving')}</span>}
{setting.secret && setting.configured && <span className="text-xs text-emerald-600">{t('settings.plugins.storedSecurely')}</span>}
{error && <span role="alert" className="text-xs text-destructive">{error}</span>}
</div>}
</div>;
}
export function PluginSettingGroups({ settings, ...props }: Omit<Parameters<typeof PluginSettingField>[0], 'setting'> & {
settings: ReadonlyArray<NetcattyPluginSettingContribution>;
}) {
const groups = new Map<string, NetcattyPluginSettingContribution[]>();
for (const setting of settings) {
if (!setting.visible) continue;
const group = setting.group ?? '';
groups.set(group, [...(groups.get(group) ?? []), setting]);
}
return <>{[...groups].map(([group, fields]) => <div key={group} className="space-y-3">
{group && <h4 className="pt-2 text-sm font-medium text-muted-foreground">{group}</h4>}
<div className="divide-y divide-border/50 rounded-xl bg-muted/30 px-4">
{fields.map((setting) => <PluginSettingField key={setting.id} {...props} setting={setting} />)}
</div>
</div>)}</>;
}
+17 -24
View File
@@ -1,40 +1,40 @@
import { useMemo } from 'react';
import { Blocks } from 'lucide-react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { activeTabStore } from '../../application/state/activeTabStore';
import { subscribeSidePanelLiveSnapshot } from '../../application/state/sidePanelLiveStore';
import { useEmbeddedPluginView } from '../../application/state/useEmbeddedPluginView';
import { usePluginContributions } from '../../application/state/usePluginContributions';
import { collectPluginTerminalSidebarPages, PLUGIN_TERMINAL_SIDEBAR_CONTEXT, requestOpenPluginTerminalSidebar,
type PluginTerminalSidebarPage } from '../../application/state/usePluginTerminalSidebar';
import { PluginContributionIcon } from './PluginContributionIcon';
import { Button } from '../ui/button';
import { Popover, PopoverClose, PopoverContent, PopoverTrigger } from '../ui/popover';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
import { PluginHostDrawnPage } from './PluginHostDrawnPage';
export function PluginTerminalSidebarLauncher({ sessionId }: { sessionId: string }) {
const { locale, t } = useI18n();
export function PluginTerminalSidebarLauncher({ sessionId, buttonClassName = 'h-6 w-6' }: { sessionId: string; buttonClassName?: string }) {
const { locale } = useI18n();
const contributions = usePluginContributions({ locale, context: PLUGIN_TERMINAL_SIDEBAR_CONTEXT });
const pages = collectPluginTerminalSidebarPages(contributions.snapshot.plugins);
if (!pages.length) return null;
return <Popover><PopoverTrigger asChild><Button variant="ghost" size="icon" className="h-6 w-6" aria-label={t('plugins.sidebar.extensions')}><Blocks size={14} /></Button></PopoverTrigger>
<PopoverContent align="end" className="max-h-80 w-64 overflow-y-auto p-1">
<p className="px-2 py-1 text-xs text-muted-foreground">{t('plugins.sidebar.extensions')}</p>
{pages.map((page) => <PopoverClose asChild key={page.tool}><button type="button" className="flex w-full items-center gap-2 rounded px-2 py-2 text-left text-sm hover:bg-accent"
return <>{pages.map((page) => <Tooltip key={page.tool}>
<TooltipTrigger asChild>
<Button type="button" variant="ghost" size="icon" className={buttonClassName}
aria-label={`${page.title} — ${page.pluginName}`}
onClick={() => requestOpenPluginTerminalSidebar(page.viewId, sessionId)}>
<PluginContributionIcon pluginId={page.pluginId} icon={page.icon} /><span className="min-w-0"><span className="block truncate">{page.title}</span><span className="block truncate text-xs text-muted-foreground">{page.pluginName}</span></span>
</button></PopoverClose>)}
</PopoverContent></Popover>;
<PluginContributionIcon pluginId={page.pluginId} icon={page.icon} size={14} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">{page.title} — {page.pluginName}</TooltipContent>
</Tooltip>)}</>;
}
export function PluginTerminalSidebarPageHost({ page, tabId, sessionId, adapter, getSession, unavailable, focused, focusOnReveal }: {
export function PluginTerminalSidebarPageHost({ page, tabId, sessionId, getSession, unavailable, focused, showHeader = true }: {
page: PluginTerminalSidebarPage;
tabId: string;
sessionId: string;
adapter: ReturnType<typeof usePluginContributions>;
getSession(): string | null;
unavailable(): void;
focused(): void;
focusOnReveal: boolean;
showHeader?: boolean;
}) {
const { t } = useI18n();
const subscribeHidden = useMemo(() => (hide: () => void) => {
@@ -42,15 +42,8 @@ export function PluginTerminalSidebarPageHost({ page, tabId, sessionId, adapter,
const stopSession = subscribeSidePanelLiveSnapshot(true, () => { if (getSession() !== sessionId) hide(); });
return () => { stopTab(); stopSession(); };
}, [getSession, sessionId, tabId]);
const { mountRef, loading } = useEmbeddedPluginView({ viewId: page.viewId, version: page.version,
slot: `terminal-sidebar:${tabId}:${sessionId}`, active: true, context: PLUGIN_TERMINAL_SIDEBAR_CONTEXT,
adapter, subscribeHidden, unavailable, focused, focusOnReveal });
// The native page never receives these internal ownership IDs. Its state is
// scoped by the host; this does not authorize reading the terminal session.
return <section className="flex h-full min-h-0 flex-col overflow-hidden" aria-label={`${page.title} — ${page.pluginName}`} data-plugin-terminal-sidebar>
<header className="flex h-7 shrink-0 items-center gap-2 border-b border-border px-2 text-xs"><PluginContributionIcon pluginId={page.pluginId} icon={page.icon} size={12} /><span className="truncate">{page.title} · {page.pluginName}</span></header>
<div ref={mountRef} className="relative min-h-0 flex-1 overflow-hidden" data-plugin-view-slot="terminal-sidebar">
{loading && <p role="status" className="p-3 text-xs text-muted-foreground">{t('plugins.sidebar.loading')}</p>}
</div>
{showHeader && <header className="flex h-7 shrink-0 items-center gap-2 border-b border-border px-2 text-xs"><PluginContributionIcon pluginId={page.pluginId} icon={page.icon} size={12} /><span className="truncate">{page.title}</span></header>}
<div className="min-h-0 flex-1" onFocusCapture={focused}><PluginHostDrawnPage pluginId={page.pluginId} viewId={page.viewId} version={page.version} slot={`terminal-sidebar:${tabId}:${sessionId}`} active context={PLUGIN_TERMINAL_SIDEBAR_CONTEXT} subscribeHidden={subscribeHidden} unavailable={unavailable} loadingLabel={t('plugins.sidebar.loading')} /></div>
</section>;
}
+7 -17
View File
@@ -1,44 +1,34 @@
import { activeTabStore } from '../../application/state/activeTabStore';
import { useI18n } from '../../application/i18n/I18nProvider';
import { useEmbeddedPluginView } from '../../application/state/useEmbeddedPluginView';
import type { usePluginVaultPages } from '../../application/state/usePluginVaultPages';
import { PluginContributionIcon } from './PluginContributionIcon';
import { Button } from '../ui/button';
import { PluginHostDrawnPage } from './PluginHostDrawnPage';
type VaultPages = ReturnType<typeof usePluginVaultPages>;
const subscribeHidden = (hide: () => void) => activeTabStore.subscribeSync((id) => { if (id !== 'vault') hide(); });
export function PluginVaultNavigation({ model, collapsed }: { model: VaultPages; collapsed: boolean }) {
const { t } = useI18n();
if (!model.pages.length) return null;
return <div role="group" aria-label={t('plugins.vault.extensions')} className="mt-3 space-y-1 border-t border-border/50 pt-3">
{!collapsed && <p className="px-2 text-xs text-muted-foreground">{t('plugins.vault.extensions')}</p>}
return <>
{model.pages.map((page) => <Button key={page.key} type="button"
variant={model.selected?.key === page.key ? 'secondary' : 'ghost'}
className={`w-full ${collapsed ? 'justify-center px-0' : 'justify-start gap-3'}`}
className={`w-full h-10 ${collapsed ? 'justify-center p-0' : 'justify-start gap-3'} ${model.selected?.key === page.key ? 'bg-foreground/10 text-foreground hover:bg-foreground/15 border-border/40' : ''}`}
title={`${page.title} — ${page.pluginName}`}
aria-label={`${page.title} — ${page.pluginName}`}
aria-current={model.selected?.key === page.key ? 'page' : undefined}
onClick={() => model.select(page.key)}>
<PluginContributionIcon pluginId={page.pluginId} icon={page.icon} className="shrink-0" />
<PluginContributionIcon pluginId={page.pluginId} icon={page.icon} size={16} className="shrink-0" />
{!collapsed && <span className="truncate">{page.title}</span>}
</Button>)}
</div>;
</>;
}
export function PluginVaultPage({ model }: { model: VaultPages }) {
const { t } = useI18n();
const page = model.selected!;
const { mountRef, loading } = useEmbeddedPluginView({ viewId: page.viewId, version: page.version, slot: 'vault-page',
active: model.active, context: model.context, adapter: model.contributions, subscribeHidden, unavailable: model.fallback });
return <section className="flex min-h-0 flex-1 flex-col" aria-label={page.title} data-plugin-vault-page={page.key}>
<header className="app-no-drag flex h-12 shrink-0 items-center justify-between gap-3 border-b border-border px-4">
<div className="min-w-0"><h2 className="truncate text-sm font-medium">{page.title}</h2>
<p className="truncate text-xs text-muted-foreground">{page.pluginName} · {page.version}</p></div>
<Button variant="ghost" onClick={model.fallback}>{t('plugins.vault.back')}</Button>
</header>
<div ref={mountRef} className="relative min-h-0 flex-1 overflow-hidden" data-plugin-view-slot="vault-page">
{loading && <p role="status" className="p-4 text-sm text-muted-foreground">{t('plugins.vault.loading')}</p>}
</div>
<PluginHostDrawnPage pluginId={page.pluginId} viewId={page.viewId} version={page.version} slot="vault-page" active={model.active} context={model.context}
subscribeHidden={subscribeHidden} unavailable={model.fallback} loadingLabel={t('plugins.vault.loading')} />
</section>;
}
+41
View File
@@ -0,0 +1,41 @@
import React, { useLayoutEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
/** Geometry and dismissal belong to the host; content belongs to the plugin. */
export function PluginViewPopover({ anchor, open, dismiss, children }: {
anchor: string; open: boolean; dismiss(): void; children: React.ReactNode;
}) {
const marker = useRef<HTMLSpanElement>(null);
const panel = useRef<HTMLDivElement>(null);
const dismissRef = useRef(dismiss); dismissRef.current = dismiss;
const [position, setPosition] = useState<{ left: number; top: number; width: number } | null>(null);
useLayoutEffect(() => {
if (!open || !marker.current) { setPosition(null); return; }
const root = marker.current.getRootNode() as ShadowRoot;
const target = root.getElementById(anchor);
if (!(target instanceof marker.current.ownerDocument.defaultView!.HTMLElement)) return;
const doc = target.ownerDocument; const win = doc.defaultView!;
const update = () => {
const bounds = target.getBoundingClientRect();
if (!bounds.width || !bounds.height) { setPosition(null); dismissRef.current(); return; }
const width = Math.min(360, Math.max(220, bounds.width), win.innerWidth - 16);
setPosition({ left: Math.max(8, Math.min(bounds.left, win.innerWidth - width - 8)), top: Math.max(8, Math.min(bounds.bottom + 4, win.innerHeight - 248)), width });
};
const dismissNow = () => { setPosition(null); dismissRef.current(); };
const key = (event: KeyboardEvent) => { if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); dismissNow(); target.focus(); } };
const outside = (event: PointerEvent) => { if (!event.composedPath().includes(target) && (!panel.current || !event.composedPath().includes(panel.current))) dismissNow(); };
const blocked = () => [...doc.querySelectorAll('[role=dialog][aria-modal=true]:not([data-plugin-dialog])')].some((dialog) => {
const rect = dialog.getBoundingClientRect(); return rect.width > 0 && rect.height > 0;
});
if (blocked()) { dismissNow(); return; }
const dialogs = new win.MutationObserver(() => { if (blocked()) dismissNow(); });
dialogs.observe(doc.body, { subtree: true, childList: true, attributes: true, attributeFilter: ['aria-modal', 'hidden', 'data-state'] });
const resize = new win.ResizeObserver(update); resize.observe(target); update();
win.addEventListener('resize', update); doc.addEventListener('scroll', update, true);
doc.addEventListener('keydown', key, true); doc.addEventListener('pointerdown', outside, true);
return () => { dialogs.disconnect(); resize.disconnect(); win.removeEventListener('resize', update); doc.removeEventListener('scroll', update, true); doc.removeEventListener('keydown', key, true); doc.removeEventListener('pointerdown', outside, true); };
}, [anchor, open]);
return <><span ref={marker} hidden />{open && position && marker.current && createPortal(
<div ref={panel} role="region" aria-label="Plugin menu" data-plugin-view-popover className="fixed z-[45] overflow-hidden rounded-lg border border-border bg-background shadow-xl" style={{ ...position, height: 240, maxHeight: 'calc(100vh - 16px)', contain: 'paint', isolation: 'isolate' }}>{children}</div>, marker.current.ownerDocument.body,
)}</>;
}
+3
View File
@@ -203,6 +203,7 @@ export const SettingCard: React.FC<SettingCardProps> = ({
interface SettingRowProps {
leading?: React.ReactNode;
label?: React.ReactNode;
className?: string;
description?: React.ReactNode;
children: React.ReactNode;
align?: "center" | "start";
@@ -213,6 +214,7 @@ interface SettingRowProps {
export const SettingRow: React.FC<SettingRowProps> = ({
leading,
label,
className,
description,
children,
align = "center",
@@ -225,6 +227,7 @@ export const SettingRow: React.FC<SettingRowProps> = ({
// Keep square corners: rounded rows bend divide-y separators at both ends.
"flex justify-between py-3 gap-4",
align === "start" ? "items-start" : "items-center",
className,
)}
>
{leading && <div className="shrink-0">{leading}</div>}
@@ -0,0 +1,125 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import React from 'react';
import { JSDOM } from 'jsdom';
import { EventEmitter } from 'node:events';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { createRequire } from 'node:module';
import { usePluginAuthorIdentity } from '../../../application/state/usePluginAuthorIdentity';
import { checkPluginUpdates } from '../../../infrastructure/services/pluginUpdateCheck';
const require = createRequire(import.meta.url);
const { PluginDatabase } = require('../../../electron/plugins/database.cjs');
const { MarketSourcesService } = require('../../../electron/plugins/marketSourcesService.cjs');
const { OFFICIAL_SOURCE } = require('../../../electron/plugins/sourceIdentity.cjs');
const { registerPluginBridge, CHANNELS } = require('../../../electron/plugins/pluginBridge.cjs');
const { createPreloadApi } = require('../../../electron/preload/api.cjs');
const { catalog, delivery, makeFiles, read, digest } = require('../../../electron/plugins/fixtures/staticDistributionFixture.cjs');
test('mounted installed authors follow actual catalog/update/source entrypoints without changing package identity', async () => {
const rootPath = mkdtempSync(join(tmpdir(), 'netcatty-author-refresh-'));
const database = new PluginDatabase(join(rootPath, 'plugins.sqlite'));
const dom = new JSDOM('<!doctype html><div id="root"></div>', { pretendToBeVisual: true });
const previous = new Map<string, PropertyDescriptor | undefined>();
for (const [key, value] of Object.entries({ window: dom.window, document: dom.window.document,
navigator: dom.window.navigator, HTMLElement: dom.window.HTMLElement, Element: dom.window.Element,
Node: dom.window.Node, IntersectionObserver: undefined, IS_REACT_ACT_ENVIRONMENT: true })) {
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
}
const entry = structuredClone(catalog[0]);
entry.artifact = { sha256: 'a'.repeat(64), url: `${OFFICIAL_SOURCE.origin}/assets/${'a'.repeat(64)}.ncpkg` };
const bytes = read(delivery.plugins[0].iconLight.path.slice(1));
const avatar = { path: `/assets/${digest(bytes)}.png`, sha256: digest(bytes), size: bytes.length };
const filesFor = (entries = [entry], image = true) => {
const files = makeFiles(entries, (index: { plugins: Array<{ id: string; versions: Array<{ distribution: string }> }> }, sidecar: { schemaVersion: number; plugins: Array<{ id: string; avatar: typeof avatar | null; artifact: { path: string; sha256: string; size: number } }> }) => {
sidecar.schemaVersion = 2;
for (const item of sidecar.plugins) {
item.avatar = image ? avatar : null;
item.artifact = { path: `/assets/${entry.artifact.sha256}.ncpkg`, sha256: entry.artifact.sha256, size: 1 };
index.plugins.find((plugin) => plugin.id === item.id).versions[0].distribution = 'release';
}
});
files.set(avatar.path, bytes); return files;
};
let files = filesFor();
database.installVersion({ pluginId: entry.id, version: entry.version, runtimeKind: "browser", apiVersion: "0.1.0-internal.1", manifest: entry.manifest, archiveSha256: entry.artifact.sha256,
packageRelativePath: 'packages/synthetic', source: OFFICIAL_SOURCE }, { enable: true });
const manager = { initialize: async () => {}, list: async () => database.listPlugins(),
changeMarketSource: async (change: () => unknown) => change() };
let avatarReads = 0;
const service = new MarketSourcesService({ database, manager,
fetch: async (url: string) => { assert.equal(new URL(url).origin, OFFICIAL_SOURCE.origin); assert.ok(!new URL(url).pathname.endsWith('.ncpkg'), 'rejected selection never downloads a package'); return new Response(files.get(new URL(url).pathname)); },
createSourceFetch: (origin: string) => async (url: string) => { assert.equal(new URL(url).origin, origin); return new Response(files.get(new URL(url).pathname)); },
resolveAvatar: async () => { avatarReads++; return 'data:image/png;base64,eA=='; } });
const ipcMain = { handlers: new Map(), handle(channel: string, handler: unknown) { this.handlers.set(channel, handler); }, on() {} };
const events = new EventEmitter();
registerPluginBridge(ipcMain, { manager, catalogService: service, isTrustedSender: () => true,
broadcast: (channel: string, payload: unknown) => events.emit(channel, {}, payload) });
const api = createPreloadApi({ ipcRenderer: { on: events.on.bind(events), removeListener: events.removeListener.bind(events),
invoke: (channel: string, payload: unknown) => ipcMain.handlers.get(channel)({}, payload) } });
let authorCalls = 0;
const original = api.getPluginAuthorPresentation;
api.getPluginAuthorPresentation = (request: NetcattyPluginAuthorRequest) => { authorCalls++; return original(request); };
Object.assign(window, { netcatty: api });
let plugin = database.getActivePlugin(entry.id) as NetcattyInstalledPlugin;
function Row() {
const { ref, identity } = usePluginAuthorIdentity({ plugin });
return <div ref={ref} data-author={identity?.author} data-avatar={identity?.avatar} data-official={identity?.officialMaintained ?? false} />;
}
const { act } = await import('react');
const { createRoot } = await import('react-dom/client');
const root = createRoot(document.getElementById('root')!);
const row = () => document.querySelector<HTMLElement>('[data-official]')!;
try {
await act(async () => root.render(<><Row /><Row /></>));
assert.equal(row().dataset.author, entry.manifest.publisher); assert.equal(row().dataset.official, 'false');
assert.equal(avatarReads, 0, 'opening installed rows never fetches a catalog/avatar');
await act(async () => { await checkPluginUpdates(api); });
assert.equal(row().dataset.author, entry.author); assert.equal(row().dataset.official, 'true');
assert.match(row().dataset.avatar!, /^data:image\/png/);
assert.equal(events.listenerCount(CHANNELS.catalogChanged), 1, 'rows share one preload subscription');
const count = authorCalls;
await act(async () => { await api.listPluginCatalog('official'); root.render(<><Row /><Row /></>); });
assert.equal(authorCalls, count, 'equivalent catalog and installation retain request dedupe');
files = filesFor([{ ...entry, author: 'Updated project name' }], false);
await act(async () => { await api.listPluginCatalog('official'); });
assert.equal(row().dataset.author, 'Updated project name'); assert.equal(row().dataset.avatar, undefined);
const beforeWithdrawal = (await api.listPluginCatalog('official')).entries[0];
files = filesFor([]);
await act(async () => {
await assert.rejects(api.installCatalogPlugin(entry.id, beforeWithdrawal.selectionToken, 'official'), /not in the curated catalog|changed/);
});
assert.equal(row().dataset.author, entry.manifest.publisher); assert.equal(row().dataset.official, 'false');
assert.equal(row().dataset.avatar, undefined, 'rejected install refresh removes withdrawn enrichment without another list or remount');
const source = await api.addPluginSource({ name: 'Community', address: 'https://community.example.com' });
const community = structuredClone(entry);
community.id = community.manifest.id = 'com.community.identity';
community.artifact.url = `${source.origin}/assets/${community.artifact.sha256}.ncpkg`;
files = filesFor([community]);
database.installVersion({ pluginId: community.id, version: community.version, manifest: community.manifest,
archiveSha256: community.artifact.sha256, packageRelativePath: 'packages/community', source }, { enable: true });
await act(async () => { await api.listPluginCatalog(source.id); });
assert.equal(authorCalls, count + 4, 'unrelated source changes do not reload official rows');
plugin = database.getActivePlugin(community.id);
await act(async () => root.render(<><Row /><Row /></>));
assert.equal(row().dataset.author, community.author); assert.match(row().dataset.avatar!, /^data:image\/png/);
assert.equal(row().dataset.official, 'false', 'copied catalog never gains official maintenance authority');
await act(async () => { await api.updatePluginSource({ id: source.id, name: 'Renamed', enabled: false }); });
assert.equal(row().dataset.author, community.manifest.publisher); assert.equal(row().dataset.avatar, undefined);
await act(async () => { await api.updatePluginSource({ id: source.id, name: 'Renamed', enabled: true }); });
assert.equal(row().dataset.author, community.author);
await act(async () => { await api.removePluginSource(source.id, false); });
assert.equal(row().dataset.author, community.manifest.publisher); assert.equal(row().dataset.avatar, undefined);
await act(async () => root.unmount());
assert.equal(events.listenerCount(CHANNELS.catalogChanged), 0, 'last row releases the shared IPC listener');
} finally {
database.close(); dom.window.close(); rmSync(rootPath, { recursive: true, force: true });
for (const [key, descriptor] of previous) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key);
}
}
});
@@ -0,0 +1,156 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { JSDOM } from 'jsdom';
import { PluginAuthorRow } from '../../plugins/PluginAuthorRow';
import { I18nProvider } from '../../../application/i18n/I18nProvider';
const entry = { id: 'com.netcatty.starter', name: 'Starter', author: 'Netcatty', version: '0.1.2',
repository: 'binaricat/Netcatty', commit: 'a'.repeat(40), selectionToken: 'b'.repeat(64),
manifest: { publisher: 'netcatty' } } as NetcattyPluginCatalogEntry;
const identity = { author: 'Netcatty', avatar: 'data:image/png;base64,eA==', officialMaintained: true,
sourceId: 'official', version: entry.version, archiveSha256: null };
test('untrusted entry strings and absent imagery show only the author without decoration or an official badge', () => {
const claimed = { ...entry, author: '<img src="https://avatars.githubusercontent.com/u/1">',
officialMaintained: true, avatar: 'https://avatars.githubusercontent.com/u/1' };
const html = renderToStaticMarkup(<I18nProvider locale="en"><PluginAuthorRow entry={claimed} /></I18nProvider>);
assert.doesNotMatch(html, /<img|<svg|data-plugin-author-placeholder|bg-muted|rounded-full|data-plugin-official-maintainer/);
assert.match(html, /&lt;img/);
});
test('installed authors share a compact row with localized sources and full accessible provenance', () => {
const plugin = { id: entry.id, manifest: { publisher: 'An extremely long publisher name' },
source: { id: 'third-party:example', kind: 'third-party', name: 'Community packages', origin: 'https://packages.example.org/catalog' },
} as NetcattyInstalledPlugin;
for (const [locale, label] of [['en', 'Third-party'], ['zh-CN', '第三方']]) {
const dom = new JSDOM(renderToStaticMarkup(<I18nProvider locale={locale}><PluginAuthorRow plugin={plugin} showSource /></I18nProvider>));
const row = dom.window.document.querySelector('[data-plugin-author-row]')!;
const source = row.querySelector<HTMLElement>('[data-plugin-source]')!;
assert.ok(source, 'source belongs to the author row');
assert.equal(source.textContent, label);
assert.match(source.title, /Community packages.*packages\.example\.org/);
assert.equal(source.getAttribute('aria-label'), source.title);
assert.ok(source.classList.contains('shrink-0'));
assert.ok(row.classList.contains('min-w-0'));
assert.ok(row.classList.contains('gap-3'), 'author and source are separated by 12px');
assert.ok(row.classList.contains('overflow-hidden'), 'metadata is bounded even at extremely narrow widths');
const authorGroup = row.querySelector('[data-plugin-author]')!;
assert.equal(authorGroup.parentElement, row);
assert.equal(source.parentElement, row);
assert.ok(authorGroup.classList.contains('min-w-0'));
assert.ok(authorGroup.classList.contains('gap-1.5'), 'avatar and author have a 6px gap');
assert.ok(source.classList.contains('gap-1'), 'source icon and text have a 4px gap');
assert.ok(row.querySelector('[title="An extremely long publisher name"]')?.classList.contains('truncate'));
assert.ok(source.querySelector('svg[aria-hidden="true"]'));
assert.equal(row.querySelector('[data-plugin-official-maintainer]'), null);
dom.window.close();
}
});
test('missing authors and unknown sources do not leave an empty author group or expose translation keys', () => {
for (const source of [undefined, { kind: 'unexpected' }]) {
const plugin = { id: entry.id, manifest: {}, source } as NetcattyInstalledPlugin;
const dom = new JSDOM(renderToStaticMarkup(<I18nProvider locale="en"><PluginAuthorRow plugin={plugin} showSource /></I18nProvider>));
const row = dom.window.document.querySelector('[data-plugin-author-row]')!;
assert.equal(row.querySelector('[data-plugin-author]'), null);
assert.equal(row.textContent, 'Source unknown');
assert.equal(row.querySelectorAll('svg').length, 1, 'only the source outline icon remains');
dom.window.close();
}
});
test('mounted author rows load only when visible, retain fallbacks, cancel stale results and show a borderless themed maintainer pill', async () => {
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', { pretendToBeVisual: true });
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 });
};
const observers: Array<{ target: Element | null; callback: IntersectionObserverCallback }> = [];
class VisibilityObserver {
record: typeof observers[number];
constructor(callback: IntersectionObserverCallback) { this.record = { callback, target: null }; observers.push(this.record); }
observe(target: Element) { this.record.target = target; }
disconnect() { this.record.target = null; }
}
for (const key of ['window', 'document', 'navigator', 'HTMLElement', 'Element', 'Node', 'Event']) {
install(key, key === 'window' ? dom.window : dom.window[key as keyof typeof dom.window]);
}
install('IntersectionObserver', VisibilityObserver);
install('IS_REACT_ACT_ENVIRONMENT', true);
const { act } = await import('react');
const { createRoot } = await import('react-dom/client');
const root = createRoot(document.getElementById('root')!);
const calls: NetcattyPluginAuthorRequest[] = [];
const pending: Array<(value: NetcattyPluginAuthorPresentation | null) => void> = [];
Object.assign(window, { netcatty: { getPluginAuthorPresentation: (request: NetcattyPluginAuthorRequest) => {
calls.push(request); return new Promise((resolve) => pending.push(resolve));
} } });
const render = async (value = entry, sourceId = 'official') => {
await act(async () => root.render(<I18nProvider locale="en"><PluginAuthorRow entry={value} sourceId={sourceId} /></I18nProvider>));
};
const visible = async () => {
await act(async () => {
for (const observer of [...observers]) if (observer.target) observer.callback([{ target: observer.target, isIntersecting: true } as IntersectionObserverEntry], {} as IntersectionObserver);
});
};
try {
await render(); assert.equal(calls.length, 0); assert.equal(document.querySelector('[data-plugin-author-placeholder]'), null);
await visible(); assert.deepEqual(calls[0], { kind: 'catalog', pluginId: entry.id, selectionToken: entry.selectionToken, sourceId: 'official' });
assert.equal(document.querySelector('img'), null, 'loading does not reserve an empty avatar');
assert.equal(document.querySelector('[data-plugin-author]')?.textContent, 'Netcatty');
await act(async () => pending.shift()!(identity));
const image = document.querySelector('img')!;
assert.equal(image.getAttribute('src'), identity.avatar); assert.equal(image.width, 16); assert.equal(image.height, 16);
const badge = document.querySelector<HTMLElement>('[data-plugin-official-maintainer]')!;
assert.equal(badge.textContent, 'Official maintainer'); assert.match(badge.title, /not a safety certification/);
assert.ok(badge.classList.contains('border-0'));
assert.ok(badge.classList.contains('bg-amber-500/15')); assert.ok(badge.classList.contains('dark:bg-amber-400/15'));
assert.ok(badge.classList.contains('text-amber-800')); assert.ok(badge.classList.contains('dark:text-amber-300'));
document.documentElement.classList.add('dark');
assert.ok(document.querySelector('[data-plugin-author-row]')?.classList.contains('items-center'));
await act(async () => image.dispatchEvent(new Event('error')));
assert.equal(document.querySelector('img'), null); assert.equal(document.querySelector('[data-plugin-author-placeholder]'), null);
assert.ok(document.querySelector('[data-plugin-official-maintainer]'), 'image failure does not alter maintenance identity');
const next = { ...entry, selectionToken: 'c'.repeat(64) };
await render(next); assert.equal(document.querySelector('[data-plugin-official-maintainer]'), null);
await visible(); const stale = pending.shift()!;
const copied = { ...entry, author: 'Community author', selectionToken: 'd'.repeat(64) };
await render(copied, 'third-party:copy'); await visible();
await act(async () => stale(identity));
assert.equal(document.querySelector('[data-plugin-official-maintainer]'), null);
assert.match(document.querySelector('[data-plugin-author-row]')!.textContent!, /Community author/);
await act(async () => pending.shift()!({ ...identity, author: copied.author, sourceId: 'third-party:copy', officialMaintained: false, avatar: 'https://avatars.githubusercontent.com/u/1' }));
assert.equal(document.querySelector('img'), null, 'renderer never uses a remote avatar URL');
assert.equal(document.querySelector('[data-plugin-official-maintainer]'), null);
await render({ ...entry, selectionToken: 'f'.repeat(64) }); await visible();
await act(async () => pending.shift()!({ ...identity, avatar: null, officialMaintained: false }));
assert.equal(document.querySelector('[data-plugin-author-placeholder]'), null);
const installed = { id: entry.id, activeVersion: entry.version, archiveSha256: 'a'.repeat(64),
source: { id: 'official', kind: 'official', origin: 'https://plugins.netcatty.app', name: 'Official' },
manifest: entry.manifest, runtime: {} } as NetcattyInstalledPlugin;
await act(async () => root.render(<I18nProvider locale="en"><PluginAuthorRow plugin={installed} /></I18nProvider>));
await visible(); assert.deepEqual(calls.at(-1), { kind: 'installed', pluginId: installed.id });
await act(async () => pending.shift()!({ ...identity, version: '9.0.0', archiveSha256: installed.archiveSha256! }));
assert.equal(document.querySelector('[data-plugin-official-maintainer]'), null, 'a stale installed version cannot gain a badge');
const count = calls.length;
await act(async () => root.render(<I18nProvider locale="en"><PluginAuthorRow plugin={{ ...installed }} /></I18nProvider>));
await visible(); assert.equal(calls.length, count, 'unchanged installed identity does not reload on a new object reference');
await render({ ...entry, selectionToken: 'e'.repeat(64) }); await visible();
const late = pending.shift()!;
await act(async () => root.unmount());
await act(async () => late(identity));
assert.equal(document.querySelector('[data-plugin-author-row]'), null);
assert.ok(observers.every((observer) => observer.target === null));
} finally {
dom.window.close();
for (const [key, descriptor] of previous) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key);
}
}
});
@@ -52,7 +52,7 @@ test('information previews on hover/focus, pins on click and Escape returns focu
const entry = { id: 'com.example.guide', name: 'Guide', author: 'Example', version: '1.0.0', license: 'MIT', repository: 'example/plugin', commit: 'a'.repeat(40), description: 'Synthetic metadata',
manifest: { id: 'com.example.guide', name: 'Guide', publisher: 'Example', main: { browser: 'index.js' }, engines: { netcatty: '*', api: '0.1.0-internal' } },
} as NetcattyPluginCatalogEntry;
const catalog = { entries: [entry], source: 'registry', sourceBinding: null, listStatus: 'ready', listError: null, readme: '# Guide', readmeStatus: 'ready', readmeError: null, error: null, busy: false, retryList() {}, retryReadme() {}, async install() {} } as ReturnType<typeof import('../../../application/state/usePluginCatalog').usePluginCatalog>;
const catalog = { entries: [entry], source: 'registry', sourceBinding: null, listStatus: 'ready', listError: null, readmeImages: {}, readme: '# Guide', readmeStatus: 'ready', readmeError: null, error: null, busy: false, retryList() {}, retryReadme() {}, async install() {} } as ReturnType<typeof import('../../../application/state/usePluginCatalog').usePluginCatalog>;
function Browser() {
const [selected, setSelected] = useState<string | null>(null);
const [toolbar, setToolbar] = useState<HTMLDivElement | null>(null);
Loaded 100 of 482 files, more files were not shown because too many files have changed in this diff. Show more