Compare commits

..
Author SHA1 Message Date
陈大猫 545bba1783 chore: retrigger CI after lint-and-test runner shutdown (#4205) 2026-11-10 18:52:15 +08:00
陈大猫 2d83553e3c fix(win): register Default Apps capabilities for ssh:// links 2026-11-10 15:50:05 +08:00
陈大猫 ad05867026 fix(linux): use Secret Service keyring on non-GNOME/KDE desktops (#4190)
Chromium only enables gnome-libsecret or kwallet when it recognizes a
GNOME/KDE desktop name, so niri/sway/Hyprland fall through to BASIC_TEXT
even when org.freedesktop.secrets is available. Set --password-store
before app ready when Secret Service is present, without overriding an
explicit switch or KDE's kwallet default.
2026-11-09 09:29:59 +08:00
github-actions[bot]andgithub-actions[bot] e72d6fc7c6 chore: update star history charts [skip ci] (#4193)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-11-09 00:07:23 +00:00
陈大猫 907f60616b fix(nix): work around bwrap ssh config owner check for home-manager (#4192)
* fix(nix): work around bwrap ssh config owner check for home-manager

Unprivileged wrapType2/bwrap maps only the caller uid, so a home-manager
~/.ssh/config symlink into the nix store appears as overflowuid 65534.
OpenSSH then rejects the default user config and the Mosh handshake
never sees MOSH CONNECT. Inject an ssh wrapper via extraPkgs that
snapshots a badly owned config and execs nixpkgs OpenSSH with -F.

* fix(nix): always refresh ssh config snapshot for home-manager

Nix store files have normalized old mtimes, so a -nt check never
notices a newer home-manager generation after the first snapshot.

* fix(nix): publish ssh config snapshot atomically

cp truncates the destination before copying, so a concurrent ssh -F
reader can see an empty or partial config and ignore HostName,
identity, or proxy settings. Copy to a unique staging file and
rename it over the snapshot.
2026-11-09 01:46:36 +08:00
陈大猫 d536b22dee fix(host-details): show inherited group proxy and jump hosts (#4188)
* fix(host-details): show inherited group proxy and jump hosts

Host editor summaries for HTTP/SOCKS5/command proxy and ProxyJump
chains now display the same values applyGroupDefaults uses at
connect time, without writing inherited settings onto the host
until the user edits them.

Fixes #4187

* fix(host-details): keep custom proxy and empty jump-host overrides

Selecting Custom while a group saved proxy is inherited now keeps a host-owned draft instead of snapping back to that profile. Removing the last hop of a multi-hop inherited chain keeps an empty override so applyGroupDefaults cannot restore the group hops.

* fix(host-details): keep empty override when clearing inherited chain

Clear Chain on an inherited-only jump-host list now stores { hostIds: [] }
instead of deleting the field so group hops cannot snap back.
2026-11-08 21:30:36 +08:00
陈大猫 74d5e126d4 fix(system-manager): show all CPU cores in the overview list (#4184)
* fix(system-manager): show all CPU cores in the overview list

The overview CPU card hardcoded cpuPerCore.slice(0, 12), so a 16-thread
host showed "16 核" at the top but only C1–C12 below. Render every
reported core and allow the list to scroll when it overflows.

Fixes #4183

* ci: retrigger lint-and-test after plugin-contract runner shutdown
2026-11-08 12:59:27 +08:00
jinshenganyuciandbincxz 4a55492c17 feat(sftp): create archives from the context menu (#3936)
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-11-07 20:44:49 +08:00
rnarany 51d9f715da feat(autocomplete): localize common commands and polish terminal hints (#4172)
* feat(autocomplete): add zh-CN descriptions for zip, unzip, make, nano

Cover 32 missing Fig descriptions, generate zh-TW, add batch coverage tests, and record source and semantic checks.

* fix(autocomplete): correct zip, unzip, and make semantics

Limit zip -9 to the compression level, warn that unzip -0 is a suspect upstream option, and describe make -N as the Apple/NeXT compatibility extension.

* fix(autocomplete): simplify unzip -0 warning and correct NeXT note

Keep the -O gbk version caveat in the research record and describe Next/NeXT as a capitalization difference.

* feat(autocomplete): complete remaining common command descriptions

Add 177 zh-CN descriptions for python, python3, node, vim, sort, awk, and sed; generate zh-TW, cover the batch with real Fig specs, and record one deferred shared node key.

* fix(autocomplete): correct sed n and sort parallel semantics

Add the automatic-print condition to sed n, remove the universal CPU default from sort --parallel, and remove duplicated adopted-entry sections from the source record.

* fix: repair macOS terminal setup and align autocomplete details

- Read OSC 7 setup payloads through base64 stdin so BSD/macOS works
- Keep the FIDO askpass socket path within the Unix sun_path limit
- Align autocomplete descriptions in a shared fixed column
- Fix an invalid optional-parameter declaration and lint warnings
- Harden macOS-sensitive OSC 7, SFTP, temp-dir and privacy tests

* feat(autocomplete): add zh-CN descriptions for file and text utilities

Cover ln, stat, wc, cut, uniq, tee and xargs with 70 localized
descriptions. Add coverage and localization tests, preload the new common
specs, and record the upstream wc/xargs wording corrections.

* fix(autocomplete): keep popup scrollbars draggable

Panel mousedown handlers no longer preventDefault across the whole box, so the right-side detail scrollbar and the suggestion list scrollbar can be dragged while padding clicks still preserve terminal focus.

* fix(autocomplete): keep detail panel open while selecting text

Delay row hover clearing so the pointer can move into the right-side detail panel, and allow native text selection there without stealing terminal focus on ordinary padding clicks.
2026-11-07 20:00:43 +08:00
陈大猫 23d3fe454b Merge pull request #4158 from GOUKI9999/feat/workspace-pane-rearrange
Rearrange workspace panes and retain empty/single-member workspaces.

Includes #4155 with its workspace lifecycle, chat continuity, empty-state, action-wiring and rendering-performance fixes. Final Codex review is clean; all CI checks and real Electron smoke passed.
2026-11-07 19:57:39 +08:00
bincxz 3622d482a4 perf(workspace): keep pane drop callbacks stable across tab switches 2026-11-07 19:34:58 +08:00
bincxz c1c5787cab fix(workspace): preserve resumed history when the last member closes 2026-11-07 19:17:55 +08:00
bincxz 419a6734fc Merge remote-tracking branch 'origin/main' into codex/preview-pr4158 2026-11-07 19:14:13 +08:00
bincxz b2be70aae7 fix(workspace): align empty state icon with workspace tabs 2026-11-07 19:02:56 +08:00
bincxz 9491b800b6 fix(workspace): retain displayed chat through final member close 2026-11-07 18:52:11 +08:00
bincxz 3c36e7e1a2 fix(workspace): prevent hidden pane moves while magnified 2026-11-07 18:32:49 +08:00
陈大猫andCodex 642bf03ea7 fix(mcp): recognize unbracketed space-separated Bash prompts as idle (#4176)
Accept space-separated Bash $ prompts while preserving delimiter and pending-input guards. Add recognition and extraction regression coverage.

Fixes #4175

Co-authored-by: Codex <codex@openai.com>
2026-11-07 18:26:57 +08:00
bincxz 22856a368a fix(workspace): guard stale drops and preserve chat scope priority 2026-11-07 18:18:38 +08:00
bincxz 4ed3d09c7a fix(workspace): complete pane dragging and empty workspace recovery 2026-11-07 16:08:00 +08:00
945722fbe8 fix(ai): place host_open tabs in the invoking chat's bound workspace (#4156)
* fix(ai): place host_open tabs in the invoking chat's bound workspace

* fix(ai): sync generated host open workspace guidance

* fix(ai): preserve invoking turn scope for resumed chats

---------

Co-authored-by: GOUKI9999 <GOUKI9999@users.noreply.github.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-11-07 15:41:46 +08:00
陈大猫 77f90999c7 fix(ui): accent border for terminal pane magnification (#4171)
* fix(ui): accent border for terminal pane magnification

* chore: retrigger CI after Actions runner shutdown flake

lint-and-test was cancelled mid-run by runner shutdown (not a product failure) on 42326458479 and 42328660175.
2026-11-07 10:44:59 +08:00
Mateus A.M Ferreiraandbincxz a62cc1bd3f feat(sftp): add a one-click Duplicate action for files and folders (#4133)
* feat(sftp): add a one-click Duplicate action for files and folders

Duplicate copies the selected entries next to their originals in the same
folder, covering files, folders, and multi-selection. It reuses the existing
transfer queue and the transfer pipeline's "(copy)" naming helper, so progress,
cancel, and error reporting behave like Copy to other pane.

The target name is resolved before the transfer starts. Resolving through the
batch conflict defaults instead would re-enter processTransfer for a task that
is still marked in flight and stall the copy.

Adds the action to both the list and tree context menus, plus translations for
en, es, ru, zh-CN, and zh-TW.

Closes #4132

* fix(sftp): keep Duplicate safe around navigation rows and reserved names

Hide and ignore the tree's synthetic ".." row: duplicating it resolved the
source to an ancestor directory and could walk the copy into its own output.
The transfer hook also drops "." / ".." inputs for duplicate batches, and the
same-pane handler filters them defensively.

Pick duplicate names around destinations already reserved by a queued or paused
transfer. Those rows have no file on disk yet, so stat-only naming returned the
same "(copy)" path and the follow-up active-path check turned the second
one-click duplicate into a conflict dialog. The naming helper now accepts an
occupied-path predicate fed from the active transfer set.

* fix(sftp): block stale entry menus on parent navigation

---------

Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-11-07 10:07:08 +08:00
GOUKI9999andGOUKI9999 c8f8adcc87 fix(ai): keep the chat panel close button visible at narrow widths (#4161)
Co-authored-by: GOUKI9999 <GOUKI9999@users.noreply.github.com>
2026-11-07 10:06:16 +08:00
陈大猫 7bb91d0fe2 fix(terminal): do not restore pane zoom on Escape (#4170) 2026-11-07 09:50:54 +08:00
陈大猫 a5a4117d8e fix(sftp): navigate into tree folders on double-click (#4166)
* fix(sftp): navigate into tree folders on double-click

* ci: retrigger cancelled lint-and-test for #4166
2026-11-07 08:17:07 +08:00
陈大猫 2ecf471a29 fix: prevent accidental Quick Connect dismissal (#4165) 2026-11-07 07:10:27 +08:00
陈大猫 968e84ff59 feat(plugins): install published plugin packages from GitHub (#4164)
* feat(plugins): install published plugin packages from GitHub

* fix(plugins): prebundle GitHub parser and reset consumed preview trust

* fix(plugins): localize GitHub install errors and trust review
2026-11-07 07:10:08 +08:00
陈大猫 7949242765 fix: bracket multiline compose submissions when supported (#4163)
Use shell-advertised bracketed paste for compose command blocks even when clipboard bracketing is disabled. Keep the final Enter outside the paste and preserve snippet preferences and compose target routing.

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

Fixes #4160
2026-11-06 19:05:23 +08:00
GOUKI9999 19ab38217b feat(workspace): rearrange member panes within a workspace by drag
Issue #4037 (phase 3 of #4098). An existing member pane can now be
dragged back onto its own workspace to rearrange it, landing with the
same snap-in split semantics an orphan drop gets.

- domain: add pure movePaneInWorkspace(root, sessionId, hint) composing
  the existing prune + insert conventions (no-op for unknown/single-pane
  self-drops; prune-first keeps target-inside-subtree drops correct).
- state: moveWorkspacePane / moveWorkspacePaneInWorkspaces touch only
  workspaces[].root — session ids, tab order, focus fields and every AI
  binding (anchorChatId etc.) are untouched by construction.
- drop flow: handleWorkspaceDrop routes same-workspace member drops to
  the move op; cross-workspace drops stay rejected (#4098 policy) and
  orphan drops are unchanged.
- preview: member panes drag via pointer events, so the split-hint
  preview and drop are mirrored on the pointer stream while a member of
  the active workspace is dragged; dropping a pane onto itself previews
  and drops as a no-op.

Identity guarantee: a move never changes session ids, workspace id,
anchorChatId, focusedSessionId (focus is keyed by session id, so it
visually follows the moved pane), focusSessionOrder or tab order; the
moved tree roundtrips through netcatty_session_restore_v1 unchanged.
2026-11-06 17:07:16 +08:00
GOUKI9999 98d75c13fe feat(ai): anchor workspace AI chats by id, not position
Phase 2 of #4098: every remaining positional fallback in the workspace
AI scope is replaced with id-based resolution.

- Workspace gains anchorChatId: pinned by the AI side panel whenever a
  chat becomes active in the workspace scope, persisted through the
  session restore snapshot, and validated lazily by consumers.
- resolveDisplayedPanelView: the auto-resume fallback now resolves the
  anchored chat first, then the most recently updated chat (updatedAt),
  else draft; sessions[0] is gone.
- retargetWorkspaceActiveChatAfterMemberLoss: when the focused member
  did not survive, the retarget lands on the most-recently-active
  survivor by chat recency, not the first positional member.
- plugin keybinding context: focused member, then the terminal member
  owning the anchored chat, then the most-recently-active member; a
  single-member workspace uses its only member.
- New domain/aiSessionRecency helper shared by all survivor rankings.
- /rebind and session_recover semantics (#4019) are unchanged.
2026-11-06 15:34:15 +08:00
GOUKI9999 ba135c0e98 feat(ai): keep workspaces alive below two members
Phase 2 of #4098: the workspace becomes a first-class object that
survives membership changes.

- Close/detach no longer dissolves a workspace at <=1 member. The
  workspace entity (and its id) survives with its remaining members,
  even at zero, where it keeps an empty split root placeholder that
  renders as an empty workspace tab.
- Focus handoff after member loss resolves to the most-recently-active
  surviving member (AI chat updatedAt via the AI session store), never
  positionally to remainingSessionIds[0]; with no recency signal focus
  is cleared and the pane renderer uses its display default.
- session restore: drop the empty-workspace skip. Workspaces closed to
  zero members persist an empty root and come back after restart with
  the same id, so their AI chats re-link. Roots that prune to null
  because their member sessions cannot be restored are still dropped.
- applyCloseSessionToSessions no longer orphans the survivor; the
  dissolved-workspace tab-order replacement machinery is removed.
2026-11-06 15:33:35 +08:00
陈大猫andCodex 3e454a84a5 fix: allow resolving convergent field conflicts in migration preview (#4148) (#4153)
Co-authored-by: Codex <codex@local.invalid>
2026-11-06 12:41:38 +08:00
陈大猫 a70e21d39b fix: reduce autocomplete history query cost (#4149) (#4152) 2026-11-06 12:09:38 +08:00
+1 16061720e6 feat(ai): add safe OSC 133 shell readiness (#4110)
* fix(terminal): clear sticky broadcast latch on vim ex (#4107) (#4109)

Fixes #4107.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

---------

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

* chore: fix outstanding ESLint warnings (#4112)

* chore: fix outstanding ESLint warnings

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

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

* chore: retrigger CI after runner SIGTERM cancel

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

* chore: retrigger CI after second runner cancel

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

* chore: retrigger CI after third runner cancel

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

* chore: retrigger CI after repeated runner cancel

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

---------

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

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

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

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

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

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

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

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

* chore: retrigger CI after flaky monitorRuntimeIsolation failure

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

* chore: retrigger CI after monitorRuntimeIsolation useEffect flake

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

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

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

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

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

* chore: retrigger CI after plugin-contract runner cancel

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

---------

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

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

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

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

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

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

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

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

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

No release.

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

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

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

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

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

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

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

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

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

* chore: retrigger CI after locale preload fix

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

* chore: retrigger CI after plugin-contract runner cancel

---------

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

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

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

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

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

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

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

Fixes #4130

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

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

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

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

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

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

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

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

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

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

This reverts commit 5b85ee70c0.

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

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

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

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

This reverts commit 7e9934cd7e.

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

---------

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

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

Fixes #4136.

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

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

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

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

Fixes #4142

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

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

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

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

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

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

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

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

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

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

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

* ci: retrigger tests after unrelated plugin SIGKILL flake

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

---------

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

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

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

Closes #4123.

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

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

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

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

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

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

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

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

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

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

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

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

* fix(ai): harden boot persistence failure paths

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

* feat(terminal): add Directory Picker context chip

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

* fix(terminal): follow manually changed working directories

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

* ci: retrigger checks after rebase onto main

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

---------

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

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

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

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

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

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

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

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

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

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

---------

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

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

* fix(terminal): follow manually changed working directories

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

Closes #4123.

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

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

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

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

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

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

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

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

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

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

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

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

* fix(ai): harden boot persistence failure paths

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

* ci: retrigger tests after unrelated plugin SIGKILL flake

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

This reverts commit 5b85ee70c0.

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

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

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

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

This reverts commit 7e9934cd7e.

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

---------

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

Fixes #4130

Gate: GH Codex CLEAN on 2c0c1c77fd (6635017901); CI 41860537287 success; Electron smoke PASS NC_OUT=/workspace/netcatty-smoke-shots/pr-4131-37c0f491a (same tree as prior tip).
2026-11-05 09:10:11 +08:00
295 changed files with 17063 additions and 2854 deletions

No files matched your search

+1
View File
@@ -141,6 +141,7 @@ Catty Agent にサーバーの健全性チェックを依頼すると、適切
### 📁 SFTP + 内蔵エディタ
- **ファイル作業** — ドラッグ&ドロップでアップロード/ダウンロード
- **その場で編集** — 内蔵エディタで小さな修正を素早く
- **リモート圧縮** — ファイルやフォルダーを1つ以上選び、右クリックの **Compress...** から、表示された保存先に名前を指定して `.tar.gz` または `.zip` アーカイブを作成できます。サーバーには選択した形式に対応する `tar`/`gzip` または `zip` が必要です。
### 🎨 パーソナライズ
- **カスタムテーマ** — UI の見た目を好みに調整
+1
View File
@@ -144,6 +144,7 @@ If you regularly work with a fleet of servers, Netcatty is built for speed and f
### 📁 SFTP + Built-in Editor
- **File workflows** — drag & drop uploads/downloads
- **Edit in place** — built-in editor for quick changes
- **Create remote archives** — select files or folders, right-click **Compress...**, and create a named `.tar.gz` or `.zip` archive in the displayed destination directory; the server needs `tar`/`gzip` or `zip` for the chosen format.
### 🎨 Personalization
- **Custom themes** — tune the app appearance to your taste
+1
View File
@@ -141,6 +141,7 @@
### 📁 SFTP + 内置编辑器
- **文件工作流** —— 拖拽上传/下载更直观
- **就地编辑** —— 内置编辑器快速修改文件
- **远程压缩** —— 单选或多选文件、文件夹,右键选择“压缩…”,在对话框显示的保存目录生成指定名称的 `.tar.gz` 或 `.zip` 压缩包;服务器需安装对应的 `tar`/`gzip` 或 `zip`。
### 🎨 个性化
- **自定义主题** —— 按喜好调整应用外观
+1
View File
@@ -141,6 +141,7 @@
### 📁 SFTP + 內建編輯器
- **檔案工作流** —— 拖放上傳/下載
- **就地編輯** —— 內建編輯器,快速修改檔案
- **遠端壓縮** —— 單選或多選檔案、資料夾,按右鍵選擇「壓縮…」,在對話方塊顯示的儲存目錄建立指定名稱的 `.tar.gz` 或 `.zip` 壓縮檔;伺服器需安裝對應的 `tar`/`gzip` 或 `zip`。
### 🎨 個人化
- **自訂主題** —— 依你的喜好調整外觀
+19 -20
View File
@@ -377,7 +377,7 @@ test('move-focus shortcut cannot send input behind a magnified pane', () => {
assert.equal(moveCalls, 0);
});
test('Escape restores magnification after transient dialogs are closed', () => {
test('Escape leaves magnification unchanged and reaches the terminal', () => {
let restores = 0;
let prevented = false;
let stopped = false;
@@ -405,30 +405,29 @@ test('Escape restores magnification after transient dialogs are closed', () => {
},
}), event);
assert.equal(restores, 1);
assert.equal(prevented, true);
assert.equal(stopped, true);
assert.equal(restores, 0);
assert.equal(prevented, false);
assert.equal(stopped, false);
});
test('consumed Escape does not restore magnification', () => {
let restores = 0;
test('Escape closes the quick switcher', () => {
let open = true;
handleEscapeKeyDownImpl(() => ({
isQuickSwitcherOpen: false,
setIsQuickSwitcherOpen: () => {},
terminalPaneMagnificationRef: {
current: {
getState: () => 'focused',
focus: () => false,
restore: () => {
restores += 1;
return true;
},
toggle: () => false,
},
},
isQuickSwitcherOpen: open,
setIsQuickSwitcherOpen: (value: boolean) => { open = value; },
}), { key: 'Escape', defaultPrevented: false } as KeyboardEvent);
assert.equal(open, false);
});
test('consumed Escape leaves the quick switcher open', () => {
let open = true;
handleEscapeKeyDownImpl(() => ({
isQuickSwitcherOpen: open,
setIsQuickSwitcherOpen: (value: boolean) => { open = value; },
}), { key: 'Escape', defaultPrevented: true } as KeyboardEvent);
assert.equal(restores, 0);
assert.equal(open, true);
});
test('close tab hotkey routes native plugin view tabs through their owner', () => {
-9
View File
@@ -365,8 +365,6 @@ export function handleEscapeKeyDownImpl(getCtx: AppContextGetter, e: KeyboardEve
const {
isQuickSwitcherOpen,
setIsQuickSwitcherOpen,
sftpPaneMagnificationRef,
terminalPaneMagnificationRef,
} = getCtx();
{
if (e.key !== 'Escape' || e.defaultPrevented) return;
@@ -374,13 +372,6 @@ export function handleEscapeKeyDownImpl(getCtx: AppContextGetter, e: KeyboardEve
setIsQuickSwitcherOpen(false);
return;
}
if (getAvailablePaneMagnificationController([
sftpPaneMagnificationRef?.current,
terminalPaneMagnificationRef?.current,
])?.restore()) {
e.preventDefault();
e.stopPropagation();
}
}
}
+15 -5
View File
@@ -4,6 +4,8 @@ import { PluginContributionHost } from '../../components/plugins/PluginContribut
import type { TerminalSession, Workspace } from '../../types';
import { useActiveTabId } from '../state/activeTabStore';
import { resolveActivePluginKeybindingContext } from '../state/pluginContributionContexts';
import { getAISessionsSnapshot } from '../state/aiSessionsStore';
import { getPersistedAiSessionsSnapshot } from '../state/aiStateSnapshots';
/**
* Leaf host for plugin keybindings so AppView does not subscribe to activeTabId.
@@ -23,11 +25,19 @@ export function AppPluginKeybindingHost({
workspaces: Workspace[];
}) {
const activeTabId = useActiveTabId();
const keybindingContext = useMemo(() => resolveActivePluginKeybindingContext({
activeTabId,
sessions,
workspaces,
}), [activeTabId, sessions, workspaces]);
const keybindingContext = useMemo(() => {
// AI chat recency drives the workspace anchor fallback (#4098 phase 2).
// Read without subscribing: keybinding context is best-effort and refreshes
// on every tab/session/workspaces change that matters here.
const live = getAISessionsSnapshot().sessions;
const aiSessions = live.length > 0 ? live : getPersistedAiSessionsSnapshot();
return resolveActivePluginKeybindingContext({
activeTabId,
sessions,
workspaces,
aiSessions,
});
}, [activeTabId, sessions, workspaces]);
return (
<PluginContributionHost
@@ -4,12 +4,13 @@ import test from 'node:test';
const source = readFileSync(new URL('./AppSideEffects.tsx', import.meta.url), 'utf8');
test('terminal Escape restoration runs before xterm can stop propagation', () => {
const effectStart = source.indexOf("const onCaptureKeyDown = (e: KeyboardEvent) => {");
const effectSource = source.slice(effectStart, effectStart + 900);
test('quick-switcher Escape handling stays in the bubble phase', () => {
const effectStart = source.indexOf('const onKeyDown = (e: KeyboardEvent) => {\n _handleEscapeKeyDown(e);');
const effectEnd = source.indexOf('}, [appLockLocked]);', effectStart);
const effectSource = source.slice(effectStart, effectEnd);
assert.notEqual(effectStart, -1);
assert.match(effectSource, /target\.closest\('\.xterm'\)/);
assert.match(effectSource, /window\.addEventListener\('keydown', onCaptureKeyDown, true\)/);
assert.match(effectSource, /window\.removeEventListener\('keydown', onCaptureKeyDown, true\)/);
assert.match(effectSource, /window\.addEventListener\('keydown', onKeyDown\)/);
assert.match(effectSource, /window\.removeEventListener\('keydown', onKeyDown\)/);
assert.doesNotMatch(source, /onCaptureKeyDown/);
});
+19 -12
View File
@@ -58,7 +58,9 @@ import {
} from '../../domain/terminalAppearance';
import { selectConnectionLogForTerminalDataCapture } from '../../domain/connectionLog';
import { collectSessionIds } from '../../domain/workspace';
import { resolveAgentHostWorkspace } from '../../domain/agentHostWorkspace';
import { resolveAgentHostOpenWorkspace } from '../../domain/agentHostWorkspace';
import { getAISessionsSnapshot } from '../state/aiSessionsStore';
import { peekAgentRuntimeCleanup } from '../../infrastructure/ai/harness/agentRuntimeHandle';
import { useHostOpenSessionReuse } from '../state/useHostOpenSessionReuse';
import type { PaneMagnificationController } from '../../domain/paneMagnification';
import { isPluginViewEditingTarget } from '../state/pluginKeybindings';
@@ -613,7 +615,7 @@ export function AppSideEffects() {
const _handleTrayPanelConnect = useEffectEvent((hostId: string) => { return handleTrayPanelConnectImpl(() => ({ addConnectionLog, connectToHost, hostId, hosts, identities, keys, resolveEffectiveHost, resolveHostAuth, systemInfoRef, t, toast }), hostId); });
const _handleTrayPanelConnectRequest = useEffectEvent((hostId: string) => { return handleTrayPanelConnectRequestImpl(() => ({ connectNow: _handleTrayPanelConnect, hostId, isVaultInitialized, queueConnect: (queuedHostId: string) => setPendingTrayPanelConnectHostIds((prev) => [...prev, queuedHostId]) }), hostId); });
const _handleGlobalHotkeyKeyDown = useEffectEvent((e: KeyboardEvent) => { return handleGlobalHotkeyKeyDownImpl(() => ({ HOTKEY_DEBUG, closeTabKeyStr, e, executeHotkeyAction, hotkeyScheme, keyBindings, matchesKeyBinding }), e); });
const _handleEscapeKeyDown = useEffectEvent((e: KeyboardEvent) => { return handleEscapeKeyDownImpl(() => ({ e, isQuickSwitcherOpen, setIsQuickSwitcherOpen, sftpPaneMagnificationRef, terminalPaneMagnificationRef }), e); });
const _handleEscapeKeyDown = useEffectEvent((e: KeyboardEvent) => { return handleEscapeKeyDownImpl(() => ({ e, isQuickSwitcherOpen, setIsQuickSwitcherOpen }), e); });
// Vault hosts for tray / auto-start; terminalHosts (vault + ephemeral) only for
// dedicated transfer resume so quick-connect rows do not break tray connect.
@@ -1233,19 +1235,11 @@ export function AppSideEffects() {
useEffect(() => {
if (appLockLocked) return;
const onCaptureKeyDown = (e: KeyboardEvent) => {
if (e.key !== 'Escape') return;
const target = e.target;
if (!(target instanceof HTMLElement) || !target.closest('.xterm')) return;
_handleEscapeKeyDown(e);
};
const onKeyDown = (e: KeyboardEvent) => {
_handleEscapeKeyDown(e);
};
window.addEventListener('keydown', onCaptureKeyDown, true);
window.addEventListener('keydown', onKeyDown);
return () => {
window.removeEventListener('keydown', onCaptureKeyDown, true);
window.removeEventListener('keydown', onKeyDown);
};
}, [appLockLocked]);
@@ -1505,8 +1499,21 @@ export function AppSideEffects() {
return sessionId;
}, [handleConnectToHost, renameSessionInline]);
const openHostForVaultAgent = useCallback((host: Host, isExternalMcpCall: boolean, options?: { workspaceId?: string }) => {
const target = resolveAgentHostWorkspace(options?.workspaceId, activeTabStore.getActiveTabId(), workspaces.map(workspace => workspace.id));
const openHostForVaultAgent = useCallback((host: Host, isExternalMcpCall: boolean, options?: { workspaceId?: string; chatSessionId?: string }) => {
// A resumed history chat can run in a panel other than its saved binding.
// Prefer that turn's captured scope, never the window's current focus.
// Outside a running turn, retain the saved chat binding as the fallback.
const chat = options?.chatSessionId
? getAISessionsSnapshot().sessions.find((session) => session.id === options.chatSessionId)
: undefined;
const turnScope = options?.chatSessionId
? peekAgentRuntimeCleanup()?.getActiveScope?.(options.chatSessionId)
: undefined;
const target = resolveAgentHostOpenWorkspace(options?.workspaceId, turnScope ? { scope: turnScope } : chat, {
activeTabId: activeTabStore.getActiveTabId(),
workspaceIds: workspaces.map(workspace => workspace.id),
terminalSessions: sessionsRef.current,
});
if (target.error) return { ok: false as const, error: target.error };
// Silent sessions only apply to actual external MCP clients (chatSessionId
// equals the reserved external-MCP scope). The in-app Catty AI chat's
+4 -2
View File
@@ -264,7 +264,7 @@ function AppViewInner({ domains }: AppViewProps) {
useMainWindowInputFocusRecovery({ onPageHidden: dismissTransientOverlays });
const {
addShellHistoryEntry, clearAllCommandHistory, removeShellHistoryEntry, addSessionToWorkspace, addToWorkspaceDialog, appendHostToWorkspace, appendLocalTerminalToWorkspace,
addShellHistoryEntry, clearAllCommandHistory, removeShellHistoryEntry, addSessionToWorkspace, addToWorkspaceDialog, appendHostToWorkspace, appendLocalTerminalToWorkspace, moveWorkspacePane,
clearAndRemoveSource, clearAndRemoveSources, closeLogView, closeSession, closeTabsBatch, closeWorkspace, commitPluginImporterData, commitVaultImportTransaction, commitVaultGroupMutation, copySessionToNewWindowWithCurrentShell, copySessionWithCurrentShell, duplicateSessionWithCurrentShell, copyWorkspaceWithCurrentShell,
convertKnownHostToHost, createWorkspaceFromSessions, createWorkspaceFromTargets, createWorkspaceWithHosts,
customGroups, currentTerminalTheme, deepLinkHostDraft, draggingSessionId, effectiveKnownHosts, editorTabs, editorWordWrap, emptyVaultConflict,
@@ -279,7 +279,7 @@ function AppViewInner({ domains }: AppViewProps) {
resolveEmptyVaultConflict, resolveSessionAppearance, runSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionLogsTimestampsEnabled, sessionRenameTarget, sshDebugLogsEnabled,
sessions, setActiveTabId, setDeepLinkHostDraft, setDraggingSessionId, setEditorWordWrap,
setNavigateToSection, setTerminalFontFamilyId, setTerminalFontSize, setVaultFocusRequest, updateSessionFontSize, updateSessionRestoreCwd, updateSessionDynamicTitle, updateSessionCodingCliProvider, clearSessionFontSizeOverride,
setWorkspaceFocusedSession, sftpAutoOpenSidebar, sftpFollowTerminalCwd, setSftpFollowTerminalCwd, sftpAutoSync, sftpDefaultViewMode, sftpDoubleClickBehavior,
setWorkspaceFocusedSession, setWorkspaceAnchorChat, sftpAutoOpenSidebar, sftpFollowTerminalCwd, setSftpFollowTerminalCwd, sftpAutoSync, sftpDefaultViewMode, sftpDoubleClickBehavior,
sftpShowHiddenFiles, sftpUseCompressedUpload, snippetPackages, snippets, splitSessionWithCurrentShell, startSessionRename,
startWorkspaceRename, submitSessionRename, submitWorkspaceRename, t, terminalFontFamilyId, terminalFontSize, terminalSettings, terminalThemeId, themeById,
toggleBroadcast, toggleScriptsSidePanelRef, toggleSidePanelRef, terminalPaneMagnificationRef, sftpPaneMagnificationRef, toggleWorkspaceViewMode, unmanageSource,
@@ -774,12 +774,14 @@ function AppViewInner({ domains }: AppViewProps) {
onTerminalDataCapture={handleTerminalDataCapture}
onCreateWorkspaceFromSessions={createWorkspaceFromSessions}
onAddSessionToWorkspace={addSessionToWorkspace}
onMoveWorkspacePane={moveWorkspacePane}
onRequestAddToWorkspace={handleRequestAddToWorkspace}
onAppendHostToWorkspace={handleAppendHostToWorkspace}
onUpdateSplitSizes={updateSplitSizes}
onSetDraggingSessionId={setDraggingSessionId}
onToggleWorkspaceViewMode={toggleWorkspaceViewMode}
onSetWorkspaceFocusedSession={setWorkspaceFocusedSession}
onSetWorkspaceAnchorChat={setWorkspaceAnchorChat}
onReorderWorkspaceSessions={reorderWorkspaceSessions}
onReorderTabs={reorderWorkTabs}
onCopySession={copySessionWithCurrentShell}
@@ -0,0 +1,93 @@
import assert from 'node:assert/strict';
import { registerHooks } from 'node:module';
import test from 'node:test';
import React from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import {
EMPTY_SESSION_SNAPSHOT,
getSessionSnapshotActions,
publishSessionSnapshot,
} from '../state/sessionSnapshotStore';
import { getAppShellPropsSnapshot } from './appShellPropsStore';
import { registerAppHandlers } from './appHandlersBridge';
// Keep both production publishers and the store between them real. Only the
// state owner and unrelated theme side effects are replaced with fixtures.
const sessionFixtureUrl = `data:text/javascript,${encodeURIComponent(`
let session;
export function setSession(value) { session = value; }
export function useSessionState() { return session; }
`)}`;
const themeFixtureUrl = `data:text/javascript,${encodeURIComponent(`
const noop = () => {};
const theme = {};
const runtime = {
globalAppearance: theme, accentedGlobalAppearance: theme,
clearIntent: noop, settleManualIntent: noop, pickTheme: noop,
resolveFocusedAppearance: noop, currentTerminalTheme: theme,
};
export function useThemeRuntime() { return runtime; }
export function useTerminalAppearanceInjection() {}
`)}`;
registerHooks({
resolve(specifier, context, nextResolve) {
if (specifier === '../../state/useSessionState' && context.parentURL?.endsWith('/SessionPublisher.tsx')) {
return { url: sessionFixtureUrl, shortCircuit: true };
}
if (specifier === '../../state/useThemeRuntime' && context.parentURL?.endsWith('/TerminalHost.tsx')) {
return { url: themeFixtureUrl, shortCircuit: true };
}
return nextResolve(specifier, context);
},
});
const { setSession } = await import(sessionFixtureUrl);
const { SessionPublisher } = await import('./publishers/SessionPublisher');
const { TerminalHost } = await import('./hosts/TerminalHost');
test('workspace actions reach the terminal shell through SessionPublisher and TerminalHost', async (t) => {
const previousActEnvironment = Object.getOwnPropertyDescriptor(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { configurable: true, value: true });
let renderer: ReactTestRenderer | undefined;
t.after(async () => {
await act(async () => renderer?.unmount());
registerAppHandlers(null);
publishSessionSnapshot(EMPTY_SESSION_SNAPSHOT);
if (previousActEnvironment) Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', previousActEnvironment);
else Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
});
const calls: unknown[][] = [];
const moveWorkspacePane = (...args: unknown[]) => calls.push(['move', ...args]);
const setWorkspaceAnchorChat = (...args: unknown[]) => calls.push(['anchor', ...args]);
setSession({ ...EMPTY_SESSION_SNAPSHOT, moveWorkspacePane, setWorkspaceAnchorChat });
registerAppHandlers({});
const renderTree = () => React.createElement(
SessionPublisher,
{ persistSessionRestore: false },
React.createElement(TerminalHost),
);
await act(async () => { renderer = create(renderTree()); });
assert.equal(getSessionSnapshotActions()?.moveWorkspacePane, moveWorkspacePane);
assert.equal(getSessionSnapshotActions()?.setWorkspaceAnchorChat, setWorkspaceAnchorChat);
const terminal = getAppShellPropsSnapshot().domains?.terminal;
assert.equal(terminal?.moveWorkspacePane, moveWorkspacePane);
assert.equal(terminal?.setWorkspaceAnchorChat, setWorkspaceAnchorChat);
const hint = { direction: 'horizontal', position: 'top', targetSessionId: 'pane-b' };
(terminal?.moveWorkspacePane as typeof moveWorkspacePane)('workspace', 'pane-a', hint);
(terminal?.setWorkspaceAnchorChat as typeof setWorkspaceAnchorChat)('workspace', 'chat');
assert.deepEqual(calls, [
['move', 'workspace', 'pane-a', hint],
['anchor', 'workspace', 'chat'],
]);
// A changed state-owner callback must not be trapped in a stale memoized
// action bag even when all session/workspace snapshots stay identical.
const replacementMove = (...args: unknown[]) => calls.push(['replacement-move', ...args]);
const replacementAnchor = (...args: unknown[]) => calls.push(['replacement-anchor', ...args]);
setSession({ ...EMPTY_SESSION_SNAPSHOT, moveWorkspacePane: replacementMove, setWorkspaceAnchorChat: replacementAnchor });
await act(async () => renderer!.update(renderTree()));
assert.equal(getAppShellPropsSnapshot().domains?.terminal.moveWorkspacePane, replacementMove);
assert.equal(getAppShellPropsSnapshot().domains?.terminal.setWorkspaceAnchorChat, replacementAnchor);
});
+2
View File
@@ -170,6 +170,7 @@ export function TerminalHost() {
if (!handlers) return null;
return {
addSessionToWorkspace: sessionActions?.addSessionToWorkspace,
moveWorkspacePane: sessionActions?.moveWorkspacePane,
appendHostToWorkspace: sessionActions?.appendHostToWorkspace,
appendLocalTerminalToWorkspace: sessionActions?.appendLocalTerminalToWorkspace,
clearSessionFontSizeOverride: sessionActions?.clearSessionFontSizeOverride,
@@ -227,6 +228,7 @@ export function TerminalHost() {
setTerminalFontFamilyId: terminalSettingsActions?.setTerminalFontFamilyId,
setTerminalFontSize: terminalSettingsActions?.setTerminalFontSize,
setWorkspaceFocusedSession: sessionActions?.setWorkspaceFocusedSession,
setWorkspaceAnchorChat: sessionActions?.setWorkspaceAnchorChat,
sftpAutoOpenSidebar: terminalSettings.sftpAutoOpenSidebar,
sftpFollowTerminalCwd: terminalSettings.sftpFollowTerminalCwd,
setSftpFollowTerminalCwd: terminalSettingsActions?.setSftpFollowTerminalCwd,
@@ -48,6 +48,7 @@ export function SessionPublisher({ persistSessionRestore, children }: SessionPub
resetWorkspaceRename,
removeSessionFromWorkspace,
setWorkspaceFocusedSession,
setWorkspaceAnchorChat,
toggleWorkspaceViewMode,
createLocalTerminal,
createSerialSession,
@@ -58,6 +59,7 @@ export function SessionPublisher({ persistSessionRestore, children }: SessionPub
createWorkspaceWithHosts,
createWorkspaceFromSessions,
addSessionToWorkspace,
moveWorkspacePane,
appendHostToWorkspace,
appendHostToWorkspaceConfirmed,
appendLocalTerminalToWorkspace,
@@ -133,6 +135,7 @@ export function SessionPublisher({ persistSessionRestore, children }: SessionPub
resetWorkspaceRename,
removeSessionFromWorkspace,
setWorkspaceFocusedSession,
setWorkspaceAnchorChat,
toggleWorkspaceViewMode,
createLocalTerminal,
createSerialSession,
@@ -143,6 +146,7 @@ export function SessionPublisher({ persistSessionRestore, children }: SessionPub
createWorkspaceWithHosts,
createWorkspaceFromSessions,
addSessionToWorkspace,
moveWorkspacePane,
appendHostToWorkspace,
appendHostToWorkspaceConfirmed,
appendLocalTerminalToWorkspace,
@@ -172,6 +176,7 @@ export function SessionPublisher({ persistSessionRestore, children }: SessionPub
updateSessionCodingCliProvider,
}), [
addSessionToWorkspace,
moveWorkspacePane,
appendHostToWorkspace,
appendHostToWorkspaceConfirmed,
appendLocalTerminalToWorkspace,
@@ -208,6 +213,7 @@ export function SessionPublisher({ persistSessionRestore, children }: SessionPub
setActiveTabId,
setDraggingSessionId,
setWorkspaceFocusedSession,
setWorkspaceAnchorChat,
splitSession,
startSessionRename,
startWorkspaceRename,
@@ -21,13 +21,20 @@ Object.defineProperty(globalThis, 'localStorage', {
});
const {
applyLegacySyncPayload,
convergentConflictAddressKey,
createConvergentSyncStateFromPayload,
dotKey,
mergeConvergentSyncStates,
planConvergentSyncMigration,
refreshConvergentSyncMigrationPlan,
stripConvergentSyncEnvelope,
} = await import('../domain/convergentSync/index.ts');
const { getConvergentSyncLocalConfig } = await import('../infrastructure/services/convergentSyncConfig.ts');
const {
initializePreparedConvergentMigration,
prepareConvergentSyncMigration,
resolvePreparedConvergentMigrationConflict,
} = await import('./convergentSyncMigration.ts');
function payload(): SyncPayload {
@@ -44,6 +51,42 @@ test.beforeEach(() => {
localStorageValues.clear();
});
test('preview resolution uses the manager device identity and only updates the prepared plan', () => {
const baseline: SyncPayload = { ...payload(), settings: { theme: 'system' } };
const base = createConvergentSyncStateFromPayload(baseline, 'seed', NOW);
const state = mergeConvergentSyncStates(
applyLegacySyncPayload(base, baseline, { ...baseline, settings: { theme: 'light' } }, 'left', NOW + 1),
applyLegacySyncPayload(base, baseline, { ...baseline, settings: { theme: 'dark' } }, 'right', NOW + 2),
);
const plan = refreshConvergentSyncMigrationPlan(planConvergentSyncMigration({
localPayload: baseline, localTrustedBaseline: null, providers: [], deviceId: 'local', now: NOW,
}), state);
const prepared = { plan, localSnapshot: baseline, providerBaselines: [] };
const conflict = plan.preview.conflicts[0];
const addressKey = convergentConflictAddressKey(conflict.address);
const selected = conflict.candidates.find((candidate) => candidate.value === 'light')!;
const manager = { getState: () => ({ deviceId: 'resolver' }) } as unknown as CloudSyncManager;
const resolved = resolvePreparedConvergentMigrationConflict(
prepared, addressKey, dotKey(selected.dot), manager, NOW + 3,
);
assert.equal(resolved.plan.preview.canInitialize, true);
assert.equal(resolved.plan.preview.conflictCount, 0);
assert.deepEqual(resolved.plan.preview.blockedReasons, []);
assert.equal(resolved.plan.payload?.settings?.theme, 'light');
assert.ok(resolved.plan.state?.vector.resolver);
assert.equal(resolved.localSnapshot, prepared.localSnapshot);
assert.equal(resolved.providerBaselines, prepared.providerBaselines);
assert.equal(prepared.plan.preview.conflictCount, 1);
assert.equal(prepared.plan.payload, null);
assert.throws(() => resolvePreparedConvergentMigrationConflict(
prepared, 'missing', dotKey(selected.dot), manager,
), /conflict no longer exists/);
assert.throws(() => resolvePreparedConvergentMigrationConflict(
prepared, addressKey, 'missing', manager,
), /candidate no longer exists/);
});
test('preparation seeds a trusted baseline for an unchanged v1 provider', async () => {
const remotePayload: SyncPayload = {
...payload(),
+22
View File
@@ -6,8 +6,11 @@ import type {
} from '../domain/sync';
import {
cloudSyncPayloadsEqual,
convergentConflictAddressKey,
materializeSyncPayloadFromConvergentState,
planConvergentSyncMigration,
refreshConvergentSyncMigrationPlan,
resolveConvergentFieldConflict,
stripConvergentSyncEnvelope,
validateConvergentSyncPayload,
type ConvergentMigrationPlan,
@@ -25,6 +28,25 @@ export interface PreparedConvergentMigration {
localSnapshot: SyncPayload;
}
export function resolvePreparedConvergentMigrationConflict(
prepared: PreparedConvergentMigration,
addressKey: string,
candidateDot: string,
manager: CloudSyncManager = getCloudSyncManager(),
now = Date.now(),
): PreparedConvergentMigration {
const conflict = prepared.plan.preview.conflicts.find(
(entry) => convergentConflictAddressKey(entry.address) === addressKey,
);
if (!prepared.plan.state || !conflict) {
throw new Error('The convergent migration conflict no longer exists');
}
const state = resolveConvergentFieldConflict(
prepared.plan.state, conflict, candidateDot, manager.getState().deviceId, now,
);
return { ...prepared, plan: refreshConvergentSyncMigrationPlan(prepared.plan, state) };
}
interface LegacyProviderBaselineSeed {
provider: CloudProvider;
remoteVersion: number;
+7
View File
@@ -99,6 +99,7 @@ export const enAiMessages: Messages = {
'ai.providers.advancedParams.hint': 'Leave blank to use provider defaults.',
'ai.providers.advancedParams.maxTokens.placeholder': 'e.g. 4096',
'ai.providers.advancedParams.default': 'Provider default',
'ai.providers.advancedParams.reasoningEffort.help': 'xhigh and max are only sent when the selected model supports them; otherwise they are clamped to the deepest supported level.',
// AI Codex
'ai.codex': 'Codex',
@@ -660,6 +661,10 @@ export const enAiMessages: Messages = {
'ai.chatShortcuts.title': 'Chat Shortcuts',
'ai.chatShortcuts.selectionAction': 'Show Add to Conversation when selecting terminal text',
'ai.chatShortcuts.selectionAction.description': 'Show a small AI button next to selected terminal text.',
'ai.chatHistory.title': 'Chat History',
'ai.chatHistory.messageCap': 'Messages kept per chat',
'ai.chatHistory.messageCap.description': 'Maximum number of messages stored per chat (50–2000). Lowering it drops the oldest messages from storage on the next write. Larger values increase storage and cloud-sync size.',
'ai.chatHistory.messageCap.unit': 'messages',
// AI Error
'ai.codex.bridgeError': 'Codex main-process handlers are not loaded yet. Fully restart Netcatty, or restart the Electron dev process, then try again.',
@@ -715,6 +720,8 @@ export const enAiMessages: Messages = {
// Unified tooltips for terminal workspace and top tabs (issue #954)
'terminal.layer.addTerminal': 'Add Terminal',
'terminal.layer.emptyWorkspace': 'No terminals in this workspace',
'terminal.layer.emptyWorkspaceHint': 'Add a local terminal or connect to a host to get started.',
'terminal.layer.switchToSplitView': 'Switch to Split View',
'terminal.layer.sftp': 'SFTP',
'terminal.layer.scripts': 'Scripts',
+38
View File
@@ -1,6 +1,44 @@
import type { Messages } from '../types';
export const enCoreMessages: Messages = {
"settings.plugins.github.install": "Install from GitHub",
"settings.plugins.github.description": "Download a published .ncpkg release, then review its source and permissions.",
"settings.plugins.github.repository": "GitHub repository",
"settings.plugins.github.formats": "Accepts https://github.com/owner/repo, github.com/owner/repo or owner/repo.",
"settings.plugins.github.busy": "Downloading, validating or installing the package...",
"settings.plugins.github.review": "Repository ownership and package integrity do not verify publisher trust. No build scripts run. The plugin installs disabled; review permission modes before enabling.",
"settings.plugins.github.accept": "I trust this repository and have reviewed the declared permissions.",
"settings.plugins.github.confirm": "Install disabled",
"settings.plugins.github.download": "Download and review",
"settings.plugins.github.error.invalidRepository": "Enter https://github.com/owner/repo, github.com/owner/repo or owner/repo (repository only; no credentials, query, fragment or extra paths).",
"settings.plugins.github.error.unavailable": "GitHub plugin installation is unavailable in this session.",
"settings.plugins.github.error.reviewFirst": "Download and review a GitHub plugin first.",
"settings.plugins.github.error.network": "Cannot reach GitHub. Check your network connection and try again.",
"settings.plugins.github.error.timeout": "The GitHub download timed out. Please try again.",
"settings.plugins.github.error.rateLimit": "GitHub API rate limit reached. Try again later.",
"settings.plugins.github.error.notFound": "GitHub repository or asset not found. Only accessible public repositories are supported; import private packages locally.",
"settings.plugins.github.error.accessDenied": "GitHub access denied. Private repositories and sign-in are not supported.",
"settings.plugins.github.error.http": "GitHub download failed (HTTP {status}). Please try again later.",
"settings.plugins.github.error.moved": "This GitHub repository has moved. Enter its current repository URL.",
"settings.plugins.github.error.redirect": "The GitHub download redirect could not be verified. Download a trusted .ncpkg package and import it locally.",
"settings.plugins.github.error.noRelease": "This repository has no published stable release. Ask the author to publish one with a .ncpkg asset, or import a built package locally.",
"settings.plugins.github.error.noAsset": "This release has no .ncpkg asset. Ask the author to build and attach one; Netcatty cannot build source repositories.",
"settings.plugins.github.error.multipleAssets": "This release contains multiple .ncpkg assets. Download the intended package and import it locally.",
"settings.plugins.github.error.metadata": "The GitHub release metadata is invalid. Ask the author to check the release, tag and .ncpkg asset.",
"settings.plugins.github.error.identity": "The package does not match the selected GitHub repository and release. Installation was stopped.",
"settings.plugins.github.error.commit": "The release tag has no accessible commit. Ask the author to repair the release tag.",
"settings.plugins.github.error.size": "The GitHub package size is invalid or exceeds the download limit.",
"settings.plugins.github.error.integrity": "The downloaded package failed its size or SHA-256 check. Download and review it again.",
"settings.plugins.github.error.package": "GitHub did not return a valid .ncpkg ZIP package or release metadata. Ask the author to check the release asset.",
"settings.plugins.github.error.busy": "A GitHub plugin operation is already active. Close the preview and try again.",
"settings.plugins.github.error.expired": "The GitHub package preview expired. Download and review it again.",
"settings.plugins.github.error.advanced": "This package requires advanced developer mode. Normal plugin management accepts browser-only packages.",
"settings.plugins.github.error.incompatible": "This plugin is incompatible with this version of Netcatty. Check the technical details with the author.",
"settings.plugins.github.error.source": "This plugin is already bound to another source. Uninstall it and explicitly clear its old source data before changing sources.",
"settings.plugins.github.error.failed": "The plugin could not be validated or installed. Check the technical details before trying again.",
"settings.plugins.github.error.details": "Technical details",
"settings.plugins.landing.checkUpdates": "Check for updates",
"settings.plugins.landing.checkingUpdates": "Checking…",
"settings.plugins.landing.updateSummary": "Checked: {checked} · Published updates: {updates} · Not checked: {skipped}.",
+18
View File
@@ -1,6 +1,24 @@
import type { Messages } from '../types';
export const enTerminalMessages: Messages = {
'terminal.directory.open': 'Current directory: {path}. Change directory',
'terminal.directory.unknown': 'Directory unknown',
'terminal.directory.search': 'Search child directories...',
'terminal.directory.directories': 'Directories',
'terminal.directory.parent': '.. (Parent directory)',
'terminal.directory.loading': 'Reading directories...',
'terminal.directory.empty': 'No child directories',
'terminal.directory.noMatches': 'No matching directories',
'terminal.directory.hidden': 'Show hidden directories',
'terminal.directory.refresh': 'Refresh',
'terminal.directory.unavailable': 'Directory navigation is unavailable for this session.',
'terminal.directory.busy': 'Wait for an empty shell prompt.',
'terminal.directory.readFailed': 'Could not read this directory. Try refreshing.',
'terminal.directory.changeFailed': 'Could not confirm the directory change. Refresh before retrying.',
'terminal.directory.unsupportedPath': 'This directory name cannot be sent safely to the terminal.',
'settings.terminal.behavior.contextChips': 'Context Chips',
'settings.terminal.behavior.contextChips.desc': 'Show the context toolbar below quick commands in the compose bar.',
'terminal.toolbar.openSessionLog': 'Open session log',
'terminal.toolbar.sessionLogActions': 'Session log actions',
'terminal.toolbar.sessionLog.menu': 'Session log',
+19
View File
@@ -316,7 +316,26 @@ export const enVaultMessages: Messages = {
'sftp.extract.extracting': 'Extracting {fileName}...',
'sftp.extract.success': 'Extracted {fileName}',
'sftp.extract.error': 'Failed to extract {fileName}: {error}',
'sftp.archive.action': 'Compress...',
'sftp.archive.title': 'Compress files',
'sftp.archive.description': 'Create an archive on the server from {count} selected items.',
'sftp.archive.name': 'Archive name',
'sftp.archive.format': 'Format',
'sftp.archive.destination': 'Save in',
'sftp.archive.create': 'Compress',
'sftp.archive.creating': 'Compressing...',
'sftp.archive.overwriteTitle': 'Replace existing archive?',
'sftp.archive.overwriteDescription': 'An archive named "{name}" already exists. Confirm to replace it with the new archive.',
'sftp.archive.overwrite': 'Replace and compress',
'sftp.archive.failed': 'Failed to create archive',
'sftp.archive.invalidSelection': 'Cannot compress this selection',
'sftp.archive.invalidName': 'Enter a valid file name without a directory path.',
'sftp.archive.invalidExtension': 'The file extension must match the selected format.',
'sftp.archive.connectionChanged': 'The connection has changed. Select the files again.',
'sftp.archive.unavailable': 'Archive creation is unavailable for this connection.',
'sftp.archive.success': 'Created {fileName}',
'sftp.context.copyToOtherPane': 'Copy to other pane',
'sftp.context.duplicate': 'Duplicate',
'sftp.copyToOtherPane.unavailable': 'Open the two-pane SFTP view and connect the destination pane before copying files.',
'sftp.copyCurrentPath': 'Copy current path',
'sftp.copyCurrentPath.success': 'Current path copied',
+7
View File
@@ -90,6 +90,7 @@ export const esAiMessages: Messages = {
'ai.providers.advancedParams.hint': 'Déjalo en blanco para usar los valores predeterminados del proveedor.',
'ai.providers.advancedParams.maxTokens.placeholder': 'p. ej. 4096',
'ai.providers.advancedParams.default': 'Predeterminado del proveedor',
'ai.providers.advancedParams.reasoningEffort.help': 'xhigh y max solo se envían cuando el modelo elegido los admite; de lo contrario se ajustan al nivel más profundo compatible.',
'ai.providers.headers': 'Cabeceras HTTP personalizadas',
'ai.providers.headers.help': 'Cabeceras opcionales para este proveedor. Los valores se ocultan y se cifran al guardarlos.',
'ai.providers.headers.name': 'Nombre de la cabecera',
@@ -660,6 +661,10 @@ export const esAiMessages: Messages = {
'ai.chatShortcuts.title': 'Atajos de chat',
'ai.chatShortcuts.selectionAction': 'Mostrar "Agregar a la conversación" al seleccionar texto de la terminal',
'ai.chatShortcuts.selectionAction.description': 'Muestra un pequeño botón de IA junto al texto seleccionado de la terminal.',
'ai.chatHistory.title': 'Historial de chat',
'ai.chatHistory.messageCap': 'Mensajes conservados por chat',
'ai.chatHistory.messageCap.description': 'Número máximo de mensajes almacenados por chat (50–2000). Al reducirlo, los mensajes más antiguos se descartan del almacenamiento en la siguiente escritura. Los valores más altos aumentan el almacenamiento y el tamaño de la sincronización en la nube.',
'ai.chatHistory.messageCap.unit': 'mensajes',
// AI Error
'ai.codex.bridgeError': 'Los controladores del proceso principal de Codex aún no están cargados. Reinicia Netcatty por completo o reinicia el proceso de desarrollo de Electron y vuelve a intentarlo.',
@@ -715,6 +720,8 @@ export const esAiMessages: Messages = {
// Unified tooltips for terminal workspace and top tabs (issue #954)
'terminal.layer.addTerminal': 'Agregar terminal',
'terminal.layer.emptyWorkspace': 'No hay terminales en este espacio de trabajo',
'terminal.layer.emptyWorkspaceHint': 'Agrega un terminal local o conecta un host para comenzar.',
'terminal.layer.switchToSplitView': 'Cambiar a vista dividida',
'terminal.layer.sftp': 'SFTP',
'terminal.layer.scripts': 'Scripts',
+38
View File
@@ -1,6 +1,44 @@
import type { Messages } from '../types';
export const esCoreMessages: Messages = {
"settings.plugins.github.install": "Instalar desde GitHub",
"settings.plugins.github.description": "Descarga una versión .ncpkg publicada y revisa su origen y permisos.",
"settings.plugins.github.repository": "Repositorio de GitHub",
"settings.plugins.github.formats": "Acepta https://github.com/owner/repo, github.com/owner/repo u owner/repo.",
"settings.plugins.github.busy": "Descargando, validando o instalando el paquete...",
"settings.plugins.github.review": "El origen y la integridad no verifican la confianza en el autor. No se ejecutan scripts de compilación. Se instala desactivado; revisa los permisos antes de activarlo.",
"settings.plugins.github.accept": "Confío en este repositorio y he revisado los permisos declarados.",
"settings.plugins.github.confirm": "Instalar desactivado",
"settings.plugins.github.download": "Descargar y revisar",
"settings.plugins.github.error.invalidRepository": "Introduce https://github.com/owner/repo, github.com/owner/repo u owner/repo. Usa solo el repositorio, sin credenciales, parámetros, fragmentos ni rutas adicionales.",
"settings.plugins.github.error.unavailable": "La instalación de plugins desde GitHub no está disponible en esta sesión.",
"settings.plugins.github.error.reviewFirst": "Primero descarga y revisa un plugin de GitHub.",
"settings.plugins.github.error.network": "No se puede conectar con GitHub. Comprueba tu conexión e inténtalo de nuevo.",
"settings.plugins.github.error.timeout": "La descarga de GitHub agotó el tiempo de espera. Inténtalo de nuevo.",
"settings.plugins.github.error.rateLimit": "Se alcanzó el límite de solicitudes de la API de GitHub. Inténtalo más tarde.",
"settings.plugins.github.error.notFound": "No se encontró el repositorio o archivo de GitHub. Solo se admiten repositorios públicos accesibles; importa los paquetes privados desde un archivo local.",
"settings.plugins.github.error.accessDenied": "GitHub denegó el acceso. No se admiten repositorios privados ni inicio de sesión.",
"settings.plugins.github.error.http": "La descarga de GitHub falló (HTTP {status}). Inténtalo más tarde.",
"settings.plugins.github.error.moved": "Este repositorio de GitHub se ha movido. Introduce su dirección actual.",
"settings.plugins.github.error.redirect": "No se pudo verificar la redirección de GitHub. Descarga un paquete .ncpkg de confianza e impórtalo desde un archivo local.",
"settings.plugins.github.error.noRelease": "Este repositorio no tiene una versión estable publicada. Pide al autor que publique una con un archivo .ncpkg o importa un paquete compilado local.",
"settings.plugins.github.error.noAsset": "Esta versión no incluye un archivo .ncpkg. Pide al autor que lo compile y adjunte; Netcatty no puede compilar repositorios de código fuente.",
"settings.plugins.github.error.multipleAssets": "Esta versión contiene varios archivos .ncpkg. Descarga el paquete deseado e impórtalo desde un archivo local.",
"settings.plugins.github.error.metadata": "Los metadatos de la versión de GitHub no son válidos. Pide al autor que revise la versión, la etiqueta y el archivo .ncpkg.",
"settings.plugins.github.error.identity": "El paquete no coincide con el repositorio y la versión de GitHub seleccionados. Se detuvo la instalación.",
"settings.plugins.github.error.commit": "La etiqueta de la versión no tiene un commit accesible. Pide al autor que corrija la etiqueta.",
"settings.plugins.github.error.size": "El tamaño del paquete de GitHub no es válido o supera el límite de descarga.",
"settings.plugins.github.error.integrity": "El paquete descargado no superó la comprobación de tamaño o SHA-256. Descárgalo y revísalo de nuevo.",
"settings.plugins.github.error.package": "GitHub no devolvió un paquete ZIP .ncpkg o metadatos de versión válidos. Pide al autor que revise el archivo publicado.",
"settings.plugins.github.error.busy": "Ya hay una operación de plugin de GitHub en curso. Cierra la vista previa e inténtalo de nuevo.",
"settings.plugins.github.error.expired": "La vista previa del paquete de GitHub caducó. Descárgalo y revísalo de nuevo.",
"settings.plugins.github.error.advanced": "Este paquete requiere el modo de desarrollo avanzado. La gestión normal solo admite paquetes para navegador.",
"settings.plugins.github.error.incompatible": "Este plugin no es compatible con esta versión de Netcatty. Consulta los detalles técnicos con el autor.",
"settings.plugins.github.error.source": "Este plugin ya está vinculado a otro origen. Desinstálalo y borra explícitamente los datos del origen anterior antes de cambiarlo.",
"settings.plugins.github.error.failed": "No se pudo validar o instalar el plugin. Revisa los detalles técnicos antes de intentarlo de nuevo.",
"settings.plugins.github.error.details": "Detalles técnicos",
"settings.plugins.landing.checkUpdates": "Buscar actualizaciones",
"settings.plugins.landing.checkingUpdates": "Buscando…",
"settings.plugins.landing.updateSummary": "Comprobados: {checked} · Actualizaciones publicadas: {updates} · Sin comprobar: {skipped}.",
+18
View File
@@ -1,6 +1,24 @@
import type { Messages } from '../types';
export const esTerminalMessages: Messages = {
'terminal.directory.open': 'Directorio actual: {path}. Cambiar directorio',
'terminal.directory.unknown': 'Directorio desconocido',
'terminal.directory.search': 'Buscar subdirectorios...',
'terminal.directory.directories': 'Directorios',
'terminal.directory.parent': '.. (Directorio padre)',
'terminal.directory.loading': 'Leyendo directorios...',
'terminal.directory.empty': 'Sin subdirectorios',
'terminal.directory.noMatches': 'Sin directorios coincidentes',
'terminal.directory.hidden': 'Mostrar directorios ocultos',
'terminal.directory.refresh': 'Actualizar',
'terminal.directory.unavailable': 'La navegación de directorios no está disponible para esta sesión.',
'terminal.directory.busy': 'Espera a que el prompt esté vacío.',
'terminal.directory.readFailed': 'No se pudo leer el directorio. Actualiza e inténtalo de nuevo.',
'terminal.directory.changeFailed': 'No se pudo confirmar el cambio. Actualiza antes de reintentar.',
'terminal.directory.unsupportedPath': 'No se puede enviar este nombre de directorio de forma segura.',
'settings.terminal.behavior.contextChips': 'Context Chips',
'settings.terminal.behavior.contextChips.desc': 'Mostrar la barra de contexto debajo de los comandos rápidos.',
'terminal.toolbar.openSessionLog': 'Abrir registro de sesión',
'terminal.toolbar.sessionLogActions': 'Acciones del registro',
'terminal.toolbar.sessionLog.menu': 'Registro de sesión',
+19
View File
@@ -316,7 +316,26 @@ export const esVaultMessages: Messages = {
'sftp.extract.extracting': 'Extrayendo {fileName}...',
'sftp.extract.success': 'Se extrajo {fileName}',
'sftp.extract.error': 'No se pudo extraer {fileName}: {error}',
'sftp.archive.action': 'Comprimir...',
'sftp.archive.title': 'Comprimir archivos',
'sftp.archive.description': 'Crea un archivo comprimido en el servidor con los {count} elementos seleccionados.',
'sftp.archive.name': 'Nombre del archivo comprimido',
'sftp.archive.format': 'Formato',
'sftp.archive.destination': 'Guardar en',
'sftp.archive.create': 'Comprimir',
'sftp.archive.creating': 'Comprimiendo...',
'sftp.archive.overwriteTitle': '¿Reemplazar el archivo comprimido existente?',
'sftp.archive.overwriteDescription': 'Ya existe un archivo comprimido llamado "{name}". Confirma para reemplazarlo por el nuevo.',
'sftp.archive.overwrite': 'Reemplazar y comprimir',
'sftp.archive.failed': 'No se pudo crear el archivo comprimido',
'sftp.archive.invalidSelection': 'No se puede comprimir esta selección',
'sftp.archive.invalidName': 'Ingresa un nombre de archivo válido sin una ruta de directorio.',
'sftp.archive.invalidExtension': 'La extensión del archivo debe coincidir con el formato seleccionado.',
'sftp.archive.connectionChanged': 'La conexión cambió. Vuelve a seleccionar los archivos.',
'sftp.archive.unavailable': 'No se pueden crear archivos comprimidos en esta conexión.',
'sftp.archive.success': 'Se creó {fileName}',
'sftp.context.copyToOtherPane': 'Copiar al otro panel',
'sftp.context.duplicate': 'Duplicar',
'sftp.copyToOtherPane.unavailable': 'Abre la vista SFTP de dos paneles y conecta el panel de destino antes de copiar archivos.',
'sftp.copyCurrentPath': 'Copiar ruta actual',
'sftp.copyCurrentPath.success': 'Ruta actual copiada',
@@ -0,0 +1,30 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { SUPPORTED_UI_LOCALES } from '../../../infrastructure/config/i18n';
import { getLoadedLocaleMessages, loadLocaleMessages } from '../messages';
import en from './en';
import { describePluginGitHubInstallError } from '../../../domain/pluginGitHubInstallError';
test('GitHub install copy and error interpolation are complete in every supported UI locale', async () => {
const keys = Object.keys(en).filter(key => key.startsWith('settings.plugins.github.'));
assert.ok(keys.length > 30);
for (const { id } of SUPPORTED_UI_LOCALES) {
await loadLocaleMessages(id);
const messages = getLoadedLocaleMessages(id)!;
for (const key of keys) {
assert.ok(messages[key]?.trim(), `${id}: ${key}`);
assert.deepEqual(messages[key].match(/\{\w+\}/g) ?? [], en[key].match(/\{\w+\}/g) ?? [], `${id}: ${key}`);
}
}
});
test('GitHub error presentation handles Electron wrappers, HTTP status and diagnostic fallback', () => {
const expired = describePluginGitHubInstallError("Error invoking remote method 'plugins:github:install': Error: GitHub package preview expired. Download and review it again");
assert.equal(expired.key, 'settings.plugins.github.error.expired');
assert.equal(expired.details, null);
assert.deepEqual(describePluginGitHubInstallError('GitHub download failed (HTTP 503)').values, { status: '503' });
const validator = 'manifest version must be a semantic version';
assert.equal(describePluginGitHubInstallError(validator).details, validator);
assert.equal(describePluginGitHubInstallError(validator).key, 'settings.plugins.github.error.failed');
assert.equal(describePluginGitHubInstallError('Plugin is incompatible: engine version').key, 'settings.plugins.github.error.incompatible');
});
+7
View File
@@ -71,6 +71,7 @@ export const ruAiMessages: Messages = {
'ai.providers.advancedParams.hint': 'Оставьте пустым, чтобы использовать настройки провайдера по умолчанию.',
'ai.providers.advancedParams.maxTokens.placeholder': 'например, 4096',
'ai.providers.advancedParams.default': 'По умолчанию у провайдера',
'ai.providers.advancedParams.reasoningEffort.help': 'xhigh и max отправляются только если выбранная модель их поддерживает; иначе значение понижается до максимального поддерживаемого уровня.',
// AI Codex
'ai.codex': 'Codex',
@@ -599,6 +600,10 @@ export const ruAiMessages: Messages = {
'ai.chatShortcuts.title': 'Быстрые действия чата',
'ai.chatShortcuts.selectionAction': 'Показывать «Добавить в чат» при выделении в терминале',
'ai.chatShortcuts.selectionAction.description': 'Показывать небольшую кнопку AI рядом с выделенным текстом терминала.',
'ai.chatHistory.title': 'История чата',
'ai.chatHistory.messageCap': 'Сообщений, сохраняемых в чате',
'ai.chatHistory.messageCap.description': 'Максимальное число сообщений, хранимых для одного чата (50–2000). При уменьшении самые старые сообщения удаляются из хранилища при следующей записи. Большие значения увеличивают объём хранилища и синхронизации с облаком.',
'ai.chatHistory.messageCap.unit': 'сообщений',
// AI Error
'ai.codex.bridgeError': 'Обработчики главного процесса Codex ещё не загружены. Полностью перезапустите Netcatty или dev-процесс Electron и попробуйте снова.',
@@ -654,6 +659,8 @@ export const ruAiMessages: Messages = {
// Unified tooltips for terminal workspace and top tabs (issue #954)
'terminal.layer.addTerminal': 'Добавить терминал',
'terminal.layer.emptyWorkspace': 'В этом рабочем пространстве пока нет терминалов',
'terminal.layer.emptyWorkspaceHint': 'Добавьте локальный терминал или подключитесь к хосту, чтобы начать.',
'terminal.layer.switchToSplitView': 'Переключить в режим разделения',
'terminal.layer.sftp': 'SFTP',
'terminal.layer.scripts': 'Скрипты',
+38
View File
@@ -1,6 +1,44 @@
import type { Messages } from '../types';
export const ruCoreMessages: Messages = {
"settings.plugins.github.install": "Установить из GitHub",
"settings.plugins.github.description": "Загрузите опубликованный выпуск .ncpkg и проверьте источник и разрешения.",
"settings.plugins.github.repository": "Репозиторий GitHub",
"settings.plugins.github.formats": "Поддерживаются https://github.com/owner/repo, github.com/owner/repo и owner/repo.",
"settings.plugins.github.busy": "Загрузка, проверка или установка пакета...",
"settings.plugins.github.review": "Источник и целостность пакета не подтверждают доверие к издателю. Скрипты сборки не запускаются. Плагин устанавливается выключенным; проверьте разрешения перед включением.",
"settings.plugins.github.accept": "Я доверяю этому репозиторию и проверил заявленные разрешения.",
"settings.plugins.github.confirm": "Установить выключенным",
"settings.plugins.github.download": "Загрузить и проверить",
"settings.plugins.github.error.invalidRepository": "Введите https://github.com/owner/repo, github.com/owner/repo или owner/repo. Укажите только репозиторий, без учётных данных, параметров, фрагментов и дополнительных путей.",
"settings.plugins.github.error.unavailable": "Установка плагинов из GitHub недоступна в этом сеансе.",
"settings.plugins.github.error.reviewFirst": "Сначала загрузите и проверьте плагин из GitHub.",
"settings.plugins.github.error.network": "Не удалось подключиться к GitHub. Проверьте соединение и повторите попытку.",
"settings.plugins.github.error.timeout": "Время ожидания загрузки из GitHub истекло. Повторите попытку.",
"settings.plugins.github.error.rateLimit": "Достигнут лимит запросов API GitHub. Повторите попытку позже.",
"settings.plugins.github.error.notFound": "Репозиторий или файл GitHub не найден. Поддерживаются только доступные публичные репозитории; импортируйте приватные пакеты из локального файла.",
"settings.plugins.github.error.accessDenied": "GitHub отказал в доступе. Приватные репозитории и вход в аккаунт не поддерживаются.",
"settings.plugins.github.error.http": "Ошибка загрузки из GitHub (HTTP {status}). Повторите попытку позже.",
"settings.plugins.github.error.moved": "Репозиторий GitHub перемещён. Введите его текущий адрес.",
"settings.plugins.github.error.redirect": "Не удалось проверить перенаправление загрузки GitHub. Загрузите доверенный пакет .ncpkg и импортируйте его из локального файла.",
"settings.plugins.github.error.noRelease": "В репозитории нет опубликованного стабильного выпуска. Попросите автора опубликовать выпуск с файлом .ncpkg или импортируйте готовый пакет из локального файла.",
"settings.plugins.github.error.noAsset": "В выпуске нет файла .ncpkg. Попросите автора собрать и прикрепить его; Netcatty не собирает репозитории исходного кода.",
"settings.plugins.github.error.multipleAssets": "В выпуске несколько файлов .ncpkg. Загрузите нужный пакет и импортируйте его из локального файла.",
"settings.plugins.github.error.metadata": "Некорректные метаданные выпуска GitHub. Попросите автора проверить выпуск, тег и файл .ncpkg.",
"settings.plugins.github.error.identity": "Пакет не соответствует выбранному репозиторию и выпуску GitHub. Установка остановлена.",
"settings.plugins.github.error.commit": "У тега выпуска нет доступного коммита. Попросите автора исправить тег.",
"settings.plugins.github.error.size": "Размер пакета GitHub некорректен или превышает лимит загрузки.",
"settings.plugins.github.error.integrity": "Загруженный пакет не прошёл проверку размера или SHA-256. Загрузите и проверьте его снова.",
"settings.plugins.github.error.package": "GitHub не вернул корректный ZIP-пакет .ncpkg или метаданные выпуска. Попросите автора проверить файл выпуска.",
"settings.plugins.github.error.busy": "Операция с плагином GitHub уже выполняется. Закройте предпросмотр и повторите попытку.",
"settings.plugins.github.error.expired": "Предпросмотр пакета GitHub устарел. Загрузите и проверьте его снова.",
"settings.plugins.github.error.advanced": "Для этого пакета нужен расширенный режим разработчика. Обычный менеджер принимает только браузерные пакеты.",
"settings.plugins.github.error.incompatible": "Плагин несовместим с этой версией Netcatty. Уточните технические подробности у автора.",
"settings.plugins.github.error.source": "Плагин уже привязан к другому источнику. Перед сменой источника удалите плагин и явно очистите данные прежнего источника.",
"settings.plugins.github.error.failed": "Не удалось проверить или установить плагин. Изучите технические подробности перед повторной попыткой.",
"settings.plugins.github.error.details": "Технические подробности",
// Common
'common.save': 'Сохранить',
'common.cancel': 'Отмена',
+18
View File
@@ -1,6 +1,24 @@
import type { Messages } from '../types';
export const ruTerminalMessages: Messages = {
'terminal.directory.open': 'Текущий каталог: {path}. Сменить каталог',
'terminal.directory.unknown': 'Каталог неизвестен',
'terminal.directory.search': 'Поиск подкаталогов...',
'terminal.directory.directories': 'Каталоги',
'terminal.directory.parent': '.. (Родительский каталог)',
'terminal.directory.loading': 'Чтение каталогов...',
'terminal.directory.empty': 'Нет подкаталогов',
'terminal.directory.noMatches': 'Нет подходящих каталогов',
'terminal.directory.hidden': 'Показывать скрытые каталоги',
'terminal.directory.refresh': 'Обновить',
'terminal.directory.unavailable': 'Навигация по каталогам недоступна для этого сеанса.',
'terminal.directory.busy': 'Дождитесь пустой командной строки.',
'terminal.directory.readFailed': 'Не удалось прочитать каталог. Попробуйте обновить.',
'terminal.directory.changeFailed': 'Не удалось подтвердить смену каталога. Обновите перед повтором.',
'terminal.directory.unsupportedPath': 'Это имя каталога нельзя безопасно передать в терминал.',
'settings.terminal.behavior.contextChips': 'Context Chips',
'settings.terminal.behavior.contextChips.desc': 'Показывать панель контекста под быстрыми командами.',
'terminal.toolbar.openSessionLog': 'Открыть журнал сессии',
'terminal.toolbar.sessionLogActions': 'Действия с журналом',
'terminal.toolbar.sessionLog.menu': 'Журнал сессии',
+19
View File
@@ -360,7 +360,26 @@ export const ruVaultMessages: Messages = {
'sftp.extract.extracting': 'Распаковка {fileName}...',
'sftp.extract.success': 'Распакован {fileName}',
'sftp.extract.error': 'Не удалось распаковать {fileName}: {error}',
'sftp.archive.action': 'Сжать...',
'sftp.archive.title': 'Сжать файлы',
'sftp.archive.description': 'Создать архив на сервере из выбранных объектов: {count}.',
'sftp.archive.name': 'Имя архива',
'sftp.archive.format': 'Формат',
'sftp.archive.destination': 'Сохранить в',
'sftp.archive.create': 'Сжать',
'sftp.archive.creating': 'Сжатие...',
'sftp.archive.overwriteTitle': 'Заменить существующий архив?',
'sftp.archive.overwriteDescription': 'Архив с именем «{name}» уже существует. Подтвердите его замену новым архивом.',
'sftp.archive.overwrite': 'Заменить и сжать',
'sftp.archive.failed': 'Не удалось создать архив',
'sftp.archive.invalidSelection': 'Невозможно сжать выбранные объекты',
'sftp.archive.invalidName': 'Введите допустимое имя файла без пути к каталогу.',
'sftp.archive.invalidExtension': 'Расширение файла должно соответствовать выбранному формату.',
'sftp.archive.connectionChanged': 'Подключение изменилось. Выберите файлы заново.',
'sftp.archive.unavailable': 'Создание архивов недоступно для этого подключения.',
'sftp.archive.success': 'Создан архив {fileName}',
'sftp.context.copyToOtherPane': 'Копировать в другую панель',
'sftp.context.duplicate': 'Дублировать',
'sftp.copyToOtherPane.unavailable': 'Откройте двухпанельный режим SFTP и подключите панель назначения перед копированием файлов.',
'sftp.copyCurrentPath': 'Копировать текущий путь',
'sftp.copyCurrentPath.success': 'Текущий путь скопирован',
+7
View File
@@ -99,6 +99,7 @@ export const zhCNAiMessages: Messages = {
'ai.providers.advancedParams.hint': '留空则使用提供商默认值。',
'ai.providers.advancedParams.maxTokens.placeholder': '例如 4096',
'ai.providers.advancedParams.default': '提供商默认',
'ai.providers.advancedParams.reasoningEffort.help': '仅当所选模型支持时才会发送 xhigh 和 max,否则自动收敛到该模型支持的最高档位。',
// AI Codex
'ai.codex': 'Codex',
@@ -660,6 +661,10 @@ export const zhCNAiMessages: Messages = {
'ai.chatShortcuts.title': '聊天快捷入口',
'ai.chatShortcuts.selectionAction': '选中终端内容时显示“添加到对话”',
'ai.chatShortcuts.selectionAction.description': '在终端里选中文本后显示 AI 快捷按钮。',
'ai.chatHistory.title': '聊天历史',
'ai.chatHistory.messageCap': '每个会话保留的消息数',
'ai.chatHistory.messageCap.description': '每个会话最多存储的消息条数(50–2000)。调低后会在下一次写入时从存储中丢弃最旧的消息。数值越大,存储占用和云同步体积也越大。',
'ai.chatHistory.messageCap.unit': '条消息',
// AI Error
'ai.codex.bridgeError': 'Codex 主进程处理器尚未加载。请完全重启 Netcatty 或重启 Electron 开发进程,然后重试。',
@@ -715,6 +720,8 @@ export const zhCNAiMessages: Messages = {
// 统一终端工作区和顶部标签的 tooltip 文案 (issue #954)
'terminal.layer.addTerminal': '添加终端',
'terminal.layer.emptyWorkspace': '工作区中还没有终端',
'terminal.layer.emptyWorkspaceHint': '添加本地终端或连接主机,开始使用这个工作区。',
'terminal.layer.switchToSplitView': '切换到分屏视图',
'terminal.layer.sftp': '文件传输',
'terminal.layer.scripts': '脚本',
+57
View File
@@ -1,6 +1,44 @@
import type { Messages } from '../types';
export const zhCNCoreMessages: Messages = {
"settings.plugins.github.install": "从 GitHub 安装",
"settings.plugins.github.description": "下载已发布的 .ncpkg 插件包,安装前请审查来源和所需权限。",
"settings.plugins.github.repository": "GitHub 仓库",
"settings.plugins.github.formats": "支持 https://github.com/owner/repo、github.com/owner/repo 或 owner/repo。",
"settings.plugins.github.busy": "正在下载、验证或安装插件包…",
"settings.plugins.github.review": "确认仓库来源并通过完整性校验,并不代表发布者值得信任。安装过程不会运行构建脚本,插件安装后默认禁用。启用前,请确认来源可信并检查各项权限的授权方式。",
"settings.plugins.github.accept": "已审查并信任此仓库,且已查看声明的权限。",
"settings.plugins.github.confirm": "安装并保持禁用",
"settings.plugins.github.download": "下载并审查",
"settings.plugins.github.error.invalidRepository": "请输入 https://github.com/owner/repo、github.com/owner/repo 或 owner/repo。请只填写仓库地址,不要包含账号密码、查询参数、片段或额外路径。",
"settings.plugins.github.error.unavailable": "当前会话无法从 GitHub 安装插件。",
"settings.plugins.github.error.reviewFirst": "请先下载并审查 GitHub 插件。",
"settings.plugins.github.error.network": "无法连接 GitHub,请检查网络连接后重试。",
"settings.plugins.github.error.timeout": "从 GitHub 下载超时,请重试。",
"settings.plugins.github.error.rateLimit": "GitHub API 请求次数已达上限,请稍后重试。",
"settings.plugins.github.error.notFound": "未找到 GitHub 仓库或发布附件。仅支持可访问的公开仓库;私有插件包请从本地导入。",
"settings.plugins.github.error.accessDenied": "GitHub 拒绝访问。此功能不支持私有仓库或登录验证。",
"settings.plugins.github.error.http": "从 GitHub 下载失败(HTTP {status}),请稍后重试。",
"settings.plugins.github.error.moved": "此 GitHub 仓库已迁移,请输入当前仓库地址。",
"settings.plugins.github.error.redirect": "无法验证 GitHub 下载重定向。请下载可信的 .ncpkg 插件包,再从本地导入。",
"settings.plugins.github.error.noRelease": "此仓库尚未发布稳定版本。请作者发布包含 .ncpkg 附件的稳定版本,或从本地导入已构建的插件包。",
"settings.plugins.github.error.noAsset": "此版本没有 .ncpkg 附件。请作者构建并上传插件包;Netcatty 无法直接构建源码仓库。",
"settings.plugins.github.error.multipleAssets": "此版本包含多个 .ncpkg 附件。请下载需要的插件包,再从本地导入。",
"settings.plugins.github.error.metadata": "GitHub 发布信息无效。请作者检查版本发布、标签和 .ncpkg 附件。",
"settings.plugins.github.error.identity": "插件包与所选 GitHub 仓库或发布版本不匹配,已停止安装。",
"settings.plugins.github.error.commit": "无法访问发布标签对应的提交,请作者修复发布标签。",
"settings.plugins.github.error.size": "GitHub 插件包大小无效或超出下载限制。",
"settings.plugins.github.error.integrity": "下载的插件包未通过大小或 SHA-256 校验,请重新下载并审查。",
"settings.plugins.github.error.package": "GitHub 返回的内容不是有效的 .ncpkg ZIP 插件包或发布信息,请作者检查发布附件。",
"settings.plugins.github.error.busy": "已有 GitHub 插件操作正在进行,请关闭预览后重试。",
"settings.plugins.github.error.expired": "GitHub 插件包预览已失效,请重新下载并审查。",
"settings.plugins.github.error.advanced": "此插件包需要高级开发者模式。普通插件管理仅接受浏览器运行时插件包。",
"settings.plugins.github.error.incompatible": "此插件与当前 Netcatty 版本不兼容,请联系作者确认技术详情。",
"settings.plugins.github.error.source": "此插件已绑定其他来源。更换来源前,请先卸载插件并明确清除原来源的私有数据。",
"settings.plugins.github.error.failed": "无法验证或安装插件,请查看技术详情后再重试。",
"settings.plugins.github.error.details": "技术详情",
"settings.plugins.landing.checkUpdates": "检查更新",
"settings.plugins.landing.checkingUpdates": "正在检查…",
"settings.plugins.landing.updateSummary": "已检查:{checked} · 已发布更新:{updates} · 未检查:{skipped}。",
@@ -1015,7 +1053,26 @@ Vaults 标签保持扁平,不使用这块背景。
'sftp.extract.extracting': '正在解压 {fileName}...',
'sftp.extract.success': '已解压 {fileName}',
'sftp.extract.error': '解压 {fileName} 失败:{error}',
'sftp.archive.action': '压缩…',
'sftp.archive.title': '压缩文件',
'sftp.archive.description': '将 {count} 个选中项目打包到服务器上的压缩包。',
'sftp.archive.name': '压缩包名称',
'sftp.archive.format': '格式',
'sftp.archive.destination': '保存目录',
'sftp.archive.create': '压缩',
'sftp.archive.creating': '正在压缩…',
'sftp.archive.overwriteTitle': '覆盖现有压缩包?',
'sftp.archive.overwriteDescription': '“{name}”已存在。确认后将用新的压缩包替换它。',
'sftp.archive.overwrite': '覆盖并压缩',
'sftp.archive.failed': '压缩失败',
'sftp.archive.invalidSelection': '无法压缩此选区',
'sftp.archive.invalidName': '请输入有效的文件名,不要包含目录路径。',
'sftp.archive.invalidExtension': '文件扩展名必须与所选格式一致。',
'sftp.archive.connectionChanged': '连接已切换,请重新选择文件。',
'sftp.archive.unavailable': '当前连接不支持创建压缩包。',
'sftp.archive.success': '已创建 {fileName}',
'sftp.context.copyToOtherPane': '复制到另一侧',
'sftp.context.duplicate': '创建副本',
'sftp.copyToOtherPane.unavailable': '请打开双栏 SFTP 文件管理,并连接目标侧后再复制文件。',
'sftp.copyCurrentPath': '复制当前路径',
'sftp.copyCurrentPath.success': '已复制当前路径',
@@ -1,6 +1,24 @@
import type { Messages } from '../types';
export const zhCNTerminalMessages: Messages = {
'terminal.directory.open': '当前目录:{path}。更改目录',
'terminal.directory.unknown': '目录未知',
'terminal.directory.search': '搜索当前目录下的文件夹…',
'terminal.directory.directories': '目录',
'terminal.directory.parent': '..(父目录)',
'terminal.directory.loading': '正在读取目录…',
'terminal.directory.empty': '没有子目录',
'terminal.directory.noMatches': '没有匹配的子目录',
'terminal.directory.hidden': '显示隐藏目录',
'terminal.directory.refresh': '刷新',
'terminal.directory.unavailable': '此会话暂不支持目录导航。',
'terminal.directory.busy': '请等待终端回到空命令提示符。',
'terminal.directory.readFailed': '无法读取此目录,请刷新重试。',
'terminal.directory.changeFailed': '未能确认目录切换,请刷新后重试。',
'terminal.directory.unsupportedPath': '此目录名称暂不支持快捷切换。',
'settings.terminal.behavior.contextChips': 'Context Chips 上下文工具栏',
'settings.terminal.behavior.contextChips.desc': '在撰写栏的快捷命令下方显示上下文工具栏。',
'terminal.toolbar.openSessionLog': '打开会话日志',
'terminal.toolbar.sessionLogActions': '会话日志操作',
'terminal.toolbar.sessionLog.menu': '会话日志',
+7
View File
@@ -90,6 +90,7 @@ export const zhTWAiMessages: Messages = {
'ai.providers.advancedParams.hint': '留空則使用供應商預設值。',
'ai.providers.advancedParams.maxTokens.placeholder': '例如 4096',
'ai.providers.advancedParams.default': '供應商預設',
'ai.providers.advancedParams.reasoningEffort.help': '僅當所選模型支援時才會送出 xhigh 與 max,否則自動收斂到該模型支援的最高檔位。',
// AI Codex
'ai.codex': 'Codex',
@@ -650,6 +651,10 @@ export const zhTWAiMessages: Messages = {
'ai.chatShortcuts.title': '聊天快捷入口',
'ai.chatShortcuts.selectionAction': '選取終端內容時顯示「新增到對話」',
'ai.chatShortcuts.selectionAction.description': '在終端裡選取文字後顯示 AI 快捷按鈕。',
'ai.chatHistory.title': '聊天歷史',
'ai.chatHistory.messageCap': '每個會話保留的訊息數',
'ai.chatHistory.messageCap.description': '每個會話最多儲存的訊息數(50–2000)。調低後會在下一次寫入時從儲存中捨棄最舊的訊息。數值越大,儲存空間與雲端同步體積也越大。',
'ai.chatHistory.messageCap.unit': '則訊息',
// AI Error
'ai.codex.bridgeError': 'Codex 主程序處理器尚未載入。請完全重新啟動 Netcatty 或重新啟動 Electron 開發程序,然後重試。',
@@ -705,6 +710,8 @@ export const zhTWAiMessages: Messages = {
// 統一終端工作區和頂部標籤的 tooltip 文案 (issue #954)
'terminal.layer.addTerminal': '新增終端',
'terminal.layer.emptyWorkspace': '工作區中還沒有終端',
'terminal.layer.emptyWorkspaceHint': '新增本機終端或連線至主機,開始使用這個工作區。',
'terminal.layer.switchToSplitView': '切換到分割檢視',
'terminal.layer.sftp': '檔案傳輸',
'terminal.layer.scripts': '指令碼',
+57
View File
@@ -1,6 +1,44 @@
import type { Messages } from '../types';
export const zhTWCoreMessages: Messages = {
"settings.plugins.github.install": "從 GitHub 安裝",
"settings.plugins.github.description": "下載已發布的 .ncpkg 外掛套件,安裝前請審查來源與所需權限。",
"settings.plugins.github.repository": "GitHub 儲存庫",
"settings.plugins.github.formats": "支援 https://github.com/owner/repo、github.com/owner/repo 或 owner/repo。",
"settings.plugins.github.busy": "正在下載、驗證或安裝套件…",
"settings.plugins.github.review": "確認儲存庫來源並通過完整性驗證,並不代表發布者值得信任。安裝過程不會執行建置腳本,外掛安裝後預設停用。啟用前,請確認來源可信並檢查各項權限的授權方式。",
"settings.plugins.github.accept": "已審查並信任此儲存庫,且已查看宣告的權限。",
"settings.plugins.github.confirm": "安裝並保持停用",
"settings.plugins.github.download": "下載並審查",
"settings.plugins.github.error.invalidRepository": "請輸入 https://github.com/owner/repo、github.com/owner/repo 或 owner/repo。請只填寫儲存庫位址,不要包含帳號密碼、查詢參數、片段或額外路徑。",
"settings.plugins.github.error.unavailable": "目前的工作階段無法從 GitHub 安裝外掛。",
"settings.plugins.github.error.reviewFirst": "請先下載並審查 GitHub 外掛。",
"settings.plugins.github.error.network": "無法連線至 GitHub,請檢查網路連線後重試。",
"settings.plugins.github.error.timeout": "從 GitHub 下載逾時,請重試。",
"settings.plugins.github.error.rateLimit": "GitHub API 請求次數已達上限,請稍後重試。",
"settings.plugins.github.error.notFound": "找不到 GitHub 儲存庫或發布附件。僅支援可存取的公開儲存庫;私有外掛套件請從本機匯入。",
"settings.plugins.github.error.accessDenied": "GitHub 拒絕存取。此功能不支援私有儲存庫或登入驗證。",
"settings.plugins.github.error.http": "從 GitHub 下載失敗(HTTP {status}),請稍後重試。",
"settings.plugins.github.error.moved": "此 GitHub 儲存庫已遷移,請輸入目前的儲存庫位址。",
"settings.plugins.github.error.redirect": "無法驗證 GitHub 下載重新導向。請下載可信的 .ncpkg 外掛套件,再從本機匯入。",
"settings.plugins.github.error.noRelease": "此儲存庫尚未發布穩定版本。請作者發布包含 .ncpkg 附件的穩定版本,或從本機匯入已建置的外掛套件。",
"settings.plugins.github.error.noAsset": "此版本沒有 .ncpkg 附件。請作者建置並上傳外掛套件;Netcatty 無法直接建置原始碼儲存庫。",
"settings.plugins.github.error.multipleAssets": "此版本包含多個 .ncpkg 附件。請下載需要的外掛套件,再從本機匯入。",
"settings.plugins.github.error.metadata": "GitHub 發布資訊無效。請作者檢查版本發布、標籤及 .ncpkg 附件。",
"settings.plugins.github.error.identity": "外掛套件與所選 GitHub 儲存庫或發布版本不符,已停止安裝。",
"settings.plugins.github.error.commit": "無法存取發布標籤對應的提交,請作者修復發布標籤。",
"settings.plugins.github.error.size": "GitHub 外掛套件大小無效或超出下載限制。",
"settings.plugins.github.error.integrity": "下載的外掛套件未通過大小或 SHA-256 驗證,請重新下載並審查。",
"settings.plugins.github.error.package": "GitHub 傳回的內容不是有效的 .ncpkg ZIP 外掛套件或發布資訊,請作者檢查發布附件。",
"settings.plugins.github.error.busy": "已有 GitHub 外掛操作正在進行,請關閉預覽後重試。",
"settings.plugins.github.error.expired": "GitHub 外掛套件預覽已失效,請重新下載並審查。",
"settings.plugins.github.error.advanced": "此外掛套件需要進階開發者模式。一般外掛管理僅接受瀏覽器執行環境的套件。",
"settings.plugins.github.error.incompatible": "此外掛與目前的 Netcatty 版本不相容,請聯絡作者確認技術詳細資料。",
"settings.plugins.github.error.source": "此外掛已綁定其他來源。更換來源前,請先解除安裝並明確清除原來源的私有資料。",
"settings.plugins.github.error.failed": "無法驗證或安裝外掛,請查看技術詳細資料後再重試。",
"settings.plugins.github.error.details": "技術詳細資料",
"settings.plugins.landing.checkUpdates": "檢查更新",
"settings.plugins.landing.checkingUpdates": "正在檢查…",
"settings.plugins.landing.updateSummary": "已檢查:{checked} · 已發佈更新:{updates} · 未檢查:{skipped}。",
@@ -1017,7 +1055,26 @@ Vaults 分頁維持扁平,不使用這塊背景。
'sftp.extract.extracting': '正在解壓 {fileName}...',
'sftp.extract.success': '已解壓 {fileName}',
'sftp.extract.error': '解壓 {fileName} 失敗:{error}',
'sftp.archive.action': '壓縮…',
'sftp.archive.title': '壓縮檔案',
'sftp.archive.description': '將 {count} 個選取項目打包成伺服器上的壓縮檔。',
'sftp.archive.name': '壓縮檔名稱',
'sftp.archive.format': '格式',
'sftp.archive.destination': '儲存目錄',
'sftp.archive.create': '壓縮',
'sftp.archive.creating': '正在壓縮…',
'sftp.archive.overwriteTitle': '覆寫現有壓縮檔?',
'sftp.archive.overwriteDescription': '「{name}」已存在。確認後將以新的壓縮檔取代它。',
'sftp.archive.overwrite': '覆寫並壓縮',
'sftp.archive.failed': '壓縮失敗',
'sftp.archive.invalidSelection': '無法壓縮此選取範圍',
'sftp.archive.invalidName': '請輸入有效的檔案名稱,不要包含目錄路徑。',
'sftp.archive.invalidExtension': '副檔名必須與所選格式一致。',
'sftp.archive.connectionChanged': '連線已切換,請重新選取檔案。',
'sftp.archive.unavailable': '目前連線不支援建立壓縮檔。',
'sftp.archive.success': '已建立 {fileName}',
'sftp.context.copyToOtherPane': '複製到另一側',
'sftp.context.duplicate': '建立副本',
'sftp.copyToOtherPane.unavailable': '請開啟雙欄 SFTP 檔案管理,並連線目標側後再複製檔案。',
'sftp.copyCurrentPath': '複製目前路徑',
'sftp.copyCurrentPath.success': '已複製目前路徑',
@@ -1,6 +1,24 @@
import type { Messages } from '../types';
export const zhTWTerminalMessages: Messages = {
'terminal.directory.open': '目前目錄:{path}。變更目錄',
'terminal.directory.unknown': '目錄未知',
'terminal.directory.search': '搜尋目前目錄下的資料夾…',
'terminal.directory.directories': '目錄',
'terminal.directory.parent': '..(父目錄)',
'terminal.directory.loading': '正在讀取目錄…',
'terminal.directory.empty': '沒有子目錄',
'terminal.directory.noMatches': '沒有符合的子目錄',
'terminal.directory.hidden': '顯示隱藏目錄',
'terminal.directory.refresh': '重新整理',
'terminal.directory.unavailable': '此工作階段暫不支援目錄導覽。',
'terminal.directory.busy': '請等待終端回到空白命令提示字元。',
'terminal.directory.readFailed': '無法讀取此目錄,請重新整理後重試。',
'terminal.directory.changeFailed': '未能確認目錄切換,請重新整理後重試。',
'terminal.directory.unsupportedPath': '此目錄名稱暫不支援快速切換。',
'settings.terminal.behavior.contextChips': 'Context Chips 上下文工具列',
'settings.terminal.behavior.contextChips.desc': '在撰寫列的快捷命令下方顯示上下文工具列。',
'terminal.toolbar.openSessionLog': '開啟工作階段日誌',
'terminal.toolbar.sessionLogActions': '工作階段日誌操作',
'terminal.toolbar.sessionLog.menu': '工作階段日誌',
+1 -1
View File
@@ -165,7 +165,7 @@ function isSuccessfulApplyResult(result: unknown): boolean {
* the DB before collection. Ordinary last-known collect snapshots are never
* re-applied, so local settings edits made after the last collect are kept.
*/
export async function collectPluginSyncSidecarsFromHost(options?: {
export async function collectPluginSyncSidecarsFromHost(options: {
/**
* When true, never fall back to last-known cache. Returns null if the host is
* gated off / non-authoritative so callers can omit `pluginSidecars` instead
@@ -0,0 +1,285 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { AISession } from '../../infrastructure/ai/types';
import { installFakeIndexedDb } from './testSupport/fakeIndexedDb';
import {
AI_SESSIONS_META_LAST_WRITE_KEY,
AI_SESSIONS_META_MIGRATION_KEY,
AI_SESSIONS_META_SCHEMA_VERSION_KEY,
idbClearSessions,
idbDeleteSessionIds,
idbGetAllSessions,
idbGetMeta,
idbPutMeta,
idbPutSession,
idbPutSessions,
isIndexedDbAvailable,
loadOrMigrateAiSessions,
mirrorActiveSessionMapToIdb,
openAiSessionsIdb,
readActiveSessionMapFromIdb,
resetAiSessionsIdbForTests,
} from './aiSessionIdbStorage';
function makeSession(id: string, updatedAt: number): AISession {
return {
id,
title: id,
agentId: 'agent',
scope: { type: 'terminal', targetId: 't' },
messages: [],
createdAt: updatedAt,
updatedAt,
} as AISession;
}
test('module wrapper roundtrips sessions through the object stores', async () => {
const idb = installFakeIndexedDb();
try {
assert.equal(isIndexedDbAvailable(), true);
const db = await openAiSessionsIdb();
assert.equal(db.objectStoreNames.contains('sessions'), true);
assert.equal(db.objectStoreNames.contains('meta'), true);
await idbPutSessions(db, [makeSession('chat-a', 2), makeSession('chat-b', 1)]);
await idbPutSession(db, { ...makeSession('chat-a', 3), title: 'updated' });
const all = await idbGetAllSessions(db);
assert.deepEqual(all.map((s) => s.id).sort(), ['chat-a', 'chat-b']);
assert.equal(all.find((s) => s.id === 'chat-a')?.title, 'updated');
await idbDeleteSessionIds(db, ['chat-a']);
assert.deepEqual((await idbGetAllSessions(db)).map((s) => s.id), ['chat-b']);
await idbClearSessions(db);
assert.deepEqual(await idbGetAllSessions(db), []);
} finally {
idb.restore();
resetAiSessionsIdbForTests();
}
});
test('meta store roundtrips schema and active-map records', async () => {
const idb = installFakeIndexedDb();
try {
const db = await openAiSessionsIdb();
await idbPutMeta(db, AI_SESSIONS_META_SCHEMA_VERSION_KEY, 1);
await idbPutMeta(db, AI_SESSIONS_META_LAST_WRITE_KEY, 1234);
assert.equal(await idbGetMeta<number>(db, AI_SESSIONS_META_SCHEMA_VERSION_KEY), 1);
assert.equal(await idbGetMeta<number>(db, AI_SESSIONS_META_LAST_WRITE_KEY), 1234);
assert.equal(await idbGetMeta<number>(db, 'missing'), undefined);
await mirrorActiveSessionMapToIdb({ 'terminal:t': 'chat-a' });
// Mirror uses its own connection.
assert.deepEqual(await readActiveSessionMapFromIdb(), { 'terminal:t': 'chat-a' });
} finally {
idb.restore();
resetAiSessionsIdbForTests();
}
});
test('loadOrMigrateAiSessions reads IndexedDB first and returns its sessions', async () => {
const idb = installFakeIndexedDb();
try {
const db = await openAiSessionsIdb();
await idbPutSessions(db, [makeSession('idb-chat', 1)]);
const result = await loadOrMigrateAiSessions(() => JSON.stringify([makeSession('legacy-chat', 2)]));
assert.equal(result.source, 'indexeddb');
assert.equal(result.idbFailed, false);
assert.deepEqual(result.sessions.map((s) => s.id), ['idb-chat']);
// Legacy key content was not consumed for a migration when IDB has data.
assert.equal(idb.db.dumpStore('sessions').length, 1);
} finally {
idb.restore();
resetAiSessionsIdbForTests();
}
});
test('loadOrMigrateAiSessions migrates a populated legacy store into empty IndexedDB, keeping the key untouched', async () => {
const idb = installFakeIndexedDb();
try {
const legacyRaw = JSON.stringify([makeSession('legacy-1', 2), makeSession('legacy-2', 1)]);
let readCount = 0;
const result = await loadOrMigrateAiSessions(() => {
readCount += 1;
return legacyRaw;
});
assert.equal(result.source, 'legacy-localstorage');
assert.equal(result.idbFailed, false);
assert.deepEqual(result.sessions.map((s) => s.id).sort(), ['legacy-1', 'legacy-2']);
// Now in IndexedDB (one record per session).
assert.deepEqual(
idb.db.dumpStore('sessions').map((s) => (s as AISession).id).sort(),
['legacy-1', 'legacy-2'],
);
// Meta records written for the migration.
const db = await openAiSessionsIdb();
assert.equal(await idbGetMeta<number>(db, AI_SESSIONS_META_SCHEMA_VERSION_KEY), 1);
assert.ok(await idbGetMeta<number>(db, AI_SESSIONS_META_LAST_WRITE_KEY));
assert.ok(readCount >= 1);
// The legacy raw string itself is only read, never rewritten or removed
// — the callback is the only interface to the legacy store.
assert.equal(readCount >= 1, true);
} finally {
idb.restore();
resetAiSessionsIdbForTests();
}
});
test('loadOrMigrateAiSessions returns none for an empty store without legacy data', async () => {
const idb = installFakeIndexedDb();
try {
const result = await loadOrMigrateAiSessions(() => null);
assert.equal(result.source, 'none');
assert.deepEqual(result.sessions, []);
assert.equal(result.idbFailed, false);
assert.equal(result.authoritativeEmpty, false, 'a newly created store still permits disk recovery');
assert.equal(result.migrationPending, false);
} finally {
idb.restore();
resetAiSessionsIdbForTests();
}
});
test('loadOrMigrateAiSessions treats a legacy empty array as an intentional wipe (no migration, no restore source)', async () => {
const idb = installFakeIndexedDb();
try {
const result = await loadOrMigrateAiSessions(() => '[]');
assert.equal(result.source, 'none');
assert.deepEqual(result.sessions, []);
assert.equal(idb.db.dumpStore('sessions').length, 0);
} finally {
idb.restore();
resetAiSessionsIdbForTests();
}
});
test('loadOrMigrateAiSessions falls back to legacy localStorage when IndexedDB is corrupt', async () => {
const idb = installFakeIndexedDb({ failOpen: true });
try {
const legacy = [makeSession('legacy-chat', 1)];
const result = await loadOrMigrateAiSessions(() => JSON.stringify(legacy));
assert.equal(result.source, 'legacy-localstorage');
assert.equal(result.idbFailed, true);
assert.deepEqual(result.sessions, legacy);
} finally {
idb.restore();
resetAiSessionsIdbForTests();
}
});
test('loadOrMigrateAiSessions falls back to legacy localStorage when IndexedDB is unavailable', async () => {
const previous = (globalThis as { indexedDB?: unknown }).indexedDB;
(globalThis as { indexedDB?: unknown }).indexedDB = undefined;
try {
const legacy = [makeSession('legacy-chat', 1)];
const result = await loadOrMigrateAiSessions(() => JSON.stringify(legacy));
assert.equal(isIndexedDbAvailable(), false);
assert.equal(result.source, 'legacy-localstorage');
assert.equal(result.idbFailed, false);
assert.deepEqual(result.sessions, legacy);
} finally {
(globalThis as { indexedDB?: unknown }).indexedDB = previous;
resetAiSessionsIdbForTests();
}
});
// ── Issue #4125 review P1②: the migration is one-shot via a persisted marker ──
test('a completed migration is one-shot: a chat deleted afterwards stays deleted on the next boot', async () => {
const idb = installFakeIndexedDb();
try {
const legacyRaw = JSON.stringify([makeSession('legacy-1', 2), makeSession('legacy-2', 1)]);
const first = await loadOrMigrateAiSessions(() => legacyRaw);
assert.equal(first.source, 'legacy-localstorage');
// The marker was persisted after the migration data became durable.
let db = await openAiSessionsIdb();
assert.ok(await idbGetMeta(db, AI_SESSIONS_META_MIGRATION_KEY));
// The user deletes a chat; the legacy key stays behind by design.
await idbDeleteSessionIds(db, ['legacy-1']);
// Next boot: the marker is present, so the legacy key is never
// re-migrated and the deletion is not resurrected.
const second = await loadOrMigrateAiSessions(() => legacyRaw);
assert.equal(second.source, 'indexeddb');
assert.deepEqual(second.sessions.map((s) => s.id), ['legacy-2']);
assert.equal((await openAiSessionsIdb()).objectStoreNames.contains('sessions'), true);
db = await openAiSessionsIdb();
assert.deepEqual((await idbGetAllSessions(db)).map((s) => s.id), ['legacy-2']);
} finally {
idb.restore();
resetAiSessionsIdbForTests();
}
});
test('an empty IndexedDB store with the migration marker ignores a populated legacy key', async () => {
const idb = installFakeIndexedDb();
try {
// First boot with nothing anywhere records the marker.
const first = await loadOrMigrateAiSessions(() => null);
assert.equal(first.source, 'none');
assert.ok(await idbGetMeta(await openAiSessionsIdb(), AI_SESSIONS_META_MIGRATION_KEY));
// The user deleted every chat, but the stale legacy key is still there.
const second = await loadOrMigrateAiSessions(
() => JSON.stringify([makeSession('resurrected', 1)]),
);
assert.equal(second.source, 'none');
assert.equal(second.authoritativeEmpty, true);
assert.deepEqual(second.sessions, []);
assert.equal(idb.db.dumpStore('sessions').length, 0);
} finally {
idb.restore();
resetAiSessionsIdbForTests();
}
});
test('partial disk migration retries missing IDs beside existing IDB records until all are durable', async () => {
const idb = installFakeIndexedDb();
try {
const primary = makeSession('primary', 3);
const diskA = makeSession('disk-a', 2);
const diskB = makeSession('disk-b', 1);
const diskIds = [diskA.id, diskB.id];
const db = await openAiSessionsIdb();
const first = await loadOrMigrateAiSessions(() => JSON.stringify([primary]), diskIds);
assert.equal(first.migrationPending, true);
assert.deepEqual(await idbGetAllSessions(db), [primary]);
assert.equal(await idbGetMeta(db, AI_SESSIONS_META_MIGRATION_KEY), undefined);
const updated = { ...primary, title: 'edited in IndexedDB', updatedAt: 4 };
await idbPutSession(db, updated);
const partial = await loadOrMigrateAiSessions(() => JSON.stringify([primary, diskA]), diskIds);
assert.equal(partial.migrationPending, true, 'one recovered disk ID does not complete migration');
assert.deepEqual(partial.sessions, [updated, diskA]);
assert.equal(await idbGetMeta(db, AI_SESSIONS_META_MIGRATION_KEY), undefined);
const complete = await loadOrMigrateAiSessions(() => JSON.stringify([primary, diskA, diskB]), diskIds);
assert.equal(complete.source, 'indexeddb');
assert.equal(complete.migrationPending, false);
assert.deepEqual(complete.sessions, [updated, diskA, diskB]);
assert.deepEqual(await idbGetAllSessions(db), [updated, diskA, diskB]);
assert.ok(await idbGetMeta(db, AI_SESSIONS_META_MIGRATION_KEY));
await idbClearSessions(db);
const deleted = await loadOrMigrateAiSessions(() => {
assert.fail('completed migration must not retry the stale overflow markers');
}, diskIds);
assert.equal(deleted.authoritativeEmpty, true);
assert.deepEqual(deleted.sessions, []);
} finally {
idb.restore();
resetAiSessionsIdbForTests();
}
});
test('a pre-marker store that already holds sessions records the marker on boot', async () => {
const idb = installFakeIndexedDb();
try {
const db = await openAiSessionsIdb();
await idbPutSessions(db, [makeSession('already-here', 1)]);
const result = await loadOrMigrateAiSessions(() => JSON.stringify([makeSession('legacy', 2)]));
assert.equal(result.source, 'indexeddb');
assert.ok(await idbGetMeta(db, AI_SESSIONS_META_MIGRATION_KEY));
assert.equal(idb.db.dumpStore('sessions').length, 1);
} finally {
idb.restore();
resetAiSessionsIdbForTests();
}
});
+372
View File
@@ -0,0 +1,372 @@
import type { AISession } from '../../infrastructure/ai/types';
/**
* IndexedDB persistence for AI chat sessions (issue #4123).
*
* The legacy store kept every session as one JSON blob in localStorage under
* a 2 MB byte budget. Each write re-serialized the whole store, and when the
* budget was exceeded the degradation ladder dropped whole oldest sessions —
* silently destroying capped chats (~1 MB each) on every write.
*
* IndexedDB removes both halves of that failure mode: records are written
* per session (no global re-serialization), and there is no shared byte
* quota to defend, so byte-driven eviction disappears entirely.
*
* Layout:
* - db `netcatty_ai_sessions`
* - object store `sessions` (keyPath `id`): one record per AISession
* - object store `meta` (keyPath `key`): schemaVersion, lastWriteAt and the
* migrated `netcatty_ai_active_session_map_v1` active-pointer map
*
* The legacy localStorage key `netcatty_ai_sessions_v1` is kept UNTOUCHED for
* one release as a read-only fallback; it is no longer written or pruned.
*/
export const AI_SESSIONS_IDB_NAME = 'netcatty_ai_sessions';
export const AI_SESSIONS_IDB_VERSION = 1;
export const AI_SESSIONS_IDB_SESSION_STORE = 'sessions';
export const AI_SESSIONS_IDB_META_STORE = 'meta';
export const AI_SESSIONS_META_SCHEMA_VERSION_KEY = 'schemaVersion';
export const AI_SESSIONS_META_LAST_WRITE_KEY = 'lastWriteAt';
export const AI_SESSIONS_META_ACTIVE_MAP_KEY = 'activeSessionMap';
export const AI_SESSIONS_META_MIGRATION_KEY = 'migrationCompleted';
/** Cross-window change notification (replaces the localStorage `storage` event for sessions). */
const AI_SESSIONS_IDB_CHANGE_CHANNEL = 'netcatty_ai_sessions_changed';
export function isIndexedDbAvailable(): boolean {
return typeof indexedDB !== 'undefined' && indexedDB !== null;
}
function requestToPromise<T>(request: IDBRequest<T>): Promise<T> {
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error ?? new Error('IndexedDB request failed'));
});
}
function transactionDone(transaction: IDBTransaction): Promise<void> {
return new Promise((resolve, reject) => {
transaction.oncomplete = () => resolve();
transaction.onabort = () => reject(transaction.error ?? new Error('IndexedDB transaction aborted'));
transaction.onerror = () => reject(transaction.error ?? new Error('IndexedDB transaction failed'));
});
}
let openPromise: Promise<IDBDatabase> | null = null;
function upgradeDatabase(db: IDBDatabase) {
if (!db.objectStoreNames.contains(AI_SESSIONS_IDB_SESSION_STORE)) {
db.createObjectStore(AI_SESSIONS_IDB_SESSION_STORE, { keyPath: 'id' });
}
if (!db.objectStoreNames.contains(AI_SESSIONS_IDB_META_STORE)) {
db.createObjectStore(AI_SESSIONS_IDB_META_STORE, { keyPath: 'key' });
}
}
export function openAiSessionsIdb(): Promise<IDBDatabase> {
if (!isIndexedDbAvailable()) {
return Promise.reject(new Error('IndexedDB is not available'));
}
if (!openPromise) {
openPromise = new Promise((resolve, reject) => {
const request = indexedDB.open(AI_SESSIONS_IDB_NAME, AI_SESSIONS_IDB_VERSION);
request.onupgradeneeded = () => upgradeDatabase(request.result);
request.onsuccess = () => {
const db = request.result;
// Drop the cached handle so a later open() re-establishes it instead
// of handing out a closed connection.
db.onclose = () => { openPromise = null; };
db.onversionchange = () => {
db.close();
openPromise = null;
};
resolve(db);
};
request.onerror = () => {
openPromise = null;
reject(request.error ?? new Error('Failed to open IndexedDB'));
};
request.onblocked = () => {
openPromise = null;
};
});
}
return openPromise;
}
export function resetAiSessionsIdbForTests() {
openPromise = null;
}
export async function idbGetAllSessions(db?: IDBDatabase): Promise<AISession[]> {
const database = db ?? await openAiSessionsIdb();
const transaction = database.transaction(AI_SESSIONS_IDB_SESSION_STORE, 'readonly');
const result = await requestToPromise(
transaction.objectStore(AI_SESSIONS_IDB_SESSION_STORE).getAll() as IDBRequest<AISession[]>,
);
return Array.isArray(result) ? result : [];
}
export async function idbPutSessions(db: IDBDatabase, sessions: AISession[]): Promise<void> {
if (sessions.length === 0) return;
const transaction = db.transaction(AI_SESSIONS_IDB_SESSION_STORE, 'readwrite');
const store = transaction.objectStore(AI_SESSIONS_IDB_SESSION_STORE);
for (const session of sessions) store.put(session);
await transactionDone(transaction);
}
export async function idbPutSession(db: IDBDatabase, session: AISession): Promise<void> {
await idbPutSessions(db, [session]);
}
export async function idbDeleteSessionIds(db: IDBDatabase, ids: string[]): Promise<void> {
if (ids.length === 0) return;
const transaction = db.transaction(AI_SESSIONS_IDB_SESSION_STORE, 'readwrite');
const store = transaction.objectStore(AI_SESSIONS_IDB_SESSION_STORE);
for (const id of ids) store.delete(id);
await transactionDone(transaction);
}
export async function idbClearSessions(db: IDBDatabase): Promise<void> {
const transaction = db.transaction(AI_SESSIONS_IDB_SESSION_STORE, 'readwrite');
transaction.objectStore(AI_SESSIONS_IDB_SESSION_STORE).clear();
await transactionDone(transaction);
}
export async function idbGetMeta<T>(db: IDBDatabase, key: string): Promise<T | undefined> {
const transaction = db.transaction(AI_SESSIONS_IDB_META_STORE, 'readonly');
const record = await requestToPromise(
transaction.objectStore(AI_SESSIONS_IDB_META_STORE).get(key) as IDBRequest<{ key: string; value: T } | undefined>,
);
return record?.value;
}
export async function idbPutMeta<T>(db: IDBDatabase, key: string, value: T): Promise<void> {
const transaction = db.transaction(AI_SESSIONS_IDB_META_STORE, 'readwrite');
transaction.objectStore(AI_SESSIONS_IDB_META_STORE).put({ key, value });
await transactionDone(transaction);
}
// ── Cross-window change notification ──
let changeChannel: BroadcastChannel | null = null;
function getChangeChannel(): BroadcastChannel | null {
if (typeof BroadcastChannel === 'undefined') return null;
try {
if (!changeChannel) {
changeChannel = new BroadcastChannel(AI_SESSIONS_IDB_CHANGE_CHANNEL);
// The channel must never keep the process alive (Node exposes unref).
(changeChannel as unknown as { unref?: () => void }).unref?.();
}
return changeChannel;
} catch {
return null;
}
}
/** Announce a session-store change to OTHER windows (never the sender). */
export function publishAiSessionsIdbChange(): void {
try {
getChangeChannel()?.postMessage(Date.now());
} catch {
// Notification is best-effort; the write itself already succeeded.
}
}
export function subscribeAiSessionsIdbChange(listener: () => void): () => void {
const channel = getChangeChannel();
if (!channel) return () => {};
const handler = () => listener();
channel.addEventListener('message', handler);
return () => channel.removeEventListener('message', handler);
}
// ── Boot load + one-release legacy migration ──
export interface AiSessionsBootLoadResult {
sessions: AISession[];
/** Where the returned sessions came from. */
source: 'indexeddb' | 'legacy-localstorage' | 'none';
/** True when IndexedDB itself could not be opened (corrupt/blocked). */
idbFailed: boolean;
/** The store was already initialized before this boot; empty means deleted. */
authoritativeEmpty: boolean;
/** Marked disk-only records are still missing; keep their backup generations. */
migrationPending: boolean;
}
/**
* Boot-time store resolution, shared with tests:
* 1. IndexedDB `getAll()` — the primary store.
* 2. If empty, migrate the legacy localStorage store into IndexedDB (the
* legacy key itself stays untouched as a read-only fallback for one
* release).
* 3. If IndexedDB is corrupt/unavailable, fall back to legacy localStorage.
*
* The migration is one-shot (issue #4125 review P1②): a `migrationCompleted`
* marker is persisted in the meta store after the first successful boot
* resolution. Once present, the legacy key is never consulted again —
* otherwise a boot whose migration ran while the legacy key could not be
* removed would re-migrate the stale legacy list and resurrect deleted
* chats. The marker also distinguishes "intentionally empty" (the user
* deleted every chat) from "uninitialized".
*
* Callers decide what to do when both sources are empty (the #4018 backup
* generations remain the last restore step in useAIState).
* The legacy reader may await disk-only recovery before migration completes.
* Missing disk-only IDs keep migration resumable, including in a populated IDB.
*/
export async function loadOrMigrateAiSessions(
readLegacyRaw: () => string | null | undefined | Promise<string | null | undefined>,
diskOnlySessionIds: readonly string[] = [],
): Promise<AiSessionsBootLoadResult> {
const hasUnresolvedDiskSessions = (sessions: AISession[]) => {
const ids = new Set(sessions.map(session => session.id));
return diskOnlySessionIds.some(id => !ids.has(id));
};
const legacyFallback = async (idbFailed: boolean): Promise<AiSessionsBootLoadResult> => {
const legacy = parseLegacySessions(await readLegacyRaw());
return {
sessions: legacy,
source: legacy.length > 0 ? 'legacy-localstorage' : 'none',
idbFailed,
authoritativeEmpty: false,
migrationPending: hasUnresolvedDiskSessions(legacy),
};
};
if (!isIndexedDbAvailable()) {
return legacyFallback(false);
}
let db: IDBDatabase;
try {
db = await openAiSessionsIdb();
} catch (error) {
console.warn('[AIState] IndexedDB unavailable, falling back to the legacy localStorage store:', error);
return legacyFallback(true);
}
let sessions: AISession[];
try {
sessions = await idbGetAllSessions(db);
} catch (error) {
console.warn('[AIState] IndexedDB read failed, falling back to the legacy localStorage store:', error);
return legacyFallback(true);
}
let marker: AiSessionsMigrationMarker | undefined;
try {
marker = await idbGetMeta<AiSessionsMigrationMarker>(db, AI_SESSIONS_META_MIGRATION_KEY);
} catch {
marker = undefined;
}
if (marker || (sessions.length > 0 && !hasUnresolvedDiskSessions(sessions))) {
if (!marker) {
// First boot on a store that already holds sessions (e.g. migrated by
// a build without the marker): record one-shot completion so the
// legacy key can never re-seed the store.
await writeMigrationMarker(db).catch(() => {});
}
return {
sessions,
source: sessions.length > 0 ? 'indexeddb' : 'none',
idbFailed: false,
authoritativeEmpty: sessions.length === 0,
migrationPending: false,
};
}
// Unfinished migration (empty or partially populated IDB). The legacy key is
// intentionally NOT removed: it stays as a read-only fallback for one
// release.
const legacy = parseLegacySessions(await readLegacyRaw());
// A partial migration may already have primary/chunk records in IDB. Only
// fill missing marked IDs; never replay stale primary data over live IDB.
const existingIds = new Set(sessions.map(session => session.id));
const diskOnlyIds = new Set(diskOnlySessionIds);
const recovered = sessions.length === 0
? legacy
: legacy.filter(session => diskOnlyIds.has(session.id) && !existingIds.has(session.id));
const merged = [...sessions, ...recovered].sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0));
const migrationPending = hasUnresolvedDiskSessions(merged);
const result: AiSessionsBootLoadResult = {
sessions: merged,
source: sessions.length > 0 ? 'indexeddb' : merged.length > 0 ? 'legacy-localstorage' : 'none',
idbFailed: false,
authoritativeEmpty: false,
migrationPending,
};
if (merged.length === 0) {
// Nothing to migrate: mark the store initialized so this state is
// distinguishable from "uninitialized" on the next boot.
if (!migrationPending) await writeMigrationMarker(db).catch(() => {});
return result;
}
try {
await idbPutSessions(db, recovered);
await idbPutMeta(db, AI_SESSIONS_META_SCHEMA_VERSION_KEY, AI_SESSIONS_IDB_VERSION);
await idbPutMeta(db, AI_SESSIONS_META_LAST_WRITE_KEY, Date.now());
// Written AFTER the migration data is durable: a crash before this point
// leaves the marker absent and the next boot safely re-migrates.
if (!migrationPending) await writeMigrationMarker(db);
} catch (error) {
console.warn('[AIState] IndexedDB migration write failed; keeping legacy store authoritative:', error);
return { ...result, idbFailed: true };
}
console.info(`[AIState] Migrated ${recovered.length} AI session(s) from localStorage to IndexedDB.`);
return result;
}
interface AiSessionsMigrationMarker {
migrationCompleted: true;
migrationAt: number;
}
async function writeMigrationMarker(db: IDBDatabase): Promise<void> {
await idbPutMeta<AiSessionsMigrationMarker>(
db,
AI_SESSIONS_META_MIGRATION_KEY,
{ migrationCompleted: true, migrationAt: Date.now() },
);
}
function parseLegacySessions(raw: string | null | undefined): AISession[] {
if (raw == null || raw === '') return [];
try {
const parsed: unknown = JSON.parse(raw);
return Array.isArray(parsed) ? parsed as AISession[] : [];
} catch {
return [];
}
}
// ── Active-session map mirror (meta store) ──
/**
* Mirror the active-pointer map into the IndexedDB meta store. The
* localStorage key remains written as before (small, quota-safe) and stays
* the read-mostly authority for this release; the mirror makes the pointer
* available to future store work without another migration.
*/
export async function mirrorActiveSessionMapToIdb(
map: Record<string, string | null>,
): Promise<void> {
try {
const db = await openAiSessionsIdb();
await idbPutMeta(db, AI_SESSIONS_META_ACTIVE_MAP_KEY, map);
} catch (error) {
console.warn('[AIState] Failed to mirror the active-session map to IndexedDB:', error);
}
}
/**
* Read the mirrored active-pointer map (boot restore aid when the
* localStorage key is missing).
*/
export async function readActiveSessionMapFromIdb(): Promise<Record<string, string | null> | null> {
try {
const db = await openAiSessionsIdb();
const value = await idbGetMeta<Record<string, string | null>>(db, AI_SESSIONS_META_ACTIVE_MAP_KEY);
return value && typeof value === 'object' ? value : null;
} catch {
return null;
}
}
+4 -2
View File
@@ -66,10 +66,12 @@ test('parseBackupSessions ignores corrupt payloads and drops malformed entries',
});
test('restore preserves all sessions when cloud sync is enabled', () => {
const sessions = Array.from({ length: 51 }, (_, index) => makeSession(`s${index}`, index));
// Issue #4123: the retention cap is a session count (raised to 1000) —
// there is no byte budget any more.
const sessions = Array.from({ length: 1001 }, (_, index) => makeSession(`s${index}`, index));
assert.deepEqual(prepareRestoredSessions(sessions, true), sessions);
const local = prepareRestoredSessions(sessions, false);
assert.equal(local.length, 50);
assert.equal(local.length, 1000);
assert.equal(local.some(session => session.id === 's0'), false);
});
File diff suppressed because it is too large. Load diff
+231 -521
View File
@@ -2,10 +2,13 @@ import { localStorageAdapter } from '../../infrastructure/persistence/localStora
import {
STORAGE_KEY_AI_ACTIVE_SESSION_MAP,
STORAGE_KEY_AI_SESSIONS,
STORAGE_KEY_AI_SESSIONS_OVERFLOW,
STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW,
STORAGE_KEY_AI_SESSION_MESSAGE_CAP,
STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS,
} from '../../infrastructure/config/storageKeys';
import {
DEFAULT_AI_SESSION_MESSAGE_CAP,
normalizeAiSessionMessageCap,
} from '../../domain/aiSessionMessageCap';
import type {
AIDraft,
AIPanelView,
@@ -13,7 +16,6 @@ import type {
AIPermissionMode,
AIToolIntegrationMode,
} from '../../infrastructure/ai/types';
import type { ProviderContinuationOptions } from '../../infrastructure/ai/providerContinuation';
import { findSafeChatMessageCompactionSplitIndex } from '../../infrastructure/ai/contextCompaction';
import {
bumpDraftMutationVersionState,
@@ -27,7 +29,16 @@ import {
import { emitAIStateChanged } from './aiStateEvents';
import { peekAgentRuntimeCleanup } from '../../infrastructure/ai/harness/agentRuntimeHandle';
import { setReversibleModelPrivacy } from '../../infrastructure/ai/harness/modelSecretRedaction';
import { DebouncedBackupMirror, mergeImportedSessions, readLiveSessionsArray } from './aiSessionsBackup';
import { DebouncedBackupMirror, readLiveSessionsArray } from './aiSessionsBackup';
import {
idbDeleteSessionIds,
idbPutMeta,
idbPutSessions,
isIndexedDbAvailable,
openAiSessionsIdb,
publishAiSessionsIdbChange,
resetAiSessionsIdbForTests,
} from './aiSessionIdbStorage';
/** Typed accessor for the Electron IPC bridge exposed on `window.netcatty`. */
export interface AIBridge {
@@ -64,6 +75,13 @@ export async function cleanupSdkAgentSessions(sessionIds: string[]): Promise<voi
await Promise.all(sessionIds.map((sessionId) => bridge?.aiSdkAgentCleanup?.(sessionId).catch(() => {})));
}
/**
* Session ids explicitly deleted by the user (or by an authoritative cloud
* merge). Their disappearance from the persisted set must not trigger the
* pre-eviction backup generation — only cap-driven drops should.
*/
const intentionallyDeletedSessionIds = new Set<string>();
export function cleanupDeletedAIChatSessions(sessionIds: string[]) {
const bridge = getAIBridge();
if (sessionIds.length === 0) return;
@@ -73,6 +91,7 @@ export function cleanupDeletedAIChatSessions(sessionIds: string[]) {
// main-process privacy mapping may still exist, so always clear those.
const runtime = peekAgentRuntimeCleanup();
for (const sessionId of sessionIds) {
intentionallyDeletedSessionIds.add(sessionId);
if (runtime) {
runtime.clearChatSession(sessionId);
} else {
@@ -108,8 +127,11 @@ export let latestAIActiveTargetIdsSnapshot: Set<string> = new Set();
export function cleanupOrphanedAISessions(activeTargetIds: Set<string>) {
latestAIActiveTargetIdsSnapshot = activeTargetIds;
const currentSessions = latestAISessionsSnapshot
?? readSessionsFromStorage();
// Child maintenance effects run before the provider's boot effects. Keep
// the live targets for post-hydration cleanup without adopting stale legacy
// history as authoritative state while IndexedDB is still loading.
if (!isAiSessionsHydrationComplete()) return;
const currentSessions = latestAISessionsSnapshot ?? getPersistedAiSessionsSnapshot();
// Sessions shown by a still-live scope must be protected from cleanup
// even when their own `scope.targetId` points at a closed terminal —
@@ -160,14 +182,7 @@ export function cleanupOrphanedAISessions(activeTargetIds: Set<string>) {
if (sessionsMarked) {
setLatestAISessionsSnapshot(markedSessions);
// Startup cleanup can run before the disk backup read resolves. Defer
// persistence until those chats are merged, preserving their recovery
// marker and avoiding a partial backup of only the loaded sessions.
const diskOnlyIds = localStorageAdapter.read<string[]>(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW) ?? [];
const loadedIds = new Set(markedSessions.map(session => session.id));
if (!diskOnlyIds.some(id => !loadedIds.has(id))) {
writeSessionsForStorage(markedSessions);
}
writeSessionsForStorage(markedSessions);
emitAIStateChanged(STORAGE_KEY_AI_SESSIONS);
}
@@ -203,159 +218,53 @@ export function cleanupOrphanedAISessions(activeTargetIds: Set<string>) {
}
/** Maximum number of sessions to keep in localStorage. */
const MAX_STORED_SESSIONS = 50;
/** Maximum number of messages per session when persisting to localStorage. */
const MAX_SESSION_MESSAGES = 200;
/**
* String-length budget for the primary sessions JSON and each overflow chunk.
* Replay bulk is shed from the primary blob first; whole sessions that still
* do not fit are retained in overflow rather than silently evicted.
* Maximum number of sessions kept in the persisted store. IndexedDB has no
* shared byte quota, so retention is a plain count cap; the cap only exists
* to keep history from growing without bound.
*/
const MAX_SESSIONS_JSON_BYTES = 2 * 1024 * 1024;
/** Retry budgets used when the primary budget still fails to persist. */
const RETRY_SESSIONS_JSON_BYTES = [
1024 * 1024,
512 * 1024,
256 * 1024,
128 * 1024,
] as const;
const MAX_STORED_SESSIONS = 1000;
/**
* Maximum persisted length of a single tool result payload. Terminal tool
* output can reach hundreds of KB per command, and AI-driven terminal work
* accumulates many of them. When storage pressure forces degradation, shedding
* this replay bulk keeps every visible conversation persisted instead of
* evicting whole chats that happen to carry it.
* Per-session message cap, read from user settings (clamped to 50–2000,
* default 200). Read on every write so a lowered cap trims the oldest
* messages on the next write; a raised cap stops further trimming.
* Best-effort: an unavailable storage layer keeps the default cap.
*/
const MAX_PERSISTED_TOOL_RESULT_CHARS = 8 * 1024;
function truncatePersistedToolResultContent(content: string): string {
if (content.length <= MAX_PERSISTED_TOOL_RESULT_CHARS) return content;
// The marker consumes budget and must stay single-line: JSON-escaped
// newlines would cost 2 characters each, letting a slightly-over-cap
// result serialize at the same (or larger) size than the original — the
// ladder assumes the degraded form is the smallest. Shrink the retained
// head+tail until the whole persisted copy fits the cap.
let half = Math.floor(MAX_PERSISTED_TOOL_RESULT_CHARS / 2);
let omitted = content.length - 2 * half;
let marker = `[Netcatty] persisted copy truncated; ${omitted} characters of tool output omitted`;
while (2 * half + marker.length > MAX_PERSISTED_TOOL_RESULT_CHARS) {
half -= Math.ceil((2 * half + marker.length - MAX_PERSISTED_TOOL_RESULT_CHARS) / 2);
omitted = content.length - 2 * half;
marker = `[Netcatty] persisted copy truncated; ${omitted} characters of tool output omitted`;
export function readAiSessionMessageCap(): number {
try {
return normalizeAiSessionMessageCap(
localStorageAdapter.readNumber(STORAGE_KEY_AI_SESSION_MESSAGE_CAP)
?? DEFAULT_AI_SESSION_MESSAGE_CAP,
);
} catch {
return DEFAULT_AI_SESSION_MESSAGE_CAP;
}
return `${content.slice(0, half)}${marker}${content.slice(content.length - half)}`;
}
/**
* Remove `reasoningEncryptedContent` ciphertext from a message's persisted
* continuation and shed bulk tool output. The ciphertext exists so stateless
* Responses turns can replay prior reasoning items; tool output dominates
* persisted size in AI-driven terminal chats. When storage pressure forces
* it, shedding this bulk keeps the visible conversation intact at the cost of
* reasoning replay and full tool output for affected messages.
*/
function stripReasoningEncryptedContent(
options: ProviderContinuationOptions,
): ProviderContinuationOptions | undefined {
const hasCiphertext = Object.values(options).some(
providerOptions => typeof providerOptions?.reasoningEncryptedContent === 'string',
);
if (!hasCiphertext) return options;
const stripped: ProviderContinuationOptions = {};
for (const [provider, providerOptions] of Object.entries(options)) {
const rest = { ...providerOptions };
delete rest.reasoningEncryptedContent;
if (Object.keys(rest).length) stripped[provider] = rest;
}
return Object.keys(stripped).length ? stripped : undefined;
}
function stripReasoningCiphertextFromMessage(message: AISession['messages'][number]) {
const continuation = message.providerContinuation;
if (!continuation?.reasoningParts) return message;
let changed = false;
const parts = continuation.reasoningParts.map(part => {
if (!part.providerOptions) return part;
const providerOptions = stripReasoningEncryptedContent(part.providerOptions);
if (providerOptions === part.providerOptions) return part;
changed = true;
return providerOptions ? { text: part.text, providerOptions } : { text: part.text };
});
if (!changed) return message;
return {
...message,
providerContinuation: {
...continuation,
reasoningParts: parts,
},
};
}
function stripEncryptedReasoningFromMessage(message: AISession['messages'][number]) {
const strippedContinuationMessage = stripReasoningCiphertextFromMessage(message);
const toolResults = strippedContinuationMessage.toolResults;
if (!toolResults?.length) return strippedContinuationMessage;
let toolResultsChanged = false;
const nextToolResults = toolResults.map((result) => {
const content = truncatePersistedToolResultContent(result.content);
if (content === result.content) return result;
toolResultsChanged = true;
return { ...result, content };
});
return toolResultsChanged
? { ...strippedContinuationMessage, toolResults: nextToolResults }
: strippedContinuationMessage;
}
function stripEncryptedReasoningFromSession(session: AISession): AISession {
let changed = false;
const messages = session.messages.map(message => {
const stripped = stripEncryptedReasoningFromMessage(message);
if (stripped !== message) changed = true;
return stripped;
});
return changed ? { ...session, messages } : session;
}
function stripCompactedEncryptedReasoningFromSession(session: AISession): AISession {
const compactedMessageCount = Math.min(
session.messages.length,
Math.max(0, session.contextCompaction?.compactedMessageCount ?? 0),
);
if (compactedMessageCount === 0) return session;
let changed = false;
const messages = session.messages.map((message, index) => {
if (index >= compactedMessageCount) return message;
const stripped = stripReasoningCiphertextFromMessage(message);
if (stripped !== message) changed = true;
return stripped;
});
return changed ? { ...session, messages } : session;
}
/**
* Prune sessions before writing to localStorage to prevent hitting the
* ~5-10 MB storage quota. Only affects what is persisted — the in-memory
* state retains all messages until the session is reloaded.
* Prune sessions before writing to the persisted store to bound retention.
* Only affects what is persisted — the in-memory state retains all messages
* until the session is reloaded.
*
* - Keeps only the MAX_STORED_SESSIONS most-recently-updated sessions.
* - Trims each session's messages to the last MAX_SESSION_MESSAGES.
* - Trims each session's messages to the last `messageCap` messages.
*/
export function pruneSessionsForStorage(sessions: AISession[]): AISession[] {
export function pruneSessionsForStorage(
sessions: AISession[],
messageCap: number = readAiSessionMessageCap(),
): AISession[] {
// Sort by updatedAt descending so we keep the newest
const sorted = [...sessions].sort((a, b) => b.updatedAt - a.updatedAt);
const limited = sorted.slice(0, MAX_STORED_SESSIONS);
return limited.map(s => {
if (s.messages.length > MAX_SESSION_MESSAGES) {
if (s.messages.length > messageCap) {
// Do not start the retained tail with a tool result whose assistant call
// was just trimmed. Reuse the same tool-safe boundary logic as context
// compaction, even if that retains a few more than the nominal cap.
const removedMessageCount = findSafeChatMessageCompactionSplitIndex(
s.messages,
MAX_SESSION_MESSAGES,
messageCap,
);
const contextCompaction = s.contextCompaction
? {
@@ -380,150 +289,119 @@ export function prepareRestoredSessions(sessions: AISession[], syncEnabled: bool
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
* sessions that JSON represents.
*
* Degradation ladder, cheapest loss first: strip replay ciphertext, shed bulk
* tool output (kept at a bounded head+tail per result), and only then drop
* oldest whole sessions. Evicting visible chats to preserve tool bulk is what
* emptied history for users under storage pressure.
*/
export function serializeSessionsForStorage(
sessions: AISession[],
budgetBytes: number = MAX_SESSIONS_JSON_BYTES,
): { json: string; sessions: AISession[] } {
const prepared = pruneSessionsForStorage(sessions).map(rawSession => {
// These messages are replaced by the durable summary before every later
// request, so their replay ciphertext can never be used again. Remove it
// before deciding whether the newest session deserves full protection;
// otherwise dead metadata can evict other visible conversations.
const session = stripCompactedEncryptedReasoningFromSession(rawSession);
return { session, json: JSON.stringify(session) };
});
// ── IndexedDB persistence (issue #4123) ──
//
// Sessions are persisted one record per session. There is no global byte
// budget and no re-serialization ladder: the 2 MB localStorage budget existed
// to defend a shared quota against the cost of serializing the whole store on
// every write, and both are gone with per-record writes. Only a count cap
// (MAX_STORED_SESSIONS) and the per-session message cap remain.
// Check whether retained history fits before preparing degradation. Use
// the existing JSON string-length budget including array delimiters, and
// avoid preparing unused stripped messages and serializing payloads again.
const fullLength = 2 + prepared.reduce((total, entry) => total + entry.json.length, 0)
+ Math.max(0, prepared.length - 1);
if (fullLength <= budgetBytes) {
return {
json: `[${prepared.map(entry => entry.json).join(',')}]`,
sessions: prepared.map(entry => entry.session),
};
}
/** Last set of sessions known to be persisted, for per-record diffing. */
let lastPersistedSessionsById: Map<string, AISession> = new Map();
/** Serialized write queue: IDB operations never interleave. */
let idbWriteQueue: Promise<void> = Promise.resolve();
// Reuse the full representation when the unchanged degradation ladder is
// needed, including on quota retries with a smaller budget.
const serialized = prepared.map(({ session, json }) => {
const degradableMessages = session.messages.flatMap((message, index) => {
const degradedMessage = stripEncryptedReasoningFromMessage(message);
if (degradedMessage === message) return [];
return [{
index,
degradedMessage,
jsonLengthDelta: JSON.stringify(degradedMessage).length - JSON.stringify(message).length,
}];
});
const strippedSession = stripEncryptedReasoningFromSession(session);
return {
session,
json,
strippedSession,
strippedJson: strippedSession === session
? json
: JSON.stringify(strippedSession),
degradableMessages,
};
});
/** Test hook: reset diff state between tests. */
export function __resetIdbPersistenceForTests() {
lastPersistedSessionsById = new Map();
idbWriteQueue = Promise.resolve();
// Tests swap the fake indexedDB factory between cases; the cached open
// handle must not survive the swap.
resetAiSessionsIdbForTests();
// Tests simulate the post-boot state by default; the boot-gating cases
// call __resetAiSessionsHydrationForTests() to go back to pending.
markAiSessionsHydrationComplete();
}
// Preserve the newest session's full continuation whenever it can fit by
// itself. That session is the one the user is most likely continuing now;
// older visible history must not make its next tool turn unreplayable.
const protectNewestContinuation = serialized.length > 0
&& serialized[0].json.length + 2 <= budgetBytes;
/** Test hook: resolve once every queued IndexedDB sync has settled. */
export function __flushIdbWritesForTests(): Promise<void> {
return idbWriteQueue.then(() => undefined, () => undefined);
}
// Determine how many sessions can fit using the smallest representation for
// older sessions while reserving the newest session's full representation
// when possible. This also prevents an old, oversized visible chat that must
// be dropped anyway from causing newer replay ciphertext to be stripped.
let minimalLength = 2
+ serialized.reduce((total, entry, index) => (
total + (protectNewestContinuation && index === 0 ? entry.json.length : entry.strippedJson.length)
), 0)
+ Math.max(0, serialized.length - 1);
while (minimalLength > budgetBytes && serialized.length > 1) {
const removed = serialized.pop();
if (!removed) break;
minimalLength -= removed.strippedJson.length + 1;
}
// ── Boot hydration signal (issue #4125 review) ──
//
// Settles once useAIState's boot load has finished (IndexedDB read, legacy
// migration, disk-backup restore). Sync collection and the active-pointer
// validation must not run before it: a pre-hydration collect reads the
// stale legacy localStorage store and would overwrite newer IndexedDB
// records, and pointer validation against the initially-empty in-memory
// array would null every pointer before the real sessions arrive.
// Then keep full continuation data for the retained sessions and shed
// replay-only bulk (ciphertext, oversized tool output) from the oldest
// retained sessions only as needed, never touching the protected newest
// session. Within one session, shed it one message at a time from oldest to
// newest so a long active chat can retain its most recent replayable tool
// exchange when that still fits.
let jsonLength = 2 + serialized.reduce((total, entry) => total + entry.json.length, 0)
+ Math.max(0, serialized.length - 1);
const firstStrippableIndex = protectNewestContinuation ? 1 : 0;
for (
let index = serialized.length - 1;
index >= firstStrippableIndex && jsonLength > budgetBytes;
index -= 1
) {
const current = serialized[index];
let degradedMessageCount = 0;
while (jsonLength > budgetBytes && degradedMessageCount < current.degradableMessages.length) {
jsonLength += current.degradableMessages[degradedMessageCount].jsonLengthDelta;
degradedMessageCount += 1;
}
if (degradedMessageCount > 0) {
const degradedMessagesByIndex = new Map(
current.degradableMessages
.slice(0, degradedMessageCount)
.map(entry => [entry.index, entry.degradedMessage] as const),
);
const nextSession = {
...current.session,
messages: current.session.messages.map((message, messageIndex) => (
degradedMessagesByIndex.get(messageIndex) ?? message
)),
};
const nextJson = JSON.stringify(nextSession);
const projectedJsonLength = current.json.length
+ current.degradableMessages
.slice(0, degradedMessageCount)
.reduce((total, entry) => total + entry.jsonLengthDelta, 0);
jsonLength += nextJson.length - projectedJsonLength;
serialized[index] = {
...current,
session: nextSession,
json: nextJson,
};
}
}
let aiSessionsHydrationComplete = false;
return {
json: `[${serialized.map(entry => entry.json).join(',')}]`,
sessions: serialized.map(entry => entry.session),
};
export function isAiSessionsHydrationComplete(): boolean {
return aiSessionsHydrationComplete;
}
/** Called by useAIState's boot effect when the boot load settles. */
export function markAiSessionsHydrationComplete(): void {
aiSessionsHydrationComplete = true;
}
/** Test hook: return the hydration signal to the pre-boot (pending) state. */
export function __resetAiSessionsHydrationForTests(): void {
aiSessionsHydrationComplete = false;
}
/** Seed the diff baseline after a boot load or migration. */
export function seedLastPersistedSessions(sessions: AISession[]) {
lastPersistedSessionsById = new Map(sessions.map(session => [session.id, session]));
}
/**
* Persist sessions to localStorage with byte-budgeted primary/overflow slices.
* Returns true when local history was saved, possibly as a newest-primary
* fallback with the full history scheduled for disk backup under quota pressure.
* Read the persisted session set synchronously. Source of truth is the
* in-memory snapshot held by useAIState; this accessor returns the last set
* handed to `writeSessionsForStorage`, falling back to the read-only legacy
* localStorage store (kept untouched for one release).
*/
export function getPersistedAiSessionsSnapshot(): AISession[] {
if (lastPersistedSessionsById.size > 0) {
return Array.from(lastPersistedSessionsById.values())
.sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0));
}
return readLiveSessionsArray(localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS));
}
// 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. Persisted history is mirrored, including
// overflow sessions, throttled to at most one write per mutation batch.
// Before a byte-budget drop, mirror the full
// count-capped set even if the subsequent localStorage writes fail.
function queueIdbSessionSync(
changed: AISession[],
removedIds: string[],
prevById: Map<string, AISession>,
persisted: AISession[],
) {
if (changed.length === 0 && removedIds.length === 0) return;
idbWriteQueue = idbWriteQueue
.then(async () => {
if (!isIndexedDbAvailable()) return;
const db = await openAiSessionsIdb();
// Per-record write: only the changed sessions are put, only removed
// ids are deleted.
if (changed.length > 0) await idbPutSessions(db, changed);
if (removedIds.length > 0) await idbDeleteSessionIds(db, removedIds);
await idbPutMeta(db, 'schemaVersion', 1);
await idbPutMeta(db, 'lastWriteAt', Date.now());
publishAiSessionsIdbChange();
})
.catch((error) => {
// IndexedDB failures must never break the in-memory session state.
// Reset the diff baseline so the next write retries every record, and
// fall back to the legacy localStorage key so a corrupt/unavailable
// IndexedDB still leaves a restorable trace (via the disk mirror).
console.warn('[AIState] IndexedDB session sync failed:', error);
lastPersistedSessionsById = new Map(prevById);
try {
localStorageAdapter.writeString(STORAGE_KEY_AI_SESSIONS, JSON.stringify(persisted));
} catch {
// Legacy fallback is best-effort too.
}
});
}
// Durability mirror (issue #4015 follow-up): every write also schedules a
// debounced disk backup so a wiped/recreated profile does not take the chat
// history with it. The persisted JSON is mirrored; when the count cap evicts
// sessions, a PRE-eviction generation is written first so evicted chats stay
// recoverable from disk (the blind spot found in issue #4123).
const backupMirror = new DebouncedBackupMirror((json) => {
try {
void getAIBridge()?.aiSessionsBackupWrite?.({ json })?.catch(() => {});
@@ -532,6 +410,14 @@ const backupMirror = new DebouncedBackupMirror((json) => {
}
});
let aiSessionsBackupRecoveryPending = false;
/** Partial migration must not rotate away generations holding disk-only chats. */
export function setAiSessionsBackupRecoveryPending(pending: boolean) {
aiSessionsBackupRecoveryPending = pending;
if (pending) backupMirror.reset();
}
export function flushBackupMirror() {
try {
backupMirror.flushNow();
@@ -542,9 +428,11 @@ export function flushBackupMirror() {
export function __resetBackupMirrorForTests() {
backupMirror.reset();
aiSessionsBackupRecoveryPending = false;
}
export function scheduleBackupMirror(json: string) {
if (aiSessionsBackupRecoveryPending) return;
try {
backupMirror.schedule(json);
} catch {
@@ -552,257 +440,79 @@ export function scheduleBackupMirror(json: string) {
}
}
/**
* Last successful write: the same immutable sessions array is written again by
* the debounced save path and by cloud-sync collection, and each write used to
* re-serialize the whole (often multi-MB) history.
*/
let lastSessionsWrite: { sessions: AISession[]; synced: boolean; json: string } | null = null;
function rememberSessionsWrite(sessions: AISession[], synced: boolean, json: string): void {
lastSessionsWrite = { sessions, synced, json };
}
function storedSessionsJsonEquals(json: string): boolean {
/** Write one immediate backup generation (bypasses the debounce window). */
function writeImmediateBackupGeneration(json: string) {
if (aiSessionsBackupRecoveryPending) return;
try {
return localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS) === json;
backupMirror.schedule(json);
backupMirror.flushNow();
} catch {
return false;
// Backup failures must never break chat persistence.
}
}
function overflowChunkKey(index: number): string {
return index === 0 ? STORAGE_KEY_AI_SESSIONS_OVERFLOW : `${STORAGE_KEY_AI_SESSIONS_OVERFLOW}_${index + 1}`;
}
function readOverflowChunks(): string[] {
const chunks: string[] = [];
for (let index = 0; ; index += 1) {
const chunk = localStorageAdapter.readString(overflowChunkKey(index));
if (chunk == null) return chunks;
chunks.push(chunk);
}
}
function clearOverflowChunks(startIndex = 0) {
for (let index = startIndex; localStorageAdapter.readString(overflowChunkKey(index)) != null; index += 1) {
localStorageAdapter.remove(overflowChunkKey(index));
}
}
function writeDiskOverflowIds(ids: string[]): boolean {
if (ids.length > 0) {
return localStorageAdapter.write(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW, ids);
}
if (localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW) != null) {
localStorageAdapter.remove(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW);
}
return true;
}
/** Replace the entire overflow blob; never leave a partially written JSON array. */
function replaceOverflowChunks(chunks: string[]): boolean {
clearOverflowChunks();
let complete = false;
try {
complete = chunks.every((chunk, index) => (
localStorageAdapter.writeString(overflowChunkKey(index), chunk)
));
return complete;
} finally {
if (!complete) clearOverflowChunks();
}
}
/** Read both local retention slices; newer copies win if a session is duplicated. */
export function readSessionsFromStorage(): AISession[] {
return mergeImportedSessions(
readLiveSessionsArray(readOverflowChunks().join('')),
readLiveSessionsArray(localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS)),
);
}
/**
* Perf (#4121): the exact same immutable sessions array is often written
* again by the debounced save path and by cloud-sync collection; skipping
* the re-stringify for a reference-identical set keeps that cheap. The
* per-record IDB diff below already makes the store write itself cheap.
*/
let lastMirroredSessions: AISession[] | null = null;
/**
* Persist sessions. Writes go to IndexedDB, one record per session, through a
* serialized queue; errors are logged, never thrown. Returns true when the
* persisted set was computed (in-memory state remains authoritative either
* way).
*/
export function writeSessionsForStorage(
sessions: AISession[],
options: { deletedSessionIds?: readonly string[] } = {},
options?: { deletedSessionIds?: readonly string[] },
): boolean {
const synced = Boolean(
localStorageAdapter.read<{ enabled?: boolean }>(STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS)?.enabled,
);
// Nothing to do when this exact array was already written in this mode and
// storage still holds that write (another window may have replaced it).
if (
lastSessionsWrite?.sessions === sessions
&& lastSessionsWrite.synced === synced
&& storedSessionsJsonEquals(lastSessionsWrite.json)
// A matching primary alone cannot establish that overflow is intact.
&& localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS_OVERFLOW) == null
&& localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW) == null
) {
return true;
}
// Synced records are authoritative after restart: silently evicting a
// session or turn here would turn local retention into cloud deletion.
if (synced) {
const json = JSON.stringify(sessions);
const previousChunks = readOverflowChunks();
// The authoritative primary replaces overflow, so free its quota first.
clearOverflowChunks();
let syncedOk = false;
try {
syncedOk = localStorageAdapter.writeString(STORAGE_KEY_AI_SESSIONS, json);
if (syncedOk) {
clearOverflowChunks();
if (localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW) != null) {
localStorageAdapter.remove(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW);
}
scheduleBackupMirror(json);
rememberSessionsWrite(sessions, true, json);
}
return syncedOk;
} finally {
if (!syncedOk) replaceOverflowChunks(previousChunks);
}
}
const inputIds = new Set(sessions.map(session => session.id));
const pruned = pruneSessionsForStorage(sessions);
const retainedIds = new Set(pruned.map(session => session.id));
const deletedIds = new Set(options.deletedSessionIds);
// A live write can race startup's backup read. Preserve marker IDs absent
// from the live input unless explicitly deleted. IDs present in the input
// are resolved even if the count cap evicts them; pending IDs block mirroring.
const diskOverflowIds = localStorageAdapter.read<unknown>(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW);
const unresolvedDiskIds: string[] = Array.isArray(diskOverflowIds)
? diskOverflowIds.filter((id): id is string => (
typeof id === 'string' && !retainedIds.has(id) && !deletedIds.has(id) && !inputIds.has(id)
))
: [];
let fullBackupScheduled = false;
const ensureFullBackup = () => {
if (!fullBackupScheduled && unresolvedDiskIds.length === 0) {
scheduleBackupMirror(JSON.stringify(pruned));
fullBackupScheduled = true;
}
};
const persistCandidate = (
candidate: { json: string; sessions: AISession[] },
budget: number,
chunkBudget = budget,
): boolean => {
const primaryIds = new Set(candidate.sessions.map(session => session.id));
const omitted = pruned.filter(session => !primaryIds.has(session.id));
if (omitted.length > 0) ensureFullBackup();
// Apply the same replay-bulk ladder to every overflow chat as budgets
// tighten. Chunking alone cannot reduce an origin's aggregate quota use.
const overflow = omitted.map(session => serializeSessionsForStorage([session], budget).sessions[0]);
const overflowJson = overflow.length > 0 ? JSON.stringify(overflow) : '';
// Split the array's JSON string, so even one oversized session can span
// chunks without losing messages. Concatenate all chunks before parsing.
const chunks: string[] = [];
// Primary budgets can shrink to a tiny empty chat. Keep overflow writes
// bounded independently while still supporting small per-key quotas.
const chunkSize = Math.max(32 * 1024, chunkBudget);
for (let offset = 0; offset < overflowJson.length; offset += chunkSize) {
chunks.push(overflowJson.slice(offset, offset + chunkSize));
}
const previousChunks = readOverflowChunks();
const previousPrimary = localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS);
let primaryWritten = false;
let persisted = false;
try {
// Reclaim both old slices before writing overflow: the final history may
// fit even when overflow plus the old, larger primary would exceed quota.
clearOverflowChunks();
if (!localStorageAdapter.writeString(STORAGE_KEY_AI_SESSIONS, candidate.json)) return false;
primaryWritten = true;
if (!replaceOverflowChunks(chunks)) return false;
if (!writeDiskOverflowIds(unresolvedDiskIds)) return false;
persisted = true;
if (!fullBackupScheduled && unresolvedDiskIds.length === 0) scheduleBackupMirror(candidate.json);
rememberSessionsWrite(sessions, false, candidate.json);
return true;
} finally {
if (!persisted) {
clearOverflowChunks();
if (primaryWritten) {
if (previousPrimary == null) localStorageAdapter.remove(STORAGE_KEY_AI_SESSIONS);
else localStorageAdapter.writeString(STORAGE_KEY_AI_SESSIONS, previousPrimary);
}
replaceOverflowChunks(previousChunks);
}
}
};
const primaryCandidates: { json: string; sessions: AISession[] }[] = [];
let previousLength = Number.POSITIVE_INFINITY;
for (const configuredBudget of [MAX_SESSIONS_JSON_BYTES, ...RETRY_SESSIONS_JSON_BYTES]) {
// A real quota failure means the next attempt must be smaller even when
// the failed payload was already below the nominal retry budget. Reduce
// materially on each bounded retry so several small sessions can be
// removed before the retry sequence is exhausted.
const reducedBudget = Number.isFinite(previousLength)
? Math.floor(previousLength * 0.75)
: configuredBudget;
const budget = Math.min(configuredBudget, reducedBudget);
if (budget < 2) break;
const candidate = serializeSessionsForStorage(pruned, budget);
primaryCandidates.push(candidate);
// Even an unchanged primary can succeed with smaller overflow chunks or
// more degraded overflow replay data at this retry budget.
if (persistCandidate(candidate, budget)) return true;
previousLength = candidate.json.length;
}
// Last resort: use the smallest primary representation (the newest chat
// with replay-only bulk removed), retaining other chats in overflow.
const minimalCandidate = serializeSessionsForStorage(pruned, 0);
const minimalChunkBudget = Math.min(minimalCandidate.json.length, RETRY_SESSIONS_JSON_BYTES[RETRY_SESSIONS_JSON_BYTES.length - 1]);
primaryCandidates.push(minimalCandidate);
if (persistCandidate(minimalCandidate, 0, minimalChunkBudget)) return true;
// If the complete retained set cannot fit, save the newest primary anyway.
// Schedule the full disk mirror before discarding overflow; do not let an
// old valid primary block persistence of every subsequent conversation turn.
ensureFullBackup();
const previousChunks = readOverflowChunks();
const previousPrimary = localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS);
const previousDiskOverflow = localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW);
clearOverflowChunks();
let primaryWritten = false;
let primaryPersisted = false;
try {
for (const candidate of primaryCandidates) {
const primaryIds = new Set(candidate.sessions.map(session => session.id));
const diskOnlyIds = [...new Set([
...unresolvedDiskIds,
...pruned.filter(session => !primaryIds.has(session.id)).map(session => session.id),
])];
// Replacing a large old primary can free the space needed by metadata.
// Roll it back below if the recovery marker still cannot be persisted.
if (!localStorageAdapter.writeString(STORAGE_KEY_AI_SESSIONS, candidate.json)) continue;
primaryWritten = true;
if (writeDiskOverflowIds(diskOnlyIds)) {
primaryPersisted = true;
rememberSessionsWrite(sessions, false, candidate.json);
return true;
}
// Explicitly deleted ids never count as cap eviction (they also must not
// trigger a pre-eviction backup generation).
for (const id of options?.deletedSessionIds ?? []) intentionallyDeletedSessionIds.add(id);
// Synced records are authoritative after restart: never prune them, or
// local retention would turn into cloud deletion on the next sync.
const syncEnabled = !!localStorageAdapter.read<{ enabled?: boolean }>(
STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS,
)?.enabled;
const persisted = syncEnabled ? sessions : pruneSessionsForStorage(sessions);
const prevById = lastPersistedSessionsById;
const persistedIds = new Set(persisted.map(session => session.id));
// Count-cap eviction: sessions that drop out of the persisted set without
// being explicitly deleted get a pre-eviction disk generation containing
// the full pre-eviction store, so they remain recoverable.
const evicted = Array.from(prevById.entries())
.filter(([id]) => !persistedIds.has(id) && !intentionallyDeletedSessionIds.has(id))
.map(([, session]) => session);
intentionallyDeletedSessionIds.clear();
if (evicted.length > 0) {
writeImmediateBackupGeneration(JSON.stringify(sessions));
console.warn(
`[AIState] Retention cap evicted ${evicted.length} session(s); a pre-eviction backup generation was written to disk.`,
);
}
} finally {
if (!primaryPersisted) {
if (primaryWritten) {
if (previousPrimary == null) localStorageAdapter.remove(STORAGE_KEY_AI_SESSIONS);
else localStorageAdapter.writeString(STORAGE_KEY_AI_SESSIONS, previousPrimary);
}
if (previousDiskOverflow != null) {
localStorageAdapter.writeString(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW, previousDiskOverflow);
} else if (localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW) != null) {
localStorageAdapter.remove(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW);
}
replaceOverflowChunks(previousChunks);
// Per-record diff (synchronous: the baseline must advance even when
// IndexedDB is unavailable, and callers read the snapshot right after).
const changed = persisted.filter(session => prevById.get(session.id) !== session);
const removedIds = Array.from(prevById.keys()).filter(id => !persistedIds.has(id));
lastPersistedSessionsById = new Map(persisted.map(session => [session.id, session]));
if (changed.length > 0 || removedIds.length > 0 || lastMirroredSessions !== persisted) {
scheduleBackupMirror(JSON.stringify(persisted));
lastMirroredSessions = persisted;
}
queueIdbSessionSync(changed, removedIds, prevById, persisted);
return true;
} catch (error) {
console.warn('[AIState] Failed to persist AI sessions:', error);
return false;
}
console.warn(
'[AIState] Failed to persist AI sessions within the storage quota; recent chat history may not survive a restart.',
);
return false;
}
export let latestAISessionsSnapshot: AISession[] | null = null;
@@ -844,10 +554,10 @@ export function setLatestAIActiveSessionMapSnapshot(activeSessionIdMap: Record<s
export function prewarmAIStateStorageSnapshots() {
try {
if (latestAISessionsSnapshot === null) {
latestAISessionsSnapshot =
readSessionsFromStorage();
}
// Sessions are no longer prewarmed from localStorage: the primary store is
// IndexedDB and loads asynchronously in useAIState. Prewarming the stale
// read-only legacy key would make the boot loader skip the IndexedDB
// contents ("never restore over non-empty live state").
if (latestAIActiveSessionMapSnapshot === null) {
latestAIActiveSessionMapSnapshot =
localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP) ?? {};
@@ -1,5 +1,6 @@
import test from "node:test";
import assert from "node:assert/strict";
import type { HistoryEntry } from "./commandHistoryStore";
import {
STORAGE_KEY_COMMAND_HISTORY,
@@ -73,7 +74,9 @@ const {
getCommandHistoryClearGeneration,
hasCommandHistoryBeenCleared,
markCommandHistoryCleared,
fuzzyQueryHistory,
queryHistory,
queryRecentHistoryByCommand,
recordCommand,
subscribeCommandHistoryCleared,
} = await import("./commandHistoryStore.ts");
@@ -343,3 +346,155 @@ test("saving after a command and peer writes read the clear generation without p
parse.mock.restore();
assert.ok(queryHistory("echo fast", { hostId: "host-fast" }).some((entry) => entry.command === "echo fast-path-2"));
});
const QUERY_NOW = 1_700_000_000_000;
const DAY_MS = 24 * 60 * 60 * 1000;
function historyEntry(command: string, overrides: Partial<HistoryEntry> = {}): HistoryEntry {
return {
command, hostId: "host-1", os: "linux", frequency: 1,
createdAt: QUERY_NOW, lastUsedAt: QUERY_NOW, ...overrides,
};
}
function installHistoryEntries(entries: HistoryEntry[]): void {
clearHistory();
const raw = JSON.stringify({
clearGeneration: getCommandHistoryClearGeneration(), version: 1, entries,
});
storage.set(COMMAND_HISTORY_STORAGE_KEY, raw);
dispatchStorageEvent(COMMAND_HISTORY_STORAGE_KEY, raw);
}
test("prefix history preserves relevance, stable ties, host isolation and best duplicate", (t) => {
t.mock.method(Date, "now", () => QUERY_NOW);
const entries = [
historyEntry("vi", { frequency: 100 }),
historyEntry("vim duplicate"),
historyEntry("vim frequent", { frequency: 8, lastUsedAt: QUERY_NOW - DAY_MS }),
historyEntry("Vim case"),
historyEntry("vim tied"),
historyEntry("vim duplicate", { hostId: "host-2", frequency: 5 }),
historyEntry("unrelated", { frequency: 100 }),
];
installHistoryEntries(entries);
assert.deepEqual(queryHistory("vi", { limit: 4 }), [entries[5], entries[2], entries[3], entries[4]]);
assert.deepEqual(queryHistory("vi", { hostId: "host-1", limit: 3 }), [entries[2], entries[1], entries[3]]);
assert.deepEqual(queryHistory("VI", { limit: 1 }), [entries[0]]);
assert.deepEqual(queryHistory("missing"), []);
assert.deepEqual(queryHistory("vi", { limit: 0 }), []);
});
test("top-k history replaces and evicts duplicates without losing distinct results", (t) => {
t.mock.method(Date, "now", () => QUERY_NOW);
const entries = [
historyEntry("vim a", { frequency: 2 }),
historyEntry("vim b", { frequency: 3 }),
historyEntry("vim c", { frequency: 4 }),
historyEntry("vim b", { hostId: "host-2", frequency: 5 }),
historyEntry("vim a", { hostId: "host-2", frequency: 6 }),
historyEntry("vim a", { hostId: "host-3", frequency: 1 }),
];
installHistoryEntries(entries);
assert.deepEqual(queryHistory("vi", { limit: 2 }), [entries[4], entries[3]]);
assert.deepEqual(fuzzyQueryHistory("vi", { limit: 2 }), [entries[4], entries[3]]);
});
test("fuzzy history preserves subsequence quality, recency, exclusions and stable ties", (t) => {
t.mock.method(Date, "now", () => QUERY_NOW);
const entries = [
historyEntry("vi", { frequency: 100 }),
historyEntry("vi alpha"),
historyEntry("vi beta"),
historyEntry("very indirect", { frequency: 2 }),
historyEntry("vi stale", { lastUsedAt: QUERY_NOW - DAY_MS }),
historyEntry("vi alpha", { hostId: "host-2", frequency: 3 }),
historyEntry("iv does not match"),
];
installHistoryEntries(entries);
assert.deepEqual(fuzzyQueryHistory("vi", { limit: 4 }), [entries[5], entries[3], entries[2], entries[4]]);
assert.deepEqual(fuzzyQueryHistory("vi", { hostId: "host-1", limit: 4 }), [entries[3], entries[1], entries[2], entries[4]]);
assert.deepEqual(fuzzyQueryHistory(""), []);
assert.deepEqual(fuzzyQueryHistory("vi", { limit: -1 }), []);
});
test("recent history preserves timestamps, command boundaries and quoted argument prefixes", () => {
const entries = [
historyEntry("cd /work/older", { lastUsedAt: QUERY_NOW - DAY_MS }),
historyEntry("cd '/work/new folder'"),
historyEntry("CD /work/tied"),
historyEntry("cd /work/older", { hostId: "host-2", lastUsedAt: QUERY_NOW + 1 }),
historyEntry("cd /other", { lastUsedAt: QUERY_NOW + 2 }),
historyEntry("cdb /work/wrong", { lastUsedAt: QUERY_NOW + 3 }),
historyEntry("cd /work/excluded", { lastUsedAt: QUERY_NOW + 4 }),
];
installHistoryEntries(entries);
const options = { commandName: " CD ", argumentPrefix: "'/work/", excludeCommand: "cd /work/excluded", limit: 3 };
assert.deepEqual(queryRecentHistoryByCommand(options), [entries[3], entries[1], entries[2]]);
assert.deepEqual(queryRecentHistoryByCommand({ ...options, hostId: "host-1" }), [entries[1], entries[2], entries[0]]);
assert.deepEqual(queryRecentHistoryByCommand({ commandName: "" }), []);
assert.deepEqual(queryRecentHistoryByCommand({ commandName: "cd", limit: 0 }), []);
});
test("fuzzy scoring preserves consecutive and word-boundary bonuses across long gaps", (t) => {
t.mock.method(Date, "now", () => QUERY_NOW);
const entries = [
historyEntry("av-x"),
historyEntry("avx"),
historyEntry(`v${"a".repeat(1900)}x`),
historyEntry("v-x"),
historyEntry("v/x"),
historyEntry("v_x"),
historyEntry("v x"),
historyEntry("vx adjacent"),
historyEntry("v missing second character"),
historyEntry("xv wrong order"),
];
installHistoryEntries(entries);
assert.deepEqual(fuzzyQueryHistory("vx"), [
entries[7], entries[3], entries[4], entries[5], entries[6], entries[2], entries[1], entries[0],
]);
assert.deepEqual(fuzzyQueryHistory("vv"), []);
});
for (const size of [4_000, 40_000]) {
test(`history queries keep five best results with linear scoring for ${size} entries`, (t) => {
t.mock.method(Date, "now", () => QUERY_NOW);
const entries = Array.from({ length: size }, (_, i) => historyEntry(`vi file-${i}`, {
// Unsorted scores/timestamps, with the best match at the end of the store.
frequency: i === size - 1 ? size + 1 : (i * 7919) % size + 1,
lastUsedAt: QUERY_NOW + (i === size - 1 ? size + 1 : (i * 7919) % size),
}));
installHistoryEntries(entries);
queryHistory("missing"); // Warm the cache before measuring query operations.
const expected = entries.slice().sort((a, b) => b.frequency - a.frequency).slice(0, 5);
const pow = t.mock.method(Math, "pow");
const lower = t.mock.method(String.prototype, "toLowerCase");
// node:test cannot mock methods directly on arrays (including Array.prototype).
const originalSort = Array.prototype.sort;
const sortedSizes: number[] = [];
Array.prototype.sort = function<T>(this: T[], compare?: (a: T, b: T) => number): T[] {
sortedSizes.push(this.length);
return originalSort.call(this, compare);
};
t.after(() => { Array.prototype.sort = originalSort; });
const queries = [
() => queryHistory("vi", { limit: 5 }),
() => fuzzyQueryHistory("vi", { limit: 5 }),
() => queryRecentHistoryByCommand({ commandName: "vi", argumentPrefix: "file-", limit: 5 }),
];
for (const query of queries) {
const beforePow = pow.mock.callCount();
const beforeLower = lower.mock.callCount();
const result = query();
assert.deepEqual(result, expected);
assert.equal(new Set(result.map((entry) => entry.command)).size, 5);
assert.ok(pow.mock.callCount() - beforePow <= size, "score each entry at most once");
assert.ok(lower.mock.callCount() - beforeLower <= 2 * size + 2, "normalize in one linear pass");
}
assert.deepEqual(sortedSizes, [], "never sort all matches before limiting");
});
}
+65 -76
View File
@@ -352,6 +352,36 @@ export interface RecentHistoryQueryOptions extends HistoryQueryOptions {
argumentPrefix?: string;
}
interface RankedHistoryEntry {
entry: HistoryEntry;
score: number;
}
/**
* Keep only the best k distinct commands. Queries score each match once and
* retain O(k) candidates instead of sorting/allocating all matches (k is 5
* for autocomplete). Entries arrive in store order for stable score ties.
*/
function retainHistoryMatch(
best: RankedHistoryEntry[],
entry: HistoryEntry,
score: number,
limit: number,
): void {
if (best.length >= limit && score <= best[best.length - 1].score) return;
const duplicate = best.findIndex((candidate) => candidate.entry.command === entry.command);
if (duplicate >= 0) {
if (score <= best[duplicate].score) return;
best.splice(duplicate, 1);
}
// Insert after equal scores to preserve the original store order on ties.
const index = best.findIndex((candidate) => score > candidate.score);
if (best.length >= limit) best.pop();
best.splice(index < 0 ? best.length : index, 0, { entry, score });
}
/**
* Query history entries matching a prefix.
* Returns entries sorted by relevance (frequency * recency).
@@ -366,33 +396,17 @@ export function queryHistory(
const lowerPrefix = prefix.toLowerCase();
const now = Date.now(); // Cache once per query
const filtered = store.entries.filter((entry) => {
const best: RankedHistoryEntry[] = [];
for (const entry of store.entries) {
if (hostId && entry.hostId !== hostId) continue;
// Must match prefix
if (!entry.command.toLowerCase().startsWith(lowerPrefix)) return false;
if (!entry.command.toLowerCase().startsWith(lowerPrefix)) continue;
// Must not be identical to prefix
if (entry.command === prefix) return false;
// Host filtering: strict per-host isolation
if (hostId) {
return entry.hostId === hostId;
}
return true;
});
// Sort by score (frequency * recency)
filtered.sort((a, b) => scoreEntryAt(b, now) - scoreEntryAt(a, now));
// Deduplicate by command text (keep highest scored)
const seen = new Set<string>();
const results: HistoryEntry[] = [];
for (const entry of filtered) {
if (seen.has(entry.command)) continue;
seen.add(entry.command);
results.push(entry);
if (results.length >= limit) break;
if (entry.command === prefix) continue;
retainHistoryMatch(best, entry, scoreEntryAt(entry, now), limit);
}
return results;
return best.map(({ entry }) => entry);
}
/**
@@ -410,7 +424,7 @@ export function fuzzyQueryHistory(
const lowerQuery = query.toLowerCase();
const now = Date.now(); // Cache once per query
const scored: { entry: HistoryEntry; matchScore: number }[] = [];
const best: RankedHistoryEntry[] = [];
for (const entry of store.entries) {
// Host filtering
@@ -420,24 +434,11 @@ export function fuzzyQueryHistory(
const matchScore = fuzzyScore(lowerQuery, entry.command.toLowerCase());
if (matchScore > 0 && entry.command !== query) {
scored.push({ entry, matchScore });
retainHistoryMatch(best, entry, matchScore * scoreEntryAt(entry, now), limit);
}
}
scored.sort((a, b) =>
b.matchScore * scoreEntryAt(b.entry, now) - a.matchScore * scoreEntryAt(a.entry, now),
);
const seen = new Set<string>();
const results: HistoryEntry[] = [];
for (const { entry } of scored) {
if (seen.has(entry.command)) continue;
seen.add(entry.command);
results.push(entry);
if (results.length >= limit) break;
}
return results;
return best.map(({ entry }) => entry);
}
/**
@@ -462,38 +463,26 @@ export function queryRecentHistoryByCommand(
const commandPrefix = `${trimmedCommandName} `;
const normalizedArgumentPrefix = normalizeArgumentToken(argumentPrefix ?? "");
const filtered = store.entries.filter((entry) => {
const best: RankedHistoryEntry[] = [];
for (const entry of store.entries) {
if (hostId && entry.hostId !== hostId) continue;
const lowerCommand = entry.command.toLowerCase();
if (lowerCommand !== trimmedCommandName && !lowerCommand.startsWith(commandPrefix)) {
return false;
continue;
}
if (excludeCommand && entry.command === excludeCommand) return false;
if (excludeCommand && entry.command === excludeCommand) continue;
if (normalizedArgumentPrefix) {
const currentToken = normalizeArgumentToken(getCurrentCommandToken(entry.command));
if (!currentToken.startsWith(normalizedArgumentPrefix)) {
return false;
continue;
}
}
if (hostId) {
return entry.hostId === hostId;
}
return true;
});
filtered.sort((a, b) => b.lastUsedAt - a.lastUsedAt);
const seen = new Set<string>();
const results: HistoryEntry[] = [];
for (const entry of filtered) {
if (seen.has(entry.command)) continue;
seen.add(entry.command);
results.push(entry);
if (results.length >= limit) break;
retainHistoryMatch(best, entry, entry.lastUsedAt, limit);
}
return results;
return best.map(({ entry }) => entry);
}
function getCurrentCommandToken(command: string): string {
@@ -569,28 +558,28 @@ function fuzzyScore(query: string, target: string): number {
if (query.length > target.length) return 0;
let score = 0;
let queryIdx = 0;
let searchFrom = 0;
let prevMatchIdx = -2;
for (let i = 0; i < target.length && queryIdx < query.length; i++) {
if (target[i] === query[queryIdx]) {
queryIdx++;
// First character bonus
if (i === 0) score += 10;
// Consecutive match bonus
if (i === prevMatchIdx + 1) score += 5;
// Word boundary bonus
if (i === 0 || target[i - 1] === " " || target[i - 1] === "/" ||
target[i - 1] === "-" || target[i - 1] === "_") {
score += 3;
}
score += 1;
prevMatchIdx = i;
for (let queryIdx = 0; queryIdx < query.length; queryIdx++) {
// Skip unmatched runs in native string search, especially for long commands.
const i = target.indexOf(query[queryIdx], searchFrom);
if (i < 0) return 0;
// First character bonus
if (i === 0) score += 10;
// Consecutive match bonus
if (i === prevMatchIdx + 1) score += 5;
// Word boundary bonus
if (i === 0 || target[i - 1] === " " || target[i - 1] === "/" ||
target[i - 1] === "-" || target[i - 1] === "_") {
score += 3;
}
score += 1;
prevMatchIdx = i;
searchFrom = i + 1;
}
// All query characters must be matched
return queryIdx === query.length ? score : 0;
return score;
}
/**
@@ -75,6 +75,95 @@ test('resolves the focused workspace session for global keybindings', () => {
});
});
test('uses the anchor chat owner when focus is missing (#4098 phase 2)', () => {
const splitWorkspace: Workspace = {
id: 'workspace-1',
title: 'Workspace',
anchorChatId: 'chat-2',
root: {
id: 'split-1',
type: 'split',
direction: 'vertical',
children: [
{ id: 'pane-1', type: 'pane', sessionId: 'session-1' },
{ id: 'pane-2', type: 'pane', sessionId: 'session-3' },
],
sizes: [1, 1],
},
};
const chatOwnerSessions: TerminalSession[] = [
...sessions,
{
id: 'session-3',
hostId: 'host-3',
hostLabel: 'Host 3',
username: 'root',
hostname: 'example.org',
status: 'connected',
protocol: 'ssh',
workspaceId: 'workspace-1',
},
];
assert.equal(
resolveActivePluginKeybindingContext({
activeTabId: 'workspace-1',
sessions: chatOwnerSessions,
workspaces: [splitWorkspace],
aiSessions: [
{ id: 'chat-2', scope: { type: 'terminal', targetId: 'session-3' }, updatedAt: 2 },
{ id: 'chat-1', scope: { type: 'terminal', targetId: 'session-1' }, updatedAt: 1 },
],
})['terminal.sessionId'],
// anchorChatId's chat is terminal-scoped to session-3, which wins even
// though session-1's chat is older and session-1 is the first member.
'session-3',
);
});
test('falls back to the most-recently-active member when there is no anchor (#4098 phase 2)', () => {
const splitWorkspace: Workspace = {
id: 'workspace-1',
title: 'Workspace',
root: {
id: 'split-1',
type: 'split',
direction: 'vertical',
children: [
{ id: 'pane-1', type: 'pane', sessionId: 'session-1' },
{ id: 'pane-2', type: 'pane', sessionId: 'session-3' },
],
sizes: [1, 1],
},
};
const chatOwnerSessions: TerminalSession[] = [
...sessions,
{
id: 'session-3',
hostId: 'host-3',
hostLabel: 'Host 3',
username: 'root',
hostname: 'example.org',
status: 'connected',
protocol: 'ssh',
workspaceId: 'workspace-1',
},
];
assert.equal(
resolveActivePluginKeybindingContext({
activeTabId: 'workspace-1',
sessions: chatOwnerSessions,
workspaces: [splitWorkspace],
aiSessions: [
{ id: 'chat-1', scope: { type: 'terminal', targetId: 'session-1' }, updatedAt: 1 },
{ id: 'chat-2', scope: { type: 'terminal', targetId: 'session-3' }, updatedAt: 9 },
],
})['terminal.sessionId'],
'session-3',
);
});
test('resolves standalone sessions and fails closed for non-terminal tabs', () => {
assert.equal(
resolveActivePluginKeybindingContext({ activeTabId: 'session-2', sessions, workspaces })['terminal.sessionId'],
@@ -89,3 +178,18 @@ test('resolves standalone sessions and fails closed for non-terminal tabs', () =
'netcatty.activeTabId': 'vault',
});
});
test('empty workspace does not target the detached owner of its anchored chat', () => {
const context = resolveActivePluginKeybindingContext({
activeTabId: 'workspace-1',
sessions: sessions.map((session) => ({ ...session, workspaceId: undefined })),
workspaces: [{ ...workspaces[0], focusedSessionId: undefined, anchorChatId: 'chat-1', root: {
id: 'empty', type: 'split', direction: 'vertical', children: [], sizes: [],
} }],
aiSessions: [{ id: 'chat-1', scope: { type: 'terminal', targetId: 'session-1' }, updatedAt: 1 }],
});
assert.equal(context['terminal.sessionId'], undefined);
assert.equal(context['host.id'], undefined);
assert.equal(context['workspace.id'], 'workspace-1');
});
@@ -1,4 +1,6 @@
import { collectSessionIds } from '../../domain/workspace';
import { resolveMostRecentlyActiveMemberTerminalId } from '../../domain/aiSessionRecency';
import type { AIChatRecencyEntry } from '../../domain/aiSessionRecency';
import type { TerminalSession, Workspace } from '../../types';
export type PluginContributionContextValue = string | boolean | number | null;
@@ -44,14 +46,17 @@ export function resolveActivePluginKeybindingContext({
activeTabId,
sessions,
workspaces,
aiSessions,
}: {
activeTabId: string;
sessions: readonly TerminalSession[];
workspaces: readonly Workspace[];
/** AI chat snapshot: resolves the workspace anchor and recency fallback (#4098 phase 2). */
aiSessions?: readonly AIChatRecencyEntry[];
}): PluginContributionContext {
const workspace = workspaces.find((candidate) => candidate.id === activeTabId);
const activeSessionId = workspace
? (workspace.focusedSessionId ?? collectSessionIds(workspace.root)[0])
? resolveWorkspaceActiveTerminalId(workspace, aiSessions)
: sessions.some((candidate) => candidate.id === activeTabId)
? activeTabId
: undefined;
@@ -72,3 +77,32 @@ export function resolveActivePluginKeybindingContext({
'netcatty.activeTabId': activeTabId,
};
}
/**
* Issue #4098 phase 2: the keybinding context's workspace session resolves by
* id, never positionally — focused member first, then the terminal member that
* owns the anchored chat, then the most-recently-active member by chat recency.
*/
function resolveWorkspaceActiveTerminalId(
workspace: Workspace,
aiSessions: readonly AIChatRecencyEntry[] | undefined,
): string | undefined {
if (workspace.focusedSessionId && collectSessionIds(workspace.root).includes(workspace.focusedSessionId)) {
return workspace.focusedSessionId;
}
const memberTerminalIds = collectSessionIds(workspace.root);
if (memberTerminalIds.length === 1) {
// A single-member workspace has exactly one candidate — not a positional
// pick among many.
return memberTerminalIds[0];
}
const anchorChat = workspace.anchorChatId
? aiSessions?.find((session) => session.id === workspace.anchorChatId && session.scope?.type === 'terminal')
: undefined;
const anchorOwner = anchorChat?.scope.targetId;
return (anchorOwner && memberTerminalIds.includes(anchorOwner) ? anchorOwner : undefined)
?? resolveMostRecentlyActiveMemberTerminalId({ memberTerminalIds, aiSessions })
?? undefined;
}
@@ -45,6 +45,7 @@ export type SessionSnapshotActions = Pick<
| 'resetWorkspaceRename'
| 'removeSessionFromWorkspace'
| 'setWorkspaceFocusedSession'
| 'setWorkspaceAnchorChat'
| 'toggleWorkspaceViewMode'
// Terminal / chrome mutators previously read only via App session runtime.
| 'createLocalTerminal'
@@ -56,6 +57,7 @@ export type SessionSnapshotActions = Pick<
| 'createWorkspaceWithHosts'
| 'createWorkspaceFromSessions'
| 'addSessionToWorkspace'
| 'moveWorkspacePane'
| 'appendHostToWorkspace'
| 'appendHostToWorkspaceConfirmed'
| 'appendLocalTerminalToWorkspace'
+96 -121
View File
@@ -1,12 +1,12 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { TerminalSession, Workspace } from "../../domain/models";
import type { AIChatRecencyEntry } from "../../domain/aiSessionRecency";
import {
applyCloseSessionToSessions,
closeSessionsState,
closeSessionWorkspaceLayoutState,
detachSessionFromWorkspaceState,
replaceDissolvedWorkspaceTabOrder,
resolveActiveTabAfterCloseSession,
} from "./sessionWorkspaceDetach";
@@ -14,11 +14,19 @@ const session = (id: string, workspaceId = "ws-1"): TerminalSession => ({
id,
hostId: id,
hostLabel: id,
username: "user",
hostname: id,
status: "connected",
workspaceId,
});
const workspace = (sessionIds: string[]): Workspace => ({
const chat = (id: string, targetId: string, updatedAt: number): AIChatRecencyEntry => ({
id,
scope: { type: "terminal", targetId },
updatedAt,
});
const workspace = (sessionIds: string[], overrides: Partial<Workspace> = {}): Workspace => ({
id: "ws-1",
title: "Workspace",
focusedSessionId: sessionIds[0],
@@ -36,9 +44,17 @@ const workspace = (sessionIds: string[]): Workspace => ({
})),
sizes: sessionIds.map(() => 1),
},
...overrides,
});
test("detach dissolves the original workspace when one session remains", () => {
const rootPaneSessionIds = (workspace: Workspace): string[] => (
workspace.root.type === "pane"
? [workspace.root.sessionId]
: workspace.root.children.map((child) => child.type === "pane" ? child.sessionId : "")
.filter((id): id is string => Boolean(id))
);
test("detaching to one member keeps the workspace alive (#4098 phase 2)", () => {
const result = detachSessionFromWorkspaceState({
sessions: [session("s1"), session("s2")],
workspaces: [workspace(["s1", "s2"])],
@@ -49,149 +65,126 @@ test("detach dissolves the original workspace when one session remains", () => {
assert.equal(result.activeTabId, "s1");
assert.deepEqual(result.sessions.map((s) => [s.id, s.workspaceId]), [
["s1", undefined],
["s2", undefined],
// The survivor keeps its workspace binding — no orphan promotion.
["s2", "ws-1"],
]);
assert.equal(result.workspaces.length, 0);
assert.equal(result.dissolvedWorkspaceId, "ws-1");
assert.deepEqual(result.replacementTabIds, ["s1", "s2"]);
assert.equal(result.workspaces.length, 1);
assert.equal(result.workspaces[0].id, "ws-1");
assert.deepEqual(rootPaneSessionIds(result.workspaces[0]), ["s2"]);
});
test("detach preserves the other sessions in a multi-pane workspace", () => {
test("detaching the last member keeps the workspace with an empty root (#4098 phase 2)", () => {
const result = detachSessionFromWorkspaceState({
sessions: [session("s1"), session("s2"), session("s3")],
workspaces: [workspace(["s1", "s2", "s3"])],
sessionId: "s2",
sessions: [session("s1")],
workspaces: [workspace(["s1"])],
sessionId: "s1",
});
assert.equal(result.changed, true);
assert.deepEqual(result.sessions.map((s) => [s.id, s.workspaceId]), [
["s1", "ws-1"],
["s2", undefined],
["s3", "ws-1"],
]);
assert.deepEqual(result.workspaces[0].focusSessionOrder, ["s1", "s3"]);
assert.equal(result.workspaces[0].focusedSessionId, "s1");
assert.equal(result.workspaces.length, 1);
assert.equal(result.workspaces[0].id, "ws-1");
assert.equal(result.workspaces[0].root.type, "split");
assert.deepEqual(
result.workspaces[0].root.type === "split"
? result.workspaces[0].root.children.map((child) => child.type === "pane" ? child.sessionId : null)
: [],
["s1", "s3"],
result.workspaces[0].root.type === "split" ? result.workspaces[0].root.children : null,
[],
);
assert.equal(result.workspaces[0].focusedSessionId, undefined);
});
test("dissolved workspace replacement preserves its tab position", () => {
assert.deepEqual(
replaceDissolvedWorkspaceTabOrder(["log-1", "ws-1", "session-3"], "ws-1", ["s1", "s2"]),
["log-1", "s1", "s2", "session-3"],
);
test("closing one member keeps the workspace and its surviving member bound (#4098 phase 2)", () => {
const result = closeSessionsState({
sessions: [session("s1"), session("s2")],
workspaces: [workspace(["s1", "s2"])],
sessionIds: ["s1"],
currentActiveTabId: "ws-1",
tabOrder: ["ws-1"],
});
assert.equal(result.workspaces.length, 1);
assert.equal(result.workspaces[0].id, "ws-1");
assert.deepEqual(result.sessions.map((s) => [s.id, s.workspaceId]), [["s2", "ws-1"]]);
// The workspace tab is still live, so the active tab does not move.
assert.equal(result.activeTabId, undefined);
});
test("dissolved workspace replacement removes duplicate replacement ids", () => {
assert.deepEqual(
replaceDissolvedWorkspaceTabOrder(["s1", "ws-1", "session-3"], "ws-1", ["s1", "s2"]),
["s1", "s2", "session-3"],
);
});
test("dissolved workspace replacement is idempotent", () => {
const once = replaceDissolvedWorkspaceTabOrder(["log-1", "ws-1", "session-3"], "ws-1", ["s1", "s2"]);
assert.deepEqual(
replaceDissolvedWorkspaceTabOrder(once, "ws-1", ["s1", "s2"]),
once,
);
});
test("single remaining session preserves dissolved workspace tab position", () => {
assert.deepEqual(
replaceDissolvedWorkspaceTabOrder(["log-1", "ws-1", "session-3"], "ws-1", ["s2"]),
["log-1", "s2", "session-3"],
);
});
test("closing a workspace session dissolves the workspace when one terminal remains", () => {
const result = closeSessionWorkspaceLayoutState([workspace(["s1", "s2"])], "ws-1", "s1");
assert.equal(result.dissolvedWorkspaceId, "ws-1");
assert.equal(result.lastRemainingSessionId, "s2");
assert.deepEqual(result.workspaces, []);
assert.deepEqual(
replaceDissolvedWorkspaceTabOrder(
["log-1", result.dissolvedWorkspaceId!, "session-3"],
result.dissolvedWorkspaceId,
result.lastRemainingSessionId ? [result.lastRemainingSessionId] : undefined,
),
["log-1", "s2", "session-3"],
);
});
test("closing the focused session hands focus to a remaining session (#3046)", () => {
test("member loss hands focus to the most-recently-active survivor, not sessions[0]", () => {
const result = closeSessionWorkspaceLayoutState(
[workspace(["s1", "s2", "s3", "s4"])],
[workspace(["s1", "s2", "s3"])],
"ws-1",
"s1",
[chat("chat-s2", "s2", 4), chat("chat-s3", "s3", 9)],
);
assert.equal(result.workspaces.length, 1);
assert.equal(result.workspaces[0].focusedSessionId, "s2");
assert.deepEqual(result.workspaces[0].focusSessionOrder, ["s2", "s3", "s4"]);
// remainingSessionIds[0] is s2, but s3's chat is the most recent.
assert.equal(result.workspaces[0].focusedSessionId, "s3");
});
test("closing a non-focused session keeps the focused session", () => {
test("reordering focus order does not change the survivor retarget", () => {
const result = closeSessionWorkspaceLayoutState(
[workspace(["s1", "s2", "s3", "s4"])],
[workspace(["s1", "s2", "s3"], { focusSessionOrder: ["s3", "s2", "s1"] })],
"ws-1",
"s1",
[chat("chat-s2", "s2", 4), chat("chat-s3", "s3", 9)],
);
assert.equal(result.workspaces[0].focusedSessionId, "s3");
});
test("closing a non-focused member keeps the focused member", () => {
const result = closeSessionWorkspaceLayoutState(
[workspace(["s1", "s2", "s3"])],
"ws-1",
"s3",
);
assert.equal(result.workspaces.length, 1);
assert.equal(result.workspaces[0].focusedSessionId, "s1");
assert.deepEqual(result.workspaces[0].focusSessionOrder, ["s1", "s2", "s4"]);
assert.deepEqual(result.workspaces[0].focusSessionOrder, ["s1", "s2"]);
});
test("closing one split pane keeps the other terminal as an orphan tab", () => {
const layoutResult = closeSessionWorkspaceLayoutState(
test("with no AI recency signal focus stays on a surviving terminal", () => {
const result = closeSessionWorkspaceLayoutState(
[workspace(["s1", "s2"])],
"ws-1",
"s1",
);
const nextSessions = applyCloseSessionToSessions(
[session("s1"), session("s2")],
"s1",
layoutResult,
);
assert.deepEqual(nextSessions.map((s) => [s.id, s.workspaceId]), [
["s2", undefined],
]);
assert.equal(
resolveActiveTabAfterCloseSession({
currentActiveTabId: "ws-1",
closedSessionId: "s1",
workspaceId: "ws-1",
layoutResult,
remainingSessions: nextSessions,
preferredTabId: "s-outside",
}),
"s2",
);
assert.equal(result.workspaces.length, 1);
assert.equal(result.workspaces[0].focusedSessionId, "s2");
});
test("closing the last workspace session does not invent a surviving terminal", () => {
test("closing ALL members keeps the workspace alive with an empty root (#4098 phase 2)", () => {
const result = closeSessionsState({
sessions: [session("s1"), session("s2")],
workspaces: [workspace(["s1", "s2"])],
sessionIds: ["s1", "s2"],
currentActiveTabId: "ws-1",
tabOrder: ["ws-1"],
});
assert.deepEqual(result.sessions, []);
assert.equal(result.workspaces.length, 1);
assert.equal(result.workspaces[0].id, "ws-1");
assert.equal(result.workspaces[0].root.type, "split");
assert.deepEqual(
result.workspaces[0].root.type === "split" ? result.workspaces[0].root.children : null,
[],
);
// The workspace tab survives, so the active tab is left alone.
assert.equal(result.activeTabId, undefined);
});
test("closing a solo workspace member still replaces the closed tab's activity via fallbacks", () => {
const layoutResult = closeSessionWorkspaceLayoutState(
[workspace(["s1"])],
"ws-1",
"s1",
);
const nextSessions = applyCloseSessionToSessions(
[session("s1")],
"s1",
layoutResult,
);
const nextSessions = applyCloseSessionToSessions([session("s1")], "s1");
assert.deepEqual(nextSessions, []);
assert.equal(layoutResult.removedWorkspaceId, "ws-1");
assert.equal(layoutResult.lastRemainingSessionId, undefined);
// The workspace tab survives, so an active workspace tab needs no fallback.
assert.equal(
resolveActiveTabAfterCloseSession({
currentActiveTabId: "ws-1",
@@ -200,25 +193,7 @@ test("closing the last workspace session does not invent a surviving terminal",
layoutResult,
remainingSessions: nextSessions,
}),
"vault",
);
});
test("closing the last visible solo tab does not fall back to a hidden MCP session", () => {
const visibleSession: TerminalSession = { ...session("s1", undefined) };
const hiddenSession: TerminalSession = { ...session("s2", undefined), hiddenFromTabs: true };
const layoutResult = closeSessionWorkspaceLayoutState([], undefined, "s1");
const remainingSessions = [hiddenSession];
assert.equal(
resolveActiveTabAfterCloseSession({
currentActiveTabId: visibleSession.id,
closedSessionId: "s1",
workspaceId: undefined,
layoutResult,
remainingSessions,
}),
"vault",
null,
);
});
+74 -126
View File
@@ -1,20 +1,17 @@
import type { TerminalSession, Workspace } from "../../domain/models";
import { collectSessionIds, pruneWorkspaceNode } from "../../domain/workspace";
import type { AIChatRecencyEntry } from "../../domain/aiSessionRecency";
import { resolveMostRecentlyActiveMemberTerminalId } from "../../domain/aiSessionRecency";
import { collectSessionIds, createEmptyWorkspaceRoot, pruneWorkspaceNode } from "../../domain/workspace";
export type DetachSessionFromWorkspaceStateResult = {
changed: boolean;
sessions: TerminalSession[];
workspaces: Workspace[];
activeTabId?: string;
dissolvedWorkspaceId?: string;
replacementTabIds?: string[];
};
export type CloseSessionWorkspaceLayoutResult = {
workspaces: Workspace[];
removedWorkspaceId?: string;
dissolvedWorkspaceId?: string;
lastRemainingSessionId?: string;
};
export type CloseSessionsStateResult = {
@@ -28,82 +25,65 @@ type DetachSessionFromWorkspaceStateOptions = {
sessions: TerminalSession[];
workspaces: Workspace[];
sessionId: string;
/**
* AI chat store snapshot used to rank surviving members by chat recency.
* Without a recency signal, terminal display still needs a live member.
*/
aiSessions?: readonly AIChatRecencyEntry[];
};
export function replaceDissolvedWorkspaceTabOrder(
tabOrder: readonly string[],
workspaceId: string | undefined,
replacementTabIds: readonly string[] | undefined,
): string[] {
if (!workspaceId || !replacementTabIds?.length) return [...tabOrder];
const uniqueReplacementIds = replacementTabIds.filter((tabId, index, list) => (
tabId && list.indexOf(tabId) === index
));
if (uniqueReplacementIds.length === 0) return [...tabOrder];
if (!tabOrder.includes(workspaceId)) {
const hasAllReplacementIds = uniqueReplacementIds.every((tabId) => tabOrder.includes(tabId));
return hasAllReplacementIds ? [...tabOrder] : [
...tabOrder,
...uniqueReplacementIds.filter((tabId) => !tabOrder.includes(tabId)),
];
/**
* Resolve the member that should receive focus (or an anchor retarget) after a
* member loss. Prefers the AI-chat recency winner; falls back to the still-
* valid preferred (focused) member because that is explicit user intent, and
* falls back to a surviving pane so focus mode never hides all terminals.
*/
const resolveSurvivingFocusMemberId = (
remainingSessionIds: readonly string[],
previousFocusedSessionId: string | undefined,
aiSessions: readonly AIChatRecencyEntry[] | undefined,
): string | undefined => {
if (previousFocusedSessionId && remainingSessionIds.includes(previousFocusedSessionId)) {
return previousFocusedSessionId;
}
const replacementIdSet = new Set(uniqueReplacementIds);
let inserted = false;
const nextOrder: string[] = [];
for (const tabId of tabOrder) {
if (tabId === workspaceId) {
if (!inserted) {
nextOrder.push(...uniqueReplacementIds);
inserted = true;
}
continue;
}
if (!replacementIdSet.has(tabId)) {
nextOrder.push(tabId);
}
}
return nextOrder;
}
return resolveMostRecentlyActiveMemberTerminalId({
memberTerminalIds: remainingSessionIds,
aiSessions,
}) ?? remainingSessionIds[0];
};
export function closeSessionWorkspaceLayoutState(
workspaces: readonly Workspace[],
workspaceId: string | undefined,
sessionId: string,
aiSessions?: readonly AIChatRecencyEntry[],
): CloseSessionWorkspaceLayoutResult {
if (!workspaceId) return { workspaces: [...workspaces] };
let removedWorkspaceId: string | undefined;
let dissolvedWorkspaceId: string | undefined;
let lastRemainingSessionId: string | undefined;
const nextWorkspaces = workspaces
.map((workspace) => {
if (workspace.id !== workspaceId) return workspace;
const prunedRoot = pruneWorkspaceNode(workspace.root, sessionId);
if (!prunedRoot) {
removedWorkspaceId = workspace.id;
return null;
// Issue #4098 phase 2: the last member closed, but the workspace is a
// first-class entity — it survives with an empty root so its id (and
// the AI chats anchored to it) survive restart and member churn.
return {
...workspace,
root: createEmptyWorkspaceRoot(),
focusedSessionId: undefined,
focusSessionOrder: [],
};
}
const remainingSessionIds = collectSessionIds(prunedRoot);
if (remainingSessionIds.length === 1) {
dissolvedWorkspaceId = workspace.id;
lastRemainingSessionId = remainingSessionIds[0];
return null;
}
// Closing the focused session must hand focus to a remaining one, or a
// focus-mode workspace is left with a dangling focusedSessionId: no pane
// qualifies as visible and the continuously rendered hidden panes all
// paint on top of each other (#3046).
const nextFocusedSessionId =
workspace.focusedSessionId && !remainingSessionIds.includes(workspace.focusedSessionId)
? remainingSessionIds[0]
: workspace.focusedSessionId;
// Closing the focused session hands focus to the most-recently-active
// surviving member when AI chat recency is available.
const nextFocusedSessionId = resolveSurvivingFocusMemberId(
remainingSessionIds,
workspace.focusedSessionId,
aiSessions,
);
const nextFocusSessionOrder = workspace.focusSessionOrder?.filter(
(candidateId) => remainingSessionIds.includes(candidateId),
);
@@ -114,36 +94,23 @@ export function closeSessionWorkspaceLayoutState(
focusedSessionId: nextFocusedSessionId,
focusSessionOrder: nextFocusSessionOrder,
};
})
.filter((workspace): workspace is Workspace => Boolean(workspace));
});
return {
workspaces: nextWorkspaces,
removedWorkspaceId,
dissolvedWorkspaceId,
lastRemainingSessionId,
};
}
/**
* Apply a close-session action to the session list using the layout result.
* When a 2-pane workspace dissolves to one terminal, that remaining session
* must become an orphan tab — not stay bound to a deleted workspaceId.
* Since issue #4098 phase 2 workspaces survive member loss (no dissolve), the
* remaining members keep their workspace binding.
*/
export function applyCloseSessionToSessions(
sessions: readonly TerminalSession[],
sessionId: string,
layoutResult: Pick<CloseSessionWorkspaceLayoutResult, "lastRemainingSessionId">,
): TerminalSession[] {
const remaining = sessions.filter((session) => session.id !== sessionId);
const lastRemainingSessionId = layoutResult.lastRemainingSessionId;
if (!lastRemainingSessionId) return remaining;
return remaining.map((session) => (
session.id === lastRemainingSessionId
? { ...session, workspaceId: undefined }
: session
));
return sessions.filter((session) => session.id !== sessionId);
}
export function isPreferredTabAvailableAfterClose(
@@ -161,12 +128,6 @@ export function isPreferredTabAvailableAfterClose(
} {
const { preferredTabId, closedSessionId, layoutResult, remainingSessions } = input;
if (!preferredTabId || preferredTabId === closedSessionId) return false;
if (
preferredTabId === layoutResult.dissolvedWorkspaceId
|| preferredTabId === layoutResult.removedWorkspaceId
) {
return false;
}
if (preferredTabId === "vault" || preferredTabId === "sftp") return true;
if (layoutResult.workspaces.some((workspace) => workspace.id === preferredTabId)) return true;
return remainingSessions.some((session) => (
@@ -179,7 +140,6 @@ export function isPreferredTabAvailableAfterClose(
export function resolveActiveTabAfterCloseSession({
currentActiveTabId,
closedSessionId,
workspaceId,
layoutResult,
remainingSessions,
preferredTabId,
@@ -202,18 +162,14 @@ export function resolveActiveTabAfterCloseSession({
const preferredFallback = isPreferredTabAvailableAfterClose(preferredInput)
? preferredInput.preferredTabId
: undefined;
const fallback = layoutResult.lastRemainingSessionId
?? preferredFallback
const fallback = preferredFallback
?? fallbackWorkspace?.id
?? fallbackSolo?.id
?? "vault";
if (
currentActiveTabId === closedSessionId
|| (layoutResult.dissolvedWorkspaceId && currentActiveTabId === layoutResult.dissolvedWorkspaceId)
|| (layoutResult.removedWorkspaceId && currentActiveTabId === layoutResult.removedWorkspaceId)
|| (workspaceId && currentActiveTabId === workspaceId && !layoutResult.workspaces.some((ws) => ws.id === workspaceId))
) {
// Workspaces survive member loss (#4098 phase 2), so only a closed session
// tab that was active needs a replacement.
if (currentActiveTabId === closedSessionId) {
return fallback;
}
@@ -227,6 +183,7 @@ export function closeSessionsState({
currentActiveTabId,
preferredTabId,
tabOrder,
aiSessions,
}: {
sessions: readonly TerminalSession[];
workspaces: readonly Workspace[];
@@ -234,6 +191,7 @@ export function closeSessionsState({
currentActiveTabId: string | null;
preferredTabId?: string | null;
tabOrder: readonly string[];
aiSessions?: readonly AIChatRecencyEntry[];
}): CloseSessionsStateResult {
let nextSessions = [...sessions];
let nextWorkspaces = [...workspaces];
@@ -250,17 +208,10 @@ export function closeSessionsState({
nextWorkspaces,
workspaceId,
sessionId,
aiSessions,
);
nextWorkspaces = layoutResult.workspaces;
nextSessions = applyCloseSessionToSessions(nextSessions, sessionId, layoutResult);
if (layoutResult.dissolvedWorkspaceId && layoutResult.lastRemainingSessionId) {
nextTabOrder = replaceDissolvedWorkspaceTabOrder(
nextTabOrder,
layoutResult.dissolvedWorkspaceId,
[layoutResult.lastRemainingSessionId],
);
}
nextSessions = applyCloseSessionToSessions(nextSessions, sessionId);
const activeTabAfterClose = resolveActiveTabAfterCloseSession({
currentActiveTabId: nextActiveTabId,
@@ -288,6 +239,7 @@ export function detachSessionFromWorkspaceState({
sessions,
workspaces,
sessionId,
aiSessions,
}: DetachSessionFromWorkspaceStateOptions): DetachSessionFromWorkspaceStateResult {
const session = sessions.find((candidate) => candidate.id === sessionId);
if (!session?.workspaceId) {
@@ -301,40 +253,36 @@ export function detachSessionFromWorkspaceState({
}
const prunedRoot = pruneWorkspaceNode(targetWorkspace.root, sessionId);
let nextSessions = sessions.map((candidate) => (
const nextSessions = sessions.map((candidate) => (
candidate.id === sessionId ? { ...candidate, workspaceId: undefined } : candidate
));
if (!prunedRoot) {
// Last member dragged out: the workspace entity survives with an empty
// root (#4098 phase 2) so its id and anchored AI chats stay stable.
return {
changed: true,
sessions: nextSessions,
workspaces: workspaces.filter((workspace) => workspace.id !== workspaceId),
workspaces: workspaces.map((workspace) => (
workspace.id === workspaceId
? {
...workspace,
root: createEmptyWorkspaceRoot(),
focusedSessionId: undefined,
focusSessionOrder: [],
}
: workspace
)),
activeTabId: sessionId,
dissolvedWorkspaceId: workspaceId,
replacementTabIds: [sessionId],
};
}
const remainingSessionIds = collectSessionIds(prunedRoot);
if (remainingSessionIds.length === 1) {
nextSessions = nextSessions.map((candidate) => (
candidate.id === remainingSessionIds[0] ? { ...candidate, workspaceId: undefined } : candidate
));
return {
changed: true,
sessions: nextSessions,
workspaces: workspaces.filter((workspace) => workspace.id !== workspaceId),
activeTabId: sessionId,
dissolvedWorkspaceId: workspaceId,
replacementTabIds: [sessionId, ...remainingSessionIds],
};
}
const nextFocusedSessionId = remainingSessionIds.includes(targetWorkspace.focusedSessionId)
? targetWorkspace.focusedSessionId
: remainingSessionIds[0];
const nextFocusedSessionId = resolveSurvivingFocusMemberId(
remainingSessionIds,
targetWorkspace.focusedSessionId,
aiSessions,
);
const nextFocusSessionOrder = (targetWorkspace.focusSessionOrder ?? [])
.filter((candidateId, index, list) => (
candidateId !== sessionId &&
@@ -0,0 +1,89 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { SftpPane } from "./types";
import type { SftpArchiveCreationResult } from "../../../domain/sftpArchiveCreation";
import { createSftpArchiveForPane } from "./createSftpArchive.ts";
function setup() {
let pane = {
id: "pane-a", connection: { id: "connection-a", isLocal: false }, filenameEncoding: "gb18030",
} as SftpPane;
const calls: unknown[][] = [];
const refreshes: string[] = [];
const params = {
getPane: () => pane,
target: { paneId: "pane-a", connectionId: "connection-a" },
options: { directory: "/srv", paths: ["中文", "logs"], archiveName: "backup.zip", format: "zip" as const },
getSftpIdForConnection: (id: string) => id === "connection-a" ? "sftp-a" : undefined,
bridge: {
createSftpArchive: async (...args: unknown[]): Promise<SftpArchiveCreationResult> => {
calls.push(args);
return { success: true };
},
},
refresh: async (id: string) => { refreshes.push(id); },
t: (key: string) => key,
};
return { params, calls, refreshes, setPane: (value: SftpPane) => { pane = value; } };
}
test("archive creation passes selected paths and encoding to the captured remote session", async () => {
const { params, calls, refreshes } = setup();
assert.deepEqual(await createSftpArchiveForPane(params), { success: true });
assert.deepEqual(calls, [["sftp-a", params.options, "gb18030"]]);
assert.deepEqual(refreshes, ["pane-a"]);
});
test("a switched or disconnected pane cannot compress files on another host", async () => {
for (const change of [
{ id: "pane-b" },
{ connection: { id: "connection-b", isLocal: false } },
{ connection: null },
]) {
const { params, calls, setPane } = setup();
setPane({ ...params.getPane(), ...change } as SftpPane);
await assert.rejects(createSftpArchiveForPane(params), /connectionChanged/);
assert.equal(calls.length, 0);
}
});
test("archive creation requires a remote session and a supported bridge", async () => {
const { params, calls, setPane } = setup();
await assert.rejects(createSftpArchiveForPane({ ...params, bridge: undefined }), /unavailable/);
await assert.rejects(createSftpArchiveForPane({ ...params, getSftpIdForConnection: () => undefined }), /unavailable/);
setPane({ ...params.getPane(), connection: { id: "connection-a", isLocal: true } } as SftpPane);
await assert.rejects(createSftpArchiveForPane(params), /unavailable/);
assert.equal(calls.length, 0);
});
test("existing archives are returned for confirmation without retry or refresh", async () => {
const { params, refreshes } = setup();
const conflict = { success: false, code: "EEXIST", error: "Already exists" };
params.bridge.createSftpArchive = async () => conflict;
assert.equal(await createSftpArchiveForPane(params), conflict);
assert.deepEqual(refreshes, []);
});
test("finishing after a tab switch does not refresh the new connection", async () => {
const { params, refreshes, setPane } = setup();
params.bridge.createSftpArchive = async () => {
setPane({ ...params.getPane(), id: "pane-b" } as SftpPane);
return { success: true };
};
assert.deepEqual(await createSftpArchiveForPane(params), { success: true });
assert.deepEqual(refreshes, []);
});
test("a failed operation is not refreshed or converted to success", async () => {
const { params, refreshes } = setup();
params.bridge.createSftpArchive = async () => { throw new Error("zip is not installed"); };
await assert.rejects(createSftpArchiveForPane(params), /zip is not installed/);
assert.deepEqual(refreshes, []);
});
test("a listing error after publication does not invite a second archive creation", async () => {
const { params, calls } = setup();
params.refresh = async () => { throw new Error("Disconnected after publication"); };
assert.deepEqual(await createSftpArchiveForPane(params), { success: true });
assert.equal(calls.length, 1);
});
@@ -0,0 +1,46 @@
import type {
SftpArchiveCreationOptions,
SftpArchiveCreationResult,
SftpArchiveCreationTarget,
} from "../../../domain/sftpArchiveCreation";
import type { SftpPane } from "./types";
import { logger } from "../../../lib/logger";
interface CreateSftpArchiveParams {
getPane: () => Pick<SftpPane, "id" | "connection" | "filenameEncoding">;
target: SftpArchiveCreationTarget;
options: SftpArchiveCreationOptions;
bridge: Pick<NetcattyBridge, "createSftpArchive"> | undefined;
getSftpIdForConnection?: (connectionId: string) => string | undefined;
refresh: (paneId: string) => Promise<void>;
t: (key: string) => string;
}
/** Bind the selected files to the pane/connection that opened the dialog. */
export async function createSftpArchiveForPane({
getPane, target, options, bridge, getSftpIdForConnection, refresh, t,
}: CreateSftpArchiveParams): Promise<SftpArchiveCreationResult> {
const pane = getPane();
if (pane.id !== target.paneId || pane.connection?.id !== target.connectionId) {
throw new Error(t("sftp.archive.connectionChanged"));
}
const sftpId = getSftpIdForConnection?.(target.connectionId);
if (pane.connection.isLocal || !sftpId || !bridge?.createSftpArchive) {
throw new Error(t("sftp.archive.unavailable"));
}
const result = await bridge.createSftpArchive(sftpId, options, pane.filenameEncoding);
if (!result.success) return result;
// A tab switch during compression must not refresh an unrelated connection.
const current = getPane();
if (current.id === target.paneId && current.connection?.id === target.connectionId) {
try {
await refresh(target.paneId);
} catch (error) {
// The archive has already been published. A listing failure must not
// invite the user to repeat a successful operation (or overwrite it).
logger.warn("[SFTP] Archive created but directory refresh failed:", error);
}
}
return result;
}
@@ -0,0 +1,131 @@
/** @test issue #4132 — a one-click duplicate copies next to the source without a conflict prompt */
import assert from "node:assert/strict";
import fs from "node:fs/promises";
import path from "node:path";
import test from "node:test";
import React from "react";
import { act, create, type ReactTestRenderer } from "react-test-renderer";
import { useSftpTransfers } from "./useSftpTransfers";
import { createEmptyPane } from "./types";
import { sftpTransferCenterStore } from "../sftpTransferCenterStore";
test("duplicate transfers resolve same-name conflicts to (copy) without prompting", async () => {
const dir = await fs.mkdtemp(path.join(process.cwd(), ".duplicate-entries-"));
const ownerId = `duplicate-entries-${crypto.randomUUID()}`;
const previousWindow = globalThis.window;
const previousStorage = globalThis.localStorage;
const globals = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
const previousAct = globals.IS_REACT_ACT_ENVIRONMENT;
globals.IS_REACT_ACT_ENVIRONMENT = true;
let renderer: ReactTestRenderer | undefined;
let ops!: ReturnType<typeof useSftpTransfers>;
// A duplicate has to cover both files and folders: the conflict type key
// differs between the two and the seeded defaults must match both.
const fileNames = ["report.txt"];
const folderNames = ["photos"];
for (const name of fileNames) {
await fs.writeFile(path.join(dir, name), "original data");
}
for (const name of folderNames) {
await fs.mkdir(path.join(dir, name));
await fs.writeFile(path.join(dir, name, "nested.txt"), "nested data");
}
const pane = createEmptyPane("duplicate-pane");
pane.connection = { id: ownerId, isLocal: true, currentPath: dir, status: "connected" } as NonNullable<typeof pane.connection>;
const statPath = async (value: string) => {
try {
const stat = await fs.lstat(value);
return { type: stat.isSymbolicLink() ? "symlink" : stat.isDirectory() ? "directory" : "file", size: stat.size, lastModified: stat.mtimeMs };
} catch {
return null;
}
};
(globalThis as { window?: unknown }).window = { requestAnimationFrame: (callback: () => void) => setTimeout(callback, 0), cancelAnimationFrame: clearTimeout, netcatty: {
realpathLocal: fs.realpath, statLocal: statPath, lstatLocal: statPath,
mkdirLocal: async (value: string) => { await fs.mkdir(value, { recursive: true }); },
deleteLocalFile: async (value: string) => { await fs.rm(value, { recursive: true, force: true }); },
renameLocalFile: async (source: string, target: string) => { await fs.rename(source, target); },
startStreamTransfer: async (options: { sourcePath: string; targetPath: string; transferId: string }) => {
await fs.copyFile(options.sourcePath, options.targetPath);
sftpTransferCenterStore.ingestBackgroundEvent({ type: "completed", transferId: options.transferId, transferred: 13, totalBytes: 13, lifecycleEpoch: 0 });
return {};
},
} };
(globalThis as { localStorage?: unknown }).localStorage = { getItem: () => null, setItem: () => undefined, removeItem: () => undefined };
function Probe() {
ops = useSftpTransfers({
ownerId, getActivePane: () => pane, getPaneByConnectionId: () => pane, getTabByConnectionId: () => ({ side: "left", tabId: pane.id, pane }),
updateTab: () => undefined, refresh: async () => undefined,
clearCacheForConnection: () => undefined, handleSessionError: () => undefined,
sftpSessionsRef: { current: new Map() }, connectionCacheKeyMapRef: { current: new Map() },
listRemoteFiles: async () => [],
listLocalFiles: async (value: string) => Promise.all((await fs.readdir(value)).map(async name => {
const stat = await fs.lstat(path.join(value, name));
return { name, type: stat.isSymbolicLink() ? "symlink" as const : stat.isDirectory() ? "directory" as const : "file" as const, size: stat.size, sizeFormatted: "", lastModified: stat.mtimeMs, lastModifiedFormatted: "" };
})),
});
return null;
}
const waitForSettledRoots = async (expected: number) => {
await act(async () => {
const deadline = Date.now() + 5000;
while (Date.now() < deadline) {
const roots = sftpTransferCenterStore.getOwnerTasks(ownerId).filter(row => !row.parentTaskId);
if (roots.length === expected && roots.every(row => ["completed", "failed"].includes(row.status))) return;
await new Promise(resolve => setTimeout(resolve, 20));
}
});
};
try {
await act(async () => { renderer = create(React.createElement(Probe)); });
const sources = [...fileNames, ...folderNames].map(name => ({ name, isDirectory: folderNames.includes(name) }));
await act(async () => {
await ops.startTransfer(sources, "left", "left", { sourcePath: dir, targetPath: dir, duplicate: true });
});
// The duplicate option must resolve conflicts by itself; a prompt here
// would defeat the one-click action the issue asks for.
assert.deepEqual(ops.conflicts, []);
await waitForSettledRoots(sources.length);
const roots = sftpTransferCenterStore.getOwnerTasks(ownerId).filter(row => !row.parentTaskId);
assert.equal(roots.length, sources.length, JSON.stringify(roots.map(row => row.fileName)));
assert.ok(roots.every(row => row.status === "completed"), JSON.stringify(roots.map(row => ({ status: row.status, error: row.error }))));
for (const name of fileNames) {
assert.equal(await fs.readFile(path.join(dir, name), "utf8"), "original data");
}
assert.equal(await fs.readFile(path.join(dir, "report (copy).txt"), "utf8"), "original data");
assert.equal(await fs.readFile(path.join(dir, "photos (copy)", "nested.txt"), "utf8"), "nested data");
// A second duplicate must pick the next free name instead of colliding
// with the copy created by the first run.
await act(async () => {
await ops.startTransfer(sources, "left", "left", { sourcePath: dir, targetPath: dir, duplicate: true });
});
assert.deepEqual(ops.conflicts, []);
await waitForSettledRoots(sources.length * 2);
assert.equal(await fs.readFile(path.join(dir, "report (copy 2).txt"), "utf8"), "original data");
assert.equal(await fs.readFile(path.join(dir, "photos (copy 2)", "nested.txt"), "utf8"), "nested data");
// The tree's ".." navigation row must never be duplicated: it would copy
// an ancestor into itself.
const tasksBefore = sftpTransferCenterStore.getOwnerTasks(ownerId).length;
let navigationResults: unknown;
await act(async () => {
navigationResults = await ops.startTransfer(
[{ name: "..", isDirectory: true }],
"left",
"left",
{ sourcePath: dir, targetPath: dir, duplicate: true },
);
});
assert.deepEqual(navigationResults, []);
assert.equal(sftpTransferCenterStore.getOwnerTasks(ownerId).length, tasksBefore);
assert.deepEqual(ops.conflicts, []);
} finally {
await act(async () => { renderer?.unmount(); });
for (const row of sftpTransferCenterStore.getOwnerTasks(ownerId)) sftpTransferCenterStore.dismiss(row.id);
(globalThis as { window?: unknown }).window = previousWindow;
(globalThis as { localStorage?: unknown }).localStorage = previousStorage;
globals.IS_REACT_ACT_ENVIRONMENT = previousAct;
await fs.rm(dir, { recursive: true, force: true });
}
});
@@ -0,0 +1,109 @@
/** @test issue #4132 — a duplicate must not reuse a destination an earlier copy still holds */
import assert from "node:assert/strict";
import fs from "node:fs/promises";
import path from "node:path";
import test from "node:test";
import React from "react";
import { act, create, type ReactTestRenderer } from "react-test-renderer";
import { useSftpTransfers } from "./useSftpTransfers";
import { createEmptyPane } from "./types";
import { sftpTransferCenterStore } from "../sftpTransferCenterStore";
test("duplicate names around a destination an in-flight copy has not created yet", async () => {
const dir = await fs.mkdtemp(path.join(process.cwd(), ".duplicate-inflight-"));
const ownerId = `duplicate-inflight-${crypto.randomUUID()}`;
const previousWindow = globalThis.window;
const previousStorage = globalThis.localStorage;
const globals = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
const previousAct = globals.IS_REACT_ACT_ENVIRONMENT;
globals.IS_REACT_ACT_ENVIRONMENT = true;
let renderer: ReactTestRenderer | undefined;
let ops!: ReturnType<typeof useSftpTransfers>;
let releaseFirst: (() => void) | undefined;
const firstCopyGate = new Promise<void>((resolve) => { releaseFirst = resolve; });
let streamStarts = 0;
await fs.writeFile(path.join(dir, "report.txt"), "original data");
const pane = createEmptyPane("duplicate-inflight-pane");
pane.connection = { id: ownerId, isLocal: true, currentPath: dir, status: "connected" } as NonNullable<typeof pane.connection>;
const statPath = async (value: string) => {
try {
const stat = await fs.lstat(value);
return { type: stat.isSymbolicLink() ? "symlink" : stat.isDirectory() ? "directory" : "file", size: stat.size, lastModified: stat.mtimeMs };
} catch {
return null;
}
};
(globalThis as { window?: unknown }).window = { requestAnimationFrame: (callback: () => void) => setTimeout(callback, 0), cancelAnimationFrame: clearTimeout, netcatty: {
realpathLocal: fs.realpath, statLocal: statPath, lstatLocal: statPath,
mkdirLocal: async (value: string) => { await fs.mkdir(value, { recursive: true }); },
deleteLocalFile: async (value: string) => { await fs.rm(value, { recursive: true, force: true }); },
renameLocalFile: async (source: string, target: string) => { await fs.rename(source, target); },
startStreamTransfer: async (options: { sourcePath: string; targetPath: string; transferId: string }) => {
streamStarts += 1;
// Hold the first copy before it writes its destination: the second
// duplicate then sees no "(copy)" on disk even though one is reserved.
if (streamStarts === 1) await firstCopyGate;
await fs.copyFile(options.sourcePath, options.targetPath);
sftpTransferCenterStore.ingestBackgroundEvent({ type: "completed", transferId: options.transferId, transferred: 13, totalBytes: 13, lifecycleEpoch: 0 });
return {};
},
} };
(globalThis as { localStorage?: unknown }).localStorage = { getItem: () => null, setItem: () => undefined, removeItem: () => undefined };
function Probe() {
ops = useSftpTransfers({
ownerId, getActivePane: () => pane, getPaneByConnectionId: () => pane, getTabByConnectionId: () => ({ side: "left", tabId: pane.id, pane }),
updateTab: () => undefined, refresh: async () => undefined,
clearCacheForConnection: () => undefined, handleSessionError: () => undefined,
sftpSessionsRef: { current: new Map() }, connectionCacheKeyMapRef: { current: new Map() },
listRemoteFiles: async () => [],
listLocalFiles: async (value: string) => Promise.all((await fs.readdir(value)).map(async name => {
const stat = await fs.lstat(path.join(value, name));
return { name, type: stat.isSymbolicLink() ? "symlink" as const : stat.isDirectory() ? "directory" as const : "file" as const, size: stat.size, sizeFormatted: "", lastModified: stat.mtimeMs, lastModifiedFormatted: "" };
})),
});
return null;
}
const sources = [{ name: "report.txt", isDirectory: false }];
const options = { sourcePath: dir, targetPath: dir, duplicate: true };
try {
await act(async () => { renderer = create(React.createElement(Probe)); });
// First duplicate starts copying and blocks before writing "(copy)".
let firstRun: ReturnType<typeof ops.startTransfer> | undefined;
await act(async () => {
firstRun = ops.startTransfer(sources, "left", "left", options);
const deadline = Date.now() + 3000;
while (streamStarts === 0 && Date.now() < deadline) {
await new Promise(resolve => setTimeout(resolve, 5));
}
});
assert.equal(streamStarts, 1, "the first copy should be in flight");
// Second duplicate runs while "(copy)" is still absent from the disk.
await act(async () => { await ops.startTransfer(sources, "left", "left", options); });
assert.equal(ops.conflicts.length, 0, "duplicate must not prompt for a reserved name");
releaseFirst?.();
await act(async () => { await firstRun; });
await act(async () => {
const deadline = Date.now() + 5000;
while (Date.now() < deadline) {
const rows = sftpTransferCenterStore.getOwnerTasks(ownerId).filter(row => !row.parentTaskId);
if (rows.length === 2 && rows.every(row => ["completed", "failed"].includes(row.status))) return;
await new Promise(resolve => setTimeout(resolve, 20));
}
});
const roots = sftpTransferCenterStore.getOwnerTasks(ownerId).filter(row => !row.parentTaskId);
assert.deepEqual(roots.map(row => row.fileName).sort(), ["report (copy 2).txt", "report (copy).txt"]);
assert.ok(roots.every(row => row.status === "completed"), JSON.stringify(roots.map(row => ({ status: row.status, error: row.error }))));
assert.equal(await fs.readFile(path.join(dir, "report (copy).txt"), "utf8"), "original data");
assert.equal(await fs.readFile(path.join(dir, "report (copy 2).txt"), "utf8"), "original data");
} finally {
releaseFirst?.();
await act(async () => { renderer?.unmount(); });
for (const row of sftpTransferCenterStore.getOwnerTasks(ownerId)) sftpTransferCenterStore.dismiss(row.id);
(globalThis as { window?: unknown }).window = previousWindow;
(globalThis as { localStorage?: unknown }).localStorage = previousStorage;
globals.IS_REACT_ACT_ENVIRONMENT = previousAct;
await fs.rm(dir, { recursive: true, force: true });
}
});
@@ -61,10 +61,11 @@ export function useSftpTransferConflictOps() {
const getDuplicateTarget = useCallback(
async (
task: TransferTask,
task: Pick<TransferTask, "fileName" | "isDirectory" | "targetPath">,
targetPane: SftpPane,
targetSftpId: string | null,
targetEncoding: SftpFilenameEncoding,
isTargetTaken?: (targetPath: string) => boolean,
) => {
const parentPath = getParentPath(task.targetPath);
const { baseName, ext } = splitNameForDuplicate(task.fileName, task.isDirectory);
@@ -73,6 +74,9 @@ export function useSftpTransferConflictOps() {
const suffix = index === 1 ? " (copy)" : ` (copy ${index})`;
const fileName = `${baseName}${suffix}${ext}`;
const targetPath = joinPath(parentPath, fileName);
// A queued/paused duplicate may not have created its destination yet,
// so the filesystem alone would hand out the same name twice.
if (isTargetTaken?.(targetPath)) continue;
// Unsupported LSTAT must propagate — do not treat as a free name.
const existing = await statTargetPath(targetPane, targetSftpId, targetPath, targetEncoding);
if (!existing) return { fileName, targetPath };
@@ -0,0 +1,161 @@
import assert from "node:assert/strict";
import test, { after } from "node:test";
import React from "react";
import { act, create, type ReactTestRenderer } from "react-test-renderer";
import type { SftpArchiveCreationResult } from "../../../domain/sftpArchiveCreation";
import { useSftpArchiveDialog } from "./useSftpArchiveDialog";
const environment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
const previous = environment.IS_REACT_ACT_ENVIRONMENT;
environment.IS_REACT_ACT_ENVIRONMENT = true;
after(() => { environment.IS_REACT_ACT_ENVIRONMENT = previous; });
type Params = Parameters<typeof useSftpArchiveDialog>[0];
async function mount(overrides: Partial<Params> = {}) {
let params: Params = {
paneId: "pane-1", connectionId: "conn-1", isLocal: false, isActive: true,
t: (key) => key, onCreateArchive: async () => ({ success: true }), onCreated: () => {},
...overrides,
};
let current!: ReturnType<typeof useSftpArchiveDialog>;
function Probe() {
current = useSftpArchiveDialog(params);
return null;
}
let renderer!: ReactTestRenderer;
await act(async () => { renderer = create(React.createElement(Probe)); });
return {
get value() { return current; },
async update(changes: Partial<Params>) {
params = { ...params, ...changes };
await act(async () => { renderer.update(React.createElement(Probe)); });
},
async unmount() { await act(async () => { renderer.unmount(); }); },
};
}
test("archive dialog snapshots nested selections, format, and connection identity", async (t) => {
const requests: unknown[] = [];
const reloaded: string[] = [];
const harness = await mount({
onCreateArchive: async (options, target) => { requests.push({ options, target }); return { success: true }; },
onCreated: (directory) => { reloaded.push(directory); },
});
t.after(() => harness.unmount());
await act(async () => { harness.value.open(["/srv/site/assets", "/srv/site/assets/logo.png", "/srv/site/config/app.json"]); });
assert.deepEqual(harness.value.state?.paths, ["assets", "config/app.json"]);
assert.equal(harness.value.state?.directory, "/srv/site");
await act(async () => { harness.value.setArchiveName("backup.tar.gz"); harness.value.setFormat("zip"); });
assert.equal(harness.value.state?.archiveName, "backup.zip");
await act(async () => { await harness.value.submit(); });
assert.deepEqual(requests, [{
options: { directory: "/srv/site", paths: ["assets", "config/app.json"], archiveName: "backup.zip", format: "zip", overwrite: false },
target: { paneId: "pane-1", connectionId: "conn-1" },
}]);
assert.deepEqual(reloaded, ["/srv/site"]);
assert.equal(harness.value.state, null);
});
test("archive dialog requires a separate overwrite decision and revokes it on edits", async (t) => {
const overwriteFlags: Array<boolean | undefined> = [];
const harness = await mount({ onCreateArchive: async (options) => {
overwriteFlags.push(options.overwrite);
return options.overwrite ? { success: true } : { success: false, code: "EEXIST" };
} });
t.after(() => harness.unmount());
await act(async () => { harness.value.open(["/srv/site"]); });
await act(async () => { await harness.value.submit(true); });
assert.deepEqual(overwriteFlags, [], "overwrite is unavailable before a collision");
await act(async () => { await harness.value.submit(); });
assert.equal(harness.value.state?.confirmOverwrite, true);
await act(async () => { await harness.value.submit(); });
assert.deepEqual(overwriteFlags, [false], "Enter must not confirm overwrite");
await act(async () => { harness.value.setArchiveName("other.tar.gz"); });
assert.equal(harness.value.state?.confirmOverwrite, false);
await act(async () => { await harness.value.submit(true); });
assert.deepEqual(overwriteFlags, [false]);
await act(async () => { await harness.value.submit(); });
await act(async () => { await harness.value.submit(true); });
assert.deepEqual(overwriteFlags, [false, false, true]);
assert.equal(harness.value.state, null);
});
test("pending archive creation blocks duplicate submits, edits, and dismissal", async (t) => {
let finish!: (result: SftpArchiveCreationResult) => void;
let requests = 0;
const harness = await mount({ onCreateArchive: async () => {
requests += 1;
return new Promise<SftpArchiveCreationResult>((resolve) => { finish = resolve; });
} });
t.after(() => harness.unmount());
await act(async () => { harness.value.open(["/srv/site"]); });
let pending!: Promise<void>;
await act(async () => {
pending = harness.value.submit();
void harness.value.submit();
harness.value.close();
harness.value.setArchiveName("changed.tar.gz");
harness.value.open(["/different"]);
});
assert.equal(requests, 1);
assert.equal(harness.value.state?.busy, true);
assert.equal(harness.value.state?.archiveName, "site.tar.gz");
await act(async () => { finish({ success: true }); await pending; });
assert.equal(harness.value.state, null);
});
test("switching connections dismisses the old dialog and ignores its late result", async (t) => {
let finish!: (result: SftpArchiveCreationResult) => void;
const reloaded: string[] = [];
let requests = 0;
const harness = await mount({
onCreateArchive: async () => {
requests += 1;
return new Promise<SftpArchiveCreationResult>((resolve) => { finish = resolve; });
},
onCreated: (directory) => { reloaded.push(directory); },
});
t.after(() => harness.unmount());
await act(async () => { harness.value.open(["/srv/site"]); });
let pending!: Promise<void>;
await act(async () => { pending = harness.value.submit(); });
await harness.update({ connectionId: "conn-2" });
assert.equal(harness.value.state, null);
await act(async () => { harness.value.open(["/srv/new-site"]); });
await act(async () => { finish({ success: false, code: "EEXIST" }); await pending; });
assert.equal(requests, 1);
assert.equal(harness.value.state?.archiveName, "new-site.tar.gz");
assert.equal(harness.value.state?.confirmOverwrite, false);
assert.deepEqual(reloaded, []);
});
test("tab switches invalidate the old form even when its connection stays mounted", async (t) => {
let requests = 0;
const harness = await mount({ onCreateArchive: async () => { requests += 1; return { success: true }; } });
t.after(() => harness.unmount());
await act(async () => { harness.value.open(["/srv/site"]); });
const staleSubmit = harness.value.submit;
await harness.update({ isActive: false });
await act(async () => { await staleSubmit(); });
assert.equal(harness.value.state, null);
assert.equal(requests, 0);
await harness.update({ isActive: true, isLocal: true });
await act(async () => { harness.value.open(["/srv/site"]); });
assert.equal(harness.value.state, null);
});
test("invalid names do not invoke the backend; failures keep a retryable form", async (t) => {
let requests = 0;
const harness = await mount({ onCreateArchive: async () => { requests += 1; throw new Error("zip is not installed"); } });
t.after(() => harness.unmount());
await act(async () => { harness.value.open(["/srv/site"]); harness.value.setArchiveName("../backup.tar.gz"); });
await act(async () => { await harness.value.submit(); });
assert.equal(requests, 0);
assert.equal(harness.value.state?.error, "sftp.archive.invalidName");
await act(async () => { harness.value.setArchiveName("backup.tar.gz"); });
await act(async () => { await harness.value.submit(); });
assert.equal(requests, 1);
assert.equal(harness.value.state?.busy, false);
assert.equal(harness.value.state?.error, "zip is not installed");
});
@@ -0,0 +1,162 @@
import { useCallback, useEffect, useRef, useState } from "react";
import {
prepareSftpArchiveSelection,
validateSftpArchiveName,
withSftpArchiveExtension,
type SftpArchiveCreationOptions,
type SftpArchiveCreationResult,
type SftpArchiveCreationTarget,
type SftpArchiveFormat,
} from "../../../domain/sftpArchiveCreation";
export type SftpArchiveTarget = SftpArchiveCreationTarget;
export interface SftpArchiveDialogState {
target: SftpArchiveTarget;
directory: string;
paths: string[];
archiveName: string;
format: SftpArchiveFormat;
busy: boolean;
error: string | null;
confirmOverwrite: boolean;
}
interface UseSftpArchiveDialogParams {
paneId: string;
connectionId?: string;
isLocal: boolean;
isActive: boolean;
t: (key: string, params?: Record<string, unknown>) => string;
onCreateArchive?: (
options: SftpArchiveCreationOptions,
target: SftpArchiveTarget,
) => Promise<SftpArchiveCreationResult>;
onCreated: (directory: string) => void;
}
export function useSftpArchiveDialog(params: UseSftpArchiveDialogParams) {
const [state, setState] = useState<SftpArchiveDialogState | null>(null);
const stateRef = useRef(state);
const paramsRef = useRef(params);
paramsRef.current = params;
const generation = useRef(0);
const update = useCallback((next: SftpArchiveDialogState | null) => {
stateRef.current = next;
setState(next);
}, []);
const matchesTarget = useCallback((target: SftpArchiveTarget) => {
const current = paramsRef.current;
return current.isActive && !current.isLocal
&& current.paneId === target.paneId
&& current.connectionId === target.connectionId;
}, []);
// A dialog belongs to the connection and pane on which it was opened. A tab
// switch may leave its component mounted, so visibility is part of identity.
useEffect(() => {
if (stateRef.current && !matchesTarget(stateRef.current.target)) {
generation.current += 1;
update(null);
}
}, [params.paneId, params.connectionId, params.isLocal, params.isActive, matchesTarget, update]);
useEffect(() => () => { generation.current += 1; }, []);
const open = useCallback((sourcePaths: string[]) => {
const current = paramsRef.current;
if (!current.connectionId || current.isLocal || !current.isActive
|| !current.onCreateArchive || stateRef.current?.busy) return;
generation.current += 1;
const target = { paneId: current.paneId, connectionId: current.connectionId };
try {
const selection = prepareSftpArchiveSelection(sourcePaths);
update({
target,
directory: selection.directory,
paths: selection.paths,
archiveName: withSftpArchiveExtension(selection.defaultName, "tar.gz"),
format: "tar.gz",
busy: false,
error: null,
confirmOverwrite: false,
});
} catch {
update({
target, directory: "", paths: [], archiveName: "", format: "tar.gz",
busy: false, error: current.t("sftp.archive.invalidSelection"), confirmOverwrite: false,
});
}
}, [update]);
const close = useCallback(() => {
if (stateRef.current?.busy) return;
generation.current += 1;
update(null);
}, [update]);
const setArchiveName = useCallback((archiveName: string) => {
const current = stateRef.current;
if (!current || current.busy) return;
update({ ...current, archiveName, error: null, confirmOverwrite: false });
}, [update]);
const setFormat = useCallback((format: SftpArchiveFormat) => {
const current = stateRef.current;
if (!current || current.busy) return;
update({
...current, format,
archiveName: withSftpArchiveExtension(current.archiveName, format),
error: null, confirmOverwrite: false,
});
}, [update]);
const submit = useCallback(async (overwrite = false) => {
const current = stateRef.current;
const { onCreateArchive, t } = paramsRef.current;
if (!current || current.busy || !onCreateArchive || !matchesTarget(current.target)
|| current.paths.length === 0 || (overwrite && !current.confirmOverwrite)) return;
// Enter in the name field must not implicitly confirm replacement.
if (current.confirmOverwrite && !overwrite) return;
const validationError = validateSftpArchiveName(current.archiveName, current.format);
if (validationError) {
update({ ...current, error: t(validationError) });
return;
}
const requestGeneration = generation.current;
update({ ...current, busy: true, error: null });
try {
const result = await onCreateArchive({
directory: current.directory,
paths: current.paths,
archiveName: current.archiveName,
format: current.format,
overwrite,
}, current.target);
if (generation.current !== requestGeneration || !matchesTarget(current.target)) return;
if (!result.success) {
update({
...current, busy: false,
confirmOverwrite: result.code === "EEXIST",
error: result.code === "EEXIST" ? null : result.error || t("sftp.archive.failed"),
});
return;
}
update(null);
paramsRef.current.onCreated(current.directory);
} catch (error) {
if (generation.current !== requestGeneration || !matchesTarget(current.target)) return;
update({
...current, busy: false, confirmOverwrite: false,
error: error instanceof Error ? error.message : String(error),
});
}
}, [matchesTarget, update]);
return {
state: state && matchesTarget(state.target) ? state : null,
open, close, setArchiveName, setFormat, submit,
};
}
+42 -3
View File
@@ -1326,6 +1326,13 @@ export const useSftpTransfers = ({
sourcePath?: string;
sourceConnectionId?: string;
targetPath?: string;
/**
* Create copies next to the originals instead of prompting for the
* same-name conflict. Each source name is resolved to the next free
* `name (copy)`, `name (copy 2)`, ... before the transfer starts.
* Used by the SFTP context menu's Duplicate action.
*/
duplicate?: boolean;
onTransferComplete?: (result: TransferResult) => void | Promise<void>;
},
) => {
@@ -1429,6 +1436,10 @@ export const useSftpTransfers = ({
}
for (const file of sourceFiles) {
// "." / ".." are navigation rows, not real entries. Duplicating one
// would resolve to an ancestor (e.g. "/") and could walk the copy into
// its own output.
if (options?.duplicate && (file.name === "." || file.name === "..")) continue;
const direction: TransferDirection =
sourcePane.connection!.isLocal && !targetPane.connection!.isLocal
? "upload"
@@ -1461,8 +1472,36 @@ export const useSftpTransfers = ({
: (freshMetadata?.lastModified ?? fileEntry?.lastModified ?? 0);
const nextSourcePath = joinPath(sourcePath, file.name);
const nextTargetPath = joinPath(targetPath, file.name);
const targetIsLocal = targetPane.connection!.isLocal;
// A duplicate targets the folder the item already lives in, so the
// same-name conflict is guaranteed. Resolve the "(copy)" name up front
// instead of routing through the conflict dialog: the one-click action
// must not prompt, and resolving it inside processTransfer would
// re-enter the same task while it is still marked in flight.
const duplicateTarget = options?.duplicate
? await getDuplicateTarget(
{ fileName: file.name, isDirectory: file.isDirectory, targetPath: joinPath(targetPath, file.name) },
targetPane,
targetIsLocal ? null : (sftpSessionsRef.current.get(targetPane.connection!.id) ?? null),
targetIsLocal ? "auto" : (targetPane.filenameEncoding || "auto"),
// A queued or paused copy has not created its destination on disk
// yet, so the next duplicate would pick the same name and surface
// a conflict dialog. Reserve active destinations while naming.
(candidatePath) => Boolean(findActivePathConflict(
[...sftpTransferCenterStore.getSnapshot().tasks, ...transfersRef.current, ...newTasks],
{
id: "",
targetPath: candidatePath,
targetConnectionId: targetIsLocal ? "local" : targetPane.connection!.id,
targetHostId: targetIsLocal ? undefined : targetPane.connection!.hostId,
targetHostLabel: targetIsLocal ? "Local" : targetPane.connection!.hostLabel,
isDirectory: file.isDirectory,
},
)),
)
: null;
const nextFileName = duplicateTarget?.fileName ?? file.name;
const nextTargetPath = duplicateTarget?.targetPath ?? joinPath(targetPath, file.name);
const pathTaken = findActivePathConflict(
[...sftpTransferCenterStore.getSnapshot().tasks, ...transfersRef.current, ...newTasks],
{
@@ -1481,7 +1520,7 @@ export const useSftpTransfers = ({
const attentionTask: TransferTask = {
id: crypto.randomUUID(),
batchId,
fileName: file.name,
fileName: nextFileName,
originalFileName: file.name,
sourcePath: nextSourcePath,
targetPath: nextTargetPath,
@@ -1522,7 +1561,7 @@ export const useSftpTransfers = ({
newTasks.push({
id: taskId,
batchId,
fileName: file.name,
fileName: nextFileName,
originalFileName: file.name,
sourcePath: nextSourcePath,
targetPath: nextTargetPath,
@@ -36,6 +36,12 @@ export interface UseSftpTransfersResult {
sourcePath?: string;
sourceConnectionId?: string;
targetPath?: string;
/**
* Create copies next to the originals instead of prompting for the
* same-name conflict. Each source name is resolved to the next free
* `name (copy)`, `name (copy 2)`, ... before the transfer starts.
*/
duplicate?: boolean;
onTransferComplete?: (result: TransferResult) => void | Promise<void>;
},
) => Promise<TransferResult[]>;
@@ -30,3 +30,20 @@ test('terminalCwdStore bumps version only when cwd or provenance changes', () =>
unsubscribe();
assert.ok(versions.length >= 2);
});
test('session revisions isolate terminals and retain identical OSC 7 reports without rerendering', () => {
const a = 'revision-a'; const b = 'revision-b';
terminalCwdStore.setCwd(a, '/same', 'osc7');
const revisionA = terminalCwdStore.getSessionRevision(a);
terminalCwdStore.setCwd(b, '/elsewhere', 'osc7');
assert.equal(terminalCwdStore.getSessionRevision(a), revisionA);
const globalVersion = terminalCwdStore.getVersion();
assert.equal(terminalCwdStore.setCwd(a, '/same', 'osc7'), false);
assert.ok(terminalCwdStore.getSessionRevision(a) > revisionA);
assert.equal(terminalCwdStore.getVersion(), globalVersion);
const revisionB = terminalCwdStore.getSessionRevision(b);
terminalCwdStore.prune(new Set([b]));
assert.equal(terminalCwdStore.getSessionRevision(a), 0);
assert.equal(terminalCwdStore.getSessionRevision(b), revisionB);
terminalCwdStore.prune(new Set());
});
+14 -1
View File
@@ -10,11 +10,16 @@ export type TerminalCwdSource = "osc7" | "inferred" | "backend-strict" | "backen
class TerminalCwdStore {
private cwdBySession = new Map<string, string>();
private sourceBySession = new Map<string, TerminalCwdSource>();
private revisionBySession = new Map<string, number>();
private revision = 0;
private version = 0;
private listeners = new Set<Listener>();
getVersion = (): number => this.version;
/** Tracks reports for one session, including unchanged authoritative OSC 7. */
getSessionRevision = (sessionId: string): number => this.revisionBySession.get(sessionId) ?? 0;
getCwd = (sessionId: string | null | undefined): string | null => {
if (!sessionId) return null;
return this.cwdBySession.get(sessionId) ?? null;
@@ -39,7 +44,10 @@ class TerminalCwdStore {
const nextSource = next
? (source ?? (current === next ? currentSource : undefined) ?? "unknown")
: undefined;
if (current === next && currentSource === nextSource) return false;
if (current === next && currentSource === nextSource) {
if (nextSource === 'osc7') this.revisionBySession.set(sessionId, ++this.revision);
return false;
}
if (next) {
this.cwdBySession.set(sessionId, next);
@@ -48,6 +56,7 @@ class TerminalCwdStore {
this.cwdBySession.delete(sessionId);
this.sourceBySession.delete(sessionId);
}
this.revisionBySession.set(sessionId, ++this.revision);
this.version += 1;
for (const listener of this.listeners) {
listener();
@@ -61,9 +70,13 @@ class TerminalCwdStore {
if (!validSessionIds.has(sessionId)) {
this.cwdBySession.delete(sessionId);
this.sourceBySession.delete(sessionId);
this.revisionBySession.delete(sessionId);
changed = true;
}
}
for (const id of this.revisionBySession.keys()) {
if (!validSessionIds.has(id)) this.revisionBySession.delete(id);
}
if (!changed) return;
this.version += 1;
for (const listener of this.listeners) {
@@ -0,0 +1,230 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createTerminalDirectoryController } from './terminalDirectoryController';
import { terminalCwdStore } from './terminalCwdStore';
import type { TerminalDirectoryIO } from '../../infrastructure/services/terminalDirectory';
function setup(overrides: Partial<TerminalDirectoryIO> = {}) {
let cwd = '/old'; let ready = true; let current = true;
const writes: string[] = [];
const published: string[] = [];
const controller = createTerminalDirectoryController({
sessionId: 'test-directory', confirmationMs: 10,
io: { cwd: async () => cwd, list: async () => ({ entries: [], home: null }), realpath: async path => path,
write: data => { writes.push(data); cwd = '/next'; }, ...overrides },
isCurrent: () => current, isReady: () => ready,
publish: value => { published.push(value); terminalCwdStore.setCwd('test-directory', value, 'backend-strict'); },
});
return { controller, writes, published, busy: () => { ready = false; }, stale: () => { current = false; } };
}
test('navigation writes one quoted cd and waits for actual cwd before confirming', async () => {
const ctx = setup(); const accepted: string[] = [];
await ctx.controller.change('/next', new AbortController().signal, () => accepted.push('accepted'));
assert.deepEqual(ctx.writes, ["cd -- '/next'\r"]);
assert.deepEqual(accepted, ['accepted']);
assert.deepEqual(ctx.published, ['/next']);
ctx.controller.dispose();
});
test('busy, control-character and stale sessions never receive bytes', async () => {
const ctx = setup(); ctx.busy();
await assert.rejects(ctx.controller.change('/next', new AbortController().signal, () => {}), { code: 'busy' });
const invalid = setup();
await assert.rejects(invalid.controller.change('/next\n', new AbortController().signal, () => {}), { code: 'unsupportedPath' });
const stale = setup(); stale.stale();
await assert.rejects(stale.controller.change('/next', new AbortController().signal, () => {}), { code: 'unavailable' });
assert.deepEqual([...ctx.writes, ...invalid.writes, ...stale.writes], []);
});
test('rechecks readiness after async path resolution; double submission is rejected', async () => {
let finish!: (path: string) => void;
const ctx = setup({ realpath: () => new Promise(resolve => { finish = resolve; }) });
const first = ctx.controller.change('/next', new AbortController().signal, () => {});
await assert.rejects(ctx.controller.change('/other', new AbortController().signal, () => {}), { code: 'busy' });
ctx.busy(); finish('/next');
await assert.rejects(first, { code: 'busy' });
assert.deepEqual(ctx.writes, []);
});
test('a disposed connection rejects delayed reads and cannot publish into a new generation', async () => {
let finish!: (cwd: string) => void;
const ctx = setup({ cwd: () => new Promise(resolve => { finish = resolve; }) });
const read = ctx.controller.refresh();
ctx.controller.dispose(); finish('/stale');
await assert.rejects(read, { code: 'unavailable' });
assert.deepEqual(ctx.published, []);
});
test('a failed cd keeps the observed cwd and never retries the write', async () => {
const ctx = setup({ write: () => {}, cwd: async () => '/old' });
await assert.rejects(ctx.controller.change('/missing', new AbortController().signal, () => {}), { code: 'changeFailed' });
assert.ok(ctx.published.every(value => value === '/old'));
});
test('directory symlinks confirm against physical cwd', async () => {
const ctx = setup({ realpath: async path => path === '/link' ? '/real' : path, cwd: async () => '/real' });
await ctx.controller.change('/link', new AbortController().signal, () => {});
assert.equal(ctx.published.at(-1), '/real');
});
test('output arriving during an old cwd query retains one fresh follow-up query', async () => {
let finish!: (cwd: string) => void;
let reads = 0;
const ctx = setup({ cwd: () => {
reads++;
return reads === 1 ? new Promise(resolve => { finish = resolve; }) : Promise.resolve('/after-cd');
} });
const oldRead = ctx.controller.refresh();
const queued = ctx.controller.refresh();
finish('/before-cd');
await Promise.all([oldRead, queued]);
await new Promise(resolve => setImmediate(resolve));
assert.equal(reads, 2);
assert.equal(ctx.published.at(-1), '/after-cd');
ctx.controller.dispose();
});
test('another terminal updating cannot override the current terminal strict cwd result', async t => {
const id = 'strict-after-other-session';
let finish!: (cwd: string) => void;
const published: string[] = [];
terminalCwdStore.setCwd(id, '/stale-osc7', 'stale');
const controller = createTerminalDirectoryController({
sessionId: id, isCurrent: () => true, isReady: () => true,
io: { cwd: () => new Promise(resolve => { finish = resolve; }),
list: async () => ({ entries: [], home: null }), realpath: async value => value, write() {} },
publish: value => { published.push(value); terminalCwdStore.setCwd(id, value, 'backend-strict'); },
});
t.after(() => controller.dispose());
const pending = controller.refresh();
terminalCwdStore.setCwd('unrelated-session', '/other', 'osc7');
finish('/actual-after-cd');
assert.equal(await pending, '/actual-after-cd');
assert.deepEqual(published, ['/actual-after-cd']);
assert.equal(terminalCwdStore.getCwd(id), '/actual-after-cd');
});
test('another terminal cannot turn this submission pre-write OSC 7 into a fresh report', async t => {
const id = 'pre-write-osc7-after-other-session';
let finish!: (cwd: string) => void;
const published: string[] = [];
terminalCwdStore.setCwd(id, '/before', 'osc7');
const controller = createTerminalDirectoryController({
sessionId: id, confirmationMs: 20, isCurrent: () => true, isReady: () => true,
io: { cwd: () => new Promise(resolve => { finish = resolve; }),
list: async () => ({ entries: [], home: null }), realpath: async value => value, write() {} },
publish: cwd => { published.push(cwd); terminalCwdStore.setCwd(id, cwd, 'backend-strict'); },
});
t.after(() => controller.dispose());
const changing = controller.change('/after', new AbortController().signal, () => {});
await new Promise(resolve => setImmediate(resolve));
terminalCwdStore.setCwd(id + '-other', '/other', 'osc7');
finish('/after');
await changing;
assert.deepEqual(published, ['/after']);
assert.equal(terminalCwdStore.getCwd(id), '/after');
});
for (const nextCwd of ['/new-osc7', '/original']) {
test(`a later OSC 7 report from this terminal wins (${nextCwd})`, async t => {
const id = 'same-session-osc7-' + nextCwd;
let finish!: (cwd: string) => void;
const published: string[] = [];
terminalCwdStore.setCwd(id, '/original', 'stale');
const controller = createTerminalDirectoryController({
sessionId: id, isCurrent: () => true, isReady: () => true,
io: { cwd: () => new Promise(resolve => { finish = resolve; }),
list: async () => ({ entries: [], home: null }), realpath: async value => value, write() {} },
publish: value => { published.push(value); },
});
t.after(() => controller.dispose());
const pending = controller.refresh();
terminalCwdStore.setCwd(id, nextCwd, 'osc7');
finish('/older-backend-result');
assert.equal(await pending, nextCwd);
assert.deepEqual(published, []);
});
}
for (const probe of ['fails', 'stalls'] as const) {
test(`a fresh post-write OSC 7 confirms navigation when the strict cwd probe ${probe}`, { timeout: 2000 }, async t => {
const id = 'osc7-confirm-' + probe;
const writes: string[] = [];
terminalCwdStore.setCwd(id, '/old', 'osc7');
const controller = createTerminalDirectoryController({
sessionId: id, confirmationMs: 1000, isCurrent: () => true, isReady: () => true,
io: {
cwd: () => probe === 'fails' ? Promise.reject(new Error('no process cwd support')) : new Promise(() => {}),
list: async () => ({ entries: [], home: null }), realpath: async value => value,
write: data => {
writes.push(data);
setImmediate(() => terminalCwdStore.setCwd(id, '/next', 'osc7'));
},
},
publish() { assert.fail('an unavailable probe must not publish a guessed cwd'); },
});
t.after(() => controller.dispose());
await controller.change('/next', new AbortController().signal, () => {});
assert.deepEqual(writes, ["cd -- '/next'\r"]);
assert.equal(controller.hasConfirmedCwd(), true);
});
}
test('navigation can probe past its pre-write OSC 7 when the shell stops reporting', async t => {
const id = 'probe-after-pre-write-osc7';
terminalCwdStore.setCwd(id, '/before', 'osc7');
const controller = createTerminalDirectoryController({
sessionId: id, isCurrent: () => true, isReady: () => true,
io: { cwd: async () => '/after', list: async () => ({ entries: [], home: null }),
realpath: async value => value, write() {} },
publish: cwd => terminalCwdStore.setCwd(id, cwd, 'backend-strict'),
});
t.after(() => controller.dispose());
await controller.change('/after', new AbortController().signal, () => {});
assert.equal(terminalCwdStore.getCwd(id), '/after');
assert.equal(terminalCwdStore.getSource(id), 'backend-strict');
});
for (const report of ['stale', 'other-session', 'other-source', 'fresh-unchanged'] as const) {
test(`OSC 7 confirmation uses this submission's session revision (${report})`, { timeout: 2000 }, async t => {
const id = 'osc7-revision-confirm-' + report;
terminalCwdStore.setCwd(id, '/next', 'osc7');
const controller = createTerminalDirectoryController({
sessionId: id, confirmationMs: 20, isCurrent: () => true, isReady: () => true,
io: {
cwd: async () => { throw new Error('no process cwd support'); },
list: async () => ({ entries: [], home: null }), realpath: async value => value,
write() {
if (report !== 'stale') setImmediate(() => terminalCwdStore.setCwd(
report === 'other-session' ? id + '-other' : id, '/next', report === 'other-source' ? 'inferred' : 'osc7',
));
},
},
publish() {},
});
t.after(() => controller.dispose());
const change = controller.change('/next', new AbortController().signal, () => {});
if (report === 'fresh-unchanged') await change;
else await assert.rejects(change, { code: 'changeFailed' });
});
}
for (const cancellation of ['abort', 'dispose'] as const) {
test(`a fresh OSC 7 cannot confirm a cancelled directory change (${cancellation})`, { timeout: 2000 }, async t => {
const id = 'osc7-cancel-' + cancellation;
const abort = new AbortController();
const controller = createTerminalDirectoryController({
sessionId: id, isCurrent: () => true, isReady: () => true,
io: { cwd: () => new Promise(() => {}), list: async () => ({ entries: [], home: null }),
realpath: async value => value,
write() { setImmediate(() => {
if (cancellation === 'abort') abort.abort(); else controller.dispose();
terminalCwdStore.setCwd(id, '/next', 'osc7');
}); } },
publish() {},
});
t.after(() => controller.dispose());
await assert.rejects(controller.change('/next', abort.signal, () => {}), { code: 'unavailable' });
});
}
@@ -0,0 +1,162 @@
import { DirectoryPickerError, isDirectoryPath } from '../../domain/directoryPicker';
import { resolveInteractiveTerminalCdIntent } from '../../domain/sessionRestore';
import type { TerminalDirectoryIO } from '../../infrastructure/services/terminalDirectory';
import { terminalCwdStore } from './terminalCwdStore';
export type TerminalDirectoryController = ReturnType<typeof createTerminalDirectoryController>;
/** One registration represents one connected interactive shell generation. */
export function createTerminalDirectoryController(options: {
sessionId: string;
io: TerminalDirectoryIO;
isCurrent: () => boolean;
isReady: () => boolean;
publish: (cwd: string) => void;
confirmationMs?: number;
}) {
let disposed = false;
let changing = false;
let confirmed = false;
let refreshing: Promise<string> | null = null;
let refreshAgain = false;
const lifetime = new AbortController();
const current = () => !disposed && options.isCurrent();
function check(signal?: AbortSignal) {
if (!current() || signal?.aborted) throw new DirectoryPickerError('unavailable');
}
function ready() {
check();
if (!options.isReady()) throw new DirectoryPickerError('busy');
}
function bounded<T>(task: Promise<T>, signal?: AbortSignal): Promise<T> {
return new Promise((resolve, reject) => {
const finish = (error?: unknown, value?: T) => {
clearTimeout(timer);
lifetime.signal.removeEventListener('abort', cancel);
signal?.removeEventListener('abort', cancel);
if (error) reject(error); else resolve(value!);
};
const cancel = () => finish(new DirectoryPickerError('unavailable'));
const timer = setTimeout(() => finish(new DirectoryPickerError('readFailed')), 5000);
lifetime.signal.addEventListener('abort', cancel, { once: true });
signal?.addEventListener('abort', cancel, { once: true });
if (signal?.aborted || lifetime.signal.aborted) cancel();
task.then(value => finish(undefined, value), error => finish(error));
});
}
async function refresh(submittedRevision?: number): Promise<string> {
check();
const activeShellCwd = () => {
const cwd = terminalCwdStore.getCwd(options.sessionId);
return cwd && terminalCwdStore.getSource(options.sessionId) === 'osc7'
&& (submittedRevision === undefined || terminalCwdStore.getSessionRevision(options.sessionId) !== submittedRevision)
? cwd : null;
};
// A process probe may read the outer PTY shell while a nested shell is
// active. OSC 7 remains authoritative across debounce delays, completion
// events and picker remounts. Only a cd submission may probe past its own
// pre-write report while waiting for fresh confirmation.
const reported = activeShellCwd();
if (reported) { confirmed = true; return reported; }
if (refreshing) {
// Output may settle while a pre-cd query is still in flight. Keep one
// follow-up query so that its older result cannot be the last update.
refreshAgain = true;
return refreshing;
}
const pending = (async () => {
const cwd = await bounded(options.io.cwd());
check();
const reported = activeShellCwd();
const live = terminalCwdStore.getCwd(options.sessionId);
confirmed = true;
if (reported) return reported;
if (live !== cwd || terminalCwdStore.getSource(options.sessionId) === 'stale') options.publish(cwd);
return cwd;
})();
refreshing = pending;
try { return await pending; } finally {
if (refreshing === pending) refreshing = null;
if (refreshAgain && current()) {
refreshAgain = false;
void refresh().catch(() => {});
}
}
}
async function change(path: string, signal: AbortSignal, accepted: () => void, expectedCwd?: string) {
ready();
if (changing) throw new DirectoryPickerError('busy');
const intent = isDirectoryPath(path) ? resolveInteractiveTerminalCdIntent(path) : null;
if (!intent) throw new DirectoryPickerError('unsupportedPath');
changing = true;
let confirmationFinished = false;
try {
const canonical = await bounded(options.io.realpath(path, signal), signal);
check(signal);
ready(); // The shell can become busy while resolving a remote symlink.
if (expectedCwd && terminalCwdStore.getCwd(options.sessionId) !== expectedCwd) throw new DirectoryPickerError('unavailable');
const submittedRevision = terminalCwdStore.getSessionRevision(options.sessionId);
options.io.write(intent.command + '\r');
accepted();
const deadline = Date.now() + (options.confirmationMs ?? 8000);
const hasFreshOsc7 = () => {
const cwd = terminalCwdStore.getCwd(options.sessionId);
return terminalCwdStore.getSessionRevision(options.sessionId) !== submittedRevision
&& terminalCwdStore.getSource(options.sessionId) === 'osc7'
&& (cwd === path || cwd === canonical);
};
const pause = () => new Promise<void>(resolve => {
const done = () => { clearTimeout(timer); lifetime.signal.removeEventListener('abort', done); resolve(); };
const timer = setTimeout(done, 200);
lifetime.signal.addEventListener('abort', done, { once: true });
});
const confirmFromProbe = async () => {
while (!confirmationFinished && Date.now() < deadline) {
check(signal);
if (hasFreshOsc7()) return;
if (options.isReady()) {
try {
const cwd = await refresh(submittedRevision);
check(signal);
if (cwd === path || cwd === canonical || await bounded(options.io.realpath(cwd, signal), signal) === canonical) return;
} catch (error) {
if (!current() || signal.aborted) throw error;
}
}
await pause();
}
if (!confirmationFinished) throw new DirectoryPickerError('changeFailed');
};
// OSC 7 is authoritative even when process cwd lookup is unavailable or
// stalled. Poll revisions too: an unchanged OSC 7 path emits no store event.
const confirmFromOsc7 = async () => {
while (!confirmationFinished) {
check(signal);
if (hasFreshOsc7()) return;
if (Date.now() >= deadline) throw new DirectoryPickerError('changeFailed');
await pause();
}
};
await Promise.race([confirmFromProbe(), confirmFromOsc7()]);
check(signal);
confirmed = true;
} finally { confirmationFinished = true; changing = false; }
}
return {
refresh: () => refresh(),
hasConfirmedCwd: () => confirmed,
async home() {
check();
const result = await bounded(options.io.home?.() ?? Promise.resolve(null));
check(); return result;
},
async list(path: string, signal: AbortSignal) {
check(signal);
const result = await bounded(options.io.list(path, signal), signal);
check(signal);
return result;
},
change,
dispose() { disposed = true; lifetime.abort(); },
};
}
@@ -0,0 +1,87 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createTerminalDirectoryController } from './terminalDirectoryController';
import { terminalCwdStore } from './terminalCwdStore';
import {
observeTerminalDirectory, registerTerminalDirectory,
refreshObservedTerminalDirectory, scheduleObservedTerminalDirectoryRefresh,
} from './terminalDirectoryRegistry';
test('manual terminal output refreshes cwd without a trusted command-completion event', async t => {
t.mock.timers.enable({ apis: ['setTimeout'] });
let cwd = '/before'; let reads = 0;
const published: string[] = [];
const controller = createTerminalDirectoryController({
sessionId: 'unrecognized-prompt', isCurrent: () => true, isReady: () => false,
io: { cwd: async () => { reads++; return cwd; },
list: async () => ({ entries: [], home: null }), realpath: async value => value, write() {} },
publish: value => { published.push(value); },
});
const unregister = registerTerminalDirectory('unrecognized-prompt', controller);
const stopObserving = observeTerminalDirectory('unrecognized-prompt');
t.after(() => { stopObserving(); unregister(); });
cwd = '/after-manual-cd';
// Multiple prompt/output chunks produce just one read. No trusted prompt
// detection, command-completed callback, or parsed `cd` is involved.
scheduleObservedTerminalDirectoryRefresh('unrecognized-prompt');
t.mock.timers.tick(80);
scheduleObservedTerminalDirectoryRefresh('unrecognized-prompt');
t.mock.timers.tick(149);
assert.equal(reads, 0);
t.mock.timers.tick(1);
await new Promise(resolve => setImmediate(resolve));
assert.deepEqual(published, ['/after-manual-cd']);
assert.equal(reads, 1);
t.mock.timers.tick(60_000);
assert.equal(reads, 1, 'no polling after output has settled');
});
test('settled output and prompt completion preserve the active nested shell OSC 7 cwd', async t => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const id = 'nested-shell-output';
const published: string[] = [];
const controller = createTerminalDirectoryController({
sessionId: id, isCurrent: () => true, isReady: () => true,
// The PTY process stays in its original directory while a child shell
// runs in /. Its cwd is only a fallback for shells without OSC 7.
io: { cwd: async () => '/outer-shell', list: async () => ({ entries: [], home: null }),
realpath: async value => value, write() {} },
publish: cwd => { published.push(cwd); terminalCwdStore.setCwd(id, cwd, 'backend-strict'); },
});
const unregister = registerTerminalDirectory(id, controller);
const stopObserving = observeTerminalDirectory(id);
t.after(() => { stopObserving(); unregister(); });
scheduleObservedTerminalDirectoryRefresh(id);
terminalCwdStore.setCwd(id, '/', 'osc7');
t.mock.timers.tick(150);
await new Promise(resolve => setImmediate(resolve));
assert.equal(terminalCwdStore.getCwd(id), '/');
// Later output chunks, prompt completion and reopening/manual refresh must
// not treat that same active-shell report as stale merely due to timing.
scheduleObservedTerminalDirectoryRefresh(id);
t.mock.timers.tick(150);
refreshObservedTerminalDirectory(id);
assert.equal(await controller.refresh(), '/');
assert.deepEqual(published, []);
assert.equal(terminalCwdStore.getSource(id), 'osc7');
});
test('disabling chips cancels a queued output refresh and ignores future terminal output', async t => {
t.mock.timers.enable({ apis: ['setTimeout'] });
let reads = 0;
const controller = createTerminalDirectoryController({
sessionId: 'hidden-chips', isCurrent: () => true, isReady: () => true,
io: { cwd: async () => { reads++; return '/unused'; }, list: async () => ({ entries: [], home: null }),
realpath: async value => value, write() {} }, publish() {},
});
const unregister = registerTerminalDirectory('hidden-chips', controller);
t.after(unregister);
const stopObserving = observeTerminalDirectory('hidden-chips');
scheduleObservedTerminalDirectoryRefresh('hidden-chips');
stopObserving();
t.mock.timers.tick(1000);
scheduleObservedTerminalDirectoryRefresh('hidden-chips');
t.mock.timers.tick(1000);
await new Promise(resolve => setImmediate(resolve));
assert.equal(reads, 0);
});
@@ -0,0 +1,58 @@
import type { TerminalDirectoryController } from './terminalDirectoryController';
const sessions = new Map<string, TerminalDirectoryController>();
const consumers = new Map<string, number>();
const scheduledRefreshes = new Map<string, ReturnType<typeof setTimeout>>();
const OUTPUT_SETTLE_MS = 150;
function cancelScheduledRefresh(id: string) {
const timer = scheduledRefreshes.get(id);
if (timer !== undefined) clearTimeout(timer);
scheduledRefreshes.delete(id);
}
const listeners = new Set<() => void>();
export const subscribeTerminalDirectories = (listener: () => void) => {
listeners.add(listener);
return () => { listeners.delete(listener); };
};
export const getTerminalDirectory = (id: string) => sessions.get(id) ?? null;
export function registerTerminalDirectory(id: string, controller: TerminalDirectoryController) {
cancelScheduledRefresh(id);
sessions.set(id, controller);
listeners.forEach(listener => listener());
return () => {
controller.dispose();
if (sessions.get(id) !== controller) return;
cancelScheduledRefresh(id);
sessions.delete(id);
listeners.forEach(listener => listener());
};
}
export function observeTerminalDirectory(id: string) {
consumers.set(id, (consumers.get(id) ?? 0) + 1);
return () => {
const next = (consumers.get(id) ?? 1) - 1;
if (next) consumers.set(id, next);
else { consumers.delete(id); cancelScheduledRefresh(id); }
};
}
/** Called by the existing command completion path, only while chips are visible. */
export function refreshObservedTerminalDirectory(id: string) {
cancelScheduledRefresh(id);
if (consumers.has(id)) void sessions.get(id)?.refresh().catch(() => {});
}
/**
* Shells with custom prompts may emit neither OSC 7/133 nor a trusted command
* completion. Debounce actual output/submission activity instead of polling or
* interpreting `cd` text. The controller prefers active-shell OSC 7 and uses
* the bound shell's process cwd only as a fallback.
*/
export function scheduleObservedTerminalDirectoryRefresh(id: string) {
if (!consumers.has(id) || !sessions.has(id)) return;
cancelScheduledRefresh(id);
scheduledRefreshes.set(id, setTimeout(() => {
scheduledRefreshes.delete(id);
refreshObservedTerminalDirectory(id);
}, OUTPUT_SETTLE_MS));
}
@@ -0,0 +1,215 @@
/**
* Minimal in-memory IndexedDB stub covering the exact subset used by
* `aiSessionIdbStorage.ts`: open + upgrade (createObjectStore), readonly
* getAll/get, readwrite put/delete/clear, and meta-store records. Not a
* general-purpose IDB implementation — extend deliberately if the wrapper
* grows new usage.
*/
type Resolve = () => void;
const microtask2 = (fn: Resolve) => {
queueMicrotask(() => queueMicrotask(fn));
};
export class FakeRequest<T> {
onsuccess: ((this: FakeRequest<T>, ev?: unknown) => void) | null = null;
onerror: ((this: FakeRequest<T>, ev?: unknown) => void) | null = null;
onupgradeneeded: ((this: FakeRequest<T>, ev?: unknown) => void) | null = null;
result: T;
error: Error | null = null;
constructor(result: T, mode: 'immediate' | 'upgrade' | 'failure' = 'immediate', error?: Error) {
this.result = result;
if (mode === 'failure') {
this.error = error ?? new Error('fake idb failure');
queueMicrotask(() => this.onerror?.(this));
return;
}
queueMicrotask(() => {
if (mode === 'upgrade') this.onupgradeneeded?.(this);
this.onsuccess?.(this);
});
}
}
interface StoreSpec {
name: string;
keyPath: string;
}
export class FakeObjectStore {
/** Instrumentation for per-record write assertions. */
putCount = 0;
deleteCount = 0;
constructor(
public readonly spec: StoreSpec,
public readonly records: Map<string, unknown>,
) {}
put(value: unknown): FakeRequest<IDBValidKey> {
this.putCount += 1;
const key = (value as Record<string, unknown>)[this.spec.keyPath] as string;
this.records.set(key, value);
return new FakeRequest(key);
}
delete(key: IDBValidKey): FakeRequest<undefined> {
this.deleteCount += 1;
this.records.delete(String(key));
return new FakeRequest(undefined);
}
clear(): FakeRequest<undefined> {
this.records.clear();
return new FakeRequest(undefined);
}
get(key: IDBValidKey): FakeRequest<unknown> {
return new FakeRequest(this.records.get(String(key)));
}
getAll(): FakeRequest<unknown[]> {
return new FakeRequest(Array.from(this.records.values()));
}
}
export class FakeTransaction {
oncomplete: Resolve | null = null;
onabort: ((ev?: unknown) => void) | null = null;
onerror: ((ev?: unknown) => void) | null = null;
error: Error | null = null;
constructor(
private readonly database: FakeIndexedDb,
private readonly storeName: string,
private readonly fail: boolean,
) {
microtask2(() => {
if (this.fail) {
this.error = new Error('fake transaction failure');
this.onerror?.(this);
this.onabort?.(this);
return;
}
this.oncomplete?.();
});
}
objectStore(name: string): FakeObjectStore {
if (name !== this.storeName) {
throw new Error(`fake idb: transaction opened for "${this.storeName}", not "${name}"`);
}
return this.database.getOrCreateStore(name);
}
}
export class FakeIndexedDb {
/** DOMStringList-like: the wrapper calls `.contains()` (browser IDB API). */
readonly objectStoreNames = new Set<string>() as Set<string> & { contains(name: string): boolean };
constructor() {
this.objectStoreNames.contains = (name: string) => this.objectStoreNames.has(name);
}
private readonly storeSpecs = new Map<string, string>();
private readonly records = new Map<string, Map<string, unknown>>();
/** Instrumentation per store: { put, delete } counters. */
readonly opCounts = new Map<string, { put: number; delete: number }>();
onclose: (() => void) | null = null;
onversionchange: (() => void) | null = null;
createObjectStore(name: string, options?: { keyPath?: string }) {
this.objectStoreNames.add(name);
this.storeSpecs.set(name, options?.keyPath ?? 'key');
return { name };
}
getOrCreateStore(name: string): FakeObjectStore {
if (!this.storeSpecs.has(name)) {
this.storeSpecs.set(name, 'key');
this.objectStoreNames.add(name);
}
if (!this.records.has(name)) this.records.set(name, new Map());
const spec = { name, keyPath: this.storeSpecs.get(name)! };
const store = new FakeObjectStore(spec, this.records.get(name)!);
const counts = this.opCounts.get(name) ?? { put: 0, delete: 0 };
// Aggregate counts lazily via proxy-free bookkeeping on each op.
const originalPut = store.put.bind(store);
store.put = (value: unknown) => {
counts.put += 1;
this.opCounts.set(name, counts);
return originalPut(value);
};
const originalDelete = store.delete.bind(store);
store.delete = (key: IDBValidKey) => {
counts.delete += 1;
this.opCounts.set(name, counts);
return originalDelete(key);
};
return store;
}
transaction(storeName: string, _mode?: string): FakeTransaction {
return new FakeTransaction(this, storeName, false);
}
close() {
this.onclose?.();
}
/** Direct read for assertions (bypasses the async wrapper). */
dumpStore(name: string): unknown[] {
return Array.from(this.records.get(name)?.values() ?? []);
}
}
export interface FakeIndexedDbHandle {
db: FakeIndexedDb;
openCount: number;
restore: () => void;
}
export function installFakeIndexedDb(options: { failOpen?: boolean; failOpenTimes?: number; onOperation?: (storeName: string, op: 'put' | 'delete') => void } = {}): FakeIndexedDbHandle {
const previous = (globalThis as { indexedDB?: IDBFactory }).indexedDB;
const handle: FakeIndexedDbHandle = {
db: new FakeIndexedDb(),
openCount: 0,
restore: () => {
(globalThis as { indexedDB?: IDBFactory }).indexedDB = previous;
},
};
if (options.onOperation) {
const onOperation = options.onOperation;
const originalGet = handle.db.getOrCreateStore.bind(handle.db);
handle.db.getOrCreateStore = (name: string) => {
const store = originalGet(name);
const originalPut = store.put.bind(store);
store.put = (value: unknown) => {
onOperation(name, 'put');
return originalPut(value);
};
const originalDelete = store.delete.bind(store);
store.delete = (key: IDBValidKey) => {
onOperation(name, 'delete');
return originalDelete(key);
};
return store;
};
}
(globalThis as { indexedDB?: unknown }).indexedDB = {
open(_name: string, _version: number) {
handle.openCount += 1;
if (options.failOpen) {
return new FakeRequest<IDBDatabase>(null as unknown as IDBDatabase, 'failure', new Error('fake open failure'));
}
if (options.failOpenTimes != null && handle.openCount <= options.failOpenTimes) {
// Fail only the first N opens, then recover (boot-retry scenarios).
return new FakeRequest<IDBDatabase>(null as unknown as IDBDatabase, 'failure', new Error('fake open failure'));
}
return new FakeRequest<IDBDatabase>(handle.db as unknown as IDBDatabase, 'upgrade');
},
};
return handle;
}
@@ -0,0 +1,18 @@
/** Install isolated browser storage for hook tests, including Node without Web Storage. */
export function installTestLocalStorage(): () => void {
const previous = Object.getOwnPropertyDescriptor(globalThis, 'localStorage');
const values = new Map<string, string>();
const storage: Storage = {
get length() { return values.size; },
clear: () => values.clear(),
getItem: key => values.get(String(key)) ?? null,
key: index => [...values.keys()][index] ?? null,
removeItem: key => { values.delete(String(key)); },
setItem: (key, value) => { values.set(String(key), String(value)); },
};
Object.defineProperty(globalThis, 'localStorage', { configurable: true, value: storage });
return () => {
if (previous) Object.defineProperty(globalThis, 'localStorage', previous);
else Reflect.deleteProperty(globalThis, 'localStorage');
};
}
+2
View File
@@ -127,6 +127,8 @@ export interface SendToCattyContext {
}
export interface SendToExternalContext {
scopeType?: 'terminal' | 'workspace' | 'global';
scopeTargetId?: string;
existingSessionId?: string;
updateExternalSessionId?: (sessionId: string, externalSessionId: string | undefined) => void;
historyMessages?: Array<{ role: 'user' | 'assistant'; content: string }>;
+29
View File
@@ -12,6 +12,7 @@ import {
STORAGE_KEY_AI_COMMAND_TIMEOUT,
STORAGE_KEY_AI_RESPONSE_IDLE_TIMEOUT,
STORAGE_KEY_AI_MAX_ITERATIONS,
STORAGE_KEY_AI_SESSION_MESSAGE_CAP,
STORAGE_KEY_AI_AGENT_MODEL_MAP,
STORAGE_KEY_AI_AGENT_PROVIDER_MAP,
STORAGE_KEY_AI_WEB_SEARCH,
@@ -34,6 +35,10 @@ import {
normalizeResponseIdleTimeoutSeconds,
} from '../../infrastructure/ai/types';
import { removeProviderReferences } from './aiProviderCleanup';
import {
DEFAULT_AI_SESSION_MESSAGE_CAP,
normalizeAiSessionMessageCap,
} from '../../domain/aiSessionMessageCap';
import { AI_STATE_CHANGED_EVENT, emitAIStateChanged } from './aiStateEvents';
import { getAIBridge } from './aiStateSnapshots';
import { useStoredBoolean } from './useStoredBoolean';
@@ -87,6 +92,12 @@ export function useAISettingsState() {
const [maxIterations, setMaxIterationsRaw] = useState<number>(() =>
localStorageAdapter.readNumber(STORAGE_KEY_AI_MAX_ITERATIONS) ?? 20
);
const [aiSessionMessageCap, setAiSessionMessageCapRaw] = useState<number>(() =>
normalizeAiSessionMessageCap(
localStorageAdapter.readNumber(STORAGE_KEY_AI_SESSION_MESSAGE_CAP)
?? DEFAULT_AI_SESSION_MESSAGE_CAP,
)
);
const [webSearchConfig, setWebSearchConfigRaw] = useState<WebSearchConfig | null>(() =>
localStorageAdapter.read<WebSearchConfig>(STORAGE_KEY_AI_WEB_SEARCH) ?? null
);
@@ -197,6 +208,12 @@ export function useAISettingsState() {
getAIBridge()?.aiMcpSetMaxIterations?.(value);
}, []);
const setAiSessionMessageCap = useCallback((value: number) => {
const normalizedValue = normalizeAiSessionMessageCap(value);
setAiSessionMessageCapRaw(normalizedValue);
localStorageAdapter.writeNumber(STORAGE_KEY_AI_SESSION_MESSAGE_CAP, normalizedValue);
}, []);
const setWebSearchConfig = useCallback((config: WebSearchConfig | null) => {
setWebSearchConfigRaw(config);
if (config) {
@@ -277,6 +294,14 @@ export function useAISettingsState() {
getAIBridge()?.aiMcpSetMaxIterations?.(iters);
break;
}
case STORAGE_KEY_AI_SESSION_MESSAGE_CAP:
setAiSessionMessageCapRaw(
normalizeAiSessionMessageCap(
localStorageAdapter.readNumber(STORAGE_KEY_AI_SESSION_MESSAGE_CAP)
?? DEFAULT_AI_SESSION_MESSAGE_CAP,
),
);
break;
case STORAGE_KEY_AI_WEB_SEARCH:
setWebSearchConfigRaw(localStorageAdapter.read<WebSearchConfig>(STORAGE_KEY_AI_WEB_SEARCH) ?? null);
break;
@@ -340,6 +365,8 @@ export function useAISettingsState() {
setResponseIdleTimeout,
maxIterations,
setMaxIterations,
aiSessionMessageCap,
setAiSessionMessageCap,
webSearchConfig,
setWebSearchConfig,
quickMessages,
@@ -373,6 +400,8 @@ export function useAISettingsState() {
setResponseIdleTimeout,
maxIterations,
setMaxIterations,
aiSessionMessageCap,
setAiSessionMessageCap,
webSearchConfig,
setWebSearchConfig,
quickMessages,
@@ -0,0 +1,489 @@
/**
* Boot-scenario regression tests for the IndexedDB migration review fixes
* (issue #4125 review): P1 maintenance gating and complete legacy migration,
* P2① baseline seeding after an IndexedDB failure,
* P2② pointer validation gated on hydration, P2③ disk-backup restore when
* IndexedDB stays unopenable, and P2④ adopting an intentional cross-window
* deletion.
*/
import assert from 'node:assert/strict';
import test from 'node:test';
import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import { JSDOM } from 'jsdom';
import type { AISession } from '../../infrastructure/ai/types';
import {
STORAGE_KEY_AI_ACTIVE_SESSION_MAP,
STORAGE_KEY_AI_SESSIONS,
STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW,
STORAGE_KEY_AI_SESSIONS_OVERFLOW,
} from '../../infrastructure/config/storageKeys';
import { useAIState } from './useAIState';
import { getAISessionsSnapshot } from './aiSessionsStore';
import {
__flushIdbWritesForTests,
__resetAiSessionsHydrationForTests,
__resetBackupMirrorForTests,
__resetIdbPersistenceForTests,
getPersistedAiSessionsSnapshot,
flushBackupMirror,
latestAISessionsSnapshot,
setLatestAIActiveSessionMapSnapshot,
setLatestAIDraftsByScopeSnapshot,
setLatestAIPanelViewByScopeSnapshot,
setLatestAISessionsSnapshot,
} from './aiStateSnapshots';
import {
idbDeleteSessionIds,
AI_SESSIONS_META_MIGRATION_KEY,
idbPutSessions,
loadOrMigrateAiSessions,
openAiSessionsIdb,
resetAiSessionsIdbForTests,
} from './aiSessionIdbStorage';
import { installFakeIndexedDb } from './testSupport/fakeIndexedDb';
function makeSession(id: string, updatedAt: number): AISession {
return {
id,
title: id,
agentId: 'catty',
scope: { type: 'global' },
messages: [],
createdAt: updatedAt,
updatedAt,
} as AISession;
}
function installDom() {
const dom = new JSDOM('<!doctype html><html><body></body></html>', { url: 'http://localhost' });
for (const name of ['window', 'document', 'localStorage', 'CustomEvent', 'Event'] as const) {
Object.defineProperty(globalThis, name, { configurable: true, value: name === 'window' ? dom.window : dom.window[name] });
}
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { configurable: true, value: true });
return dom;
}
interface Mounted {
ai: ReturnType<typeof useAIState>;
root: ReturnType<typeof createRoot>;
container: HTMLDivElement;
}
async function mountAiState(onMaintenance?: (ai: ReturnType<typeof useAIState>) => void): Promise<Mounted> {
const holder: { ai?: ReturnType<typeof useAIState> } = {};
function Maintenance({ ai }: { ai: ReturnType<typeof useAIState> }) {
const mountAi = React.useRef(ai);
React.useEffect(() => { onMaintenance?.(mountAi.current); }, []);
return null;
}
function Harness() {
holder.ai = useAIState();
return onMaintenance ? React.createElement(Maintenance, { ai: holder.ai }) : null;
}
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
await act(async () => { root.render(React.createElement(Harness)); });
return { ai: holder.ai!, root, container };
}
async function unmount(mounted: Mounted, dom: JSDOM) {
await act(async () => { mounted.root.unmount(); });
mounted.container.remove();
dom.window.close();
}
function resetBootState() {
// Post-boot defaults first, then back to pending so every test starts
// from the pre-hydration state its own hook instance controls.
__resetIdbPersistenceForTests();
__resetAiSessionsHydrationForTests();
// Module-level snapshots leak across tests (the hook's initial state and
// the boot merge path read them) — reset to the cold-start state. The
// map snapshot must be null so the hook falls back to localStorage.
setLatestAISessionsSnapshot([]);
setLatestAIActiveSessionMapSnapshot(null as unknown as Record<string, string | null>);
setLatestAIDraftsByScopeSnapshot({});
setLatestAIPanelViewByScopeSnapshot({});
}
test.beforeEach(() => {
__resetBackupMirrorForTests();
resetBootState();
});
test.afterEach(() => { __resetBackupMirrorForTests(); });
test('P1: deleting the last migrated chat with no legacy key never restores a stale disk backup', async () => {
const dom = installDom();
const idb = installFakeIndexedDb();
const deleted = makeSession('deleted-chat', 1);
await loadOrMigrateAiSessions(() => JSON.stringify([deleted]));
await idbDeleteSessionIds(await openAiSessionsIdb(), [deleted.id]);
let backupReads = 0;
const backupWrites: string[] = [];
Object.assign(dom.window, {
netcatty: {
aiSessionsBackupReadLatest: async () => {
backupReads += 1;
return { ok: true, json: JSON.stringify([deleted]) };
},
aiSessionsBackupWrite: async ({ json }: { json: string }) => {
backupWrites.push(json);
return { ok: true };
},
},
});
const mounted = await mountAiState();
try {
flushBackupMirror();
await __flushIdbWritesForTests();
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_AI_SESSIONS), null);
assert.equal(backupReads, 0);
assert.deepEqual(backupWrites, []);
assert.deepEqual(getAISessionsSnapshot().sessions, []);
assert.deepEqual(idb.db.dumpStore('sessions'), []);
} finally {
await unmount(mounted, dom);
idb.restore();
resetAiSessionsIdbForTests();
}
});
test('P1: an uninitialized IndexedDB still restores the disk backup', async () => {
const dom = installDom();
const idb = installFakeIndexedDb();
const backup = [makeSession('backup-chat', 1)];
Object.assign(dom.window, {
netcatty: { aiSessionsBackupReadLatest: async () => ({ ok: true, json: JSON.stringify(backup) }) },
});
const mounted = await mountAiState();
try {
await __flushIdbWritesForTests();
assert.deepEqual(getAISessionsSnapshot().sessions, backup);
assert.deepEqual(idb.db.dumpStore('sessions'), backup);
} finally {
await unmount(mounted, dom);
idb.restore();
resetAiSessionsIdbForTests();
}
});
for (const failure of ['not-ok', 'rejection', 'missing-id'] as const) {
test(`P1: disk overflow ${failure} keeps migration pending and backups intact, then retries beside primary IDB records`, async () => {
const dom = installDom();
const idb = installFakeIndexedDb();
const primary = makeSession('primary-chat', 3);
const chunk = makeSession('chunk-chat', 2);
const diskOnly = makeSession('disk-only-chat', 1);
const unmarked = makeSession('unmarked-deleted-chat', 0);
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS, JSON.stringify([primary]));
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS_OVERFLOW, JSON.stringify([chunk]));
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW, JSON.stringify([diskOnly.id]));
let recoveryAvailable = false;
let backupReads = 0;
const backupWrites: string[] = [];
Object.assign(dom.window, {
netcatty: {
aiSessionsBackupReadLatest: async () => {
backupReads += 1;
if (recoveryAvailable) return { ok: true, json: JSON.stringify([primary, chunk, diskOnly, unmarked]) };
if (failure === 'rejection') throw new Error('disk temporarily unavailable');
if (failure === 'missing-id') return { ok: true, json: JSON.stringify([unmarked]) };
return { ok: false };
},
aiSessionsBackupWrite: async ({ json }: { json: string }) => {
backupWrites.push(json);
return { ok: true };
},
},
});
let mounted = await mountAiState();
const hasMarker = () => idb.db.dumpStore('meta').some(entry => (entry as { key: string }).key === AI_SESSIONS_META_MIGRATION_KEY);
try {
assert.equal(backupReads, 1);
assert.equal(hasMarker(), false);
assert.deepEqual(getAISessionsSnapshot().sessions, [primary, chunk]);
assert.deepEqual(idb.db.dumpStore('sessions'), [primary, chunk]);
flushBackupMirror();
assert.deepEqual(backupWrites, [], 'boot must not mirror a partial migration');
// Ordinary edits and unload must also preserve the recovery generation.
await act(async () => {
mounted.ai.addMessageToSession(primary.id, { id: 'new-message', role: 'user', content: 'New edit', timestamp: 4 });
});
await new Promise(resolve => setTimeout(resolve, 600));
await __flushIdbWritesForTests();
dom.window.dispatchEvent(new dom.window.Event('beforeunload'));
assert.deepEqual(backupWrites, []);
const updated = getAISessionsSnapshot().sessions.find(session => session.id === primary.id)!;
assert.equal(updated.messages.length, 1);
assert.equal(hasMarker(), false);
await act(async () => { mounted.root.unmount(); });
mounted.container.remove();
resetBootState();
recoveryAvailable = true;
mounted = await mountAiState();
await __flushIdbWritesForTests();
assert.equal(backupReads, 2, 'populated IDB must still retry unresolved disk IDs');
assert.equal(hasMarker(), true);
const expected = [updated, chunk, diskOnly];
assert.deepEqual(getAISessionsSnapshot().sessions, expected);
assert.deepEqual(idb.db.dumpStore('sessions'), expected);
flushBackupMirror();
assert.equal(backupWrites.length, 1, 'mirroring resumes only after all marked IDs are recovered');
assert.deepEqual(JSON.parse(backupWrites[0]), expected);
} finally {
await unmount(mounted, dom);
idb.restore();
resetAiSessionsIdbForTests();
}
});
}
test('P1: child mount maintenance waits for hydration and marks the IndexedDB transcript dangling', async () => {
const dom = installDom();
const idb = installFakeIndexedDb();
const stale: AISession = {
...makeSession('closed-chat', 1),
scope: { type: 'terminal', targetId: 'closed-terminal' },
messages: [{ id: 'm-1', role: 'user', content: 'Old history', timestamp: 1 }],
};
const current: AISession = {
...stale,
updatedAt: 2,
messages: [...stale.messages, { id: 'm-2', role: 'assistant', content: 'New history', timestamp: 2 }],
};
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS, JSON.stringify([stale]));
await idbPutSessions(await openAiSessionsIdb(), [current]);
setLatestAISessionsSnapshot(null as unknown as AISession[]);
let snapshotAfterMaintenance: AISession[] | null | undefined;
let putsAfterMaintenance = 0;
const mounted = await mountAiState((ai) => {
ai.cleanupOrphanedSessions(new Set(['live-terminal']));
snapshotAfterMaintenance = latestAISessionsSnapshot;
putsAfterMaintenance = idb.db.opCounts.get('sessions')!.put;
});
try {
assert.equal(snapshotAfterMaintenance, null, 'pending maintenance must not adopt the legacy transcript');
assert.equal(putsAfterMaintenance, 1, 'pending maintenance must not persist stale history');
await __flushIdbWritesForTests();
const expected = [{ ...current, scopeDangling: true }];
assert.deepEqual(getAISessionsSnapshot().sessions, expected);
assert.deepEqual(idb.db.dumpStore('sessions'), expected);
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_AI_SESSIONS), JSON.stringify([stale]));
} finally {
await unmount(mounted, dom);
idb.restore();
resetAiSessionsIdbForTests();
}
});
test('P1: migration includes chunked overflow alongside a populated primary', async () => {
const dom = installDom();
const idb = installFakeIndexedDb();
const primary = makeSession('primary-chat', 3);
const overflow = [makeSession('overflow-a', 2), makeSession('overflow-b', 1), { ...primary, updatedAt: 0 }];
const overflowJson = JSON.stringify(overflow);
const split = Math.floor(overflowJson.length / 2);
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS, JSON.stringify([primary]));
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS_OVERFLOW, overflowJson.slice(0, split));
dom.window.localStorage.setItem(`${STORAGE_KEY_AI_SESSIONS_OVERFLOW}_2`, overflowJson.slice(split));
const mounted = await mountAiState();
try {
const expected = [primary, ...overflow.slice(0, 2)];
assert.deepEqual(getAISessionsSnapshot().sessions, expected);
assert.deepEqual(idb.db.dumpStore('sessions'), expected);
assert.ok(idb.db.dumpStore('meta').some((entry) => (entry as { key: string }).key === AI_SESSIONS_META_MIGRATION_KEY));
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_AI_SESSIONS_OVERFLOW), overflowJson.slice(0, split));
assert.equal(dom.window.localStorage.getItem(`${STORAGE_KEY_AI_SESSIONS_OVERFLOW}_2`), overflowJson.slice(split));
} finally {
await unmount(mounted, dom);
idb.restore();
resetAiSessionsIdbForTests();
}
});
test('P1: migration awaits disk-only recovery before recording completion beside a populated primary', async () => {
const dom = installDom();
const idb = installFakeIndexedDb();
const primary = makeSession('primary-chat', 3);
const diskOnly = makeSession('disk-only-chat', 2);
const deleted = makeSession('deleted-chat', 1);
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS, JSON.stringify([primary]));
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW, JSON.stringify([diskOnly.id]));
let resolveBackup!: (result: { ok: boolean; json: string }) => void;
const backup = new Promise<{ ok: boolean; json: string }>((resolve) => { resolveBackup = resolve; });
let backupReads = 0;
Object.assign(dom.window, {
netcatty: { aiSessionsBackupReadLatest: () => { backupReads += 1; return backup; } },
});
const mounted = await mountAiState();
try {
assert.equal(backupReads, 1);
assert.equal(idb.db.dumpStore('meta').some((entry) => (entry as { key: string }).key === AI_SESSIONS_META_MIGRATION_KEY), false);
await act(async () => {
resolveBackup({ ok: true, json: JSON.stringify([primary, diskOnly, deleted]) });
});
assert.deepEqual(getAISessionsSnapshot().sessions, [primary, diskOnly]);
assert.deepEqual(idb.db.dumpStore('sessions'), [primary, diskOnly]);
assert.ok(idb.db.dumpStore('meta').some((entry) => (entry as { key: string }).key === AI_SESSIONS_META_MIGRATION_KEY));
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW), JSON.stringify([diskOnly.id]));
} finally {
await unmount(mounted, dom);
idb.restore();
resetAiSessionsIdbForTests();
}
});
// ── P2①: idbFailed boot must persist the legacy set before seeding the baseline ──
test('P2①a: after a transient IndexedDB open failure the legacy sessions are persisted and the baseline seeded', async () => {
const dom = installDom();
const idb = installFakeIndexedDb({ failOpenTimes: 1 });
const legacy = [makeSession('chat-a', 2), makeSession('chat-b', 1)];
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS, JSON.stringify(legacy));
const mounted = await mountAiState();
try {
// One open retry happened inside the boot effect: the legacy set now
// lives in IndexedDB (old code never retried and left the store empty).
const ids = idb.db.dumpStore('sessions').map((s) => (s as AISession).id).sort();
assert.deepEqual(ids, ['chat-a', 'chat-b']);
assert.deepEqual(
getPersistedAiSessionsSnapshot().map((s) => s.id).sort(),
['chat-a', 'chat-b'],
);
assert.deepEqual(getAISessionsSnapshot().sessions.map((s) => s.id).sort(), ['chat-a', 'chat-b']);
} finally {
await unmount(mounted, dom);
idb.restore();
resetAiSessionsIdbForTests();
}
});
test('P2①b: with IndexedDB unreachable the baseline stays unset, so a later edit rewrites every record', async () => {
const dom = installDom();
const failing = installFakeIndexedDb({ failOpen: true });
const legacy = [makeSession('chat-a', 2), makeSession('chat-b', 1)];
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS, JSON.stringify(legacy));
const mounted = await mountAiState();
let recovered: ReturnType<typeof installFakeIndexedDb> | null = null;
try {
// The retry open also failed: nothing was persisted, and the diff
// baseline must remain unset.
assert.deepEqual(failing.db.dumpStore('sessions'), []);
// IndexedDB recovers, then the user edits ONE session. The full set
// must reach the store, not only the edited record.
failing.restore();
resetAiSessionsIdbForTests();
recovered = installFakeIndexedDb();
await act(async () => {
mounted.ai.addMessageToSession('chat-a', { id: 'm-1', role: 'user', content: 'Edited', timestamp: 3 });
});
await new Promise((resolve) => setTimeout(resolve, 600)); // debounced persist
await act(async () => {});
await __flushIdbWritesForTests();
const ids = recovered.db.dumpStore('sessions').map((s) => (s as AISession).id).sort();
assert.deepEqual(ids, ['chat-a', 'chat-b']);
} finally {
await unmount(mounted, dom);
if (recovered) recovered.restore();
resetAiSessionsIdbForTests();
}
});
// ── P2②: pointer validation waits for hydration ──
test('P2②: pointers survive cold mount before hydration and stay valid after the boot load', async () => {
const dom = installDom();
const source = makeSession('chat-1', 1);
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS, JSON.stringify([source]));
dom.window.localStorage.setItem(
STORAGE_KEY_AI_ACTIVE_SESSION_MAP,
JSON.stringify({ 'terminal:t1': 'chat-1' }),
);
const mounted = await mountAiState();
try {
// Old code nulled the pointer here: the validation effect ran against
// the initially-empty in-memory session array.
assert.equal(getAISessionsSnapshot().activeSessionIdMap['terminal:t1'], 'chat-1');
assert.equal(
JSON.parse(dom.window.localStorage.getItem(STORAGE_KEY_AI_ACTIVE_SESSION_MAP)!)['terminal:t1'],
'chat-1',
);
// After hydration the session is restored and the pointer is intact.
await act(async () => {});
assert.deepEqual(getAISessionsSnapshot().sessions.map((s) => s.id), ['chat-1']);
assert.equal(getAISessionsSnapshot().activeSessionIdMap['terminal:t1'], 'chat-1');
} finally {
await unmount(mounted, dom);
}
});
// ── P2③: unopenable IndexedDB + corrupt legacy → disk backup is applied ──
test('P2③: with IndexedDB unopenable and the legacy store corrupt the newest disk backup generation is applied', async () => {
const dom = installDom();
installFakeIndexedDb({ failOpen: true });
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS, 'not-json'); // corrupt legacy
const backup = [makeSession('backup-chat', 5)];
const warns: unknown[][] = [];
const originalWarn = console.warn;
console.warn = (...args: unknown[]) => { warns.push(args); };
try {
Object.assign(dom.window, {
netcatty: { aiSessionsBackupReadLatest: async () => ({ ok: true, json: JSON.stringify(backup) }) },
});
const mounted = await mountAiState();
try {
await act(async () => {});
// The backup generation became the recovery source (old code: the
// failing IndexedDB recheck rejection was swallowed and the restore
// never ran).
assert.deepEqual(getAISessionsSnapshot().sessions.map((s) => s.id), ['backup-chat']);
assert.deepEqual(getPersistedAiSessionsSnapshot().map((s) => s.id), ['backup-chat']);
// The unopenable-store error was surfaced, not swallowed.
assert.ok(warns.some((args) => String(args[0]).includes('IndexedDB recheck failed')));
} finally {
await unmount(mounted, dom);
}
} finally {
console.warn = originalWarn;
delete (dom.window as unknown as { netcatty?: unknown }).netcatty;
resetAiSessionsIdbForTests();
}
});
// ── P2④: an empty getAll after hydration is an intentional deletion ──
test('P2④: adopting an empty IndexedDB store after another window deleted the last chat', async () => {
const dom = installDom();
const idb = installFakeIndexedDb();
const source = makeSession('chat-1', 1);
await idbPutSessions(await openAiSessionsIdb(), [source]);
const mounted = await mountAiState();
try {
await act(async () => {});
assert.deepEqual(getAISessionsSnapshot().sessions.map((s) => s.id), ['chat-1']);
// Another window deletes the last chat and announces the change. The
// announcement must come from a second BroadcastChannel: a channel never
// delivers to itself (the module-level channel is the receiving window).
await idbDeleteSessionIds(await openAiSessionsIdb(), ['chat-1']);
const otherWindow = new BroadcastChannel('netcatty_ai_sessions_changed');
await act(async () => {
otherWindow.postMessage(Date.now());
await new Promise((resolve) => setTimeout(resolve, 25));
});
otherWindow.close();
// Old code early-returned on the empty getAll and kept the deleted
// session alive locally.
assert.deepEqual(getAISessionsSnapshot().sessions, []);
assert.deepEqual(getPersistedAiSessionsSnapshot(), []);
} finally {
await unmount(mounted, dom);
idb.restore();
resetAiSessionsIdbForTests();
}
});
+11 -4
View File
@@ -8,7 +8,7 @@ import type { AISession } from '../../infrastructure/ai/types.ts';
import { getAgentRuntime } from '../../infrastructure/ai/harness/globalAgentRuntime.ts';
import { useAIState } from './useAIState.ts';
import { useAISessionsStore } from './aiSessionsStore.ts';
import { setLatestAISessionsSnapshot } from './aiStateSnapshots.ts';
import { getPersistedAiSessionsSnapshot, setLatestAISessionsSnapshot } from './aiStateSnapshots.ts';
const SOURCE_SESSION: AISession = {
id: 'chat-source',
@@ -121,11 +121,18 @@ for (const newerCount of [49, 50]) {
let branch: AISession | null = null;
await act(async () => { branch = await capture.ai!.forkSessionFromMessage(SOURCE_SESSION.id, 'assistant-1'); });
assert.ok(branch);
const saved: AISession[] = JSON.parse(dom.window.localStorage.getItem('netcatty_ai_sessions_v1')!);
assert.equal(saved.length, 50);
// Issue #4123: the persisted set lives in IndexedDB (count cap 1000, so
// nothing is evicted at these sizes) — not in a 50-capped localStorage
// blob.
const saved: AISession[] = getPersistedAiSessionsSnapshot();
assert.equal(saved.length, newerCount + 2);
assert.deepEqual(saved.find(item => item.id === SOURCE_SESSION.id)?.messages, SOURCE_SESSION.messages);
assert.deepEqual(saved.find(item => item.id === branch!.id)?.messages, SOURCE_SESSION.messages.slice(0, 2));
await act(async () => root.unmount()); container.remove(); dom.window.close();
await act(async () => root.unmount()); container.remove();
// Reload from the read-only legacy store (the stand-in for what the
// migration persists on a profile without IndexedDB).
dom.window.localStorage.setItem('netcatty_ai_sessions_v1', JSON.stringify(saved));
dom.window.close();
const reloaded = await setupAiState(saved);
assert.equal(reloaded.capture.sessions.find(item => item.id === SOURCE_SESSION.id)?.messages.length, 4);
assert.equal(reloaded.capture.sessions.find(item => item.id === branch!.id)?.messages.length, 2);
@@ -4,10 +4,10 @@ import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import { JSDOM } from 'jsdom';
import type { AISession } from '../../infrastructure/ai/types';
import { STORAGE_KEY_AI_ACTIVE_SESSION_MAP, STORAGE_KEY_AI_SESSIONS, STORAGE_KEY_AI_SESSIONS_OVERFLOW, STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW, STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS } from '../../infrastructure/config/storageKeys';
import { STORAGE_KEY_AI_SESSIONS, STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS } from '../../infrastructure/config/storageKeys';
import { useAIState } from './useAIState';
import { useAISessionsStore } from './aiSessionsStore';
import { __resetBackupMirrorForTests, flushBackupMirror, readSessionsFromStorage, setLatestAIActiveSessionMapSnapshot, setLatestAISessionsSnapshot, writeSessionsForStorage } from './aiStateSnapshots';
import { setLatestAISessionsSnapshot } from './aiStateSnapshots';
for (const enabled of [true, false]) {
test(`appending to long history preserves the synced prefix (chat sync ${enabled})`, async () => {
@@ -54,403 +54,3 @@ for (const enabled of [true, false]) {
});
}
test('startup and cross-window updates load overflow chats and mirror the merged history', async () => {
const dom = new JSDOM('<!doctype html><html><body></body></html>', { url: 'http://localhost' });
for (const name of ['window', 'document', 'localStorage', 'CustomEvent', 'Event'] as const) {
Object.defineProperty(globalThis, name, { configurable: true, value: name === 'window' ? dom.window : dom.window[name] });
}
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { configurable: true, value: true });
const primary: AISession = {
id: 'primary', title: 'Primary', agentId: 'catty', scope: { type: 'global' },
createdAt: 1, updatedAt: 2, messages: [],
};
const overflow = { ...primary, id: 'overflow', title: 'Overflow', updatedAt: 1 };
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS, JSON.stringify([primary]));
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS_OVERFLOW, JSON.stringify([overflow]));
setLatestAISessionsSnapshot(null as unknown as AISession[]);
__resetBackupMirrorForTests();
const backups: string[] = [];
Object.assign(dom.window, { netcatty: { aiSessionsBackupWrite: async ({ json }: { json: string }) => {
backups.push(json);
return { ok: true };
} } });
let sessions: readonly AISession[] = [];
function Harness() {
useAIState();
sessions = useAISessionsStore().sessions;
return null;
}
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
try {
await act(async () => { root.render(React.createElement(Harness)); });
assert.deepEqual(sessions, [primary, overflow]);
flushBackupMirror();
assert.deepEqual(JSON.parse(backups[0]), [primary, overflow]);
const updatedOverflow = { ...overflow, title: 'Updated elsewhere', updatedAt: 3 };
await act(async () => {
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS_OVERFLOW, JSON.stringify([updatedOverflow]));
dom.window.dispatchEvent(new dom.window.StorageEvent('storage', { key: STORAGE_KEY_AI_SESSIONS_OVERFLOW }));
});
assert.deepEqual(sessions, [updatedOverflow, primary]);
} finally {
await act(async () => { root.unmount(); });
__resetBackupMirrorForTests();
setLatestAISessionsSnapshot([]);
container.remove();
dom.window.close();
}
});
for (const scopeType of ['global', 'terminal'] as const) {
test(`import retains disk-only chats in live history across rename and deletion (${scopeType})`, async () => {
const dom = new JSDOM('<!doctype html><html><body></body></html>', {
url: 'http://localhost', storageQuota: 5 * 1024 * 1024,
});
const previousDOMException = globalThis.DOMException;
for (const name of ['window', 'document', 'localStorage', 'CustomEvent', 'Event', 'DOMException'] as const) {
Object.defineProperty(globalThis, name, { configurable: true, value: name === 'window' ? dom.window : dom.window[name] });
}
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { configurable: true, value: true });
const source: AISession[] = Array.from({ length: 6 }, (_, index) => ({
id: `imported-${index}`, title: `Imported ${index}`, agentId: 'catty',
scope: scopeType === 'global' ? { type: 'global' } : { type: 'terminal', targetId: 'closed-terminal' },
createdAt: 1, updatedAt: 6 - index,
messages: [{ id: 'm', role: 'user', content: 'x'.repeat(Math.floor(1.1 * 1024 * 1024)), timestamp: 1 }],
}));
const ids = source.map(session => session.id);
const backups: string[] = [];
Object.assign(dom.window, { netcatty: {
aiSessionsBackupImport: async () => ({ success: true, json: JSON.stringify(source) }),
aiSessionsBackupWrite: async ({ json }: { json: string }) => { backups.push(json); return { ok: true }; },
} });
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS, '[]');
setLatestAISessionsSnapshot([]);
setLatestAIActiveSessionMapSnapshot({});
__resetBackupMirrorForTests();
let ai!: ReturnType<typeof useAIState>;
let sessions: readonly AISession[] = [];
function Harness() {
ai = useAIState();
sessions = useAISessionsStore().sessions;
return null;
}
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
try {
await act(async () => { root.render(React.createElement(Harness)); });
await act(async () => { ai.cleanupOrphanedSessions(new Set()); });
let importedCount = 0;
await act(async () => { importedCount = await ai.importSessionsBackup(); });
assert.equal(importedCount, source.length);
assert.deepEqual(sessions.map(session => session.id), ids);
assert.deepEqual(readSessionsFromStorage().map(session => session.id), [ids[0]]);
assert.deepEqual(JSON.parse(dom.window.localStorage.getItem(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW)!), ids.slice(1));
assert.equal(sessions.every(session => !!session.scopeDangling === (scopeType === 'terminal')), true);
flushBackupMirror();
assert.deepEqual(JSON.parse(backups.at(-1)!), sessions);
await act(async () => { ai.updateSessionTitle(ids[0], 'Renamed after import'); });
assert.deepEqual(sessions.map(session => session.id), ids);
assert.equal(sessions[0].title, 'Renamed after import');
assert.deepEqual(JSON.parse(dom.window.localStorage.getItem(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW)!), ids.slice(1));
flushBackupMirror();
assert.deepEqual(JSON.parse(backups.at(-1)!), sessions);
await act(async () => { ai.deleteSession(ids[5]); });
assert.deepEqual(sessions.map(session => session.id), ids.slice(0, 5));
assert.deepEqual(JSON.parse(dom.window.localStorage.getItem(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW)!), ids.slice(1, 5));
flushBackupMirror();
assert.deepEqual(JSON.parse(backups.at(-1)!), sessions);
} finally {
await act(async () => { root.unmount(); });
__resetBackupMirrorForTests();
setLatestAISessionsSnapshot([]);
setLatestAIActiveSessionMapSnapshot({});
container.remove();
dom.window.close();
Object.defineProperty(globalThis, 'DOMException', { configurable: true, value: previousDOMException });
}
});
}
test('startup recovers disk-only chats before mirroring a valid partial primary', async () => {
const dom = new JSDOM('<!doctype html><html><body></body></html>', {
url: 'http://localhost', storageQuota: 5 * 1024 * 1024,
});
const previousDOMException = globalThis.DOMException;
for (const name of ['window', 'document', 'localStorage', 'CustomEvent', 'Event', 'DOMException'] as const) {
Object.defineProperty(globalThis, name, { configurable: true, value: name === 'window' ? dom.window : dom.window[name] });
}
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { configurable: true, value: true });
const source: AISession[] = Array.from({ length: 6 }, (_, index) => ({
id: `chat-${index}`, title: `Chat ${index}`, agentId: 'catty', scope: { type: 'global' },
createdAt: 1, updatedAt: 6 - index,
messages: [{ id: 'm', role: 'user', content: 'x'.repeat(Math.floor(1.1 * 1024 * 1024)), timestamp: 1 }],
}));
const backups: string[] = [];
let reads = 0;
let resolveRead!: (backup: { ok: boolean; json: string }) => void;
Object.assign(dom.window, { netcatty: {
aiSessionsBackupWrite: async ({ json }: { json: string }) => { backups.push(json); return { ok: true }; },
aiSessionsBackupReadLatest: () => {
reads += 1;
return new Promise(resolve => { resolveRead = resolve; });
},
} });
__resetBackupMirrorForTests();
assert.equal(writeSessionsForStorage(source), true);
flushBackupMirror();
assert.deepEqual(JSON.parse(backups[0]), source);
assert.deepEqual(readSessionsFromStorage(), [source[0]]);
assert.deepEqual(JSON.parse(dom.window.localStorage.getItem(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW)!), source.slice(1).map(session => session.id));
// A newer local copy must win, and an old deleted chat in the backup must
// stay deleted: only the explicitly recorded disk-only IDs are recovered.
const primary = { ...source[0], title: 'Newer primary', updatedAt: 10 };
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS, JSON.stringify([primary]));
const expected = [primary, ...source.slice(1)];
const staleBackup = JSON.stringify([...source, { ...source[5], id: 'deleted-chat' }]);
backups.length = 0;
__resetBackupMirrorForTests();
setLatestAISessionsSnapshot(null as unknown as AISession[]);
let sessions: readonly AISession[] = [];
function Harness() {
useAIState();
sessions = useAISessionsStore().sessions;
return null;
}
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
try {
await act(async () => { root.render(React.createElement(Harness)); });
assert.equal(reads, 1);
flushBackupMirror();
assert.deepEqual(backups, []);
await act(async () => { resolveRead({ ok: true, json: staleBackup }); });
assert.deepEqual(sessions, expected);
flushBackupMirror();
assert.deepEqual(JSON.parse(backups.at(-1)!), expected);
assert.deepEqual(readSessionsFromStorage(), [primary]);
} finally {
await act(async () => { root.unmount(); });
__resetBackupMirrorForTests();
setLatestAISessionsSnapshot([]);
container.remove();
dom.window.close();
Object.defineProperty(globalThis, 'DOMException', { configurable: true, value: previousDOMException });
}
});
for (const scopeType of ['terminal', 'workspace'] as const) {
test(`startup orphan cleanup preserves pending disk-only recovery (${scopeType})`, async () => {
const dom = new JSDOM('<!doctype html><html><body></body></html>', { url: 'http://localhost' });
for (const name of ['window', 'document', 'localStorage', 'CustomEvent', 'Event'] as const) {
Object.defineProperty(globalThis, name, { configurable: true, value: name === 'window' ? dom.window : dom.window[name] });
}
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { configurable: true, value: true });
const primary: AISession = {
id: 'closed-primary', title: 'Closed primary', agentId: 'catty',
scope: { type: scopeType, targetId: 'closed-target' },
createdAt: 1, updatedAt: 2, messages: [],
};
const diskOnly: AISession = {
...primary, id: 'disk-only', title: 'Disk only', updatedAt: 1,
scope: { type: scopeType, targetId: 'another-closed-target' },
};
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS, JSON.stringify([primary]));
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW, JSON.stringify([diskOnly.id]));
setLatestAISessionsSnapshot(null as unknown as AISession[]);
__resetBackupMirrorForTests();
const backups: string[] = [];
let reads = 0;
let resolveRead!: (backup: { ok: boolean; json: string }) => void;
Object.assign(dom.window, { netcatty: {
aiSessionsBackupWrite: async ({ json }: { json: string }) => { backups.push(json); return { ok: true }; },
aiSessionsBackupReadLatest: () => {
reads += 1;
return new Promise(resolve => { resolveRead = resolve; });
},
} });
let sessions: readonly AISession[] = [];
function Harness() {
const ai = useAIState();
sessions = useAISessionsStore().sessions;
// TerminalLayerSupport performs this cleanup on mount, while the disk
// backup IPC read can still be pending.
React.useEffect(() => { ai.cleanupOrphanedSessions(new Set()); }, [ai.cleanupOrphanedSessions]);
return null;
}
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
try {
await act(async () => { root.render(React.createElement(Harness)); });
assert.equal(reads, 1);
assert.deepEqual(sessions, [{ ...primary, scopeDangling: true }]);
assert.deepEqual(JSON.parse(dom.window.localStorage.getItem(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW)!), [diskOnly.id]);
flushBackupMirror();
assert.deepEqual(backups, []);
await act(async () => { resolveRead({ ok: true, json: JSON.stringify([primary, diskOnly]) }); });
const expected = [primary, diskOnly].map(session => ({ ...session, scopeDangling: true }));
assert.deepEqual(sessions, expected);
assert.deepEqual(readSessionsFromStorage(), expected);
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW), null);
flushBackupMirror();
assert.deepEqual(JSON.parse(backups.at(-1)!), expected);
} finally {
await act(async () => { root.unmount(); });
__resetBackupMirrorForTests();
setLatestAISessionsSnapshot([]);
container.remove();
dom.window.close();
}
});
}
for (const mutation of ['rename', 'create', 'delete', 'delete-pending'] as const) {
test(`live ${mutation} preserves intended history across pending disk recovery`, async () => {
const dom = new JSDOM('<!doctype html><html><body></body></html>', { url: 'http://localhost' });
for (const name of ['window', 'document', 'localStorage', 'CustomEvent', 'Event'] as const) {
Object.defineProperty(globalThis, name, { configurable: true, value: name === 'window' ? dom.window : dom.window[name] });
}
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { configurable: true, value: true });
const primary: AISession = {
id: 'primary', title: 'Primary', agentId: 'catty', scope: { type: 'global' },
createdAt: 1, updatedAt: 2, messages: [],
};
const diskOnly = { ...primary, id: 'disk-only', updatedAt: 1 };
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS, JSON.stringify([primary]));
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW, JSON.stringify([diskOnly.id]));
dom.window.localStorage.setItem(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, JSON.stringify({ 'global:': diskOnly.id }));
setLatestAIActiveSessionMapSnapshot({ 'global:': diskOnly.id });
setLatestAISessionsSnapshot(null as unknown as AISession[]);
__resetBackupMirrorForTests();
const backups: string[] = [];
let resolveRead!: (backup: { ok: boolean; json: string }) => void;
Object.assign(dom.window, { netcatty: {
aiSessionsBackupWrite: async ({ json }: { json: string }) => { backups.push(json); return { ok: true }; },
aiSessionsBackupReadLatest: () => new Promise(resolve => { resolveRead = resolve; }),
} });
let ai!: ReturnType<typeof useAIState>;
let snapshot!: ReturnType<typeof useAISessionsStore>;
function Harness() {
ai = useAIState();
snapshot = useAISessionsStore();
return null;
}
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
try {
await act(async () => { root.render(React.createElement(Harness)); });
assert.equal(snapshot.activeSessionIdMap['global:'], diskOnly.id);
await act(async () => {
if (mutation === 'rename') ai.updateSessionTitle(primary.id, 'Renamed before recovery');
else if (mutation === 'create') ai.createSession({ type: 'global' }, 'catty');
else ai.deleteSession(mutation === 'delete-pending' ? diskOnly.id : primary.id);
});
const liveBeforeRecovery = [...snapshot.sessions];
const expectedActiveId = mutation === 'create' ? liveBeforeRecovery[0].id
: mutation === 'delete-pending' ? null : diskOnly.id;
assert.equal(snapshot.activeSessionIdMap['global:'], expectedActiveId);
assert.deepEqual(readSessionsFromStorage(), liveBeforeRecovery);
const recoveryMarker = dom.window.localStorage.getItem(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW);
assert.deepEqual(recoveryMarker == null ? null : JSON.parse(recoveryMarker), mutation === 'delete-pending' ? null : [diskOnly.id]);
flushBackupMirror();
assert.deepEqual(backups.map(json => JSON.parse(json)), mutation === 'delete-pending' ? [liveBeforeRecovery] : []);
await act(async () => { resolveRead({ ok: true, json: JSON.stringify([primary, diskOnly]) }); });
const expected = mutation === 'delete-pending' ? liveBeforeRecovery : [...liveBeforeRecovery, diskOnly];
assert.deepEqual(snapshot.sessions, expected);
assert.deepEqual(readSessionsFromStorage(), expected);
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW), null);
flushBackupMirror();
assert.deepEqual(JSON.parse(backups.at(-1)!), expected);
} finally {
await act(async () => { root.unmount(); });
__resetBackupMirrorForTests();
setLatestAISessionsSnapshot([]);
setLatestAIActiveSessionMapSnapshot({});
container.remove();
dom.window.close();
}
});
}
test('count-cap eviction clears a loaded disk-only marker and resumes backup mirroring', () => {
const dom = new JSDOM('<!doctype html><html><body></body></html>', { url: 'http://localhost' });
for (const name of ['window', 'document', 'localStorage', 'CustomEvent', 'Event'] as const) {
Object.defineProperty(globalThis, name, { configurable: true, value: name === 'window' ? dom.window : dom.window[name] });
}
const source: AISession[] = Array.from({ length: 51 }, (_, index) => ({
id: `chat-${index}`, title: `Chat ${index}`, agentId: 'catty', scope: { type: 'global' },
createdAt: 1, updatedAt: 51 - index, messages: [],
}));
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW, JSON.stringify([source[50].id]));
const backups: string[] = [];
Object.assign(dom.window, { netcatty: { aiSessionsBackupWrite: async ({ json }: { json: string }) => {
backups.push(json);
return { ok: true };
} } });
__resetBackupMirrorForTests();
try {
assert.equal(writeSessionsForStorage(source), true);
assert.deepEqual(readSessionsFromStorage(), source.slice(0, 50));
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW), null);
flushBackupMirror();
assert.deepEqual(backups.map(json => JSON.parse(json)), [source.slice(0, 50)]);
} finally {
__resetBackupMirrorForTests();
dom.window.close();
}
});
for (const keepPrimary of [true, false]) {
test(`deletion clears disk-only recovery and remains authoritative (keep primary ${keepPrimary})`, async () => {
const dom = new JSDOM('<!doctype html><html><body></body></html>', { url: 'http://localhost' });
for (const name of ['window', 'document', 'localStorage', 'CustomEvent', 'Event'] as const) {
Object.defineProperty(globalThis, name, { configurable: true, value: name === 'window' ? dom.window : dom.window[name] });
}
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { configurable: true, value: true });
const primary: AISession = {
id: 'primary', title: 'Primary', agentId: 'catty', scope: { type: 'global' },
createdAt: 1, updatedAt: 2, messages: [],
};
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS, JSON.stringify([primary]));
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW, JSON.stringify(['deleted-chat']));
const remaining = keepPrimary ? [primary] : [];
assert.equal(writeSessionsForStorage(remaining, { deletedSessionIds: ['deleted-chat'] }), true);
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW), null);
__resetBackupMirrorForTests();
setLatestAISessionsSnapshot(null as unknown as AISession[]);
let reads = 0;
Object.assign(dom.window, { netcatty: { aiSessionsBackupReadLatest: async () => {
reads += 1;
return { ok: true, json: JSON.stringify([primary, { ...primary, id: 'deleted-chat' }]) };
} } });
let sessions: readonly AISession[] = [];
function Harness() {
useAIState();
sessions = useAISessionsStore().sessions;
return null;
}
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
try {
await act(async () => { root.render(React.createElement(Harness)); });
assert.equal(reads, 0);
assert.deepEqual(sessions, remaining);
} finally {
await act(async () => { root.unmount(); });
__resetBackupMirrorForTests();
setLatestAISessionsSnapshot([]);
container.remove();
dom.window.close();
}
});
}
+253 -69
View File
@@ -66,18 +66,23 @@ import {
bumpDraftUploadGeneration,
cleanupDeletedAIChatSessions,
cleanupOrphanedAISessions,
latestAIActiveTargetIdsSnapshot,
getAIBridge,
flushBackupMirror,
getAIBridge,
getDraftUploadGeneration,
getPersistedAiSessionsSnapshot,
isAiSessionsHydrationComplete,
latestAIActiveSessionMapSnapshot,
latestAIActiveTargetIdsSnapshot,
latestAIDraftsByScopeSnapshot,
latestAIPanelViewByScopeSnapshot,
latestAISessionsSnapshot,
markAiSessionsHydrationComplete,
prepareRestoredSessions,
readSessionsFromStorage,
scheduleBackupMirror,
readAiSessionMessageCap,
rebindActiveSessionMap,
scheduleBackupMirror,
seedLastPersistedSessions,
setAiSessionsBackupRecoveryPending,
writeSessionsForStorage,
setLatestAIActiveSessionMapSnapshot,
setLatestAIDraftsByScopeSnapshot,
@@ -86,11 +91,25 @@ import {
type DraftsByScope,
type PanelViewByScope,
} from './aiStateSnapshots';
import {
AI_SESSIONS_IDB_VERSION,
AI_SESSIONS_META_LAST_WRITE_KEY,
AI_SESSIONS_META_SCHEMA_VERSION_KEY,
idbGetAllSessions,
idbPutMeta,
idbPutSessions,
loadOrMigrateAiSessions,
mirrorActiveSessionMapToIdb,
openAiSessionsIdb,
publishAiSessionsIdbChange,
readActiveSessionMapFromIdb,
subscribeAiSessionsIdbChange,
} from './aiSessionIdbStorage';
import {
isMissingOrCorruptSessionsStore,
parseSessionsStore,
mergeImportedSessions,
parseBackupSessions,
readLiveSessionsArray,
} from './aiSessionsBackup';
import { AI_STATE_CHANGED_EVENT, emitAIStateChanged } from './aiStateEvents';
import {
@@ -181,53 +200,165 @@ export function useAIState() {
);
// ── Sessions ──
const [sessions, setSessionsRaw] = useState<AISession[]>(() =>
latestAISessionsSnapshot
?? readSessionsFromStorage()
);
// Memory-only initial state: the primary store is IndexedDB and loads
// asynchronously in the boot effect below (issue #4123). The legacy
// localStorage key is read-only fallback and must not pre-seed stale data.
const [sessions, setSessionsRaw] = useState<AISession[]>(() => latestAISessionsSnapshot ?? []);
// Issue #4125 review P2②: flips true when the boot load settles. The
// active-pointer validation effect waits for it — the initially-empty
// in-memory session array must never null the persisted pointers.
const [sessionsHydrated, setSessionsHydrated] = useState(() => isAiSessionsHydrationComplete());
// An explicit empty array is authoritative, even if the backup is stale.
// Disk-only overflow IDs also permit recovery, without reviving deleted chats.
// 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);
const liveStore = parseSessionsStore(liveRaw);
const liveSessions = readSessionsFromStorage();
const diskOnlyRecovery = (localStorageAdapter.read<string[]>(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW)?.length ?? 0) > 0;
if (!diskOnlyRecovery && (liveStore.valid || liveSessions.length > 0)) {
if (liveSessions.length > 0) scheduleBackupMirror(JSON.stringify(liveSessions));
return;
}
if (!diskOnlyRecovery && latestAISessionsSnapshot && latestAISessionsSnapshot.length > 0) return;
const bridge = getAIBridge();
if (!bridge?.aiSessionsBackupReadLatest) return;
let cancelled = false;
const canRestore = () => diskOnlyRecovery
? (localStorageAdapter.read<string[]>(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW)?.length ?? 0) > 0
: isMissingOrCorruptSessionsStore(localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS))
&& readSessionsFromStorage().length === 0
&& (latestAISessionsSnapshot?.length ?? 0) === 0
&& sessionsRef.current.length === 0;
let cleanupAfterHydration = false;
void (async () => {
try {
if (!canRestore()) return;
// 1. IndexedDB (primary) → one-time legacy localStorage migration.
const diskOnlyIds = new Set(localStorageAdapter.read<string[]>(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW) ?? []);
setAiSessionsBackupRecoveryPending(diskOnlyIds.size > 0);
const boot = await loadOrMigrateAiSessions(
async () => {
// Legacy retention split history between the primary key, JSON
// chunks, and explicitly marked disk-only records. Resolve all
// slices before the one-shot migration records completion.
const chunks: string[] = [];
for (let index = 0; ; index += 1) {
const key = index === 0 ? STORAGE_KEY_AI_SESSIONS_OVERFLOW : `${STORAGE_KEY_AI_SESSIONS_OVERFLOW}_${index + 1}`;
const chunk = localStorageAdapter.readString(key);
if (chunk == null) break;
chunks.push(chunk);
}
let legacy = mergeImportedSessions(
readLiveSessionsArray(chunks.join('')),
readLiveSessionsArray(localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS)),
);
if (diskOnlyIds.size > 0) {
try {
const backup = await getAIBridge()?.aiSessionsBackupReadLatest?.();
if (backup?.ok) {
legacy = mergeImportedSessions(
(parseBackupSessions(backup.json) ?? []).filter(session => diskOnlyIds.has(session.id)),
legacy,
);
}
} catch (error) {
console.warn('[AIState] Disk-only session recovery failed; migration will retry on the next boot:', error);
}
}
return JSON.stringify(legacy);
},
[...diskOnlyIds],
);
setAiSessionsBackupRecoveryPending(boot.migrationPending);
if (cancelled) return;
if (boot.idbFailed && boot.sessions.length > 0) {
// Issue #4125 review P2①: the legacy set is not in IndexedDB yet.
// Persist it best-effort (one re-open retry) BEFORE seeding the
// diff baseline; if the store is still unreachable, leave the
// baseline unset so the next edit performs a full write instead of
// a diff that would drop the un-persisted sessions after IndexedDB
// recovers.
try {
const db = await openAiSessionsIdb();
if (!cancelled) {
await idbPutSessions(db, boot.sessions);
await idbPutMeta(db, AI_SESSIONS_META_SCHEMA_VERSION_KEY, AI_SESSIONS_IDB_VERSION);
await idbPutMeta(db, AI_SESSIONS_META_LAST_WRITE_KEY, Date.now());
publishAiSessionsIdbChange();
seedLastPersistedSessions(boot.sessions);
}
} catch (error) {
console.warn(
'[AIState] Could not persist the legacy sessions after an IndexedDB failure;'
+ ' the diff baseline stays unset so the next edit rewrites every record:',
error,
);
}
} else {
seedLastPersistedSessions(boot.sessions);
}
// Restore the mirrored active-pointer map when the localStorage key
// is missing (fresh profile restored from IndexedDB).
if (localStorageAdapter.readString(STORAGE_KEY_AI_ACTIVE_SESSION_MAP) == null) {
const mirrored = await readActiveSessionMapFromIdb();
if (cancelled) return;
if (mirrored && Object.keys(mirrored).length > 0) {
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, mirrored);
setLatestAIActiveSessionMapSnapshot(mirrored);
setActiveSessionIdMapRaw(mirrored);
}
}
const bootSessions = boot.sessions;
if (bootSessions.length > 0) {
const live = latestAISessionsSnapshot ?? sessionsRef.current;
if (live.length > 0) {
// Sessions were created before the async load finished: merge
// (live records win by id) instead of clobbering either side.
const byId = new Map(bootSessions.map(session => [session.id, session]));
for (const session of live) byId.set(session.id, session);
const merged = Array.from(byId.values())
.sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0));
setLatestAISessionsSnapshot(merged);
sessionsRef.current = merged;
setSessionsRaw(merged);
writeSessionsForStorage(merged);
return;
}
setLatestAISessionsSnapshot(bootSessions);
sessionsRef.current = bootSessions;
setSessionsRaw(bootSessions);
scheduleBackupMirror(JSON.stringify(bootSessions));
cleanupAfterHydration = true;
return;
}
// A previously initialized empty IDB is a committed deletion. An
// incomplete migration must retry only its marked disk-only records.
if (boot.authoritativeEmpty || boot.migrationPending) return;
// 2. Uninitialized store (and no legacy data to migrate): the #4018 disk
// backup remains the last restore step. Only when the legacy store
// is also missing or corrupt — a valid empty legacy array is an
// intentional wipe and stays authoritative.
if (!isMissingOrCorruptSessionsStore(localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS))) {
return;
}
const bridge = getAIBridge();
if (!bridge?.aiSessionsBackupReadLatest) return;
const backup = await bridge.aiSessionsBackupReadLatest?.();
if (cancelled || !backup?.ok) return;
// Re-check after the async gap so live mutations and deletions win.
if (!canRestore()) return;
const backupSessions = parseBackupSessions(backup.json);
if (!backupSessions) return;
const diskOnlyIds = new Set(localStorageAdapter.read<string[]>(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW) ?? []);
const recovered = diskOnlyRecovery
? mergeImportedSessions(
backupSessions.filter(session => diskOnlyIds.has(session.id)),
latestAISessionsSnapshot ?? readSessionsFromStorage(),
)
: backupSessions;
// Re-check after the async gap: a session may have been created
// while the backup was being read; never restore over live state.
// Cover persisted storage and in-memory snapshot/React state —
// createSession updates the snapshot before the debounced disk write.
if ((latestAISessionsSnapshot?.length ?? 0) > 0) return;
if (sessionsRef.current.length > 0) return;
// Issue #4125 review P2③: one open retry already happened inside
// the boot load. If the store is still unopenable, do not let the
// recheck rejection reach the outer catch — the disk backup below
// is the recovery source.
try {
if ((await idbGetAllSessions()).length > 0) return;
} catch (error) {
console.warn(
'[AIState] IndexedDB recheck failed during the disk-backup restore;'
+ ' applying the newest backup generation as the recovery source:',
error,
);
}
if (!isMissingOrCorruptSessionsStore(localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS))) {
return;
}
const syncEnabled = !!localStorageAdapter.read<{ enabled?: boolean }>(STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS)?.enabled;
const restored = prepareRestoredSessions(recovered, syncEnabled);
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
@@ -235,11 +366,7 @@ export function useAIState() {
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);
cleanupAfterHydration = true;
const nextSessions = latestAISessionsSnapshot ?? restored;
sessionsRef.current = nextSessions;
setSessionsRaw(nextSessions);
@@ -249,10 +376,25 @@ export function useAIState() {
?? {},
);
console.info(
`[AIState] Restored ${restored.length} AI session(s) from the disk backup.`,
`[AIState] Restored ${nextSessions.length} AI session(s) from the disk backup.`,
);
} catch {
// Backup restore must never break startup.
} catch (error) {
// Boot load must never break startup — but it must not vanish
// silently either (issue #4125 review P2③).
console.warn('[AIState] AI session boot load failed:', error);
} finally {
markAiSessionsHydrationComplete();
if (!cancelled) {
// Maintenance may have mounted before hydration with unchanged live
// targets. Replay it now against the authoritative boot history.
if (cleanupAfterHydration) {
cleanupOrphanedAISessions(latestAIActiveTargetIdsSnapshot);
const nextSessions = latestAISessionsSnapshot ?? sessionsRef.current;
sessionsRef.current = nextSessions;
setSessionsRaw(nextSessions);
}
setSessionsHydrated(true);
}
}
})();
return () => {
@@ -321,6 +463,9 @@ export function useAIState() {
useEffect(() => {
setLatestAIActiveSessionMapSnapshot(activeSessionIdMap);
// Issue #4123: mirror the active-pointer map into the IndexedDB meta
// store (localStorage stays written at the mutation sites as before).
void mirrorActiveSessionMapToIdb(activeSessionIdMap);
}, [activeSessionIdMap]);
useEffect(() => {
@@ -332,12 +477,12 @@ export function useAIState() {
}, [panelViewByScope]);
useEffect(() => {
// Issue #4125 review P2②: before boot hydration settles, `sessions` is
// the initially-empty in-memory array; validating pointers against it
// would null every pointer and break workspace restore. No pointer
// writes before hydration completes.
if (!sessionsHydrated) return;
const validSessionIds = new Set<string>(sessions.map((session) => session.id));
// Disk-only chats may not have arrived from the startup backup read yet.
// Keep their active pointers and panel views until recovery or deletion.
for (const id of localStorageAdapter.read<string[]>(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW) ?? []) {
validSessionIds.add(id);
}
let changed = false;
const nextActiveSessionIdMap: Record<string, string | null> = {};
@@ -365,7 +510,7 @@ export function useAIState() {
emitAIStateChanged(AI_STATE_CHANGED_PANEL_VIEW_BY_SCOPE);
return next;
});
}, [sessions, activeSessionIdMap]);
}, [sessions, activeSessionIdMap, sessionsHydrated]);
const setActiveSessionId = useCallback((scopeKey: string, id: string | null) => {
let nextActiveSessionIdMap: Record<string, string | null> | null = null;
@@ -557,8 +702,7 @@ export function useAIState() {
useEffect(() => {
const handleStorage = (e: StorageEvent) => {
try {
const key = e.key === STORAGE_KEY_AI_SESSIONS_OVERFLOW || e.key?.startsWith(`${STORAGE_KEY_AI_SESSIONS_OVERFLOW}_`)
? STORAGE_KEY_AI_SESSIONS : e.key;
const key = e.key;
switch (key) {
case STORAGE_KEY_AI_PROVIDERS: {
const parsed = localStorageAdapter.read<ProviderConfig[]>(STORAGE_KEY_AI_PROVIDERS);
@@ -651,7 +795,10 @@ export function useAIState() {
break;
}
case STORAGE_KEY_AI_SESSIONS: {
const nextSessions = readSessionsFromStorage();
// Session writes no longer land in localStorage (issue #4123);
// the cross-window signal is the IndexedDB change channel and the
// storage event only fires as a legacy fallback.
const nextSessions = getPersistedAiSessionsSnapshot();
setLatestAISessionsSnapshot(nextSessions);
setSessionsRaw(nextSessions);
break;
@@ -693,7 +840,7 @@ export function useAIState() {
case STORAGE_KEY_AI_SESSIONS:
setSessionsRaw(
latestAISessionsSnapshot
?? readSessionsFromStorage(),
?? getPersistedAiSessionsSnapshot(),
);
return;
case STORAGE_KEY_AI_ACTIVE_SESSION_MAP:
@@ -720,6 +867,37 @@ export function useAIState() {
};
}, []);
// Cross-window AI session sync: session writes land in IndexedDB now, so
// the localStorage `storage` event no longer fires for them. Other windows
// announce their writes on a BroadcastChannel; the receiver reloads the
// store from IndexedDB.
useEffect(() => subscribeAiSessionsIdbChange(() => {
void (async () => {
try {
const next = await idbGetAllSessions();
if (next.length === 0) {
if (!isAiSessionsHydrationComplete()) return;
// Issue #4125 review P2④: after hydration an empty store is an
// intentional cross-window deletion — adopt it instead of keeping
// the deleted session alive locally (its next edit would
// resurrect it). The pointer-validation effect clears the
// now-stale pointers. Only the pre-hydration window keeps the
// early-return: the boot load owns the store view until it
// settles.
setLatestAISessionsSnapshot([]);
seedLastPersistedSessions([]);
setSessionsRaw([]);
return;
}
setLatestAISessionsSnapshot(next);
seedLastPersistedSessions(next);
setSessionsRaw(next);
} catch {
// Cross-window refresh is best-effort.
}
})();
}), []);
// ── Sync initial safety settings to MCP Server on mount ──
useEffect(() => {
const bridge = getAIBridge();
@@ -1031,8 +1209,6 @@ export function useAIState() {
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;
// Local readback can omit disk-only overflow chats. Keep the complete
// prepared history live so cleanup and later writes retain those chats.
const importedIds = new Set(imported.map(session => session.id));
sessionsRef.current = next;
setLatestAISessionsSnapshot(next);
@@ -1069,22 +1245,27 @@ export function useAIState() {
});
}, [debouncedPersistSessions]);
// Local-only retention. Synced history must match the full-history storage policy.
const MAX_MESSAGES_PER_SESSION = 500;
// Local-only retention. Synced history must match the full-history storage
// policy. The in-memory bound follows the user-configurable per-session cap
// (see aiStateSnapshots.readAiSessionMessageCap) but never below this floor,
// so the default behavior is unchanged and RAM stays bounded when the user
// lowers the persisted cap.
const MIN_IN_MEMORY_MESSAGES_PER_SESSION = 500;
const addMessageToSession = useCallback((sessionId: string, message: ChatMessage) => {
const syncEnabled = localStorageAdapter.read<{ enabled?: boolean }>(STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS)?.enabled === true;
const inMemoryCap = Math.max(readAiSessionMessageCap(), MIN_IN_MEMORY_MESSAGES_PER_SESSION);
setSessionsRaw(prev => {
const next = prev.map(s => {
if (s.id !== sessionId) return s;
let msgs = [...s.messages, message];
// Dropping synced prefixes makes the next cloud merge add them back.
if (!syncEnabled && msgs.length > MAX_MESSAGES_PER_SESSION) {
if (!syncEnabled && msgs.length > inMemoryCap) {
const systemMsgs = msgs.filter(m => m.role === 'system');
const nonSystemMsgs = msgs.filter(m => m.role !== 'system');
const dropped = nonSystemMsgs.length - (MAX_MESSAGES_PER_SESSION - systemMsgs.length);
console.warn(`[useAIState] Session ${sessionId}: trimmed ${dropped} oldest non-system message(s) to stay within ${MAX_MESSAGES_PER_SESSION} limit`);
msgs = [...systemMsgs, ...nonSystemMsgs.slice(-MAX_MESSAGES_PER_SESSION + systemMsgs.length)];
const dropped = nonSystemMsgs.length - (inMemoryCap - systemMsgs.length);
console.warn(`[useAIState] Session ${sessionId}: trimmed ${dropped} oldest non-system message(s) to stay within ${inMemoryCap} limit`);
msgs = [...systemMsgs, ...nonSystemMsgs.slice(-inMemoryCap + systemMsgs.length)];
}
return { ...s, messages: msgs, updatedAt: Date.now() };
});
@@ -1322,10 +1503,11 @@ export function useAIState() {
const cleanupOrphanedSessions = useCallback((activeTargetIds: Set<string>) => {
cleanupOrphanedAISessions(activeTargetIds);
if (!isAiSessionsHydrationComplete()) return;
const nextSessions =
latestAISessionsSnapshot
?? readSessionsFromStorage();
?? getPersistedAiSessionsSnapshot();
sessionsRef.current = nextSessions;
setSessionsRaw(nextSessions);
setActiveSessionIdMapRaw(
@@ -1377,7 +1559,7 @@ export function useAIState() {
?? {};
const currentSessions =
latestAISessionsSnapshot
?? readSessionsFromStorage();
?? getPersistedAiSessionsSnapshot();
const currentPanelViewByScope = latestAIPanelViewByScopeSnapshot ?? {};
const result = handoffDissolvedWorkspaceAIScope({
activeSessionIdMap: currentMap,
@@ -1415,6 +1597,7 @@ export function useAIState() {
previousMemberTerminalIds: readonly string[];
currentMemberTerminalIds: readonly string[];
preferredTerminalId?: string | null;
activeTargetIds?: ReadonlySet<string>;
}) => {
const currentMap =
latestAIActiveSessionMapSnapshot
@@ -1422,7 +1605,7 @@ export function useAIState() {
?? {};
const currentSessions =
latestAISessionsSnapshot
?? readSessionsFromStorage();
?? getPersistedAiSessionsSnapshot();
const result = retargetWorkspaceActiveChatAfterMemberLoss({
activeSessionIdMap: currentMap,
sessions: currentSessions,
@@ -1430,6 +1613,7 @@ export function useAIState() {
previousMemberTerminalIds: input.previousMemberTerminalIds,
currentMemberTerminalIds: input.currentMemberTerminalIds,
preferredTerminalId: input.preferredTerminalId,
activeTargetIds: input.activeTargetIds,
});
if (!result.changed) return;
@@ -9,7 +9,7 @@ import { getAgentRuntime } from '../../infrastructure/ai/harness/globalAgentRunt
import { useAIState } from './useAIState.ts';
import { useAISessionsStore } from './aiSessionsStore.ts';
import { getSessionScopeMatchRank } from '../../components/ai/sessionScopeMatch.ts';
import { setLatestAISessionsSnapshot } from './aiStateSnapshots.ts';
import { getPersistedAiSessionsSnapshot, setLatestAISessionsSnapshot } from './aiStateSnapshots.ts';
const SCOPE_KEY = 'terminal:terminal-a';
@@ -175,17 +175,21 @@ for (const newerCount of [49, 50]) {
branchId = (await capture.ai!.undoLastTurnInSession(SOURCE_SESSION.id))?.sessionId;
});
assert.ok(branchId);
const saved: AISession[] = JSON.parse(dom.window.localStorage.getItem('netcatty_ai_sessions_v1')!);
assert.equal(saved.length, 50);
// Issue #4123: the persisted set lives in IndexedDB (count cap 1000, so
// nothing is evicted at these sizes) — not in a 50-capped localStorage blob.
const saved: AISession[] = getPersistedAiSessionsSnapshot();
assert.equal(saved.length, newerCount + 2);
const original = saved.find(item => item.id === SOURCE_SESSION.id);
assert.ok(original, 'the original must survive updatedAt-based history pruning');
assert.deepEqual({ ...original, updatedAt: SOURCE_SESSION.updatedAt }, SOURCE_SESSION);
assert.deepEqual(saved.find(item => item.id === branchId)?.messages, SOURCE_SESSION.messages.slice(0, 2));
assert.ok(original.updatedAt >= newer.at(-1)!.updatedAt);
await act(async () => root.unmount());
container.remove(); dom.window.close();
// Remount the actual hook from the persisted records, as on app reload.
container.remove();
// Seed the read-only legacy store with the persisted set (the stand-in
// for what the migration persists on a profile without IndexedDB).
dom.window.localStorage.setItem('netcatty_ai_sessions_v1', JSON.stringify(saved));
dom.window.close();
const reloaded = await setupAiState(saved);
assert.deepEqual(reloaded.capture.sessions.find(item => item.id === SOURCE_SESSION.id)?.messages, SOURCE_SESSION.messages);
assert.deepEqual(reloaded.capture.sessions.find(item => item.id === branchId)?.messages, SOURCE_SESSION.messages.slice(0, 2));
@@ -223,7 +227,7 @@ for (const viewingScope of [
), 3);
const original = capture.sessions.find(item => item.id === source.id)!;
assert.deepEqual({ ...original, updatedAt: sourceBefore.updatedAt }, sourceBefore);
const saved = JSON.parse(dom.window.localStorage.getItem('netcatty_ai_sessions_v1')!) as AISession[];
const saved = getPersistedAiSessionsSnapshot();
assert.deepEqual(saved.find(item => item.id === branchId)?.scope, branch.scope);
await act(async () => root.unmount());
container.remove(); dom.window.close();
@@ -156,6 +156,139 @@ for (const preference of ["system", "light", "dark"] as const) {
}
}
// A hanging jsdom/React render must fail this test quickly instead of
// consuming the whole CI job budget.
test("terminal chrome never replaces the application native theme policy", { timeout: 15000 }, async () => {
const { JSDOM } = await import("jsdom");
const dom = new JSDOM('<!doctype html><html><head></head><body><div data-top-tabs-root></div></body></html>');
const originalWindow = globalThis.window;
const originalDocument = globalThis.document;
const originalSnapshot = getSettingsChromeSnapshot();
const themeSources: string[] = [];
const backgrounds: string[] = [];
const testWindow = dom.window as unknown as Window & typeof globalThis;
testWindow.netcatty = {
setTheme: async (source: string) => { themeSources.push(source); return true; },
setBackgroundColor: async (background: string) => { backgrounds.push(background); return true; },
} as unknown as NetcattyBridge;
globalThis.window = testWindow;
globalThis.document = testWindow.document;
try {
publishSettingsChromeSnapshot({ ...originalSnapshot, theme: "system" });
for (const type of ['light', 'dark'] as const) {
const theme = TERMINAL_THEMES.find((candidate) => candidate.type === type);
assert.ok(theme);
syncActiveChromeTheme(theme, () => {});
assert.equal(document.documentElement.classList.contains(type), true);
assert.equal(backgrounds.at(-1), theme.colors.background);
}
assert.deepEqual(themeSources, ['system', 'system'], 'terminal colors must not pin nativeTheme.themeSource to light/dark');
} finally {
publishSettingsChromeSnapshot(originalSnapshot);
globalThis.window = originalWindow;
globalThis.document = originalDocument;
dom.window.close();
}
});
test("system preference refresh reapplies an unchanged independent terminal chrome", { timeout: 15000 }, async () => {
const { JSDOM } = await import("jsdom");
const dom = new JSDOM('<!doctype html><html><head></head><body><div data-top-tabs-root></div></body></html>');
const originalWindow = globalThis.window;
const originalDocument = globalThis.document;
const originalSnapshot = getSettingsChromeSnapshot();
const backgrounds: string[] = [];
const testWindow = dom.window as unknown as Window & typeof globalThis;
testWindow.netcatty = {
setTheme: (source: string) => {
assert.equal(source, 'system', 'chrome refresh must preserve native system theme policy');
return Promise.resolve(true);
},
setBackgroundColor: async (background: string) => { backgrounds.push(background); return true; },
} as unknown as NetcattyBridge;
globalThis.window = testWindow;
globalThis.document = testWindow.document;
try {
publishSettingsChromeSnapshot({ ...originalSnapshot, theme: "system" });
for (const type of ['light', 'dark'] as const) {
const theme = TERMINAL_THEMES.find((candidate) => candidate.type === type);
assert.ok(theme);
syncActiveChromeTheme(theme, () => {});
// The application settings effect runs first on an OS preference change.
document.documentElement.className = type === 'dark' ? 'light' : 'dark';
backgrounds.push('application-background');
syncActiveChromeTheme(theme, () => {}, { refreshSurfaces: true });
assert.equal(document.documentElement.classList.contains(type), true);
assert.equal(backgrounds.at(-1), theme.colors.background);
}
syncActiveChromeTheme(null, () => {
document.documentElement.className = 'light';
backgrounds.push('application-background');
});
assert.equal(document.documentElement.className, 'light');
assert.equal(document.documentElement.dataset.activeChromeTheme, undefined);
assert.equal(document.getElementById('netcatty-active-chrome-theme'), null);
assert.equal(backgrounds.at(-1), 'application-background');
} finally {
publishSettingsChromeSnapshot(originalSnapshot);
globalThis.window = originalWindow;
globalThis.document = originalDocument;
dom.window.close();
}
});
test("chrome hook reacts to OS preference changes with stable terminal props", { timeout: 15000 }, async () => {
const { JSDOM } = await import('jsdom');
const { act, createElement } = await import('react');
const { createRoot } = await import('react-dom/client');
const { useActiveChromeTheme } = await import('./useActiveChromeTheme');
const { getSettingsChromeSnapshot, publishSettingsChromeSnapshot } = await import('./settingsChromeStore');
const dom = new JSDOM('<!doctype html><html><head></head><body></body></html>');
const originalWindow = globalThis.window;
const originalDocument = globalThis.document;
const originalSnapshot = getSettingsChromeSnapshot();
const environment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
const originalActEnvironment = environment.IS_REACT_ACT_ENVIRONMENT;
environment.IS_REACT_ACT_ENVIRONMENT = true;
const backgrounds: string[] = [];
const testWindow = dom.window as unknown as Window & typeof globalThis;
globalThis.window = testWindow;
globalThis.document = dom.window.document;
testWindow.netcatty = {
setBackgroundColor: async (color: string) => { backgrounds.push(color); return true; },
} as unknown as NetcattyBridge;
const theme = TERMINAL_THEMES.find((candidate) => candidate.type === 'dark');
assert.ok(theme);
const applyAppTheme = () => {};
function Harness() {
useActiveChromeTheme({ activeTheme: theme!, applyAppTheme });
return null;
}
publishSettingsChromeSnapshot({ ...originalSnapshot, resolvedTheme: 'dark' });
let renderer: ReturnType<typeof createRoot> | undefined;
try {
await act(async () => {
renderer = createRoot(document.createElement('div'));
renderer.render(createElement(Harness));
});
assert.equal(document.documentElement.classList.contains('dark'), true);
document.documentElement.className = 'light';
backgrounds.push('application-background');
await act(async () => {
publishSettingsChromeSnapshot({ ...originalSnapshot, resolvedTheme: 'light' });
});
assert.equal(document.documentElement.classList.contains('dark'), true);
assert.equal(backgrounds.at(-1), theme.colors.background);
} finally {
await act(async () => { renderer?.unmount(); });
environment.IS_REACT_ACT_ENVIRONMENT = originalActEnvironment;
publishSettingsChromeSnapshot(originalSnapshot);
globalThis.window = originalWindow;
globalThis.document = originalDocument;
dom.window.close();
}
});
test("active chrome theme foreground uses contrast calculation", () => {
const source = readFileSync(new URL("./useActiveChromeTheme.ts", import.meta.url), "utf8");
+8 -4
View File
@@ -237,9 +237,9 @@ function applyActiveChromeTheme(theme: TerminalTheme) {
}
function refreshActiveChromeThemeSurfaces(theme: TerminalTheme) {
const targetClass = theme.type === "dark" ? "dark" : "light";
if (typeof window !== "undefined") {
// Preserve OS preference events instead of locking nativeTheme to the chrome type.
const targetClass = theme.type === "dark" ? "dark" : "light";
const themeSource = getSettingsChromeSnapshot().theme === "system" ? "system" : targetClass;
netcattyBridge.get()?.setTheme?.(themeSource);
netcattyBridge.get()?.setBackgroundColor?.(theme.colors.background);
@@ -250,10 +250,11 @@ function refreshActiveChromeThemeSurfaces(theme: TerminalTheme) {
export function syncActiveChromeTheme(
activeTheme: TerminalTheme | null,
applyAppTheme: () => void,
{ refreshSurfaces = false }: { refreshSurfaces?: boolean } = {},
): void {
const nextFingerprint = activeTheme ? themeFingerprint(activeTheme) : null;
const appliedFingerprint = getAppliedChromeFingerprint();
if (nextFingerprint === appliedFingerprint) {
if (nextFingerprint === appliedFingerprint && !(activeTheme && refreshSurfaces)) {
return;
}
@@ -283,8 +284,11 @@ export function useActiveChromeTheme({
applyAppThemeRef.current = applyAppTheme;
useLayoutEffect(() => {
syncActiveChromeTheme(effectiveTheme, applyAppTheme);
}, [effectiveTheme, applyAppTheme, pluginThemeRevision]);
// Settings may have just applied the OS theme over a fixed terminal theme.
// Reassert its visual surfaces even when the terminal fingerprint is stable;
// tab-switch callers retain the default fingerprint fast path.
syncActiveChromeTheme(effectiveTheme, applyAppTheme, { refreshSurfaces: true });
}, [effectiveTheme, applyAppTheme, pluginThemeRevision, resolvedTheme]);
useLayoutEffect(() => {
return () => {
+2 -2
View File
@@ -2,8 +2,8 @@ import { useCallback } from 'react';
import { STORAGE_KEY_COMPOSE_BAR_HEIGHT } from '../../infrastructure/config/storageKeys';
import { useStoredNumber } from './useStoredNumber';
export const COMPOSE_BAR_HEIGHT_DEFAULT = 120;
export const COMPOSE_BAR_HEIGHT_MIN = 72;
export const COMPOSE_BAR_HEIGHT_DEFAULT = 144;
export const COMPOSE_BAR_HEIGHT_MIN = 104;
export const COMPOSE_BAR_HEIGHT_MAX = 360;
const HEIGHT_CLAMP = { min: COMPOSE_BAR_HEIGHT_MIN, max: COMPOSE_BAR_HEIGHT_MAX };
@@ -0,0 +1,106 @@
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 { useComposeDirectoryPicker } from './useComposeDirectoryPicker';
import { createTerminalDirectoryController } from './terminalDirectoryController';
import { refreshObservedTerminalDirectory, registerTerminalDirectory } from './terminalDirectoryRegistry';
import { terminalCwdStore } from './terminalCwdStore';
import { DirectoryPickerError } from '../../domain/directoryPicker';
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
test('opening loads children once; search is local; unmount stops chip refresh demand', async () => {
let reads = 0; let probes = 0;
const id = 'hook-directory';
const controller = createTerminalDirectoryController({ sessionId: id,
io: { cwd: async () => { probes++; return '/base'; }, realpath: async path => path, write() {},
list: async () => { reads++; return { home: '/base', entries: [{ name: 'child', path: '/base/child', hidden: false }] }; } },
isCurrent: () => true, isReady: () => true,
publish: cwd => { terminalCwdStore.setCwd(id, cwd, 'backend-strict'); },
});
const cleanup = registerTerminalDirectory(id, controller);
let model!: ReturnType<typeof useComposeDirectoryPicker>;
function Harness() { model = useComposeDirectoryPicker(id); return null; }
let root!: ReactTestRenderer;
await act(async () => { root = create(React.createElement(Harness)); });
assert.equal(model.cwd, '/base');
await act(async () => { model.setOpen(true); });
assert.equal(reads, 1);
await act(async () => { model.setQuery('CH'); });
assert.equal(model.entries.length, 1);
assert.equal(reads, 1);
await act(async () => { root.unmount(); });
const before = probes;
refreshObservedTerminalDirectory(id);
await Promise.resolve();
assert.equal(probes, before);
cleanup();
});
test('switching terminal cancels an old directory read and closes its popover', async () => {
let finish!: (value: { entries: { name: string; path: string; hidden: boolean }[]; home: null }) => void;
const cleanups = ['hook-a', 'hook-b'].map(id => registerTerminalDirectory(id, createTerminalDirectoryController({
sessionId: id, isCurrent: () => true, isReady: () => true,
io: { cwd: async () => '/' + id, realpath: async path => path, write() {},
list: () => new Promise(resolve => { finish = resolve; }) },
publish: cwd => { terminalCwdStore.setCwd(id, cwd, 'backend-strict'); },
})));
let model!: ReturnType<typeof useComposeDirectoryPicker>;
function Harness({ id }: { id: string }) { model = useComposeDirectoryPicker(id); return null; }
let root!: ReactTestRenderer;
await act(async () => { root = create(React.createElement(Harness, { id: 'hook-a' })); });
await act(async () => { model.setOpen(true); });
await act(async () => { root.update(React.createElement(Harness, { id: 'hook-b' })); });
await act(async () => { finish({ entries: [{ name: 'old', path: '/hook-a/old', hidden: false }], home: null }); });
assert.equal(model.open, false);
assert.equal(model.cwd, '/hook-b');
assert.deepEqual(model.entries, []);
await act(async () => { root.unmount(); });
cleanups.forEach(cleanup => cleanup());
});
test('reconnect after an aborted listing allows manual recovery from a failed cwd probe', async t => {
const id = 'hook-directory-reconnect';
let finishOld!: (value: { entries: { name: string; path: string; hidden: boolean }[]; home: null }) => void;
const oldController = createTerminalDirectoryController({
sessionId: id, isCurrent: () => true, isReady: () => true,
io: { cwd: async () => '/old', realpath: async value => value, write() {},
list: () => new Promise(resolve => { finishOld = resolve; }) },
publish: cwd => { terminalCwdStore.setCwd(id, cwd, 'backend-strict'); },
});
const unregisterOld = registerTerminalDirectory(id, oldController);
let model!: ReturnType<typeof useComposeDirectoryPicker>;
function Harness() { model = useComposeDirectoryPicker(id); return null; }
let root!: ReactTestRenderer;
let unregisterNew = () => {};
t.after(async () => {
await act(async () => { root.unmount(); });
unregisterOld(); unregisterNew();
});
await act(async () => { root = create(React.createElement(Harness)); });
await act(async () => { model.setOpen(true); });
assert.equal(model.loading, true);
// Close while the request is pending, so the read is already aborted when
// the disconnect disposes its controller and settles the old promise.
await act(async () => { model.setOpen(false); });
await act(async () => { unregisterOld(); terminalCwdStore.setCwd(id, null); });
let failProbe = true;
const nextController = createTerminalDirectoryController({
sessionId: id, isCurrent: () => true, isReady: () => true,
io: { cwd: async () => { if (failProbe) throw new DirectoryPickerError('unknown'); return '/recovered'; },
realpath: async value => value, write() {},
list: async () => ({ entries: [{ name: 'new', path: '/recovered/new', hidden: false }], home: null }) },
publish: cwd => { terminalCwdStore.setCwd(id, cwd, 'backend-strict'); },
});
await act(async () => { unregisterNew = registerTerminalDirectory(id, nextController); });
assert.equal(model.cwd, null);
assert.equal(model.error, 'unknown');
assert.equal(model.loading, false, 'cancelled reads must not leave the manual Refresh action disabled');
await act(async () => { failProbe = false; await model.refresh(); model.setOpen(true); });
assert.equal(model.cwd, '/recovered');
assert.equal(model.loading, false);
assert.deepEqual(model.entries.map(entry => entry.name), ['new']);
await act(async () => { finishOld({ entries: [{ name: 'stale', path: '/old/stale', hidden: false }], home: null }); });
assert.deepEqual(model.entries.map(entry => entry.name), ['new']);
});
@@ -0,0 +1,94 @@
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
import { DirectoryPickerError, directoryParent, filterDirectories, type DirectoryEntry, type DirectoryPickerErrorCode } from '../../domain/directoryPicker';
import { terminalCwdStore } from './terminalCwdStore';
import { getTerminalDirectory, observeTerminalDirectory, subscribeTerminalDirectories } from './terminalDirectoryRegistry';
export function useComposeDirectoryPicker(sessionId: string) {
const controller = useSyncExternalStore(subscribeTerminalDirectories, () => getTerminalDirectory(sessionId), () => null);
useSyncExternalStore(terminalCwdStore.subscribe, terminalCwdStore.getVersion, terminalCwdStore.getVersion);
const [confirmed, setConfirmed] = useState(false);
const cwd = controller && (confirmed || controller.hasConfirmedCwd() || terminalCwdStore.getSource(sessionId) === 'osc7')
? terminalCwdStore.getCwd(sessionId) : null;
const [open, setOpen] = useState(false);
const [query, setQuery] = useState('');
const [showHidden, setShowHidden] = useState(false);
const [entries, setEntries] = useState<DirectoryEntry[]>([]);
const [home, setHome] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [pending, setPending] = useState(false);
const listingBase = useRef<string | null>(null);
const [error, setError] = useState<DirectoryPickerErrorCode | null>(null);
const generation = useRef(0);
const lifetime = useRef(new AbortController());
const read = useRef<AbortController | null>(null);
const sending = useRef(false);
const report = useCallback((failure: unknown, fallback: DirectoryPickerErrorCode) => {
setError(failure instanceof DirectoryPickerError ? failure.code : fallback);
}, []);
useEffect(() => {
const id = ++generation.current;
const stopObserving = observeTerminalDirectory(sessionId);
lifetime.current = new AbortController();
sending.current = false;
setConfirmed(false); setOpen(false); setPending(false); setLoading(false); setHome(null); setError(null); setEntries([]);
listingBase.current = null;
if (controller) void Promise.all([controller.refresh(), controller.home()]).then(([, nextHome]) => {
if (generation.current === id) { setConfirmed(true); setHome(nextHome); }
}).catch(failure => { if (generation.current === id) report(failure, 'unknown'); });
return () => {
generation.current = id + 1;
lifetime.current.abort(); read.current?.abort(); stopObserving();
};
}, [sessionId, controller, report]);
const refresh = useCallback(async () => {
if (!controller) { setError('unavailable'); return; }
const id = generation.current;
setError(null);
try { await controller.refresh(); if (generation.current === id) setConfirmed(true); }
catch (failure) { if (generation.current === id) report(failure, 'unknown'); }
}, [controller, report]);
const load = useCallback(async () => {
if (!controller || !cwd) return;
read.current?.abort();
const task = new AbortController(); read.current = task;
const id = generation.current;
setLoading(true); setEntries([]); setError(null);
try {
const result = await controller.list(cwd, task.signal);
if (!task.signal.aborted && id === generation.current) {
listingBase.current = cwd; setEntries(result.entries); setHome(result.home);
}
} catch (failure) {
if (!task.signal.aborted && id === generation.current) report(failure, 'readFailed');
} finally { if (!task.signal.aborted && id === generation.current) setLoading(false); }
}, [controller, cwd, report]);
useEffect(() => {
if (open) { setQuery(''); void load(); }
return () => { read.current?.abort(); };
}, [open, load]);
const change = useCallback(async (path: string) => {
if (!controller) { setError('unavailable'); return; }
if (sending.current) return;
if (!cwd || terminalCwdStore.getCwd(sessionId) !== cwd
|| (path !== directoryParent(cwd) && listingBase.current !== cwd)) { setError('unavailable'); return; }
sending.current = true; setPending(true); setError(null);
const id = generation.current;
try {
await controller.change(path, lifetime.current.signal, () => {
if (id === generation.current) setOpen(false);
}, cwd ?? undefined);
} catch (failure) { if (id === generation.current) report(failure, 'changeFailed'); }
finally {
if (id === generation.current) { sending.current = false; setPending(false); }
}
}, [controller, report, cwd, sessionId]);
const filtered = useMemo(() => filterDirectories(entries, query, showHidden), [entries, query, showHidden]);
return { cwd, home, open, setOpen, query, setQuery, showHidden, setShowHidden, entries: filtered,
parent: cwd ? directoryParent(cwd) : null, loading, pending, error, available: !!controller, refresh, reload: load, change };
}
@@ -0,0 +1,59 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
import { parsePluginGitHubRepository } from '@/domain/pluginGitHubRepository.cjs';
export function usePluginGitHubInstall(onInstalled: () => Promise<unknown>) {
const [preview, setPreview] = useState<NetcattyGitHubPluginPreview | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const token = useRef<string | null>(null);
const active = useRef(false);
const mounted = useRef(true);
const reset = useCallback(() => {
const previous = token.current;
token.current = null;
setPreview(null);
setError(null);
if (previous) void netcattyBridge.get()?.discardGitHubPlugin?.(previous).catch(() => {});
}, []);
useEffect(() => {
mounted.current = true;
return () => {
mounted.current = false;
if (token.current) void netcattyBridge.get()?.discardGitHubPlugin?.(token.current).catch(() => {});
};
}, []);
const run = async (operation: () => Promise<void>) => {
if (active.current) return;
active.current = true;
setBusy(true);
setError(null);
try { await operation(); }
catch (cause) { if (mounted.current) setError(cause instanceof Error ? cause.message : String(cause)); }
finally { active.current = false; if (mounted.current) setBusy(false); }
};
return {
preview, busy, error, reset,
prepare: (input: string) => run(async () => {
const { origin } = parsePluginGitHubRepository(input);
const bridge = netcattyBridge.require();
if (!bridge.prepareGitHubPlugin) throw new Error('GitHub plugin installation unavailable');
reset();
const result = await bridge.prepareGitHubPlugin(origin);
if (!mounted.current) { await bridge.discardGitHubPlugin?.(result.token); return; }
token.current = result.token;
setPreview(result);
}),
install: () => run(async () => {
const bridge = netcattyBridge.require();
if (!bridge.installGitHubPlugin || !token.current) throw new Error('Download and review a GitHub plugin first');
const selected = token.current;
// The host consumes the selection even on failure; retry starts with a new review.
token.current = null;
setPreview(null);
await bridge.installGitHubPlugin(selected);
await onInstalled();
window.dispatchEvent(new Event('netcatty:plugin-packages-changed'));
}),
};
}
@@ -4,6 +4,9 @@ import React from "react";
import { act, create, type ReactTestRenderer } from "react-test-renderer";
import { useSessionState } from "./useSessionState.ts";
import { installTestLocalStorage } from "./testSupport/localStorage.ts";
test.beforeEach((t) => { t.after(installTestLocalStorage()); });
type SessionState = ReturnType<typeof useSessionState>;
@@ -4,6 +4,9 @@ import React from "react";
import { act, create, type ReactTestRenderer } from "react-test-renderer";
import { useSessionState } from "./useSessionState.ts";
import { installTestLocalStorage } from "./testSupport/localStorage.ts";
test.beforeEach((t) => { t.after(installTestLocalStorage()); });
type SessionState = ReturnType<typeof useSessionState>;
@@ -86,6 +89,15 @@ test("global broadcast availability follows visible orphan session count", async
await act(async () => {
current().removeSessionFromWorkspace(second.id);
});
// The one-member workspace stays intact; only the detached terminal is
// an orphan, so global broadcast remains unavailable until another opens.
assert.equal(current().workspaces.length, 1);
assert.equal(current().sessions.find(session => session.id === first.id)?.workspaceId, current().workspaces[0].id);
assert.deepEqual(current().orphanSessions.map(session => session.id), [second.id]);
assert.equal(current().canUseGlobalBroadcast, false);
assert.equal(current().isGlobalBroadcastEnabled, false);
await act(async () => { current().createLocalTerminal({ shellName: "04" }); });
assert.equal(current().orphanSessions.length, 2);
assert.equal(current().canUseGlobalBroadcast, true);
assert.equal(current().isGlobalBroadcastEnabled, false);
} finally {
@@ -0,0 +1,92 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { Workspace } from '../../domain/models';
import { collectSessionIds } from '../../domain/workspace';
import { moveWorkspacePaneInWorkspaces } from './useSessionState';
// Issue #4037: moving a member pane must rearrange only the tree shape.
// Session ids, workspace id, anchorChatId (AI binding), focus fields and tab
// order all live outside the touched surface, and these tests pin that.
const workspace = (id = 'ws-1'): Workspace => ({
id,
title: 'Workspace',
focusedSessionId: 's1',
focusSessionOrder: ['s1', 's2', 's3'],
anchorChatId: 'chat-anchor-1',
root: {
id: 'split-1',
type: 'split',
direction: 'vertical',
children: [
{ id: 'pane-1', type: 'pane', sessionId: 's1' },
{ id: 'pane-2', type: 'pane', sessionId: 's2' },
{ id: 'pane-3', type: 'pane', sessionId: 's3' },
],
sizes: [1, 1, 1],
},
});
test('moveWorkspacePaneInWorkspaces rearranges the tree and preserves every identity field', () => {
const ws = workspace();
const next = moveWorkspacePaneInWorkspaces([ws], 'ws-1', 's3', {
direction: 'vertical',
position: 'left',
targetSessionId: 's1',
});
assert.equal(next.length, 1);
const moved = next[0];
assert.equal(moved.id, ws.id, 'workspace id (AI binding key) unchanged');
assert.equal(moved.title, ws.title);
assert.equal(moved.anchorChatId, 'chat-anchor-1', 'anchorChatId unchanged');
assert.equal(moved.focusedSessionId, 's1', 'focus stays on the same session id');
assert.deepEqual(moved.focusSessionOrder, ['s1', 's2', 's3'], 'tab/focus order unchanged');
assert.deepEqual(collectSessionIds(moved.root), ['s3', 's1', 's2']);
assert.notEqual(moved.root, ws.root, 'the tree shape actually changed');
});
test('moveWorkspacePaneInWorkspaces leaves other workspaces and no-op cases untouched', () => {
const ws1 = workspace('ws-1');
const ws2 = workspace('ws-2');
// Same-reference no-op: pane dropped onto itself.
const selfDrop = moveWorkspacePaneInWorkspaces([ws1, ws2], 'ws-1', 's2', {
direction: 'vertical',
position: 'left',
targetSessionId: 's2',
});
assert.equal(selfDrop[0], ws1, 'no-op workspace keeps its reference');
assert.equal(selfDrop[1], ws2);
// Unknown session / foreign workspace id: nothing moves.
const ghost = moveWorkspacePaneInWorkspaces([ws1, ws2], 'ws-1', 'ghost', {
direction: 'vertical',
position: 'left',
targetSessionId: 's1',
});
assert.equal(ghost[0], ws1);
assert.equal(ghost[1], ws2);
const foreign = moveWorkspacePaneInWorkspaces([ws1, ws2], 'ws-other', 's1', {
direction: 'vertical',
position: 'left',
targetSessionId: 's2',
});
assert.equal(foreign[0], ws1);
assert.equal(foreign[1], ws2);
});
test('moveWorkspacePaneInWorkspaces only rewrites the targeted workspace entry', () => {
const ws1 = workspace('ws-1');
const ws2 = workspace('ws-2');
const next = moveWorkspacePaneInWorkspaces([ws1, ws2], 'ws-2', 's2', {
direction: 'vertical',
position: 'right',
targetSessionId: 's3',
});
assert.equal(next[0], ws1, 'sibling workspaces keep their references');
assert.notEqual(next[1], ws2);
assert.deepEqual(collectSessionIds(next[1].root), ['s1', 's3', 's2']);
});
+68 -8
View File
@@ -19,6 +19,7 @@ createWorkspaceFromSessionIds,
FocusDirection,
getNextFocusSessionId,
insertPaneIntoWorkspace,
movePaneInWorkspace,
pruneWorkspaceNode,
reorderWorkspaceFocusSessionOrder,
setWorkspaceFocusedSessionId,
@@ -42,8 +43,9 @@ import {
import {
closeSessionsState,
detachSessionFromWorkspaceState,
replaceDissolvedWorkspaceTabOrder,
} from './sessionWorkspaceDetach';
import { getAISessionsSnapshot } from './aiSessionsStore';
import { getPersistedAiSessionsSnapshot } from './aiStateSnapshots';
import {
createCopiedTerminalSessionClone,
createSplitTerminalSessionClone,
@@ -122,6 +124,26 @@ export function appendWorkspaceRootPaneIfMissing(
});
}
/**
* Issue #4037: rearrange an existing member pane within its own workspace.
* Only `workspaces[].root` shape changes — session ids, tab order, focus
* fields and every AI binding (anchorChatId / activeSessionIdMap scopes) are
* untouched, because the moved session keeps its id and its workspaceId.
*/
export function moveWorkspacePaneInWorkspaces(
workspaces: Workspace[],
workspaceId: string,
sessionId: string,
hint: SplitHint,
): Workspace[] {
return workspaces.map(ws => {
if (ws.id !== workspaceId) return ws;
if (!collectSessionIds(ws.root).includes(sessionId)) return ws;
const nextRoot = movePaneInWorkspace(ws.root, sessionId, hint);
return nextRoot === ws.root ? ws : { ...ws, root: nextRoot };
});
}
export function insertCopiedTabOrderIdOnce(
prevTabOrder: string[],
sourceTabId: string,
@@ -615,6 +637,11 @@ export const useSessionState = ({
currentActiveTabId: activeTabStore.getActiveTabId(),
preferredTabId: activeTabStore.getPreviousActiveTabId(),
tabOrder: tabOrderRef.current,
// AI chat recency decides which surviving member receives focus/anchor
// after member loss (#4098 phase 2); hot store first, persisted fallback.
aiSessions: getAISessionsSnapshot().sessions.length > 0
? getAISessionsSnapshot().sessions
: getPersistedAiSessionsSnapshot(),
});
setWorkspaces(result.workspaces);
@@ -822,6 +849,20 @@ export const useSessionState = ({
});
}, [setActiveTabId]);
// Issue #4037: drop an existing member pane back into its own workspace to
// rearrange it. Deliberately touches nothing but the pane tree: no session
// fields, no tab order, no focus changes (focusedSessionId keeps pointing
// at the same session id, so focus visually follows the moved pane), and
// no AI state — the anchorChatId lives on the workspace, which keeps its id.
const moveWorkspacePane = useCallback((
workspaceId: string,
sessionId: string,
hint: SplitHint
) => {
if (!hint) return;
setWorkspaces(prev => moveWorkspacePaneInWorkspaces(prev, workspaceId, sessionId, hint));
}, []);
// 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
@@ -1106,6 +1147,25 @@ export const useSessionState = ({
setWorkspaces(prev => setWorkspaceFocusedSessionId(prev, workspaceId, sessionId));
}, []);
// Issue #4098 phase 2: pin the AI chat a workspace scope is anchored to.
// Written by the AI side panel when a chat becomes active in the workspace
// scope; consumed by the non-positional fallbacks and persisted to the
// session restore snapshot.
const setWorkspaceAnchorChat = useCallback((workspaceId: string, chatId: string | null) => {
setWorkspaces(prev => {
const index = prev.findIndex(ws => ws.id === workspaceId);
if (index < 0) return prev;
if (prev[index].anchorChatId === (chatId ?? undefined)) return prev;
const next = prev.slice();
next[index] = chatId
? { ...prev[index], anchorChatId: chatId }
: Object.fromEntries(
Object.entries(prev[index]).filter(([key]) => key !== 'anchorChatId'),
) as Workspace;
return next;
});
}, []);
const reorderWorkspaceSessions = useCallback((
workspaceId: string,
draggedSessionId: string,
@@ -1435,18 +1495,16 @@ export const useSessionState = ({
sessions: sessionsRef.current,
workspaces: workspacesRef.current,
sessionId,
aiSessions: getAISessionsSnapshot().sessions.length > 0
? getAISessionsSnapshot().sessions
: getPersistedAiSessionsSnapshot(),
});
if (!result.changed) return;
setWorkspaces(result.workspaces);
setSessions(result.sessions);
setTabOrder((prevTabOrder) => {
const replacedOrder = replaceDissolvedWorkspaceTabOrder(
prevTabOrder,
result.dissolvedWorkspaceId,
result.replacementTabIds,
);
if (!tabInsertionTarget) return replacedOrder;
if (!tabInsertionTarget) return prevTabOrder;
const allTabIds = [
...result.sessions.filter((s) => !s.workspaceId).map((s) => s.id),
@@ -1455,7 +1513,7 @@ export const useSessionState = ({
...(tabInsertionTarget.additionalTabIds ?? []),
];
return reorderWorkTabIds(
replacedOrder,
prevTabOrder,
allTabIds,
sessionId,
tabInsertionTarget.tabId,
@@ -1515,6 +1573,7 @@ export const useSessionState = ({
createWorkspaceFromTargets,
createWorkspaceFromSessions,
addSessionToWorkspace,
moveWorkspacePane,
removeSessionFromWorkspace,
appendHostToWorkspace,
appendHostToWorkspaceConfirmed,
@@ -1523,6 +1582,7 @@ export const useSessionState = ({
splitSession,
toggleWorkspaceViewMode,
setWorkspaceFocusedSession,
setWorkspaceAnchorChat,
reorderWorkspaceSessions,
moveFocusInWorkspace,
runSnippet,
@@ -4,6 +4,10 @@ import React from "react";
import { act, create, type ReactTestRenderer } from "react-test-renderer";
import { useSessionState } from "./useSessionState.ts";
import { installTestLocalStorage } from "./testSupport/localStorage.ts";
import { collectSessionIds } from "../../domain/workspace.ts";
test.beforeEach((t) => { t.after(installTestLocalStorage()); });
type SessionState = ReturnType<typeof useSessionState>;
@@ -121,6 +125,15 @@ test("merged tab titles follow the workspace lifecycle until the user renames it
assert.equal(workspace(workspaceId).title, "Pinned");
assert.equal(workspace(workspaceId).generatedTitle, false);
await act(async () => { current().closeSession(firstSessionId); });
assert.equal(workspace(workspaceId).title, "Pinned");
assert.deepEqual(collectSessionIds(workspace(workspaceId).root), [secondSessionId]);
assert.equal(current().sessions.find(session => session.id === secondSessionId)?.workspaceId, workspaceId);
await act(async () => { current().closeSession(secondSessionId); });
assert.equal(workspace(workspaceId).title, "Pinned");
assert.equal(workspace(workspaceId).generatedTitle, false);
assert.deepEqual(collectSessionIds(workspace(workspaceId).root), []);
let leftBaseSessionId = "";
let leftJoiningSessionId = "";
await act(async () => {
+9
View File
@@ -1231,6 +1231,15 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
return () => mql.removeEventListener('change', handler);
}, []);
// Listen for system theme changes from main process (fallback when matchMedia doesn't fire)
useEffect(() => {
const bridge = netcattyBridge.get();
if (!bridge?.onSystemThemeChanged) return;
return bridge.onSystemThemeChanged((payload) => {
setSystemPreference(payload.isDark ? 'dark' : 'light');
});
}, []);
useLayoutEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_UI_LANGUAGE, uiLanguage);
document.documentElement.lang = uiLanguage;
@@ -0,0 +1,95 @@
import assert from 'node:assert/strict';
import test, { type TestContext } from 'node:test';
import React from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import type { Host } from '../../domain/models';
import { getTerminalDirectory, observeTerminalDirectory } from './terminalDirectoryRegistry';
import { useTerminalDirectorySession } from './useTerminalDirectorySession';
import { useComposeDirectoryPicker } from './useComposeDirectoryPicker';
type SessionProps = Parameters<typeof useTerminalDirectorySession>[0];
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
function installBridge(t: TestContext, getAppInfo?: NetcattyBridge['getAppInfo']) {
const previous = Object.getOwnPropertyDescriptor(globalThis, 'window');
let probes = 0; let lists = 0;
Object.defineProperty(globalThis, 'window', { configurable: true, value: { netcatty: {
getAppInfo,
getSessionPwd: async () => { probes++; return { success: true, cwd: '/actual' }; },
listLocalDir: async () => { lists++; return []; },
} } });
t.after(() => {
if (previous) Object.defineProperty(globalThis, 'window', previous);
else Reflect.deleteProperty(globalThis, 'window');
});
return { counts: () => ({ probes, lists }) };
}
const options = (id: string, protocol = 'local', shellType = 'posix'): SessionProps => ({
sessionId: id, status: 'connected',
host: { id, label: 'Test', hostname: 'host', username: 'me', protocol } as Host,
shellType, getBackendId: () => id, isReady: () => true, publish() {},
});
for (const wrapper of ['wsl-Ubuntu', 'git-bash', 'cygwin']) {
test(`Windows ${wrapper} reports directory navigation unavailable and performs no reads`, async t => {
const bridge = installBridge(t, async () => ({ name: 'Netcatty', version: 'test', platform: 'win32' }));
const id = 'windows-' + wrapper;
let model!: ReturnType<typeof useComposeDirectoryPicker>;
function Harness() {
const props = options(id);
props.host = { ...props.host, localShell: wrapper };
useTerminalDirectorySession(props);
model = useComposeDirectoryPicker(id);
return null;
}
let root!: ReactTestRenderer;
await act(async () => { root = create(React.createElement(Harness)); });
t.after(async () => { await act(async () => { root.unmount(); }); });
assert.equal(getTerminalDirectory(id), null);
assert.equal(model.available, false);
await act(async () => { model.setOpen(true); await model.refresh(); });
assert.equal(model.error, 'unavailable');
assert.deepEqual(bridge.counts(), { probes: 0, lists: 0 });
});
}
for (const platform of ['linux', 'darwin']) {
test(`local POSIX directory controller registers on ${platform}`, async t => {
installBridge(t, async () => ({ name: 'Netcatty', version: 'test', platform }));
const id = 'local-' + platform;
function Harness() { useTerminalDirectorySession(options(id)); return null; }
let root!: ReactTestRenderer;
await act(async () => { root = create(React.createElement(Harness)); });
t.after(async () => { await act(async () => { root.unmount(); }); });
assert.ok(getTerminalDirectory(id));
});
}
test('Windows SSH sessions do not inherit the local cwd platform limitation', async t => {
let platformQueries = 0;
installBridge(t, async () => { platformQueries++; return { name: 'Netcatty', version: 'test', platform: 'win32' }; });
const id = 'windows-ssh';
function Harness() { useTerminalDirectorySession(options(id, 'ssh')); return null; }
let root!: ReactTestRenderer;
await act(async () => { root = create(React.createElement(Harness)); });
t.after(async () => { await act(async () => { root.unmount(); }); });
assert.ok(getTerminalDirectory(id));
assert.equal(platformQueries, 0);
});
test('a late platform query cannot register a disconnected terminal', async t => {
let finish!: (info: { name: string; version: string; platform: string }) => void;
installBridge(t, () => new Promise(resolve => { finish = resolve; }));
const id = 'disconnected-during-platform-query';
const stopObserving = observeTerminalDirectory(id);
t.after(stopObserving);
function Harness({ status }: { status: string }) { useTerminalDirectorySession({ ...options(id), status }); return null; }
let root!: ReactTestRenderer;
await act(async () => { root = create(React.createElement(Harness, { status: 'connected' })); });
await act(async () => { root.update(React.createElement(Harness, { status: 'disconnected' })); });
await act(async () => { finish({ name: 'Netcatty', version: 'test', platform: 'linux' }); });
assert.equal(getTerminalDirectory(id), null);
await act(async () => { root.unmount(); });
});
@@ -0,0 +1,56 @@
import { useEffect, useRef } from 'react';
import type { Host } from '../../domain/models';
import { classifyDistroId, hostRestrictsExtraSshChannels } from '../../domain/host';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
import { createTerminalDirectoryIO } from '../../infrastructure/services/terminalDirectory';
import { createTerminalDirectoryController } from './terminalDirectoryController';
import { registerTerminalDirectory } from './terminalDirectoryRegistry';
export function useTerminalDirectorySession(options: {
sessionId: string;
status: string;
host: Host;
shellType?: string;
getBackendId: () => string | null;
isReady: () => boolean;
publish: (cwd: string) => void;
}) {
const live = useRef(options);
live.current = options;
const { sessionId, status, shellType, host } = options;
const local = host.protocol === 'local';
const supported = (local || ((host.protocol ?? 'ssh') === 'ssh' && !host.moshEnabled && !host.etEnabled))
&& shellType !== 'powershell' && shellType !== 'cmd'
&& host.deviceType !== 'network' && classifyDistroId(host.distro) !== 'network-device'
&& !hostRestrictsExtraSshChannels(host);
useEffect(() => {
const bridge = netcattyBridge.get();
const backendId = live.current.getBackendId();
if (!bridge || !backendId || status !== 'connected' || !supported) return;
let active = true;
const isCurrent = () => active && live.current.status === 'connected' && live.current.getBackendId() === backendId;
let unregister = () => {};
const register = () => {
if (!isCurrent()) return;
const controller = createTerminalDirectoryController({
sessionId,
io: createTerminalDirectoryIO(bridge, backendId, {
local, hostname: host.hostname, username: host.username || 'root', port: host.port,
encoding: host.sftpEncoding,
}, isCurrent),
isCurrent,
isReady: () => live.current.isReady(),
publish: cwd => live.current.publish(cwd),
});
unregister = registerTerminalDirectory(sessionId, controller);
};
if (local) {
// POSIX wrappers on Windows still use win32 PTYs. Neither their cwd nor
// their path namespace is supported by readLocalSessionCwd/local FS.
void bridge.getAppInfo?.().then(info => {
if (info.platform === 'darwin' || info.platform === 'linux') register();
}).catch(() => {});
} else register();
return () => { active = false; unregister(); };
}, [sessionId, status, supported, local, host.hostname, host.username, host.port, host.sftpEncoding, shellType]);
}
+74 -23
View File
@@ -8,6 +8,8 @@ import React from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { getAgentRuntime } from '../infrastructure/ai/harness/globalAgentRuntime';
import { useAIChatStreaming, type UseAIChatStreamingReturn } from './state/useAIChatStreaming';
import { installFakeIndexedDb } from './state/testSupport/fakeIndexedDb';
import { idbPutSessions, openAiSessionsIdb } from './state/aiSessionIdbStorage';
installBrowserLockFixture();
@@ -43,7 +45,47 @@ function save(sessions: AISession[], enabled = true) {
storage.set(keys.STORAGE_KEY_AI_SESSIONS, JSON.stringify(sessions));
storage.set(keys.STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS, JSON.stringify({ enabled }));
}
test.beforeEach(() => { storage.clear(); save([], false); });
test.beforeEach(() => {
storage.clear();
save([], false);
snapshots.__resetIdbPersistenceForTests();
});
test.afterEach(() => {
snapshots.__resetIdbPersistenceForTests();
});
// Issue #4125 review P1①: a sync round that runs before the boot hydration
// has settled must not collect (and persist) the stale legacy store — it
// would overwrite newer IndexedDB records with pre-migration data.
test('a sync round before boot hydration skips chat collection and never overwrites IndexedDB with stale legacy data', async () => {
const idb = installFakeIndexedDb();
try {
snapshots.__resetIdbPersistenceForTests();
snapshots.__resetAiSessionsHydrationForTests(); // boot load still pending
const newer = { ...session(), title: 'Newer IndexedDB copy', updatedAt: 9 };
await idbPutSessions(await openAiSessionsIdb(), [newer]);
// Stale pre-migration copy left in the read-only legacy key.
storage.set(keys.STORAGE_KEY_AI_SESSIONS, JSON.stringify([{ ...session(), title: 'Stale legacy copy', updatedAt: 1 }]));
storage.set(keys.STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS, JSON.stringify({ enabled: true }));
snapshots.setLatestAISessionsSnapshot(null as unknown as AISession[]);
// Pre-hydration collection skips the round entirely.
assert.equal(collectAiSessionsForSync(), undefined);
await snapshots.__flushIdbWritesForTests();
assert.equal(
idb.db.dumpStore('sessions').find((s) => (s as AISession).id === 'chat')?.title,
'Newer IndexedDB copy',
);
// After hydration the collector returns the live snapshot again.
snapshots.markAiSessionsHydrationComplete();
const hydrated = [newer];
snapshots.seedLastPersistedSessions(hydrated);
snapshots.setLatestAISessionsSnapshot(hydrated);
assert.equal(collectAiSessionsForSync()?.[0].title, 'Newer IndexedDB copy');
} finally {
idb.restore();
snapshots.__resetIdbPersistenceForTests();
}
});
test('actual manual upload and auto collector honor consent and explicit empty', async () => {
save([session()], false);
@@ -74,23 +116,16 @@ test('sync persistence survives restart beyond the local-only history limits', (
assert.equal(afterRestart[0].messages.length, 201);
save(many, false);
assert.equal(snapshots.writeSessionsForStorage(many), true);
const localOnly = JSON.parse(storage.get(keys.STORAGE_KEY_AI_SESSIONS)!);
assert.equal(localOnly.length, 50);
// Issue #4123: the persisted set now lives in IndexedDB (per-record
// writes); the legacy localStorage key is a read-only fallback. The
// session-count cap rose to 1000, so all 51 chats survive retention —
// only the per-session message cap (200) still trims.
const localOnly = snapshots.getPersistedAiSessionsSnapshot();
assert.equal(localOnly.length, 51);
assert.equal(localOnly[0].messages.length, 200);
});
test('sync collection after restart includes overflow before clearing it', () => {
const primary = session();
const overflow = { ...session(), id: 'overflow-chat', updatedAt: 1 };
save([primary], true);
storage.set(keys.STORAGE_KEY_AI_SESSIONS_OVERFLOW, JSON.stringify([overflow]));
snapshots.setLatestAISessionsSnapshot(null as unknown as AISession[]);
assert.deepEqual(collectAiSessionsForSync()?.map(value => value.id), [primary.id, overflow.id]);
assert.equal(storage.has(keys.STORAGE_KEY_AI_SESSIONS_OVERFLOW), false);
assert.deepEqual(JSON.parse(storage.get(keys.STORAGE_KEY_AI_SESSIONS)!).map((value: AISession) => value.id), [primary.id, overflow.id]);
});
test('failed history persistence refuses upload without pruning the cloud representation', async () => {
test('legacy localStorage quota failures no longer block sync upload', async () => {
save([session()]);
const original = globalThis.localStorage.setItem;
globalThis.localStorage.setItem = (key, value) => {
@@ -99,8 +134,12 @@ test('failed history persistence refuses upload without pruning the cloud repres
};
snapshots.setLatestAISessionsSnapshot([{ ...session(), title: 'Unsaved change' }]);
try {
await assert.rejects(buildCloudSyncPayload(vault, [], { collectPluginSidecars: () => undefined }), /persist|save/i);
assert.equal(JSON.parse(storage.get(keys.STORAGE_KEY_AI_SESSIONS)!)[0].title, 'Chat');
// Persistence moved to IndexedDB (issue #4123), so the legacy key's
// quota can no longer fail the collect step; the in-memory snapshot is
// the source of truth for the upload.
const cloud = await buildCloudSyncPayload(vault, [], { collectPluginSidecars: () => undefined });
assert.equal(cloud.aiSessions?.[0].title, 'Unsaved change');
assert.equal(JSON.stringify(cloud).includes('cHJpdmF0ZQ=='), false);
const backup = buildLocalVaultPayload(vault);
assert.equal(backup.aiSessions?.[0].title, 'Unsaved change');
assert.equal(backup.aiSessions?.[0].messages[0].attachments?.[0].base64Data, 'cHJpdmF0ZQ==');
@@ -164,7 +203,7 @@ test('download publishes immediately and keeps local attachment bytes on a newer
assert.equal(current.messages[0].content, 'Updated');
assert.equal(current.messages[0].attachments?.[0].base64Data, 'cHJpdmF0ZQ==');
assert.equal(snapshots.latestAISessionsSnapshot?.[0], current);
assert.equal(JSON.parse(storage.get(keys.STORAGE_KEY_AI_SESSIONS)!)[0].messages[0].content, 'Updated');
assert.equal(snapshots.getPersistedAiSessionsSnapshot()[0].messages[0].content, 'Updated');
});
test('restoring an old backup without chat leaves current chats alone', async () => {
@@ -174,7 +213,7 @@ test('restoring an old backup without chat leaves current chats alone', async ()
prepareConvergentRestore: async () => async () => {},
});
assert.deepEqual(snapshots.latestAISessionsSnapshot, [local]);
assert.deepEqual(JSON.parse(storage.get(keys.STORAGE_KEY_AI_SESSIONS)!), [local]);
assert.deepEqual(snapshots.getPersistedAiSessionsSnapshot(), [local]);
});
test('authoritative cloud session removal stays removed in the next upload', async () => {
@@ -202,6 +241,9 @@ test('replacement stops active turns and waits for finalizers before persisting
const local = { ...session(), messages: [...session().messages,
{ id: 'reply', role: 'assistant' as const, content: 'Partial', timestamp: 2, executionStatus: 'running' as const }] };
save([local]);
// Seed the per-record diff baseline so the only 'persist' marker in
// this scenario is the final replacement write (issue #4123).
snapshots.seedLastPersistedSessions([local]);
hook!.abortControllersRef.current.set('chat', controller);
await act(async () => { hook!.setStreamingForScope('chat', true); });
Object.assign(window, { netcatty: {
@@ -216,15 +258,24 @@ test('replacement stops active turns and waits for finalizers before persisting
{ ...local.messages[1], content: 'Settled', executionStatus: 'cancelled' }] }]);
order.push('settled');
});
globalThis.localStorage.setItem = (key, value) => { if (key === keys.STORAGE_KEY_AI_SESSIONS) order.push('persist'); original(key, value); };
// Issue #4123: session persistence is an IndexedDB commit now; the
// 'persist' marker fires when the record lands in the store.
globalThis.localStorage.setItem = original;
const idb = installFakeIndexedDb({ onOperation: (store, _op) => {
// Both puts and deletes are persistence commits for the sessions store.
if (store === 'sessions') order.push('persist');
} });
const incoming = { ...session(), updatedAt: 3, messages: [...session().messages,
{ id: 'remote', role: 'user' as const, content: 'Remote', timestamp: 3 }] };
await applyLocalVaultPayload(remotePayload(remove ? [] : [incoming]), { importVaultData: () => undefined }, {
prepareConvergentRestore: async () => async () => {},
});
await snapshots.__flushIdbWritesForTests();
idb.restore();
snapshots.__resetIdbPersistenceForTests();
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.ok(order.includes('cleanup'));
assert.equal(hook!.abortControllersRef.current.has('chat'), false);
if (remove) assert.deepEqual(getAISessionsSnapshot().sessions, []);
else assert.equal(getAISessionsSnapshot().sessions[0].messages.at(-1)?.content, 'Remote');
@@ -268,7 +319,7 @@ test('cloud sync preserves an active trimmed conversation and its native thread'
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');
assert.equal(snapshots.getPersistedAiSessionsSnapshot()[0].externalSessionId, 'native-thread');
}
} finally {
hook!.abortControllersRef.current.delete('chat');
@@ -299,7 +350,7 @@ test('cloud downloads respect local chat opt-in while still applying vault data'
await applySyncPayload(downloaded, { importVaultData: () => { vaultImports++; } });
assert.equal(vaultImports, 1);
assert.deepEqual(snapshots.latestAISessionsSnapshot, []);
assert.deepEqual(JSON.parse(storage.get(keys.STORAGE_KEY_AI_SESSIONS)!), []);
assert.deepEqual(snapshots.getPersistedAiSessionsSnapshot(), []);
save([], true);
await applySyncPayload(downloaded, { importVaultData: () => { vaultImports++; } });
assert.equal(vaultImports, 2);
+9 -2
View File
@@ -50,8 +50,9 @@ import { decryptField, encryptField } from '../infrastructure/persistence/secure
import { sanitizeQuickMessages } from '../infrastructure/ai/quickMessages';
import { emitAIStateChanged } from './state/aiStateEvents';
import {
isAiSessionsHydrationComplete,
latestAISessionsSnapshot,
readSessionsFromStorage,
getPersistedAiSessionsSnapshot,
setLatestAISessionsSnapshot,
writeSessionsForStorage,
cleanupSdkAgentSessions,
@@ -1065,6 +1066,12 @@ function pruneOrphanPerAgentBindings(): void {
export function collectAiSessionsForSync(): SyncPayload['aiSessions'] {
const enabled = localStorageAdapter.read<{ enabled?: boolean }>(STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS);
if (enabled?.enabled !== true) return undefined;
// Issue #4125 review P1①: never collect before the boot hydration has
// settled. A pre-hydration collect falls back to the stale legacy
// localStorage store and the writeSessionsForStorage() call below would
// overwrite newer IndexedDB records with pre-migration data. Skip this
// sync round; the next auto-sync picks up the hydrated store.
if (!isAiSessionsHydrationComplete()) return undefined;
const sessions = collectLocalAiSessions();
if (!writeSessionsForStorage(sessions)) {
throw new Error('Unable to persist chat history for cloud sync');
@@ -1074,7 +1081,7 @@ export function collectAiSessionsForSync(): SyncPayload['aiSessions'] {
function collectLocalAiSessions(): NonNullable<SyncPayload['aiSessions']> {
return latestAISessionsSnapshot
?? readSessionsFromStorage();
?? getPersistedAiSessionsSnapshot();
}
/** Build the portable payload; device-local data is added by the backup builder. */
+30
View File
@@ -22,9 +22,39 @@
WriteRegStr SHCTX "Software\Classes\Directory\Background\shell\Netcatty" "MUIVerb" "Open in Netcatty"
WriteRegStr SHCTX "Software\Classes\Directory\Background\shell\Netcatty" "Icon" "$INSTDIR\${APP_EXECUTABLE_FILENAME},0"
WriteRegStr SHCTX "Software\Classes\Directory\Background\shell\Netcatty\command" "" '"$INSTDIR\${APP_EXECUTABLE_FILENAME}" -- --open-terminal-path="%V."'
; #4204: Windows Default Apps requires Capabilities/RegisteredApplications
; to list URL handlers; Electron's bare scheme keys are not sufficient.
WriteRegStr SHCTX "Software\Classes\Netcatty.SSH" "" "URL:ssh Protocol"
WriteRegStr SHCTX "Software\Classes\Netcatty.SSH" "URL Protocol" ""
WriteRegStr SHCTX "Software\Classes\Netcatty.SSH\DefaultIcon" "" '"$INSTDIR\${APP_EXECUTABLE_FILENAME}",0'
WriteRegStr SHCTX "Software\Classes\Netcatty.SSH\shell\open\command" "" '"$INSTDIR\${APP_EXECUTABLE_FILENAME}" "%1"'
WriteRegStr SHCTX "Software\Classes\Netcatty.Telnet" "" "URL:telnet Protocol"
WriteRegStr SHCTX "Software\Classes\Netcatty.Telnet" "URL Protocol" ""
WriteRegStr SHCTX "Software\Classes\Netcatty.Telnet\DefaultIcon" "" '"$INSTDIR\${APP_EXECUTABLE_FILENAME}",0'
WriteRegStr SHCTX "Software\Classes\Netcatty.Telnet\shell\open\command" "" '"$INSTDIR\${APP_EXECUTABLE_FILENAME}" "%1"'
WriteRegStr SHCTX "Software\Classes\Netcatty.JMS" "" "URL:jms Protocol"
WriteRegStr SHCTX "Software\Classes\Netcatty.JMS" "URL Protocol" ""
WriteRegStr SHCTX "Software\Classes\Netcatty.JMS\DefaultIcon" "" '"$INSTDIR\${APP_EXECUTABLE_FILENAME}",0'
WriteRegStr SHCTX "Software\Classes\Netcatty.JMS\shell\open\command" "" '"$INSTDIR\${APP_EXECUTABLE_FILENAME}" "%1"'
WriteRegStr SHCTX "Software\Netcatty\Capabilities" "ApplicationName" "Netcatty"
WriteRegStr SHCTX "Software\Netcatty\Capabilities" "ApplicationDescription" "Open SSH, Telnet and JMS links in Netcatty."
WriteRegStr SHCTX "Software\Netcatty\Capabilities" "ApplicationIcon" '"$INSTDIR\${APP_EXECUTABLE_FILENAME}",0'
WriteRegStr SHCTX "Software\Netcatty\Capabilities\URLAssociations" "ssh" "Netcatty.SSH"
WriteRegStr SHCTX "Software\Netcatty\Capabilities\URLAssociations" "telnet" "Netcatty.Telnet"
WriteRegStr SHCTX "Software\Netcatty\Capabilities\URLAssociations" "jms" "Netcatty.JMS"
WriteRegStr SHCTX "Software\RegisteredApplications" "Netcatty" "Software\Netcatty\Capabilities"
!macroend
!macro customUnInstall
DeleteRegKey SHCTX "Software\Classes\Directory\shell\Netcatty"
DeleteRegKey SHCTX "Software\Classes\Directory\Background\shell\Netcatty"
DeleteRegKey SHCTX "Software\Classes\Netcatty.SSH"
DeleteRegKey SHCTX "Software\Classes\Netcatty.Telnet"
DeleteRegKey SHCTX "Software\Classes\Netcatty.JMS"
DeleteRegKey SHCTX "Software\Netcatty\Capabilities"
DeleteRegValue SHCTX "Software\RegisteredApplications" "Netcatty"
!macroend
+48 -9
View File
@@ -50,6 +50,7 @@ import {
tryBeginSendForKey,
} from './ai/draftSendGate';
import { draftsByScopeEqualIgnoringComposerText, selectDraftForAgentSwitch } from '../application/state/aiDraftState';
import { latestAIActiveSessionMapSnapshot, latestAISessionsSnapshot } from '../application/state/aiStateSnapshots';
import { sanitizeContextWindow } from '../infrastructure/ai/contextCompaction';
import {
buildPromptWithTerminalSelectionAttachments,
@@ -316,6 +317,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
scopeHostIds,
scopeLabel,
focusedSessionId,
onSetWorkspaceAnchorChat,
workspaces = [],
terminalSessions = [],
liveScopeTargetIds,
@@ -444,9 +446,16 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
preferredTerminalId: focusedSessionId,
visibleSessionIds: visibleHistorySessionIds,
});
// Issue #4098 phase 2: this workspace's anchored chat — used by the panel
// view resolver before any positional fallback could apply.
const workspaceAnchorChatId = scopeType === 'workspace'
? workspaces.find((workspace) => workspace.id === scopeTargetId)?.anchorChatId
: undefined;
// getScopedHistorySessions orders by scope match first, recency second.
const preferredHistorySessionId = historySessions[0]?.id ?? null;
const normalizedPanelView = useMemo<AIPanelView>(
() => resolveDisplayedPanelView(explicitPanelView, currentDraft != null, historySessions, persistedSessionId, scopeType),
[explicitPanelView, currentDraft, historySessions, persistedSessionId, scopeType],
() => resolveDisplayedPanelView(explicitPanelView, currentDraft != null, historySessions, persistedSessionId, scopeType, workspaceAnchorChatId, preferredHistorySessionId),
[explicitPanelView, currentDraft, historySessions, persistedSessionId, scopeType, workspaceAnchorChatId, preferredHistorySessionId],
);
const activeSession = useMemo(
() => resolveDisplayedSession(normalizedPanelView, historySessions),
@@ -641,6 +650,21 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
}, { initialDelayMs: 250 });
}, [isVisible, terminalSessions, activeSessionId]);
// Workspace maintenance can retarget an inherited chat earlier in this same
// passive-effect flush. Before clearing a selection, recheck its membership
// against the synchronous snapshot instead of this render's old history.
const isSessionVisibleInCurrentScope = useCallback((sessionId: string) => (
getScopedHistorySessions(
latestAISessionsSnapshot ?? sessions,
scopeType,
scopeTargetId,
scopeHostIds,
activeTerminalSessionIds,
workspaceMemberTerminalIds,
latestAIActiveSessionMapSnapshot ?? activeSessionIdMap,
).some((session) => session.id === sessionId)
), [sessions, scopeType, scopeTargetId, scopeHostIds, activeTerminalSessionIds, workspaceMemberTerminalIds, activeSessionIdMap]);
useEffect(() => {
if (!isVisible) return;
// Predicate must match normalizePanelView's list (scoped history), not the
@@ -648,16 +672,23 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
if (!shouldForceDraftViewSync(
explicitPanelView,
normalizedPanelView,
(sessionId) => historySessions.some((session) => session.id === sessionId),
isSessionVisibleInCurrentScope,
)) {
return;
}
showDraftView(scopeKey);
}, [isVisible, normalizedPanelView, explicitPanelView, scopeKey, historySessions, showDraftView]);
}, [isVisible, normalizedPanelView, explicitPanelView, scopeKey, isSessionVisibleInCurrentScope, showDraftView]);
useEffect(() => {
if (!activeSession) return;
// Issue #4098 phase 2: a chat becoming active in the workspace scope pins
// the workspace's anchorChatId so member loss / restart resolve by id,
// never by position.
if (scopeType === 'workspace' && scopeTargetId && isVisible) {
onSetWorkspaceAnchorChat?.(scopeTargetId, activeSession.id);
}
if (isVisible && activeSessionIdMap[scopeKey] !== activeSession.id) {
setActiveSessionId(activeSession.id);
}
@@ -665,18 +696,24 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
activeSession,
activeSessionIdMap,
scopeKey,
scopeType,
scopeTargetId,
isVisible,
onSetWorkspaceAnchorChat,
setActiveSessionId,
]);
useEffect(() => {
if (!isVisible) return;
if (normalizedPanelView.mode !== 'draft') return;
// Keep ownership while an explicit session is still displayable in this
// scope (normalize has not demoted it). Out-of-scope / missing ids clear.
// Keep a selection that maintenance just made displayable, but always
// honor an explicit New Chat draft. Out-of-scope / missing ids still clear.
const selectedSessionId = explicitPanelView?.mode === 'session'
? explicitPanelView.sessionId : persistedSessionId;
if (
explicitPanelView?.mode === 'session'
&& historySessions.some((session) => session.id === explicitPanelView.sessionId)
explicitPanelView?.mode !== 'draft'
&& selectedSessionId
&& isSessionVisibleInCurrentScope(selectedSessionId)
) {
return;
}
@@ -686,7 +723,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
isVisible,
normalizedPanelView.mode,
explicitPanelView,
historySessions,
isSessionVisibleInCurrentScope,
persistedSessionId,
setActiveSessionId,
]);
@@ -1788,6 +1825,8 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
try {
const existingExternalSessionId = currentSession?.externalSessionId;
await sendToExternalAgent(sessionId, assistantMsgId, modelPrompt, agentConfig, abortController, modelAttachments, {
scopeType,
scopeTargetId,
existingSessionId: existingExternalSessionId,
updateExternalSessionId: updateSessionExternalSessionId,
historyMessages: buildExternalAgentHistoryMessagesForBridge(currentSession?.messages ?? [], existingExternalSessionId),
+4 -2
View File
@@ -109,8 +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 }>;
/** Issue #4098 phase 2: pin the AI chat a workspace scope is anchored to. */
onSetWorkspaceAnchorChat?: (workspaceId: string, chatId: string | null) => void;
/** Live workspaces (with their anchor chat), used for scope titles and anchor resolution. */
workspaces?: Array<{ id: string; title: string; anchorChatId?: string }>;
/** All currently live terminal (and workspace) scope target ids. */
liveScopeTargetIds?: Set<string>;
+11 -1
View File
@@ -35,6 +35,7 @@ import {
import {
initializePreparedConvergentMigration,
prepareConvergentSyncMigration,
resolvePreparedConvergentMigrationConflict,
type PreparedConvergentMigration,
} from '../application/convergentSyncMigration';
import type { ShrinkFinding } from '../domain/syncGuards';
@@ -358,6 +359,15 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
setConvergentBusy(true);
setConvergentError(null);
try {
if (preparedConvergentMigration && !sync.convergentSyncConfig.initialized) {
const prepared = resolvePreparedConvergentMigrationConflict(
preparedConvergentMigration, addressKey, candidateDot,
);
setPreparedConvergentMigration(prepared);
setConvergentPreview(prepared.plan.preview);
toast.success(t('cloudSync.convergent.conflict.resolved'));
return;
}
const { results } = await sync.resolveConvergentConflict(
addressKey,
candidateDot,
@@ -371,7 +381,7 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
} finally {
setConvergentBusy(false);
}
}, [onApplyConvergentPayload, sync, t]);
}, [onApplyConvergentPayload, preparedConvergentMigration, sync, t]);
const handleDowngradeConvergent = useCallback(async () => {
if (!window.confirm(t('cloudSync.convergent.downgrade.confirm'))) return;
+19 -4
View File
@@ -49,11 +49,13 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
showAlgorithmOverrides,
setShowAlgorithmOverrides,
chainedHosts,
hostChainInherited = false,
setActiveSubPanel,
clearHostChain,
proxySummaryType,
proxySummaryLabel,
proxySummaryTooltip,
proxyInherited = false,
clearProxyConfig,
}) => {
const inheritedDeviceType = effectiveGroupDefaults?.deviceType;
@@ -641,9 +643,16 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
title={t("hostDetails.jumpHosts")}
action={
chainedHosts.length > 0 ? (
<Badge variant="secondary" className="text-xs">
{t("hostDetails.jumpHosts.hops", { count: chainedHosts.length })}
</Badge>
<div className="flex items-center gap-1">
{hostChainInherited && (
<Badge variant="outline" className="text-xs text-muted-foreground">
{t("vault.groups.details.inherited")}
</Badge>
)}
<Badge variant="secondary" className="text-xs">
{t("hostDetails.jumpHosts.hops", { count: chainedHosts.length })}
</Badge>
</div>
) : (
<Badge variant="outline" className="text-xs text-muted-foreground">
{t("hostDetails.jumpHosts.direct")}
@@ -664,8 +673,10 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
/>
<span className="text-xs text-muted-foreground">
{t("hostDetails.jumpHosts.hops", { count: chainedHosts.length })}
{hostChainInherited ? ` · ${t("vault.groups.details.inherited")}` : ""}
</span>
</div>
{!hostChainInherited && (
<X
size={14}
className="text-muted-foreground hover:text-destructive flex-shrink-0"
@@ -674,6 +685,7 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
clearHostChain();
}}
/>
)}
</div>
<div className="w-full space-y-1 pl-5">
{chainedHosts.slice(0, 5).map((h, idx) => (
@@ -735,7 +747,7 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
<X size={14} />
</Button>
</div>
) : form.proxyConfig?.host || form.proxyConfig?.command || form.proxyProfileId ? (
) : form.proxyConfig?.host || form.proxyConfig?.command || form.proxyProfileId || proxyInherited ? (
<div className="w-full min-w-0 grid grid-cols-[minmax(0,1fr)_auto] items-center gap-1">
<button
type="button"
@@ -750,6 +762,7 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
<TooltipTrigger asChild>
<span className="block min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-sm">
{proxySummaryLabel}
{proxyInherited ? ` · ${t("vault.groups.details.inherited")}` : ""}
</span>
</TooltipTrigger>
<TooltipContent side="bottom" align="start" className="max-w-xs break-all">
@@ -758,6 +771,7 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
</Tooltip>
</TooltipProvider>
</button>
{!proxyInherited && (
<Button
type="button"
variant="ghost"
@@ -768,6 +782,7 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
>
<X size={14} />
</Button>
)}
</div>
) : (
<Button
+67
View File
@@ -1,4 +1,5 @@
import type { GroupConfig } from "../domain/models";
import { applyGroupDefaults } from "../domain/groupConfig";
import {
findProxyProfile,
hasIncompleteProxyIdentity,
@@ -157,3 +158,69 @@ export const prepareProxyConfigForSave = (params: {
normalizedProxyConfig: normalizeManualProxyConfig(params.proxyConfig),
};
};
export const EMPTY_CUSTOM_PROXY_DRAFT: ProxyConfig = { type: "http", host: "", port: 8080 };
export const resolveDisplayProxyProfileId = (
form: Pick<Host, "directProxy" | "proxyProfileId" | "proxyConfig">,
inheritedProfileId?: string,
): string | undefined => {
if (form.directProxy || form.proxyConfig !== undefined) return form.proxyProfileId;
return form.proxyProfileId ?? inheritedProfileId;
};
export const nextFormAfterSelectProxyProfile = (
prev: Host,
profileId: string | undefined,
groupDefaults?: Partial<GroupConfig>,
): Host => {
const { proxyConfig: _proxyConfig, proxyProfileId: _proxyProfileId, directProxy: _directProxy, ...rest } = prev;
if (!profileId) {
const inheritedConfig = !prev.directProxy
&& prev.proxyProfileId === undefined
&& prev.proxyConfig === undefined
&& groupDefaults
? applyGroupDefaults(prev, groupDefaults).proxyConfig
: undefined;
return {
...rest,
proxyConfig: prev.proxyConfig ?? inheritedConfig ?? EMPTY_CUSTOM_PROXY_DRAFT,
} as Host;
}
return { ...rest, proxyProfileId: profileId } as Host;
};
export const resolveFormHostChainIds = (
host: Host,
groupDefaults?: Partial<GroupConfig>,
): string[] => {
if (host.hostChain !== undefined) return host.hostChain.hostIds || [];
if (!groupDefaults) return [];
return applyGroupDefaults(host, groupDefaults).hostChain?.hostIds || [];
};
export const nextFormAfterClearHostChain = (
prev: Host,
groupDefaults?: Partial<GroupConfig>,
): Host => {
const groupWouldRestore = Boolean(
groupDefaults
&& applyGroupDefaults({ ...prev, hostChain: undefined }, groupDefaults).hostChain?.hostIds?.length,
);
if (prev.hostChain === undefined || groupWouldRestore) {
return { ...prev, hostChain: { hostIds: [] } };
}
const { hostChain: _hostChain, ...rest } = prev;
return rest as Host;
};
export const nextFormAfterRemoveHostFromChain = (
prev: Host,
index: number,
groupDefaults?: Partial<GroupConfig>,
): Host => {
const ids = resolveFormHostChainIds(prev, groupDefaults).filter((_, i) => i !== index);
if (ids.length > 0) return { ...prev, hostChain: { hostIds: ids } };
return nextFormAfterClearHostChain(prev, groupDefaults);
};
@@ -6,12 +6,17 @@ import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "../application/i18n/I18nProvider.tsx";
import type { Host, Identity } from "../types.ts";
import HostDetailsPanel, { parseOptionalPortInput } from "./HostDetailsPanel.tsx";
import { applyGroupDefaults } from "../domain/groupConfig.ts";
import {
prepareProxyConfigForSave,
prepareTelnetCredentialsForSave,
resolvePrimaryProtocolSavePort,
resolvePrimaryProtocolSwitchPort,
validateProxyConfigForSave,
nextFormAfterSelectProxyProfile,
nextFormAfterClearHostChain,
nextFormAfterRemoveHostFromChain,
resolveDisplayProxyProfileId,
} from "./HostDetailsPanel.helpers.ts";
import { TooltipProvider } from "./ui/tooltip.tsx";
@@ -549,3 +554,198 @@ test("HostDetailsPanel expands notes when the host already has notes", () => {
"expected notes to render after connection settings",
);
});
const networkSshHost: Host = {
...hostWithMissingProxyProfile,
proxyProfileId: undefined,
group: "network",
};
const jumpHost: Host = {
id: "jump-1",
label: "Bastion",
hostname: "bastion.example.com",
username: "root",
tags: [],
os: "linux",
port: 22,
protocol: "ssh",
authMethod: "password",
createdAt: 1,
};
const hostOwnedJump: Host = {
...jumpHost,
id: "jump-host",
label: "Edge",
hostname: "edge.example.com",
};
const renderHostDetailsWithGroup = (
initialData: Host,
groupConfig: Record<string, unknown>,
extraHosts: Host[] = [],
) =>
renderToStaticMarkup(
React.createElement(
I18nProvider,
{ locale: "en" },
React.createElement(
TooltipProvider,
null,
React.createElement(HostDetailsPanel, {
initialData,
availableKeys: [],
identities: [],
proxyProfiles: [],
groups: ["network"],
managedSources: [],
allTags: [],
allHosts: extraHosts,
terminalThemeId: "default",
terminalFontSize: 14,
groupConfigs: [{ path: "network", ...groupConfig }],
onSave: () => {},
onCancel: () => {},
}),
),
),
);
test("HostDetailsPanel displays inherited group HTTP proxy", () => {
const markup = renderHostDetailsWithGroup(networkSshHost, {
proxyConfig: { type: "http", host: "group-proxy.example.com", port: 8080 },
});
assert.match(markup, /group-proxy\.example\.com/);
assert.match(markup, /Inherited from group/);
});
test("HostDetailsPanel host proxy override wins over the group proxy", () => {
const markup = renderHostDetailsWithGroup({
...networkSshHost,
proxyConfig: { type: "http", host: "host-proxy.example.com", port: 9090 },
}, {
proxyConfig: { type: "http", host: "group-proxy.example.com", port: 8080 },
});
assert.match(markup, /host-proxy\.example\.com/);
assert.doesNotMatch(markup, /group-proxy\.example\.com/);
assert.doesNotMatch(markup, /Inherited from group/);
});
test("HostDetailsPanel direct proxy hides inherited group proxy", () => {
const markup = renderHostDetailsWithGroup({
...networkSshHost,
directProxy: true,
}, {
proxyConfig: { type: "http", host: "group-proxy.example.com", port: 8080 },
});
assert.match(markup, /Direct \(no proxy\)/);
assert.doesNotMatch(markup, /group-proxy\.example\.com/);
});
test("HostDetailsPanel displays inherited group jump hosts", () => {
const markup = renderHostDetailsWithGroup(networkSshHost, {
hostChain: { hostIds: ["jump-1"] },
}, [jumpHost]);
assert.match(markup, /bastion\.example\.com/);
assert.match(markup, /Inherited from group/);
});
test("HostDetailsPanel host jump-host override wins over the group chain", () => {
const markup = renderHostDetailsWithGroup({
...networkSshHost,
hostChain: { hostIds: ["jump-host"] },
}, {
hostChain: { hostIds: ["jump-1"] },
}, [jumpHost, hostOwnedJump]);
assert.match(markup, /edge\.example\.com/);
assert.doesNotMatch(markup, /bastion\.example\.com/);
assert.doesNotMatch(markup, /Inherited from group/);
});
test("selecting Custom while inheriting a saved proxy keeps a custom draft instead of the profile", () => {
const group = { proxyProfileId: "office-proxy" };
const next = nextFormAfterSelectProxyProfile(networkSshHost, undefined, group);
assert.equal(next.proxyProfileId, undefined);
assert.equal(next.directProxy, undefined);
assert.deepEqual(next.proxyConfig, { type: "http", host: "", port: 8080 });
assert.equal(resolveDisplayProxyProfileId(next, "office-proxy"), undefined);
assert.equal(applyGroupDefaults(next, group).proxyProfileId, undefined);
});
test("removing the last hop of a multi-hop inherited chain keeps an empty override", () => {
const group = { hostChain: { hostIds: ["jump-1", "jump-host"] } };
const afterFirst = nextFormAfterRemoveHostFromChain(networkSshHost, 0, group);
assert.deepEqual(afterFirst.hostChain, { hostIds: ["jump-host"] });
const afterLast = nextFormAfterRemoveHostFromChain(afterFirst, 0, group);
assert.deepEqual(afterLast.hostChain, { hostIds: [] });
assert.deepEqual(applyGroupDefaults(afterLast, group).hostChain, { hostIds: [] });
});
test("clearing an inherited-only chain keeps an empty override instead of restoring group hops", () => {
const group = { hostChain: { hostIds: ["jump-1", "jump-host"] } };
const cleared = nextFormAfterClearHostChain(networkSshHost, group);
assert.deepEqual(cleared.hostChain, { hostIds: [] });
assert.deepEqual(applyGroupDefaults(cleared, group).hostChain, { hostIds: [] });
});
test("HostDetailsPanel custom proxy draft hides the inherited saved proxy", () => {
const markup = renderToStaticMarkup(
React.createElement(
I18nProvider,
{ locale: "en" },
React.createElement(
TooltipProvider,
null,
React.createElement(HostDetailsPanel, {
initialData: {
...networkSshHost,
proxyConfig: { type: "http", host: "", port: 8080 },
},
availableKeys: [],
identities: [],
proxyProfiles: [{
id: "office-proxy",
label: "Office Proxy",
config: { type: "http", host: "office-proxy.example.com", port: 8080 },
createdAt: 1,
}],
groups: ["network"],
managedSources: [],
allTags: [],
allHosts: [],
terminalThemeId: "default",
terminalFontSize: 14,
groupConfigs: [{ path: "network", proxyProfileId: "office-proxy" }],
onSave: () => {},
onCancel: () => {},
}),
),
),
);
assert.doesNotMatch(markup, /Office Proxy/);
assert.doesNotMatch(markup, /office-proxy\.example\.com/);
assert.doesNotMatch(markup, /Inherited from group/);
});
test("HostDetailsPanel empty jump-host override hides the inherited group chain", () => {
const markup = renderHostDetailsWithGroup({
...networkSshHost,
hostChain: { hostIds: [] },
}, {
hostChain: { hostIds: ["jump-1", "jump-host"] },
}, [jumpHost, hostOwnedJump]);
assert.doesNotMatch(markup, /bastion\.example\.com/);
assert.doesNotMatch(markup, /edge\.example\.com/);
assert.doesNotMatch(markup, /Inherited from group/);
});
+52 -31
View File
@@ -55,6 +55,11 @@ import {
resolvePrimaryProtocolSwitchPort,
prepareTelnetCredentialsForSave,
prepareProxyConfigForSave,
nextFormAfterSelectProxyProfile,
nextFormAfterClearHostChain,
nextFormAfterRemoveHostFromChain,
resolveFormHostChainIds,
resolveDisplayProxyProfileId,
} from "./HostDetailsPanel.helpers";
export { parseOptionalPortInput } from "./HostDetailsPanel.helpers";
import { TerminalEncodingSelect } from "./TerminalEncodingSelect";
@@ -424,24 +429,34 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
);
const effectiveFormDistro = getEffectiveHostDistro(form);
const hasHostProxyOverride = form.directProxy === true
|| form.proxyProfileId !== undefined
|| form.proxyConfig !== undefined;
const displayProxyProfileId = resolveDisplayProxyProfileId(form, effectiveAuthHost.proxyProfileId);
const displayProxyConfig = form.directProxy
? undefined
: (form.proxyConfig ?? effectiveAuthHost.proxyConfig);
const selectedProxyProfile = useMemo(
() => proxyProfiles.find((profile) => profile.id === form.proxyProfileId),
[form.proxyProfileId, proxyProfiles],
() => proxyProfiles.find((profile) => profile.id === displayProxyProfileId),
[displayProxyProfileId, proxyProfiles],
);
const hasMissingProxyProfile = Boolean(form.proxyProfileId && !selectedProxyProfile);
const hasMissingProxyProfile = Boolean(displayProxyProfileId && !selectedProxyProfile);
const proxyInherited = !hasHostProxyOverride && Boolean(
displayProxyProfileId || displayProxyConfig?.host || displayProxyConfig?.command,
) && Boolean(effectiveGroupDefaults?.proxyProfileId || effectiveGroupDefaults?.proxyConfig);
const proxySummaryType = hasMissingProxyProfile
? t("hostDetails.proxyPanel.missing")
: formatProxyConfigType(selectedProxyProfile?.config || form.proxyConfig) || "HTTP";
: formatProxyConfigType(selectedProxyProfile?.config || displayProxyConfig) || "HTTP";
const proxySummaryLabel = hasMissingProxyProfile
? t("hostDetails.proxyPanel.missingSaved")
: selectedProxyProfile
? selectedProxyProfile.label
: formatProxyConfigEndpoint(form.proxyConfig);
: formatProxyConfigEndpoint(displayProxyConfig);
const proxySummaryTooltip = hasMissingProxyProfile
? t("hostDetails.proxyPanel.missingSaved")
: selectedProxyProfile
? `${selectedProxyProfile.label} - ${formatProxyConfigEndpoint(selectedProxyProfile.config)}`
: `${formatProxyConfigType(form.proxyConfig)} ${formatProxyConfigEndpoint(form.proxyConfig)}`;
: `${formatProxyConfigType(displayProxyConfig)} ${formatProxyConfigEndpoint(displayProxyConfig)}`;
const handleDistroModeChange = useCallback((mode: "auto" | "manual") => {
setForm((prev) => ({
@@ -458,13 +473,19 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
(field: keyof ProxyConfig, value: ProxyConfig[keyof ProxyConfig]) => {
setForm((prev) => {
const { proxyProfileId: _proxyProfileId, directProxy: _directProxy, ...rest } = prev;
const inheritedConfig = !prev.directProxy
&& prev.proxyProfileId === undefined
&& prev.proxyConfig === undefined
&& effectiveGroupDefaults
? applyGroupDefaults(prev, effectiveGroupDefaults).proxyConfig
: undefined;
return {
...rest,
proxyConfig: updateProxyConfigField(prev.proxyConfig, field, value),
proxyConfig: updateProxyConfigField(prev.proxyConfig ?? inheritedConfig, field, value),
} as Host;
});
},
[],
[effectiveGroupDefaults],
);
const clearProxyConfig = useCallback(() => {
@@ -475,12 +496,8 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
}, []);
const selectProxyProfile = useCallback((profileId: string | undefined) => {
setForm((prev) => {
const { proxyConfig: _proxyConfig, proxyProfileId: _proxyProfileId, directProxy: _directProxy, ...rest } = prev;
if (!profileId) return rest as Host;
return { ...rest, proxyProfileId: profileId } as Host;
});
}, []);
setForm((prev) => nextFormAfterSelectProxyProfile(prev, profileId, effectiveGroupDefaults));
}, [effectiveGroupDefaults]);
const selectDirectProxy = useCallback(() => {
setForm((prev) => {
@@ -493,24 +510,18 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
setForm((prev) => ({
...prev,
hostChain: {
hostIds: [...(prev.hostChain?.hostIds || []), hostId],
hostIds: [...resolveFormHostChainIds(prev, effectiveGroupDefaults), hostId],
},
}));
};
const removeHostFromChain = (index: number) => {
setForm((prev) => {
const ids = (prev.hostChain?.hostIds || []).filter((_, i) => i !== index);
return { ...prev, hostChain: ids.length > 0 ? { hostIds: ids } : undefined };
});
setForm((prev) => nextFormAfterRemoveHostFromChain(prev, index, effectiveGroupDefaults));
};
const clearHostChain = useCallback(() => {
setForm((prev) => {
const { hostChain: _hostChain, ...rest } = prev;
return rest as Host;
});
}, []);
setForm((prev) => nextFormAfterClearHostChain(prev, effectiveGroupDefaults));
}, [effectiveGroupDefaults]);
const [newEnvName, setNewEnvName] = useState("");
const [newEnvValue, setNewEnvValue] = useState("");
@@ -773,8 +784,16 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
setActiveSubPanel("none");
};
const displayHostChainIds = useMemo(
() => form.hostChain !== undefined
? (form.hostChain.hostIds || [])
: (effectiveAuthHost.hostChain?.hostIds || []),
[effectiveAuthHost.hostChain?.hostIds, form.hostChain],
);
const hostChainInherited = form.hostChain === undefined && displayHostChainIds.length > 0;
const availableHostsForChain = useMemo(() => {
const chainedIds = new Set(form.hostChain?.hostIds || []);
const chainedIds = new Set(displayHostChainIds);
// Chain hops are SSH ProxyJump hops; never offer local/serial/telnet/plugin
// entries (e.g. saved local shells) as jump hosts.
return allHosts.filter((h) =>
@@ -782,14 +801,13 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
&& !chainedIds.has(h.id)
&& canServeAsSshJumpHost(h),
);
}, [allHosts, form.id, form.hostChain?.hostIds]);
}, [allHosts, displayHostChainIds, form.id]);
const chainedHosts = useMemo(() => {
const ids = form.hostChain?.hostIds || [];
return ids
return displayHostChainIds
.map((id) => allHosts.find((h) => h.id === id))
.filter(Boolean) as Host[];
}, [allHosts, form.hostChain?.hostIds]);
}, [allHosts, displayHostChainIds]);
const groupOptions: ComboboxOption[] = useMemo(() => {
return groups.map((g) => ({
@@ -905,11 +923,12 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
if (activeSubPanel === "proxy") {
return (
<ProxyPanel
proxyConfig={form.proxyConfig}
proxyConfig={displayProxyConfig}
proxyProfiles={proxyProfiles}
identities={identities}
selectedProxyProfileId={form.proxyProfileId}
selectedProxyProfileId={displayProxyProfileId}
directProxy={form.directProxy}
allowClear={hasHostProxyOverride}
onUpdateProxy={updateProxyConfig}
onSelectProxyProfile={selectProxyProfile}
onSelectDirectProxy={selectDirectProxy}
@@ -1237,11 +1256,13 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
showAlgorithmOverrides={showAlgorithmOverrides}
setShowAlgorithmOverrides={setShowAlgorithmOverrides}
chainedHosts={chainedHosts}
hostChainInherited={hostChainInherited}
setActiveSubPanel={setActiveSubPanel}
clearHostChain={clearHostChain}
proxySummaryType={proxySummaryType}
proxySummaryLabel={proxySummaryLabel}
proxySummaryTooltip={proxySummaryTooltip}
proxyInherited={proxyInherited}
clearProxyConfig={clearProxyConfig}
/>
@@ -0,0 +1,229 @@
import assert from "node:assert/strict";
import test from "node:test";
import { JSDOM } from "jsdom";
import type { Identity, Host } from "../types";
async function withWizard(
run: (fixture: Awaited<ReturnType<typeof mountWizard>>) => Promise<void>,
identities: Identity[] = [],
) {
const fixture = await mountWizard(identities);
try {
await run(fixture);
} finally {
await fixture.cleanup();
}
}
async function mountWizard(identities: Identity[]) {
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', {
url: "http://localhost", 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 });
};
for (const key of ["window", "document", "navigator", "HTMLElement", "HTMLInputElement", "Element", "Node", "MutationObserver", "Event", "CustomEvent", "MouseEvent", "KeyboardEvent", "FocusEvent"] as const) {
install(key, key === "window" ? dom.window : dom.window[key]);
}
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 { act, useState } = await import("react");
const { createRoot } = await import("react-dom/client");
const { I18nProvider } = await import("../application/i18n/I18nProvider");
const { default: QuickConnectWizard } = await import("./QuickConnectWizard");
const target = { hostname: "example.com" };
const connected: Host[] = [];
const saved: Host[] = [];
let closeCount = 0;
function Fixture() {
const [open, setOpen] = useState(true);
return (
<I18nProvider locale="en">
{open && <QuickConnectWizard
open target={target} keys={[]} identities={identities}
onConnect={(host) => connected.push(host)}
onSaveHost={(host) => saved.push(host)}
onClose={() => { closeCount++; setOpen(false); }}
/>}
</I18nProvider>
);
}
const root = createRoot(document.getElementById("root")!);
await act(async () => { root.render(<Fixture />); });
const button = (name: string) => {
const element = [...document.querySelectorAll<HTMLButtonElement>("button")]
.find((candidate) => candidate.getAttribute("aria-label") === name || candidate.textContent?.trim() === name);
assert.ok(element, `Button ${name} is present`);
return element;
};
const input = (selector: string) => {
const element = document.querySelector<HTMLInputElement>(selector);
assert.ok(element, `Input ${selector} is present`);
return element;
};
const overlay = () => {
const element = document.querySelector<HTMLElement>(".fixed.inset-0");
assert.ok(element, "Quick Connect is still open");
return element;
};
const click = async (element: HTMLElement) => {
await act(async () => { element.click(); });
};
const type = async (selector: string, value: string) => {
await act(async () => {
const element = input(selector);
Object.getOwnPropertyDescriptor(dom.window.HTMLInputElement.prototype, "value")!.set!.call(element, value);
element.dispatchEvent(new dom.window.Event("input", { bubbles: true }));
});
assert.equal(input(selector).value, value);
};
const key = async (element: HTMLElement, key: string) => {
await act(async () => { element.dispatchEvent(new dom.window.KeyboardEvent("keydown", { key, bubbles: true })); });
};
const settle = async () => { await act(async () => { await new Promise((resolve) => setTimeout(resolve, 50)); }); };
const outsideClick = async () => {
await act(async () => {
overlay().dispatchEvent(new dom.window.MouseEvent("mousedown", { bubbles: true }));
overlay().click();
});
assert.equal(closeCount, 0, "Background clicks must not close Quick Connect");
overlay();
};
return {
button, input, overlay, click, type, key, settle, outsideClick, connected, saved,
closeCount: () => closeCount,
async cleanup() {
await act(async () => { root.unmount(); });
dom.window.close();
for (const [key, descriptor] of previous) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else Reflect.deleteProperty(globalThis, key);
}
},
};
}
test("background clicks preserve each Quick Connect step and typed connection details", async () => {
await withWizard(async (f) => {
await f.type('input[type="number"]', "2222");
await f.outsideClick();
assert.equal(f.input('input[type="number"]').value, "2222");
await f.click(f.button("Continue"));
await f.type("#quick-username", "alice");
await f.outsideClick();
assert.equal(f.input("#quick-username").value, "alice");
await f.click(f.button("Continue"));
await f.type("#quick-password", "partly configured password");
await f.outsideClick();
await f.key(f.input("#quick-password"), "Escape");
assert.equal(f.closeCount(), 0);
assert.equal(f.input("#quick-password").value, "partly configured password");
await f.click(f.button("Back"));
assert.equal(f.input("#quick-username").value, "alice");
await f.click(f.button("Back"));
assert.equal(f.input('input[type="number"]').value, "2222");
await f.click(f.button("Continue"));
await f.click(f.button("Continue"));
assert.equal(f.input("#quick-password").value, "partly configured password");
await f.click(f.button("Continue"));
assert.equal(f.connected.length, 1);
assert.equal(f.connected[0].username, "alice");
assert.equal(f.connected[0].port, 2222);
assert.equal(f.connected[0].password, "partly configured password");
assert.equal(f.closeCount(), 1);
assert.equal(document.querySelector("h2"), null);
});
});
test("the portaled credential picker retains the wizard and selected identity", async () => {
await withWizard(async (f) => {
await f.type('input[type="number"]', "2222");
await f.click(f.input('[role="combobox"]'));
await f.settle();
const option = document.querySelector<HTMLElement>('[role="option"]');
assert.ok(option);
assert.equal(f.overlay().contains(option), false, "Options are portaled outside the wizard DOM");
await f.click(option);
assert.equal(f.closeCount(), 0);
assert.equal(f.input('[role="combobox"]').value, "Production identity");
assert.equal(f.input('input[type="number"]').value, "2222");
await f.outsideClick();
await f.click(f.button("Connect with preset"));
assert.equal(f.connected.length, 1);
assert.equal(f.connected[0].identityId, "identity-alice");
assert.equal(f.connected[0].username, "alice");
assert.equal(f.connected[0].port, 2222);
assert.equal(f.closeCount(), 1);
}, [{ id: "identity-alice", label: "Production identity", username: "alice", authMethod: "password", password: "preset secret", created: 1 }]);
});
test("the portaled save menu preserves credentials until explicit save and connect", async () => {
await withWizard(async (f) => {
await f.click(f.button("Continue"));
await f.type("#quick-username", "alice");
await f.key(f.input("#quick-username"), "Enter");
await f.type("#quick-password", "secret");
const trigger = f.button("Continue & Save");
await f.click(trigger);
await f.settle();
const menuItem = [...document.querySelectorAll<HTMLButtonElement>("button")]
.find((candidate) => candidate.textContent === "Continue & Save");
assert.ok(menuItem);
assert.equal(f.overlay().contains(menuItem), false, "Save menu is portaled outside the wizard DOM");
await f.click(menuItem.parentElement!);
assert.equal(f.closeCount(), 0);
assert.equal(f.input("#quick-password").value, "secret");
await f.key(document.body, "Escape");
assert.equal(trigger.getAttribute("aria-expanded"), "false");
assert.equal(f.closeCount(), 0);
await f.click(trigger);
await f.settle();
await f.outsideClick();
assert.equal(trigger.getAttribute("aria-expanded"), "false");
assert.equal(f.input("#quick-password").value, "secret");
await f.click(trigger);
await f.settle();
const save = [...document.querySelectorAll<HTMLButtonElement>("button")]
.find((candidate) => candidate.textContent === "Continue & Save");
assert.ok(save);
await f.click(save);
assert.equal(f.saved.length, 1);
assert.equal(f.connected.length, 1);
assert.equal(f.saved[0], f.connected[0]);
assert.equal(f.connected[0].username, "alice");
assert.equal(f.connected[0].password, "secret");
assert.equal(f.closeCount(), 1);
});
});
test("explicit Close dismisses the protocol step", async () => {
await withWizard(async (f) => {
await f.click(f.button("Close"));
assert.equal(f.closeCount(), 1);
assert.equal(document.querySelector("h2"), null);
assert.equal(f.connected.length, 0);
});
});
for (const step of ["username", "auth"]) {
test(`the header X explicitly closes the ${step} step`, async () => {
await withWizard(async (f) => {
await f.click(f.button("Continue"));
await f.type("#quick-username", "alice");
if (step === "auth") {
await f.click(f.button("Continue"));
await f.type("#quick-password", "secret");
}
const close = f.button("Close");
assert.ok(close.querySelector("svg.lucide-x"), "A visible X is provided on steps with Back");
await f.click(close);
assert.equal(f.closeCount(), 1);
assert.equal(document.querySelector("h2"), null);
assert.equal(f.connected.length, 0);
});
});
}
+13 -2
View File
@@ -8,6 +8,7 @@ import {
Plug,
Plus,
User,
X,
} from "lucide-react";
import React, { useMemo, useState } from "react";
import { createPortal } from "react-dom";
@@ -675,7 +676,6 @@ const QuickConnectWizard: React.FC<QuickConnectWizardProps> = ({
const wizard = (
<div
className="fixed inset-0 z-50 flex items-center justify-center"
onClick={onClose}
>
<div
className="w-[560px] max-w-[90vw] bg-background border border-border rounded-2xl animate-in fade-in-0 zoom-in-95 duration-200"
@@ -686,7 +686,7 @@ const QuickConnectWizard: React.FC<QuickConnectWizardProps> = ({
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="px-6 py-4 border-b border-border/50">
<div className="px-6 py-4 border-b border-border/50 flex items-center justify-between gap-4">
<div className="flex items-center gap-2 min-w-0">
<Plug size={18} className="shrink-0 text-foreground" />
<div className="min-w-0">
@@ -696,6 +696,17 @@ const QuickConnectWizard: React.FC<QuickConnectWizardProps> = ({
) : null}
</div>
</div>
{step !== "protocol" && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
aria-label={t("common.close")}
onClick={onClose}
>
<X size={16} />
</Button>
)}
</div>
+2
View File
@@ -247,6 +247,8 @@ const SettingsAITabContainer: React.FC = () => {
setResponseIdleTimeout={aiState.setResponseIdleTimeout}
maxIterations={aiState.maxIterations}
setMaxIterations={aiState.setMaxIterations}
aiSessionMessageCap={aiState.aiSessionMessageCap}
setAiSessionMessageCap={aiState.setAiSessionMessageCap}
webSearchConfig={aiState.webSearchConfig}
setWebSearchConfig={aiState.setWebSearchConfig}
quickMessages={aiState.quickMessages}
+1 -1
View File
@@ -759,7 +759,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
data-section="pane-magnification-hint"
>
{t('terminal.paneMagnification.hint')}: {t('terminal.layer.sftp')}
{paneMagnificationShortcutLabel ? ` · ${paneMagnificationShortcutLabel} / Esc` : ' · Esc'}
{paneMagnificationShortcutLabel ? ` · ${paneMagnificationShortcutLabel}` : ''}
</div>
)}
</div>
+22 -1
View File
@@ -1,3 +1,6 @@
import { terminalCwdStore } from '../application/state/terminalCwdStore';
import { useTerminalDirectorySession } from '../application/state/useTerminalDirectorySession';
import { refreshObservedTerminalDirectory, scheduleObservedTerminalDirectoryRefresh } from '../application/state/terminalDirectoryRegistry';
import { publishTerminalSelectionTarget } from "../infrastructure/plugins/terminalSelectionRegistry";
import { usePluginTerminalSelection } from "../application/state/usePluginTerminalSelection";
import { clearTerminalBroadcastUserInput, markTerminalBroadcastUserInput } from "./terminal/runtime/terminalPacedBroadcast";
@@ -1246,6 +1249,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const clearTerminalCwd = useCallback((options?: { persistRestoreMetadata?: boolean }) => {
terminalCwdTracker.clearRendererCwd();
terminalCwdStore.setCwd(sessionId, null);
knownCwdRef.current = undefined;
if (options?.persistRestoreMetadata === false) return;
onTerminalCwdChange?.(sessionId, null);
@@ -2590,6 +2594,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const cwdAwareOnCommandSubmitted = useCallback((
...args: Parameters<NonNullable<typeof onCommandSubmitted>>
) => {
scheduleObservedTerminalDirectoryRefresh(sessionId);
if (!shouldPreserveTerminalCwdAcrossCommand(hostRestrictsExtraSshChannels(host))) {
invalidateTerminalCwdAfterCommand(
terminalCwdTracker,
@@ -2603,6 +2608,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
}, [host, onCommandSubmitted, onTerminalCwdChange, sessionId, terminalCwdTracker]);
const pluginAwareOnCommandCompleted = useCallback(() => {
pluginTerminalLifecycle.onCommandCompleted();
refreshObservedTerminalDirectory(sessionId);
void xtermRuntimeRef.current?.pluginProviderHost?.commandCompleted();
// Hibernate wake builds its runtime from this callback. Same local
// prompt-return refresh as useTerminalEffects (#1952).
@@ -2615,6 +2621,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
cwd: string | null,
meta?: TerminalCwdChangeMeta,
) => {
terminalCwdStore.setCwd(changedSessionId, cwd, meta?.source);
pluginTerminalLifecycle.onCwdChanged(cwd);
onTerminalCwdChange?.(changedSessionId, cwd, meta);
}, [onTerminalCwdChange, pluginTerminalLifecycle]);
@@ -2630,6 +2637,13 @@ const TerminalComponent: React.FC<TerminalProps> = ({
pluginAwareOnTerminalCwdChange(sessionId, normalizedCwd ?? null, meta);
void refreshProviderOutputs('cwd-changed');
}, [pluginAwareOnTerminalCwdChange, refreshProviderOutputs, sessionId, terminalCwdTracker]);
useTerminalDirectorySession({
sessionId, status, host, shellType,
getBackendId: () => sessionRef.current,
isReady: () => isTerminalReadyForCommandInjection(sessionId),
publish: cwd => pluginAwareOnRuntimeCwdChange(cwd, { source: 'backend-strict' }),
});
const pluginAwareOnTerminalTitleChange = useCallback((changedSessionId: string, title: string | null) => {
terminalTitleRef.current = title || undefined;
pluginTerminalLifecycle.onTitleChanged(title);
@@ -2771,6 +2785,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
onTerminalDataCapture: handleTerminalDataCaptureOnce,
onTerminalOutput: (chunk: string, meta?: TerminalSessionDataMeta) => {
observeTerminalInputPrompt(chunk, meta);
scheduleObservedTerminalDirectoryRefresh(sessionId);
appendOutputTriggerOutputRef.current(chunk, meta);
if (onTerminalOutput) {
onTerminalOutput(sessionId, chunk);
@@ -3521,6 +3536,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
options?: {
broadcast?: boolean;
multiLineRunMode?: Snippet["multiLineRunMode"];
/** Submit compose blocks through the shell's advertised paste support. */
preferBracketedPaste?: boolean;
/** When false, skip term.focus() so multi-tab fan-out does not steal focus. */
focus?: boolean;
/** Force sensitive classification on the write (bypassed password fan-out, #3488). */
@@ -3569,7 +3586,11 @@ const TerminalComponent: React.FC<TerminalProps> = ({
// Wrap in bracketed paste BEFORE appending \r so the Enter is sent
// outside the paste markers — otherwise shells treat it as pasted text
// instead of a submit action.
if (!lineDelayMs && isMultiLine && term.modes.bracketedPasteMode && !disableBracketedPasteRef.current) {
// Compose submits a command block, so keep it in the shell's input buffer
// even when clipboard bracketing is disabled. Otherwise a running command
// can consume the later lines as stdin (#4062). Still require shell support.
if (!lineDelayMs && isMultiLine && term.modes.bracketedPasteMode
&& (options?.preferBracketedPaste || !disableBracketedPasteRef.current)) {
data = wrapBracketedPaste(data);
}
if (!noAutoRun) data = `${data}\r`;
Loaded 100 of 295 files, more files were not shown because too many files have changed in this diff. Show more