Compare commits

...
Author SHA1 Message Date
bincxz 0d942d4563 fix(ai): retain old source chats when undoing a turn 2026-10-15 12:56:47 +08:00
71373d1ad1 feat(ai-chat): add non-destructive undo last turn with safe-boundary branching (#3602)
* fix(#3599): automated AI fix

* fix: address Codex review on PR #3602

* fix: address Codex review on PR #3602

* fix: address Codex review on PR #3602

* fix: address Codex review on PR #3602

* fix: address Codex review on PR #3602

* fix: address Codex review on PR #3602

* fix: address Codex review on PR #3602

* fix: address Codex review on PR #3602

* fix: address Codex review on PR #3602

* fix: address Codex review on PR #3602

* fix: address Codex review on PR #3602

* fix: address Codex review on PR #3602

* fix: address Codex review on PR #3602

* fix: address Codex review on PR #3602

* fix: address Codex review on PR #3602

* fix: address Codex review on PR #3602

* fix: address Codex review on PR #3602

* fix: address Codex review on PR #3602

* fix: address Codex review on PR #3602

* fix: address Codex review on PR #3602

* fix: address Codex review on PR #3602

* fix: address Codex review on PR #3602

* fix: address Codex review on PR #3602

* fix: address Codex review on PR #3602

* fix: address Codex review on PR #3602

* fix: address Codex review on PR #3602

* fix: address Codex review on PR #3602

* fix: address Codex review on PR #3602

* fix: address Codex review on PR #3602

* fix: address Codex review on PR #3602

* fix: address Codex review on PR #3602

* refactor(ai-chat): limit undo to conversation and draft restoration

---------

Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-10-15 12:50:14 +08:00
Netcatty-Bot b4e056dd4a fix(sftp): support writable GVFS destinations without losing file protection
Handle unsupported copyFile operations on writable GVFS/FUSE destinations using the existing exclusive bounded copy path. New files accept the mount's creation permissions; replacing an existing file still requires equivalent protection, and failures retain recoverable data.

Only explicitly optional permission/timestamp updates may tolerate permission refusal. Content writes, close errors, required protection, and rollback preservation remain strict. Current-head review and required checks were verified before merging.

Related to #3611. Keep the report open pending delivery and actual environment confirmation.
2026-10-15 12:19:58 +08:00
d5c7d3fbf4 fix(ai): make host_open reuse-first and keep reconnecting sessions in get_environment (#3614)
* fix(#3613): automated AI fix

* fix(ai): preserve owners when reusing active host sessions

* fix(ai): match opening connection identity before host tab reuse

* fix(ai): preserve serial settings when matching reusable tabs

* fix: capture restored plugin connection before host reuse

---------

Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-10-15 12:17:34 +08:00
陈大猫 1ab07ffee4 fix(ssh): preserve password fallback after unavailable agent
Allow ssh2 to continue to another authentication method when a system SSH agent is unavailable, for both target and jump-host connections. Keep the authentication deadline and fatal-error handling intact.

Validated with real SSH servers and Unix agent sockets through normal terminal startup, including forwarding, successful password fallback, real authentication failures, and timeouts. 118 focused checks and lint passed. Current-head review and required CI were checked before merging.

Related to #3640. Keep the report open for the reporter's environment and delivery validation.
2026-10-15 12:13:43 +08:00
Netcatty-Bot 4af4af274b fix(ssh): accurately report missing remote exit status
Report transport closure without inventing a successful remote exit status, while preserving any actual exit code or signal already received. Reuse the existing exit classification to keep normal exits from being treated as reconnectable disconnects.

Related to #3631. This changes the diagnostic message only; the frequent-disconnection report remains open.

Validation: 458 focused checks, lint, production build, real Electron/local SSH disconnect workflow, current-head Codex review, and repository CI passed.
2026-10-15 11:43:32 +08:00
GOUKI9999andClaude Sonnet 5.5 ccfc88cd50 fix(ai): check only the tail of the output for the idle prompt (#3661)
hasExpectedPromptSuffix ran for every PTY chunk and normalised the whole
retained output (up to 256K chars of ANSI/CR stripping) just to test whether
it ends with the prompt. During an output flood with CRLF line endings that
kept the terminal worker busy for seconds at a time, delaying cancel
requests, timers and keepalive handling (#3443). Look at a bounded tail
window instead; the result is unchanged for any prompt that fits in it.

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 12:07:57 +08:00
Netcatty-Bot 602a24a991 fix(zmodem): keep rz upload progress moving on SSH backpressure (#3665)
Report delivered-byte rz progress while parked on SSH backpressure, and race each progress poll against drain so upload resumes immediately when the channel drains.

Fixes #3663
2026-10-14 10:42:49 +08:00
Netcatty-Bot 5df2181392 fix(terminal): self-heal cursor line highlight on external decoration disposal (#3657)
Recreate cursor-line markers/decorations when xterm disposes them externally, and treat empty decoration sets as intact so fully colored rows do not churn markers every frame.

Fixes #3656
2026-10-13 17:35:01 +08:00
Netcatty-Bot 451205770e fix(zmodem): withhold split ZRQINIT/ZRINIT preamble instead of leaking it to the terminal (#3658)
Fixes #3651. Codex P2 on >1s mid-prefix stall skipped by judgment as edge-case thrash relative to ordinary chunk-boundary splits.
2026-10-13 17:30:43 +08:00
Netcatty-Bot 231982d673 fix(window): fit minimum size to small displays and restore terminal focus after resize (#3648)
Clamps the window layout minimum to the current display work area so small screens (e.g. 1366×768 at 125%) can fit and shrink. Restores terminal keyboard focus after OS resize/maximize, without stealing from active editable fields.

Fixes #3647

Merged by judgment: four Codex rounds already addressed the real bugs and multi-monitor sync; the latest P2 (protect every non-editable focusable control on resize) is edge-case thrash.
2026-10-13 17:29:35 +08:00
陈大猫 8eb52853a0 fix(ssh): skip auto-reconnect after an administrative exit-signal (#3659)
Classify SSH exit-signal as signaled so auto-reconnect does not fight admin idle kicks. Bare transport closed and error still reconnect.

Related to #3617.
2026-10-13 17:29:16 +08:00
11c90b45ec feat(terminal): disable auto-close-on-exit by default (#3619)
* feat(terminal): disable auto-close-on-exit by default

Remote graceful channel closes (VPN half-close, server-side idle kick) can be reported as a clean exit with code 0, so with auto-close enabled the tab gets destroyed exactly when the user needs it kept - bypassing the SSH auto-reconnect loop, which intentionally skips exited events.

Keep tabs on exit by default; users who want close-on-exit can enable it in Settings > Terminal > Behavior. Attach-mode close behavior is unchanged.

* fix(terminal): keep session tabs when the auto-close setting is unavailable

The resolver's own default and the TerminalLayer call-site fallback still
treated a missing terminal setting as auto-close enabled. Remote graceful
channel closes (a jump host reaping an idle session) are reported as a
clean exit, so before the settings store had loaded, those closes silently
deleted the session tab and its scrollback. This completes the opt-in
default introduced above: only an explicit autoCloseOnExit true closes tabs.

* fix(terminal): migrate the materialized auto-close-on-exit default once

Builds before the default flip shipped autoCloseOnExit: true as the
default and materialized it into netcatty_term_settings_v1 on any
settings write, so most upgraders carry a value they never chose and
the new default-off never reaches them: bastion and idle-harvesting
servers reporting clean exits keep silently deleting their tabs.

Reset that materialized true exactly once behind a marker key before
the settings blob is first ingested, then persist the marker so every
later stored value is treated as the user's own choice. The cost is
documented: a user who deliberately enabled auto-close before this
release must re-enable it once.

* fix(terminal): keep synced legacy auto-close from coming back

A device that already wrote the one-shot migration marker was accepting
a pre-migration cloud or backup payload with autoCloseOnExit true.
Unversioned true is rewritten to false. Payloads that record the
migration still sync an explicit opt-in, including across a three-way
merge that would otherwise glue the local marker onto a legacy true.

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

---------

Co-authored-by: xu <tbqyomnnnbltzxy2t@aliyun.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-13 17:11:15 +08:00
ac644a5e51 feat(terminal): enable SSH auto-reconnect by default (#3617)
* feat(terminal): enable SSH auto-reconnect by default

Unexpected transport drops (VPN / Wi-Fi blips) are recoverable events, not
shell exits: the tab stays and the 5s in-place reconnect loop keeps the same
session id, so AI sessions and long-running work continue once the link is
back. With the previous default (off), a dropped link left the tab parked on
'Press Enter to reconnect', which reads as 'the session closed'.

Clean shell exits still close the tab; explicit opt-out in Settings is
preserved. mosh/et sessions self-heal and are unaffected by this gate.

* fix(terminal): skip auto-reconnect after idle timeouts

SSH auto-reconnect stays on by default for transport drops. A timeout
exit (TMOUT, server idle logout, or connect timeout) leaves the tab
disconnected instead of logging back in every 5 seconds.

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

---------

Co-authored-by: yuyang2230 <yuyang2230@users.noreply.github.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-13 17:11:00 +08:00
0f890701d4 feat(ai): raise default AI session idle timeout from 30m to 8h (#3618)
* feat(ai): raise default AI session idle timeout from 30m to 24h

Sessions opened via host_open are auto-closed after 30 minutes without
terminal/file activity. In production use over flaky links this silently
destroyed in-flight agent work: the AI reopens a host, the link drops, the
session sits disconnected (counts as idle), and the reaper closes it
mid-workflow with no error surfaced anywhere. 24h is the normalize clamp
maximum; users can still choose a shorter window in Settings → AI, and the
idle manager still skips sessions with active executions.

* fix(ai): preserve 24-hour session default during renderer sync

* style(ai): keep session idle manager comment ASCII-only

Replace the unicode arrow in the Settings pointer comment with ASCII per the repo AGENTS.md convention (Maintain ASCII-only unless required by existing file content).

* fix(ai): default AI session idle timeout to 8 hours

480 minutes still covers a workday of flaky-link agent work without
sitting on the 24h clamp. Stored values are left untouched.

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

---------

Co-authored-by: yuyang2230 <yuyang2230@users.noreply.github.com>
Co-authored-by: 陈奇 <16399091+binaricat@users.noreply.github.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-13 17:10:53 +08:00
予明andxu 21e6a91f36 fix(ai): shed oversized tool output before evicting visible chat history (#3643)
* fix(ai): shed oversized tool output before evicting visible chat history

Under localStorage quota pressure the AI sessions serializer degraded
replay ciphertext fine-grained but never shed tool result payloads, so
AI-driven terminal chats (whose persisted bulk is terminal output)
forced the ladder to evict whole oldest sessions and finally collapse
persistence to the newest chat alone. After a restart the history list
looked empty.

Extend the per-message degradation pass to cap oversized tool result
content at a bounded head+tail with a truncation marker, mirroring the
existing ciphertext-shedding tradeoff: every visible conversation now
survives storage pressure at the cost of full tool output on affected
messages.

* fix(ai): keep tool-result truncation strictly smaller than the original

Codex review P2: for tool results only slightly over the cap, the
truncation marker (with JSON-escaped newlines) could make the degraded
copy serialize at the same size or larger than the original, breaking
the ladder's assumption that the degraded representation is the
smallest and sending quota retries farther off.

Drop the marker newlines and shrink the retained head+tail until the
whole copy fits the 8 KiB cap, so a truncated result is always
strictly shorter than its source.

---------

Co-authored-by: xu <tbqyomnnnbltzxy2t@aliyun.com>
2026-10-13 16:44:38 +08:00
陈大猫 d3c4273886 fix(sftp): cancel independent uploads without stopping siblings (#3638) 2026-10-13 16:44:33 +08:00
陈大猫 747fdeb0f3 fix(shortcuts): save modifier edits when the chord is released (#3641) 2026-10-13 16:44:27 +08:00
minmie 0bb75055c4 fix(ci): unbreak Claude Code smoke helper freeze step (#3586)
The `Freeze trusted helper` step in the `sandbox_smoke` job used a plain
multi-line YAML scalar:

    run: cp scripts/ai-automation.cjs "$RUNNER_TEMP/ai-automation.cjs"
      chmod 0444 "$RUNNER_TEMP/ai-automation.cjs"

YAML folds the continuation line into the same scalar, so the step ran:

    cp scripts/ai-automation.cjs "$RUNNER_TEMP/ai-automation.cjs" chmod 0444 "$RUNNER_TEMP/ai-automation.cjs"

`cp` then sees four operands and treats the last one as the destination
directory, which does not exist:

    cp: target '/home/runner/work/_temp/ai-automation.cjs': No such file or directory

The job fails in 7s at step 3, and the remaining 12 steps are skipped.

Reuse the existing `&freeze_ai_helper` anchor, which is what the other eight
`Freeze trusted helper` steps in this workflow already do. It also picks up
`set -euo pipefail` and `test -f scripts/ai-automation.cjs`, so a missing
helper now fails with a clear message instead of a confusing `cp` error.

Observed on a fork mirror running commit b2ed83ae: runs 36847532363 and
36991962473 both failed at this step.
2026-10-13 16:44:22 +08:00
f82355cf1b fix(ssh): inject ET PATH ssh wrapper without a host OpenSSH binary (#3650)
Unit tests stub execFileSync and only found ssh via /usr/bin/ssh, so the
PATH wrapper was skipped on images without that path. Prefer an absolute
findExecutable result, then command -v, then the well-known locations.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-13 15:56:04 +08:00
43249cd7e0 test(ssh): do not dial the SSH port for stats latency (#3649)
Stats latency already pings the authenticated session. Lock that in with
a unit test that fails if getServerStats dials the session host, which
is what produced kex_exchange_identification every ~30s (#3630).

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-13 15:55:57 +08:00
GOUKI9999 483410b132 fix(ai): expand Claude fallback while preserving saved selections (#3625)
Retain family aliases and bare explicit model IDs without introducing an unsolicited effort suffix.
2026-10-12 12:26:07 +08:00
Netcatty-Bot 201ebcb21a feat(ai): search session history titles and bounded content (#3601)
Keep history search bounds local and preserve complete-field shared pinyin matching.

Fixes #3598
2026-10-12 11:52:21 +08:00
Netcatty-Bot 6ac6c3dbfa fix(snippets): preserve all-hosts scope in snippet import/export (#3636)
Preserve portable all-hosts and group scopes. Export v3 and accept v1/v2/v3; older importers reject v3.

Fixes #3635
2026-10-12 11:50:31 +08:00
Netcatty-Bot 11f2b7dbd8 fix(terminal): surface snippet completions while typing an argument (#3623)
Match argument snippets conservatively and remove stale snippet rows on input edits before the debounced refresh.

Related to #3622; the email-entry/editor concern remains unverified and the issue stays open.
2026-10-12 11:41:39 +08:00
Netcatty-Bot af574078e7 fix(crash-logs): cap per-day crash log file size and trim oversized logs (#3627)
Bound daily crash log growth and trim oversized regular files after validating the opened inode.

Fixes #3626.
2026-10-12 11:41:02 +08:00
陈大猫 c89e4c7246 fix(notes): preserve pending drafts after storage write failures (#3629)
Preserve pending local notes and conflicting external content across failed writes and successful retries.

Retain explicit clear/import replacement ownership until the current generation persists.
2026-10-12 11:40:17 +08:00
陈大猫 282a557f67 fix: upgrade Electron to preserve sessions on second launch (#3628)
Upgrade Electron to 42.11.12 with its extraction dependency changes.

Related to #3391.
2026-10-12 11:39:37 +08:00
github-actions[bot]andgithub-actions[bot] 7cb3e2e2ad chore: update star history charts [skip ci] (#3637)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-10-12 00:12:10 +00:00
90ab87efcc feat(editor): detached multi-tab window with acknowledged content handoff (#3606)
Add a detached multi-tab editor window with acknowledged content ownership, focused Monaco shortcuts, and shared close/save confirmation. Preserve source contents until handoff acknowledgement and block edits or incoming transfers during ownership changes.

Continuation of #3439. Original implementation by pplulee.

Co-authored-by: pplulee <hi@pplulee.me>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-08 18:11:23 +08:00
陈大猫andnetcatty-bot 4cd6a7191d feat(sftp): add Cancel all action to transfer center (#3574)
* feat(sftp): add cancel all to transfer center

* fix: address Codex review on PR #3574

* fix: address Codex review on PR #3574

* fix: address Codex review on PR #3574

* fix: address Codex review on PR #3574

* fix: address Codex review on PR #3574

* fix: address Codex review on PR #3574

* fix: address Codex review on PR #3574

* fix: address Codex review on PR #3574

* fix: address Codex review on PR #3574

* fix: address Codex review on PR #3574

* fix: address Codex review on PR #3574

* fix: address Codex review on PR #3574

* fix: address Codex review on PR #3574

* fix: address Codex review on PR #3574

* fix: address Codex review on PR #3574

* fix: address Codex review on PR #3574

* fix: address Codex review on PR #3574

* fix: address Codex review on PR #3574

* fix(sftp): settle cancelled walks before directory recovery

* fix(sftp): report failed cancel attempts without blocking retries

* fix(sftp): batch pending cancel cleanup for large queues

* fix(sftp): preserve settled children during cancel recovery

* fix(sftp): release skipped cancel-all pre-latches

* fix(sftp): satisfy cancellation result and fixture types

* fix(sftp): dispatch cancel batches before walk settlement

* fix(sftp): distinguish live compression recovery from restored attention

---------

Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
2026-10-08 17:50:41 +08:00
陈大猫 3a0528bbd4 fix(terminal): bound asynchronous Windows PATH refresh (#3608)
* fix(terminal): bound asynchronous Windows PATH refresh

Replace blocking per-terminal registry queries with parallel async reads,
a one-second deadline, and a shared five-second snapshot. Preserve machine
before user PATH ordering and place guessed CLI directories last. Retain
per-hive successful values when registry reads fail, and honor explicit
PATH overrides without querying the registry.

* fix(terminal): cancel pending local starts through boot epochs

Register the existing pending boot controller before awaiting PATH refresh.
Honor tab close and reconnect cancellation before spawning, and clear only
the matching controller so stale completion cannot erase a newer boot.

Regression tests reproduce both the closed-tab PTY spawn and stale-close
ownership failures against the prior revision.

* fix(terminal): abort pending starts before worker close queue

Propagate an owned close to the existing pending boot controller before
waiting for its serialized start. Keep transport cleanup ordered and keep
stale boot-epoch closes from cancelling replacements.

Exercise both fire-and-forget and awaited close messages through the real
worker dispatcher and local-session bridge with a controlled PATH wait.

* fix(terminal): cancel superseded worker starts before queuing

Signal the existing boot-abort controller when a replacement arrives,
reject stale boot epochs, and recheck queued epochs before startup.
Only the latest reconnect may spawn after a pending PATH refresh.

Cover repeated reconnect requests and delayed old starts/closes through
the real worker dispatcher and local-session bridge.
2026-10-08 16:25:15 +08:00
陈大猫 c349d4716f fix(ssh): wire host connection test cancellation to its module export (#3609) 2026-10-08 15:50:56 +08:00
f7483f0593 fix(scripts): add persistable stacked chip view to script library panel (#3567)
* fix(#3565): automated AI fix

* fix: address Codex review on PR #3567

* fix: address Codex review on PR #3567

* fix: address Codex review on PR #3567

* fix: address Codex review on PR #3567

* fix(scripts): observe stacked sentinel again after returning to library

* fix(scripts): reuse shared persisted string hook for cross-window view changes

---------

Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
Co-authored-by: 陈大猫 <16399091+binaricat@users.noreply.github.com>
2026-10-08 15:20:22 +08:00
Netcatty-Botandnetcatty-bot 1209bfa61c fix(ai): opencode model catalog loading state, retry, and warmer list server (#3585)
* fix(#3584): automated AI fix

* fix: address Codex review on PR #3585

* fix: address Codex review on PR #3585

---------

Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
2026-10-08 15:16:49 +08:00
陈大猫 6baa87e3c3 fix(ssh): recognize the distinctive CloudLinker single-channel banner (#3607) 2026-10-08 15:16:13 +08:00
a1d1115e0a fix(opencode): pass explicit server-start timeout so Windows cold starts survive (#3580)
* fix(#3579): automated AI fix

* fix(opencode): include cold startup in catalog request deadline

* fix(opencode): release environment gate after synchronous spawn

---------

Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
Co-authored-by: 陈大猫 <16399091+binaricat@users.noreply.github.com>
2026-10-08 15:15:48 +08:00
Netcatty-Botandsi1enteee 16376a2117 fix(terminal): use xterm synchronized-output state for clear handling (#3596)
Use xterm's native synchronized output state for viewport preservation, remove the redundant mode tracker, and exercise the production callback path in regression tests.

Fixes #3595

Co-authored-by: si1enteee <si1enteee@users.noreply.github.com>
2026-10-08 15:08:11 +08:00
陈大猫 f0833049e0 fix(terminal): recover paused session after PTY startup timeout (#3605) 2026-10-08 14:47:56 +08:00
Yohannandyangheng 75c155b748 feat(vault): add host connection test to the host editor (#2984)
* feat(vault): add host connection test to the host editor

Add a 'Test' button next to Save in the host editor that opens a dialog reusing the terminal's connection progress/auth/host-key UI, and runs a headless connection probe:

- Main process: netcatty:test-connection reuses startSSHSession in a testMode that authenticates then tears down without opening a shell, reports via netcatty:test:result, and supports cancellation (netcatty:test-connection:cancel).
- Renderer: useHostConnectionTest hook + HostConnectionTestDialog component; single-credential auth (no agent/default-key fallback) with short test timeouts (8s TCP / 15s auth).
- Reuses terminal chain progress, host-key verification, and auth prompts; success keeps the same dialog with retry/close.

* fix(ssh): cancel test connections during chain/proxy dial

Register the headless test-connection transport before connectThroughChain / createProxySocket runs, and update its pendingConn / chainConnections as hops and proxy sockets are established. This lets cancelTestConnection abort a slow/unreachable bastion or proxy dial instead of leaving it running until timeout.

* fix(ssh): address host connection test review feedback

- Route jump-host keyboard-interactive prompts through the external scope in test mode
- Always offer keyboard-interactive for requiresMfa hosts in test mode, including key/certificate-only auth
- Derive the renderer test deadline from the probe options and cancel the attempt on timeout
- Run the macOS Local Network preflight before connection tests
- Add regression tests for jump-host MFA, MFA auth order, test deadline, and macOS preflight

Author: yangheng
Model: claude-opus-5

---------

Co-authored-by: yangheng <492238647@qq.com>
2026-10-08 14:42:18 +08:00
244 changed files with 15941 additions and 853 deletions

No files matched your search

+1 -2
View File
@@ -1202,8 +1202,7 @@ jobs:
persist-credentials: false
- name: Freeze trusted helper
run: cp scripts/ai-automation.cjs "$RUNNER_TEMP/ai-automation.cjs"
chmod 0444 "$RUNNER_TEMP/ai-automation.cjs"
run: *freeze_ai_helper
- name: Install Claude Code CLI
run: |
+11
View File
@@ -8,6 +8,7 @@ import {
import { updateActiveChromeThemeDeps } from '../state/activeChromeThemeSync';
import { useActiveChromeTheme } from '../state/useActiveChromeTheme';
import { useAppearanceChromeStore } from '../state/appearanceChromeStore';
import { focusEditorInWindow } from '../state/editorWindowClient';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
import { resolveActiveChromeTheme } from './activeChromeTheme';
import type { TerminalAppearanceHostScope, ResolvedAppearance } from '../../domain/terminalAppearanceRuntime';
@@ -71,6 +72,16 @@ export function AppActiveTabChrome({
}
}, [showSftpTab, activeTabId, setActiveTabId]);
useEffect(() => {
if (!isEditorTabId(activeTabId)) return;
const editorId = fromEditorTabId(activeTabId);
if (!editorId) return;
const tab = editorTabs.find((entry) => entry.id === editorId);
if (tab?.placement !== 'window') return;
void focusEditorInWindow(editorId);
setActiveTabId('vault');
}, [activeTabId, editorTabs, setActiveTabId]);
const chromeThemeDeps = useMemo(() => ({
accentMode,
applyAppTheme,
+9
View File
@@ -22,6 +22,7 @@ import { useVaultAgentBridge } from '../state/useVaultAgentBridge';
import { useWindowControls } from '../state/useWindowControls';
import { useTerminalKeyboardFocus } from '../state/useTerminalKeyboardFocus';
import { editorTabStore, useEditorTabChromeList } from '../state/editorTabStore';
import { installEditorWindowSourceListeners } from '../state/editorWindowClient';
import { findEditorSftpOwnerTabId } from '../state/editorSftpOwnerRegistry';
import {
isPluginViewTabId,
@@ -52,6 +53,7 @@ import {
} from '../../domain/terminalAppearance';
import { selectConnectionLogForTerminalDataCapture } from '../../domain/connectionLog';
import { collectSessionIds } from '../../domain/workspace';
import { useHostOpenSessionReuse } from '../state/useHostOpenSessionReuse';
import type { PaneMagnificationController } from '../../domain/paneMagnification';
import { resolveCloseIntent } from '../state/resolveCloseIntent';
import { resolveSnippetsShortcutIntent } from '../state/resolveSnippetsShortcutIntent';
@@ -641,6 +643,8 @@ export function AppSideEffects() {
workspaces,
});
useEffect(() => installEditorWindowSourceListeners(), []);
const pendingTrayPortForwardsWhileLockedRef = useRef<Array<{ ruleId: string; start: boolean }>>([]);
const _handleTrayTogglePortForwardMaybeDeferred = useEffectEvent((ruleId: string, start: boolean) => {
@@ -1429,6 +1433,10 @@ export function AppSideEffects() {
return { ok: true as const, sessionId, host };
}, [handleConnectToHost]);
const findExistingSessionForHostForVaultAgent = useHostOpenSessionReuse({
sessions, hosts, keys, identities, resolveEffectiveHost,
});
const closeSessionForVaultAgent = useCallback((sessionId: string) => {
if (!sessions.some((session) => session.id === sessionId)) {
return { ok: false as const, error: `Session "${sessionId}" was not found.` };
@@ -1471,6 +1479,7 @@ export function AppSideEffects() {
stopTunnel,
stopRuleTunnels,
openHost: openHostForVaultAgent,
findExistingSessionForHost: findExistingSessionForHostForVaultAgent,
closeSession: closeSessionForVaultAgent,
getScriptSessionMeta: (sessionId) => sessions.find((session) => session.id === sessionId),
});
+34 -3
View File
@@ -2,8 +2,9 @@
import React, { Suspense, lazy, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { AlertTriangle, Download, Trash2 } from 'lucide-react';
import { activeTabStore, toEditorTabId, useIsEditorTabActive } from '../state/activeTabStore';
import { editorTabStore } from '../state/editorTabStore';
import { editorTabStore, tabIsDirty } from '../state/editorTabStore';
import { releaseEditorTabSaveCoordinator, saveEditorTab } from '../state/editorTabSave';
import { focusEditorInWindow, popOutEditorTab } from '../state/editorWindowClient';
import { useTerminalHostTreeLayoutWidth } from '../state/terminalHostTreeStore';
import { TopTabs } from '../../components/TopTabs';
import { VaultView } from '../../components/VaultView';
@@ -420,6 +421,23 @@ function AppViewInner({ domains }: AppViewProps) {
const tab = editorTabStore.getTab(id);
if (!tab) return false;
if (tab.placement === "window") {
const result = await netcattyBridge.get()?.closeEditorWindowTabs?.({ editorIds: [id] });
const ok = Boolean(result?.success) && result?.cancelled !== true;
if (ok) {
const closingTabId = toEditorTabId(id);
const list = orderedTabsWithEditorsRef.current;
const idx = list.indexOf(closingTabId);
releaseEditorTabSaveCoordinator(id);
editorTabStore.close(id);
if (activeTabStore.getActiveTabId() === closingTabId) {
const next = list[idx - 1] ?? list[idx + 1] ?? 'vault';
activeTabStore.setActiveTabId(next === closingTabId ? 'vault' : next);
}
}
return ok;
}
const closeEditorAndActivateNeighbor = () => {
const closingTabId = toEditorTabId(id);
const list = orderedTabsWithEditorsRef.current;
@@ -431,7 +449,7 @@ function AppViewInner({ domains }: AppViewProps) {
activeTabStore.setActiveTabId(next === closingTabId ? 'vault' : next);
};
const dirty = tab.content !== tab.baselineContent;
const dirty = tabIsDirty(tab);
if (!dirty) {
closeEditorAndActivateNeighbor();
return true;
@@ -462,6 +480,16 @@ function AppViewInner({ domains }: AppViewProps) {
// App.tsx stub `() => false` between commit and useEffect.
handleRequestCloseEditorTabRef.current = handleRequestCloseEditorTab;
const handlePopOutEditorTab = useCallback(async (id: string) => {
const tab = editorTabStore.getTab(id);
if (!tab || tab.placement === "window") {
if (tab) void focusEditorInWindow(id);
return;
}
const host = hostById.get(tab.hostId);
await popOutEditorTab(id, host?.label);
}, [hostById]);
const handleSaveSessionRename = useCallback((name: string) => {
if (!sessionRenameTarget) return;
if (!name.trim()) return;
@@ -538,6 +566,8 @@ function AppViewInner({ domains }: AppViewProps) {
pluginViewTabs={pluginViewTabs}
onClosePluginViewTab={closePluginViewTab}
onRequestCloseEditorTab={handleRequestCloseEditorTab}
onPopOutEditorTab={handlePopOutEditorTab}
onFocusEditorWindowTab={(id) => { void focusEditorInWindow(id); }}
hostById={hostById}
/>
@@ -788,7 +818,7 @@ function AppViewInner({ domains }: AppViewProps) {
))}
{/* Editor Tabs — kept mounted for Monaco instance persistence; visibility toggled via CSS */}
{editorTabs.map((tab) => (
{editorTabs.filter((tab) => tab.placement !== "window").map((tab) => (
<LazyLoadBoundary key={tab.id} name="Editor" resetKey={tab.id}>
<Suspense fallback={<TextEditorTabFallback tabId={tab.id} />}>
<LazyTextEditorTabView
@@ -797,6 +827,7 @@ function AppViewInner({ domains }: AppViewProps) {
keyBindings={keyBindings}
hostById={hostById}
onRequestClose={(id) => handleRequestCloseEditorTabRef.current(id)}
onPopOut={(id) => { void handlePopOutEditorTab(id); }}
/>
</Suspense>
</LazyLoadBoundary>
@@ -29,7 +29,7 @@ test('every renderer root mounts under StrictMode', () => {
assert.match(indexSource, /import \{ StrictMode, Suspense, lazy \} from 'react'/);
const renderCalls = indexSource.match(/root\.render\(/g) ?? [];
assert.equal(renderCalls.length, 4, 'main, settings, tray and terminal-popup roots');
assert.equal(renderCalls.length, 5, 'main, settings, tray, terminal-popup and editor-window roots');
let cursor = 0;
for (let index = 0; index < renderCalls.length; index += 1) {
@@ -0,0 +1,47 @@
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 { useAppStartupEffects } from "./useAppStartupEffects.ts";
import { editorTabStore } from "../state/editorTabStore.ts";
import { popOutEditorTab } from "../state/editorWindowClient.ts";
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge.ts";
import { toast } from "../../components/ui/toast.tsx";
test("app Quit sees the source's unsaved contents until the detached receiver accepts them", async (t) => {
const previous = Object.getOwnPropertyDescriptor(globalThis, "IS_REACT_ACT_ENVIRONMENT");
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", { configurable: true, value: true });
let renderer: ReactTestRenderer | undefined;
const editorId = "quit-during-editor-handoff";
t.after(async () => {
await act(async () => renderer?.unmount());
editorTabStore.close(editorId);
if (previous) Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", previous);
else Reflect.deleteProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT");
});
let query!: () => Promise<void>;
let accept!: (result: { success: boolean }) => void;
const receipt = new Promise<{ success: boolean }>((resolve) => { accept = resolve; });
const reports: boolean[] = [];
t.mock.method(toast, "warning", () => {});
t.mock.method(netcattyBridge, "get", () => ({
openEditorWindow: () => receipt,
onCheckDirtyEditors: (callback: typeof query) => { query = callback; return () => {}; },
reportDirtyEditorsResult: (dirty: boolean) => reports.push(dirty),
}));
function SourceWindow() {
useAppStartupEffects({ enabled: false, updateState: {}, sessions: [], hosts: [], keys: [], identities: [], knownHosts: [], proxyProfiles: [], groupConfigs: [], workspaces: [], portForwardingRules: [], t: (key: string) => key });
return null;
}
await act(async () => { renderer = create(React.createElement(SourceWindow)); });
editorTabStore.upsertFromSnapshot({ editorId, sessionId: "source", sftpTabId: "sftp", hostId: "fixture", remotePath: "/alpha.txt", fileName: "alpha.txt", languageId: "plaintext", content: "unsaved", baselineContent: "original", wordWrap: false, viewState: null });
const opening = popOutEditorTab(editorId);
assert.equal(editorTabStore.getTab(editorId)?.placement, "window");
await query();
assert.deepEqual(reports, [true], "a cold receiver must not make the only owned unsaved copy invisible to Quit");
accept({ success: true });
assert.equal(await opening, true);
assert.equal(editorTabStore.getTab(editorId)?.content, "");
await query();
assert.deepEqual(reports, [true, false], "after receipt the destination owns dirty reporting; the source copy is released");
});
+1
View File
@@ -357,6 +357,7 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
// exactly the same outcome.
let hasDirty = false;
try {
// Pending handoffs retain source contents; accepted handoffs clear them.
hasDirty = editorTabStore.getTabs().some((tab) => tab.content !== tab.baselineContent);
if (hasDirty) toast.warning(t('sftp.editor.quitBlockedByDirty'), 'SFTP');
if (!hasDirty) {
+7
View File
@@ -5,6 +5,7 @@ export const enAiMessages: Messages = {
'ai.agentSettings': 'Agent Settings',
'ai.chat.preparing': 'Preparing…',
'ai.chat.modelCatalogWarning': 'Could not refresh the live model list. Showing the last available or built-in models.',
'ai.chat.modelCatalogRetry': 'Retry',
'ai.chat.compactingContext': 'Compacting earlier context…',
'ai.chat.compactingStep': 'Trimming context for next step…',
'ai.chat.compactionRetry': 'Request was too large. Compacting context and retrying…',
@@ -521,12 +522,18 @@ export const enAiMessages: Messages = {
'ai.chat.hoursAgo': '{n}h ago',
'ai.chat.daysAgo': '{n}d ago',
'ai.chat.newChat': 'New Chat',
'ai.chat.undoLastTurn': 'Undo last turn',
'ai.chat.undoLastTurnNotice': "Original chat kept. Commands and file changes are not undone. Return to the original chat for saved full outputs and existing background tasks.",
'ai.chat.allSessions': 'All Sessions',
'ai.chat.loadEarlierMessages': 'Load earlier messages ({n} more)',
'ai.chat.jumpNav': 'Jump to message',
'ai.chat.jumpUntitled': '(empty message)',
'ai.chat.usedTools': 'Tools used: {n}',
'ai.chat.loadMoreSessions': 'Load more sessions ({n} more)',
'ai.chat.searchSessions': 'Search titles and recent content…',
'ai.chat.clearSessionSearch': 'Clear session search',
'ai.chat.searchSessionsHint': 'Searches titles, prioritizing newer messages and entries. For long messages and tool outputs, only a limited portion at the beginning is searched. This is not full-text search.',
'ai.chat.noMatchingSessions': 'No matches in titles or recent retained content',
'ai.chat.noSessions': 'No previous sessions',
'ai.chat.retryHint': 'You can retry by sending your message again.',
'ai.chat.approvalTimeout': 'Tool approval timed out after 5 minutes. You can retry by sending your message again.',
+2
View File
@@ -36,6 +36,8 @@ export const enScriptsMessages = {
'scripts.sidePanel.library': 'Library',
'scripts.sidePanel.running': 'Running',
'scripts.sidePanel.newScript': 'New script',
'scripts.sidePanel.viewList': 'List view',
'scripts.sidePanel.viewStacked': 'Stacked view',
'scripts.running.empty': 'No scripts are running in this session.',
'scripts.running.unnamed': 'Untitled script',
'scripts.running.status.running': 'Running',
+8
View File
@@ -199,6 +199,7 @@ export const enTerminalMessages: Messages = {
'terminal.progress.timeoutIn': 'Timeout in {seconds}s',
'terminal.progress.waitingForUserInput': 'Waiting for user input',
'terminal.progress.disconnected': 'Disconnected',
'terminal.progress.connected': 'Connected',
'terminal.progress.cancelling': 'Cancelling...',
'terminal.progress.startOver': 'Start over',
'terminal.progress.enterReconnectHint': 'Press Enter to reconnect',
@@ -868,6 +869,13 @@ export const enTerminalMessages: Messages = {
// Text Editor
'sftp.editor.wordWrap': 'Word Wrap',
'sftp.editor.maximize': 'Maximize',
'sftp.editor.popOut': 'Open in editor window',
'sftp.editor.dock': 'Dock to main window',
'sftp.editor.focusWindow': 'Focus editor window',
'sftp.editor.close': 'Close',
'sftp.editor.windowTitle': 'Editor',
'sftp.editor.windowEmpty': 'No files open',
'sftp.editor.dockFailed': 'Could not dock this file back to the main window',
'sftp.editor.unsavedTitle': 'Unsaved changes',
'sftp.editor.unsavedMessage': '{fileName} has unsaved changes. Save before closing?',
'sftp.editor.discardChanges': 'Discard',
+11
View File
@@ -407,6 +407,7 @@ export const enVaultMessages: Messages = {
'sftp.transferCenter.prioritize': 'Transfer next',
'sftp.transferCenter.pauseAll': 'Pause all',
'sftp.transferCenter.resumeAll': 'Resume all',
'sftp.transferCenter.cancelAll': 'Cancel all',
'sftp.transferCenter.empty': 'No transfers in this category',
'sftp.transferCenter.showBackground': 'Show {count} background transfers',
'sftp.transferCenter.hideBackground': 'Hide background transfers',
@@ -714,6 +715,16 @@ export const enVaultMessages: Messages = {
'hostDetails.title.new': 'New Host',
'vault.panel.resizeWidth': 'Resize panel width',
'hostDetails.saveAria': 'Save',
// Host connection test
'hostConnectionTest.title': 'Test Connection',
'hostConnectionTest.test': 'Test',
'hostConnectionTest.chainOf': 'Chain {current} of {total}',
'hostConnectionTest.success': 'Connection successful',
'hostConnectionTest.successHint': 'The host accepted your credentials. You can close this window.',
'hostConnectionTest.connecting': 'Connecting… timeout in {seconds}s',
'hostConnectionTest.failed': 'Connection failed.',
'hostConnectionTest.retry': 'Retry',
'hostConnectionTest.credentialsUnavailable': 'Saved credentials cannot be decrypted on this device. Re-enter them below.',
'hostDetails.section.address': 'Address',
'hostDetails.hostname.placeholder': 'IP or Hostname',
'hostDetails.section.general': 'General',
+7
View File
@@ -5,6 +5,7 @@ export const esAiMessages: Messages = {
'ai.agentSettings': 'Configuración del agente',
'ai.chat.preparing': 'Preparando…',
'ai.chat.modelCatalogWarning': 'No se pudo actualizar la lista de modelos. Se muestran los últimos modelos disponibles o los integrados.',
'ai.chat.modelCatalogRetry': 'Reintentar',
'ai.chat.compactingContext': 'Compactando el contexto anterior…',
'ai.chat.compactingStep': 'Recortando el contexto para el siguiente paso…',
'ai.chat.compactionRetry': 'La petición era demasiado grande. Compactando el contexto y reintentando…',
@@ -512,12 +513,18 @@ export const esAiMessages: Messages = {
'ai.chat.hoursAgo': 'hace {n} h',
'ai.chat.daysAgo': 'hace {n} d',
'ai.chat.newChat': 'Nuevo chat',
'ai.chat.undoLastTurn': 'Deshacer el último turno',
'ai.chat.undoLastTurnNotice': "Se conserva el chat original. Los comandos y cambios de archivos no se deshacen. Consulta allí las salidas completas guardadas y las tareas en segundo plano existentes.",
'ai.chat.allSessions': 'Todas las sesiones',
'ai.chat.loadEarlierMessages': 'Cargar mensajes anteriores ({n} más)',
'ai.chat.jumpNav': 'Ir al mensaje',
'ai.chat.jumpUntitled': '(mensaje vacío)',
'ai.chat.usedTools': 'Herramientas usadas: {n}',
'ai.chat.loadMoreSessions': 'Cargar más sesiones ({n} más)',
'ai.chat.searchSessions': 'Buscar títulos y contenido reciente…',
'ai.chat.clearSessionSearch': 'Borrar la búsqueda de sesiones',
'ai.chat.searchSessionsHint': 'Busca en los títulos y da prioridad a los mensajes y entradas más recientes. De los mensajes largos y las salidas de herramientas, solo se busca una parte limitada del comienzo. No es una búsqueda de texto completo.',
'ai.chat.noMatchingSessions': 'Sin coincidencias en títulos ni contenido reciente conservado',
'ai.chat.noSessions': 'No hay sesiones anteriores',
'ai.chat.retryHint': 'Puedes reintentar enviando tu mensaje de nuevo.',
'ai.chat.approvalTimeout': 'La aprobación de la herramienta expiró después de 5 minutos. Puedes reintentar enviando tu mensaje de nuevo.',
+2
View File
@@ -36,6 +36,8 @@ export const esScriptsMessages = {
'scripts.sidePanel.library': 'Biblioteca',
'scripts.sidePanel.running': 'En ejecución',
'scripts.sidePanel.newScript': 'Nuevo script',
'scripts.sidePanel.viewList': 'Vista de lista',
'scripts.sidePanel.viewStacked': 'Vista apilada',
'scripts.running.empty': 'No hay scripts en ejecución en esta sesión.',
'scripts.running.unnamed': 'Script sin título',
'scripts.running.status.running': 'En ejecución',
+7
View File
@@ -868,6 +868,13 @@ export const esTerminalMessages: Messages = {
// Text Editor
'sftp.editor.wordWrap': 'Ajuste de línea',
'sftp.editor.maximize': 'Maximizar',
'sftp.editor.popOut': 'Abrir en ventana de editor',
'sftp.editor.dock': 'Anclar a la ventana principal',
'sftp.editor.focusWindow': 'Enfocar ventana de editor',
'sftp.editor.close': 'Cerrar',
'sftp.editor.windowTitle': 'Editor',
'sftp.editor.windowEmpty': 'No hay archivos abiertos',
'sftp.editor.dockFailed': 'No se pudo anclar este archivo a la ventana principal',
'sftp.editor.unsavedTitle': 'Cambios sin guardar',
'sftp.editor.unsavedMessage': '{fileName} tiene cambios sin guardar. ¿Guardar antes de cerrar?',
'sftp.editor.discardChanges': 'Descartar',
+1
View File
@@ -407,6 +407,7 @@ export const esVaultMessages: Messages = {
'sftp.transferCenter.prioritize': 'Transferir a continuación',
'sftp.transferCenter.pauseAll': 'Pausar todas',
'sftp.transferCenter.resumeAll': 'Reanudar todas',
'sftp.transferCenter.cancelAll': 'Cancelar todas',
'sftp.transferCenter.empty': 'No hay transferencias en esta categoría',
'sftp.transferCenter.showBackground': 'Mostrar {count} transferencias en segundo plano',
'sftp.transferCenter.hideBackground': 'Ocultar transferencias en segundo plano',
+7
View File
@@ -6,6 +6,7 @@ export const ruAiMessages: Messages = {
'ai.agentSettings': 'Настройки агента',
'ai.chat.preparing': 'Подготовка…',
'ai.chat.modelCatalogWarning': 'Не удалось обновить список моделей. Показаны последние доступные или встроенные модели.',
'ai.chat.modelCatalogRetry': 'Повторить',
'ai.chat.contextCompacted': 'Контекст сжат',
'ai.title': 'AI',
'ai.description': 'Настройка AI-провайдеров, агентов и параметров безопасности',
@@ -468,12 +469,18 @@ export const ruAiMessages: Messages = {
'ai.chat.hoursAgo': '{n}ч назад',
'ai.chat.daysAgo': '{n}д назад',
'ai.chat.newChat': 'Новый чат',
'ai.chat.undoLastTurn': 'Отменить последний ход',
'ai.chat.undoLastTurnNotice': "Исходный чат сохранён. Команды и изменения файлов не отменяются. Полные сохранённые результаты и фоновые задачи доступны в исходном чате.",
'ai.chat.allSessions': 'Все сессии',
'ai.chat.loadEarlierMessages': 'Загрузить более ранние сообщения (ещё {n})',
'ai.chat.jumpNav': 'Перейти к сообщению',
'ai.chat.jumpUntitled': '(пустое сообщение)',
'ai.chat.usedTools': 'Использовано инструментов: {n}',
'ai.chat.loadMoreSessions': 'Загрузить больше сессий (ещё {n})',
'ai.chat.searchSessions': 'Поиск по заголовкам и недавнему содержимому…',
'ai.chat.clearSessionSearch': 'Очистить поиск сессий',
'ai.chat.searchSessionsHint': 'Поиск по заголовкам с приоритетом более новых сообщений и записей. В длинных сообщениях и результатах инструментов поиск охватывает только ограниченную начальную часть. Это не полнотекстовый поиск.',
'ai.chat.noMatchingSessions': 'Нет совпадений в заголовках и недавнем сохранённом содержимом',
'ai.chat.noSessions': 'Предыдущих сессий нет',
'ai.chat.retryHint': 'Вы можете повторить попытку, отправив сообщение ещё раз.',
'ai.chat.approvalTimeout': 'Время ожидания одобрения инструмента истекло через 5 минут. Вы можете повторить попытку, отправив сообщение ещё раз.',
+2
View File
@@ -36,6 +36,8 @@ export const ruScriptsMessages = {
'scripts.sidePanel.library': 'Библиотека',
'scripts.sidePanel.running': 'Выполняются',
'scripts.sidePanel.newScript': 'Новый скрипт',
'scripts.sidePanel.viewList': 'Список',
'scripts.sidePanel.viewStacked': 'Плитка',
'scripts.running.empty': 'В этой сессии не выполняются скрипты.',
'scripts.running.unnamed': 'Скрипт без названия',
'scripts.running.status.running': 'Выполняется',
+8
View File
@@ -220,6 +220,7 @@ export const ruTerminalMessages: Messages = {
'terminal.progress.timeoutIn': 'Тайм-аут через {seconds}с',
'terminal.progress.waitingForUserInput': 'Ожидание ввода пользователя',
'terminal.progress.disconnected': 'Отключено',
'terminal.progress.connected': 'Подключено',
'terminal.progress.cancelling': 'Отмена...',
'terminal.progress.startOver': 'Начать заново',
'terminal.progress.enterReconnectHint': 'Нажмите Enter, чтобы подключиться снова',
@@ -875,6 +876,13 @@ export const ruTerminalMessages: Messages = {
// Text Editor
'sftp.editor.wordWrap': 'Перенос строк',
'sftp.editor.maximize': 'Развернуть',
'sftp.editor.popOut': 'Открыть в окне редактора',
'sftp.editor.dock': 'Вернуть в главное окно',
'sftp.editor.focusWindow': 'Перейти к окну редактора',
'sftp.editor.close': 'Закрыть',
'sftp.editor.windowTitle': 'Редактор',
'sftp.editor.windowEmpty': 'Нет открытых файлов',
'sftp.editor.dockFailed': 'Не удалось вернуть файл в главное окно',
'sftp.editor.unsavedTitle': 'Несохранённые изменения',
'sftp.editor.unsavedMessage': 'В файле {fileName} есть несохранённые изменения. Сохранить перед закрытием?',
'sftp.editor.discardChanges': 'Отбросить',
+10
View File
@@ -445,6 +445,7 @@ export const ruVaultMessages: Messages = {
'sftp.transferCenter.prioritize': 'Передать следующей',
'sftp.transferCenter.pauseAll': 'Приостановить все',
'sftp.transferCenter.resumeAll': 'Продолжить все',
'sftp.transferCenter.cancelAll': 'Отменить все',
'sftp.transferCenter.empty': 'В этой категории нет передач',
'sftp.transferCenter.showBackground': 'Показать фоновые: {count}',
'sftp.transferCenter.hideBackground': 'Скрыть фоновые',
@@ -752,6 +753,15 @@ export const ruVaultMessages: Messages = {
'hostDetails.title.new': 'Новый хост',
'vault.panel.resizeWidth': 'Изменить ширину панели',
'hostDetails.saveAria': 'Сохранить',
'hostConnectionTest.title': 'Проверка подключения',
'hostConnectionTest.test': 'Проверить',
'hostConnectionTest.chainOf': 'Цепочка {current} из {total}',
'hostConnectionTest.success': 'Подключение успешно',
'hostConnectionTest.successHint': 'Хост принял ваши учётные данные. Можно закрыть окно.',
'hostConnectionTest.connecting': 'Подключение… таймаут через {seconds} с',
'hostConnectionTest.failed': 'Не удалось подключиться.',
'hostConnectionTest.retry': 'Повторить',
'hostConnectionTest.credentialsUnavailable': 'Сохранённые учётные данные не удаётся расшифровать на этом устройстве. Введите их заново ниже.',
'hostDetails.section.address': 'Адрес',
'hostDetails.hostname.placeholder': 'IP или имя хоста',
'hostDetails.section.general': 'Общие',
@@ -0,0 +1,16 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import en from './en.ts';
import ru from './ru.ts';
import es from './es.ts';
import zhCN from './zh-CN.ts';
import zhTW from './zh-TW.ts';
test('scripts side panel view-toggle copy exists in every locale', () => {
const KEYS = ['scripts.sidePanel.viewList', 'scripts.sidePanel.viewStacked'];
for (const [locale, messages] of Object.entries({ en, ru, es, zhCN, zhTW })) {
const missing = KEYS.filter((key) => !(messages as Record<string, string>)[key]);
assert.deepEqual(missing, [], `${locale} is missing scripts view-toggle copy`);
}
});
@@ -0,0 +1,20 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import en from './en.ts';
import ru from './ru.ts';
import es from './es.ts';
import zhCN from './zh-CN.ts';
import zhTW from './zh-TW.ts';
const UNDO_KEYS = [
'ai.chat.undoLastTurn',
'ai.chat.undoLastTurnNotice',
] as const;
test('Undo-last-turn UI is localized in every supported locale', () => {
for (const [name, messages] of Object.entries({ en, es, 'zh-CN': zhCN, 'zh-TW': zhTW, ru })) {
const missing = UNDO_KEYS.filter(key => !messages[key]);
assert.deepEqual(missing, [], `${name} is missing undo-last-turn labels`);
}
});
+7
View File
@@ -5,6 +5,7 @@ export const zhCNAiMessages: Messages = {
'ai.agentSettings': 'Agent 设置',
'ai.chat.preparing': '准备中…',
'ai.chat.modelCatalogWarning': '暂时无法更新实时模型列表,当前显示上次可用或内置的模型。',
'ai.chat.modelCatalogRetry': '重试',
'ai.chat.compactingContext': '正在压缩较早的上下文…',
'ai.chat.compactingStep': '正在为下一步整理上下文…',
'ai.chat.compactionRetry': '请求过大,正在压缩上下文并重试…',
@@ -521,12 +522,18 @@ export const zhCNAiMessages: Messages = {
'ai.chat.hoursAgo': '{n}小时前',
'ai.chat.daysAgo': '{n}天前',
'ai.chat.newChat': '新对话',
'ai.chat.undoLastTurn': '撤销上一轮',
'ai.chat.undoLastTurnNotice': "原对话已保留。已执行的命令和文件修改不会撤销;之前保存的完整输出和后台任务,请回原对话查看。",
'ai.chat.allSessions': '所有会话',
'ai.chat.loadEarlierMessages': '加载更早的消息(还有 {n} 条)',
'ai.chat.jumpNav': '跳转到消息',
'ai.chat.jumpUntitled': '(空消息)',
'ai.chat.usedTools': '已使用 {n} 个工具',
'ai.chat.loadMoreSessions': '加载更多会话(还有 {n} 条)',
'ai.chat.searchSessions': '搜索标题和近期内容…',
'ai.chat.clearSessionSearch': '清空会话搜索',
'ai.chat.searchSessionsHint': '搜索标题,并优先搜索较新的消息和条目;超长消息及工具输出仅搜索开头的有限部分。这不是全文搜索。',
'ai.chat.noMatchingSessions': '标题和近期保留的内容中没有匹配结果',
'ai.chat.noSessions': '没有历史会话',
'ai.chat.retryHint': '你可以重新发送消息来重试。',
'ai.chat.approvalTimeout': '工具审批已超时(5 分钟)。你可以重新发送消息来重试。',
+1
View File
@@ -1051,6 +1051,7 @@ export const zhCNCoreMessages: Messages = {
'sftp.transferCenter.prioritize': '优先传输',
'sftp.transferCenter.pauseAll': '全部暂停',
'sftp.transferCenter.resumeAll': '全部继续',
'sftp.transferCenter.cancelAll': '全部取消',
'sftp.transferCenter.empty': '当前分类没有传输任务',
'sftp.transferCenter.showBackground': '显示 {count} 个后台任务',
'sftp.transferCenter.hideBackground': '隐藏后台任务',
@@ -36,6 +36,8 @@ export const zhCNScriptsMessages = {
'scripts.sidePanel.library': '脚本库',
'scripts.sidePanel.running': '运行中',
'scripts.sidePanel.newScript': '新建脚本',
'scripts.sidePanel.viewList': '列表视图',
'scripts.sidePanel.viewStacked': '堆叠视图',
'scripts.running.empty': '当前会话没有正在运行的脚本。',
'scripts.running.unnamed': '未命名脚本',
'scripts.running.status.running': '运行中',
@@ -968,6 +968,13 @@ export const zhCNTerminalMessages: Messages = {
// Text Editor
'sftp.editor.wordWrap': '自动换行',
'sftp.editor.maximize': '最大化',
'sftp.editor.popOut': '在编辑器窗口打开',
'sftp.editor.dock': '停靠回主窗口',
'sftp.editor.focusWindow': '聚焦编辑器窗口',
'sftp.editor.close': '关闭',
'sftp.editor.windowTitle': '编辑器',
'sftp.editor.windowEmpty': '没有打开的文件',
'sftp.editor.dockFailed': '无法把该文件停靠回主窗口',
'sftp.editor.unsavedTitle': '未保存的修改',
'sftp.editor.unsavedMessage': '{fileName} 有未保存的修改,是否保存后关闭?',
'sftp.editor.discardChanges': '不保存',
+10
View File
@@ -130,6 +130,15 @@ export const zhCNVaultMessages: Messages = {
'hostDetails.title.new': '新建主机',
'vault.panel.resizeWidth': '调整面板宽度',
'hostDetails.saveAria': '保存',
'hostConnectionTest.title': '测试连接',
'hostConnectionTest.test': '测试',
'hostConnectionTest.chainOf': '第 {current}/{total} 跳',
'hostConnectionTest.success': '连接成功',
'hostConnectionTest.successHint': '主机已接受您的凭据。您可以关闭此窗口。',
'hostConnectionTest.connecting': '正在连接… {seconds} 秒后超时',
'hostConnectionTest.failed': '连接失败。',
'hostConnectionTest.retry': '重试',
'hostConnectionTest.credentialsUnavailable': '保存的凭据无法在此设备上解密。请在下方重新输入。',
'hostDetails.section.address': '地址',
'hostDetails.hostname.placeholder': 'IP 或 主机名',
'hostDetails.section.general': '通用',
@@ -591,6 +600,7 @@ export const zhCNVaultMessages: Messages = {
'terminal.progress.timeoutIn': '将在 {seconds}s 后超时',
'terminal.progress.waitingForUserInput': '等待用户输入',
'terminal.progress.disconnected': '已断开',
'terminal.progress.connected': '已连接',
'terminal.progress.cancelling': '正在取消...',
'terminal.progress.startOver': '重新开始',
'terminal.connection.dismissDisconnectedDialog': '关闭断连提示',
+7
View File
@@ -5,6 +5,7 @@ export const zhTWAiMessages: Messages = {
'ai.agentSettings': 'Agent 設定',
'ai.chat.preparing': '準備中…',
'ai.chat.modelCatalogWarning': '暫時無法更新即時模型清單,目前顯示上次可用或內建的模型。',
'ai.chat.modelCatalogRetry': '重試',
'ai.chat.compactingContext': '正在壓縮較早的上下文…',
'ai.chat.compactingStep': '正在為下一步整理上下文…',
'ai.chat.compactionRetry': '請求過大,正在壓縮上下文並重試…',
@@ -511,12 +512,18 @@ export const zhTWAiMessages: Messages = {
'ai.chat.hoursAgo': '{n}小時前',
'ai.chat.daysAgo': '{n}天前',
'ai.chat.newChat': '新對話',
'ai.chat.undoLastTurn': '復原上一輪',
'ai.chat.undoLastTurnNotice': "原對話已保留。已執行的命令和檔案修改不會復原;之前儲存的完整輸出和背景工作,請回原對話查看。",
'ai.chat.allSessions': '所有工作階段',
'ai.chat.loadEarlierMessages': '載入更早的訊息(還有 {n} 條)',
'ai.chat.jumpNav': '跳轉到訊息',
'ai.chat.jumpUntitled': '(空訊息)',
'ai.chat.usedTools': '已使用 {n} 個工具',
'ai.chat.loadMoreSessions': '載入更多工作階段(還有 {n} 條)',
'ai.chat.searchSessions': '搜尋標題和近期內容…',
'ai.chat.clearSessionSearch': '清空會話搜尋',
'ai.chat.searchSessionsHint': '搜尋標題,並優先搜尋較新的訊息與項目;超長訊息與工具輸出僅搜尋開頭的有限部分。這不是全文搜尋。',
'ai.chat.noMatchingSessions': '標題和近期保留的內容中沒有符合的結果',
'ai.chat.noSessions': '沒有歷史工作階段',
'ai.chat.retryHint': '你可以重新發送訊息來重試。',
'ai.chat.approvalTimeout': '工具核准已超時(5 分鐘)。你可以重新發送訊息來重試。',
+1
View File
@@ -1053,6 +1053,7 @@ export const zhTWCoreMessages: Messages = {
'sftp.transferCenter.prioritize': '優先傳輸',
'sftp.transferCenter.pauseAll': '全部暫停',
'sftp.transferCenter.resumeAll': '全部繼續',
'sftp.transferCenter.cancelAll': '全部取消',
'sftp.transferCenter.empty': '目前分類沒有傳輸工作',
'sftp.transferCenter.showBackground': '顯示 {count} 個背景工作',
'sftp.transferCenter.hideBackground': '隱藏背景工作',
@@ -36,6 +36,8 @@ export const zhTWScriptsMessages = {
'scripts.sidePanel.library': '指令碼庫',
'scripts.sidePanel.running': '執行中',
'scripts.sidePanel.newScript': '新增指令碼',
'scripts.sidePanel.viewList': '列表檢視',
'scripts.sidePanel.viewStacked': '堆疊檢視',
'scripts.running.empty': '目前工作階段沒有正在執行的指令碼。',
'scripts.running.unnamed': '未命名指令碼',
'scripts.running.status.running': '執行中',
@@ -968,6 +968,13 @@ export const zhTWTerminalMessages: Messages = {
// Text Editor
'sftp.editor.wordWrap': '自動換行',
'sftp.editor.maximize': '最大化',
'sftp.editor.popOut': '在編輯器視窗開啟',
'sftp.editor.dock': '停靠回主視窗',
'sftp.editor.focusWindow': '聚焦編輯器視窗',
'sftp.editor.close': '關閉',
'sftp.editor.windowTitle': '編輯器',
'sftp.editor.windowEmpty': '沒有開啟的檔案',
'sftp.editor.dockFailed': '無法把此檔案停靠回主視窗',
'sftp.editor.unsavedTitle': '未儲存的修改',
'sftp.editor.unsavedMessage': '{fileName} 有未儲存的修改,是否儲存後關閉?',
'sftp.editor.discardChanges': '不儲存',
+10
View File
@@ -129,6 +129,15 @@ export const zhTWVaultMessages: Messages = {
'hostDetails.title.details': '主機詳情',
'hostDetails.title.new': '新增主機',
'hostDetails.saveAria': '儲存',
'hostConnectionTest.title': '測試連線',
'hostConnectionTest.test': '測試',
'hostConnectionTest.chainOf': '第 {current}/{total} 跳',
'hostConnectionTest.success': '連線成功',
'hostConnectionTest.successHint': '主機已接受您的憑證。您可以關閉此視窗。',
'hostConnectionTest.connecting': '正在連線… {seconds} 秒後逾時',
'hostConnectionTest.failed': '連線失敗。',
'hostConnectionTest.retry': '重試',
'hostConnectionTest.credentialsUnavailable': '儲存的憑證無法在此裝置上解密。請在下方重新輸入。',
'hostDetails.section.address': '位址',
'hostDetails.hostname.placeholder': 'IP 或 主機名',
'hostDetails.section.general': '通用',
@@ -591,6 +600,7 @@ export const zhTWVaultMessages: Messages = {
'terminal.progress.timeoutIn': '將在 {seconds}s 後超時',
'terminal.progress.waitingForUserInput': '等待使用者輸入',
'terminal.progress.disconnected': '已斷開',
'terminal.progress.connected': '已連線',
'terminal.progress.cancelling': '正在取消...',
'terminal.progress.startOver': '重新開始',
'terminal.connection.dismissDisconnectedDialog': '關閉斷連提示',
+109
View File
@@ -466,6 +466,115 @@ test('writeSessionsForStorage reduces several small sessions to fit a sub-512 KB
}
});
test('serializeSessionsForStorage truncates slightly-over-cap tool results to strictly smaller sizes', async () => {
const { serializeSessionsForStorage } = await import('./aiStateSnapshots');
// Just over the 8 KiB cap: the truncation marker must not make the degraded
// copy larger than the original, or the ladder could evict a session whose
// unmodified JSON would have fit the budget.
for (const oversize of [8193, 8266, 20000]) {
const session = makeSession('near-cap', 1, [{
id: 'tool-message',
role: 'tool',
content: '',
timestamp: 1,
toolResults: [{ toolCallId: 'call-1', content: 'z'.repeat(oversize) }],
}]);
const fullJson = JSON.stringify([session]);
const result = serializeSessionsForStorage([session], fullJson.length - 10);
const content = result.sessions[0].messages[0].toolResults?.[0].content ?? '';
assert.ok(content.length <= 8 * 1024, `cap respected for ${oversize}`);
assert.ok(
JSON.stringify(result.sessions).length < fullJson.length,
`degraded copy smaller than original for ${oversize}`,
);
assert.ok(content.includes('truncated'));
}
});
test('serializeSessionsForStorage sheds oversized tool output before dropping visible sessions', async () => {
const { serializeSessionsForStorage } = await import('./aiStateSnapshots');
const newest = makeSession('newest', 2, [{
id: 'new-message',
role: 'assistant',
content: 'still-open chat',
timestamp: 2,
}]);
const toolBulk = makeSession('tool-bulk', 1, [{
id: 'tool-message',
role: 'tool',
content: '',
timestamp: 1,
toolResults: [{ toolCallId: 'call-1', content: 'z'.repeat(400 * 1024) }],
}]);
const result = serializeSessionsForStorage([toolBulk, newest], 100 * 1024);
// The bloated tool result must not evict the other visible chat.
assert.deepEqual(result.sessions.map(session => session.id), ['newest', 'tool-bulk']);
assert.ok(result.json.length <= 100 * 1024);
const persistedToolContent = result.sessions[1].messages[0].toolResults?.[0].content;
assert.ok(persistedToolContent);
assert.ok(persistedToolContent.length <= 9 * 1024);
assert.ok(persistedToolContent.includes('truncated'));
assert.ok(persistedToolContent.startsWith('zzz'));
assert.equal(result.sessions[0].messages[0].content, 'still-open chat');
});
test('writeSessionsForStorage keeps every chat by shedding tool output under quota pressure', async () => {
const { writeSessionsForStorage } = await import('./aiStateSnapshots');
let stored: string | undefined;
const previousLocalStorage = globalThis.localStorage;
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: {
getItem: () => null,
setItem: (_key: string, value: string) => {
if (value.length > 300 * 1024) {
throw new DOMException('quota exceeded', 'QuotaExceededError');
}
stored = value;
},
removeItem: () => {},
},
});
try {
const sessions = Array.from({ length: 10 }, (_, index) => makeSession(
`session-${index}`,
10 - index,
[{
id: `message-${index}`,
role: 'tool' as const,
content: '',
timestamp: index,
toolResults: [{ toolCallId: `call-${index}`, content: 'z'.repeat(40 * 1024) }],
}],
));
assert.equal(writeSessionsForStorage(sessions), true);
assert.ok(stored);
assert.ok(stored.length <= 300 * 1024);
const persisted = JSON.parse(stored) as AISession[];
assert.deepEqual(
persisted.map(session => session.id),
sessions.map(session => session.id),
);
// Degradation sheds only as many messages as the budget demands, so at
// least one chat keeps its full tool output while the persisted set stays
// complete. Assert completion first; truncation second.
const shedContents = persisted.map(
session => session.messages[0].toolResults?.[0].content ?? '',
);
assert.ok(shedContents.some(content => content.includes('truncated')));
assert.ok(shedContents.some(content => content.startsWith('zzz') && !content.includes('truncated')));
} finally {
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: previousLocalStorage,
});
}
});
test('writeSessionsForStorage makes a final attempt with only the newest session', async () => {
const { writeSessionsForStorage } = await import('./aiStateSnapshots');
const writes: string[] = [];
+79 -29
View File
@@ -195,12 +195,40 @@ const RETRY_SESSIONS_JSON_BYTES = [
128 * 1024,
] as const;
/**
* 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.
*/
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`;
}
return `${content.slice(0, half)}${marker}${content.slice(content.length - half)}`;
}
/**
* Remove `reasoningEncryptedContent` ciphertext from a message's persisted
* continuation. The ciphertext exists so stateless Responses turns can replay
* prior reasoning items; it is also by far the largest per-message payload.
* When storage pressure forces it, dropping the ciphertext keeps the visible
* conversation intact at the cost of reasoning replay for affected messages.
* 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,
@@ -218,7 +246,7 @@ function stripReasoningEncryptedContent(
return Object.keys(stripped).length ? stripped : undefined;
}
function stripEncryptedReasoningFromMessage(message: AISession['messages'][number]) {
function stripReasoningCiphertextFromMessage(message: AISession['messages'][number]) {
const continuation = message.providerContinuation;
if (!continuation?.reasoningParts) return message;
let changed = false;
@@ -239,6 +267,22 @@ function stripEncryptedReasoningFromMessage(message: AISession['messages'][numbe
};
}
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 => {
@@ -259,7 +303,7 @@ function stripCompactedEncryptedReasoningFromSession(session: AISession): AISess
let changed = false;
const messages = session.messages.map((message, index) => {
if (index >= compactedMessageCount) return message;
const stripped = stripEncryptedReasoningFromMessage(message);
const stripped = stripReasoningCiphertextFromMessage(message);
if (stripped !== message) changed = true;
return stripped;
});
@@ -310,6 +354,11 @@ export function pruneSessionsForStorage(sessions: AISession[]): AISession[] {
* 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[],
@@ -321,13 +370,13 @@ export function serializeSessionsForStorage(
// before deciding whether the newest session deserves full protection;
// otherwise dead metadata can evict other visible conversations.
const session = stripCompactedEncryptedReasoningFromSession(rawSession);
const ciphertextMessages = session.messages.flatMap((message, index) => {
const strippedMessage = stripEncryptedReasoningFromMessage(message);
if (strippedMessage === message) return [];
const degradableMessages = session.messages.flatMap((message, index) => {
const degradedMessage = stripEncryptedReasoningFromMessage(message);
if (degradedMessage === message) return [];
return [{
index,
strippedMessage,
jsonLengthDelta: JSON.stringify(strippedMessage).length - JSON.stringify(message).length,
degradedMessage,
jsonLengthDelta: JSON.stringify(degradedMessage).length - JSON.stringify(message).length,
}];
});
const strippedSession = stripEncryptedReasoningFromSession(session);
@@ -339,7 +388,7 @@ export function serializeSessionsForStorage(
strippedJson: strippedSession === session
? json
: JSON.stringify(strippedSession),
ciphertextMessages,
degradableMessages,
};
});
@@ -364,11 +413,12 @@ export function serializeSessionsForStorage(
minimalLength -= removed.strippedJson.length + 1;
}
// Then keep full continuation data for the retained sessions and remove
// replay-only ciphertext from the oldest retained sessions only as needed,
// never touching the protected newest session. Within one session, remove
// 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.
// 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;
@@ -378,27 +428,27 @@ export function serializeSessionsForStorage(
index -= 1
) {
const current = serialized[index];
let strippedMessageCount = 0;
while (jsonLength > budgetBytes && strippedMessageCount < current.ciphertextMessages.length) {
jsonLength += current.ciphertextMessages[strippedMessageCount].jsonLengthDelta;
strippedMessageCount += 1;
let degradedMessageCount = 0;
while (jsonLength > budgetBytes && degradedMessageCount < current.degradableMessages.length) {
jsonLength += current.degradableMessages[degradedMessageCount].jsonLengthDelta;
degradedMessageCount += 1;
}
if (strippedMessageCount > 0) {
const strippedMessagesByIndex = new Map(
current.ciphertextMessages
.slice(0, strippedMessageCount)
.map(entry => [entry.index, entry.strippedMessage] as const),
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) => (
strippedMessagesByIndex.get(messageIndex) ?? message
degradedMessagesByIndex.get(messageIndex) ?? message
)),
};
const nextJson = JSON.stringify(nextSession);
const projectedJsonLength = current.json.length
+ current.ciphertextMessages
.slice(0, strippedMessageCount)
+ current.degradableMessages
.slice(0, degradedMessageCount)
.reduce((total, entry) => total + entry.jsonLengthDelta, 0);
jsonLength += nextJson.length - projectedJsonLength;
serialized[index] = {
+109
View File
@@ -0,0 +1,109 @@
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 { editorTabStore } from "../../application/state/editorTabStore.ts";
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge.ts";
import { toast } from "../../components/ui/toast.tsx";
import { useSftpViewFileOps } from "../../components/sftp/hooks/useSftpViewFileOps.ts";
import type { UseSftpViewFileOpsParams, UseSftpViewFileOpsResult } from "../../components/sftp/hooks/useSftpViewFileOps.types.ts";
for (const action of ["onPopOut", "onPromoteToTab"] as const) {
test(`${action} preserves a modified modal when the file is already detached`, async (t) => {
const environment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
const previous = environment.IS_REACT_ACT_ENVIRONMENT;
environment.IS_REACT_ACT_ENVIRONMENT = true;
let ops!: UseSftpViewFileOpsResult;
const params = {
sftpRef: { current: {
leftPane: { id: "pane", connection: { id: "conn", hostId: "host" } },
readTextFile: async () => "original",
} },
behaviorRef: { current: "" }, autoSyncRef: { current: false },
getOpenerForFileRef: { current: () => null }, setOpenerForExtension: () => {},
t: (key: string) => key,
} as unknown as UseSftpViewFileOpsParams;
const original = {
editorId: "modal-test", sessionId: "conn", sftpTabId: "pane", hostId: "host",
remotePath: "/file.txt", fileName: "file.txt", languageId: "plaintext",
content: "window edits", baselineContent: "original", wordWrap: false, viewState: null,
};
editorTabStore.upsertFromSnapshot(original, "window");
const before = editorTabStore.getTab(original.editorId);
t.after(() => editorTabStore.close(original.editorId));
let focusCount = 0;
t.mock.method(netcattyBridge, "get", () => ({
focusEditorWindow: async () => { focusCount++; return { success: true }; },
}));
const errors = t.mock.method(toast, "error", () => {});
function Harness() { ops = useSftpViewFileOps(params); return null; }
let renderer!: ReactTestRenderer;
await act(async () => { renderer = create(React.createElement(Harness)); });
t.after(async () => {
await act(async () => renderer.unmount());
environment.IS_REACT_ACT_ENVIRONMENT = previous;
});
await act(async () => {
await ops.onEditFileLeft({ name: "file.txt", size: 8 } as Parameters<typeof ops.onEditFileLeft>[0], "/./file.txt");
});
const modal = { ...original, content: "modal edits" };
await act(async () => { ops[action](modal); });
assert.equal(ops.showTextEditor, true);
assert.equal(ops.textEditorTarget?.fullPath, "/./file.txt");
assert.equal(ops.textEditorContent, "original");
assert.equal(modal.content, "modal edits");
assert.equal(editorTabStore.getTab(original.editorId), before);
assert.equal(focusCount, 0);
assert.equal(errors.mock.callCount(), 1);
});
}
for (const focusSucceeds of [true, false]) {
test(`Maximize dismisses a clean duplicate modal only after detached focus succeeds: ${focusSucceeds}`, async (t) => {
const environment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
const previous = environment.IS_REACT_ACT_ENVIRONMENT;
environment.IS_REACT_ACT_ENVIRONMENT = true;
let ops!: UseSftpViewFileOpsResult;
const params = {
sftpRef: { current: {
leftPane: { id: "pane", connection: { id: "conn", hostId: "host" } },
readTextFile: async () => "original",
} },
behaviorRef: { current: "" }, autoSyncRef: { current: false },
getOpenerForFileRef: { current: () => null }, setOpenerForExtension: () => {},
t: (key: string) => key,
} as unknown as UseSftpViewFileOpsParams;
const detached = {
editorId: "clean-duplicate", sessionId: "conn", sftpTabId: "pane", hostId: "host",
remotePath: "/file.txt", fileName: "file.txt", languageId: "plaintext",
content: "detached edits", baselineContent: "original", wordWrap: false, viewState: null,
};
editorTabStore.upsertFromSnapshot(detached, "window");
const before = editorTabStore.getTab(detached.editorId);
t.after(() => editorTabStore.close(detached.editorId));
let finishFocus!: (result: { success: boolean }) => void;
const focusing = new Promise<{ success: boolean }>((resolve) => { finishFocus = resolve; });
let focusCount = 0;
t.mock.method(netcattyBridge, "get", () => ({
focusEditorWindow: () => { focusCount++; return focusing; },
}));
function Harness() { ops = useSftpViewFileOps(params); return null; }
let renderer!: ReactTestRenderer;
await act(async () => { renderer = create(React.createElement(Harness)); });
t.after(async () => {
await act(async () => renderer.unmount());
environment.IS_REACT_ACT_ENVIRONMENT = previous;
});
await act(async () => {
await ops.onEditFileLeft({ name: "file.txt", size: 8 } as Parameters<typeof ops.onEditFileLeft>[0], "/file.txt");
});
await act(async () => { ops.onPromoteToTab({ ...detached, content: "original" }); });
assert.equal(focusCount, 1);
assert.equal(ops.showTextEditor, true, "keep the modal until focus is acknowledged");
await act(async () => { finishFocus({ success: focusSucceeds }); });
assert.equal(ops.showTextEditor, !focusSucceeds);
assert.equal(ops.textEditorTarget?.fullPath ?? null, focusSucceeds ? null : "/file.txt");
assert.equal(ops.textEditorContent, focusSucceeds ? "" : "original");
assert.equal(editorTabStore.getTab(detached.editorId), before, "focusing must preserve existing detached edits");
});
}
@@ -0,0 +1,16 @@
import assert from "node:assert/strict";
import test from "node:test";
test("independent renderers creating editors in the same millisecond get distinct identities", async (t) => {
t.mock.method(Date, "now", () => 1234567890);
const [firstRenderer, secondRenderer] = await Promise.all([
import(new URL("./editorTabStore.ts?renderer=first", import.meta.url).href),
import(new URL("./editorTabStore.ts?renderer=second", import.meta.url).href),
]);
assert.notEqual(firstRenderer.EditorTabStore, secondRenderer.EditorTabStore, "use independent renderer module state");
const snapshot = { sessionId: "session", sftpTabId: "pane", hostId: "host", remotePath: "/file.txt", fileName: "file.txt", languageId: "plaintext", content: "edits", baselineContent: "original", wordWrap: false, viewState: null };
const first = new firstRenderer.EditorTabStore().promoteFromModal(snapshot);
const second = new secondRenderer.EditorTabStore().promoteFromModal(snapshot);
assert.notEqual(first, second, "process-wide ownership must not collide across renderer-local counters");
assert.match(first, /^edt_[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}$/i);
});
+14
View File
@@ -28,6 +28,20 @@ const makeTab = (overrides: Partial<EditorTab> = {}): EditorTab => ({
...overrides,
});
test("clean modal reuses a detached tab without changing its dirty state or content", () => {
const store = new EditorTabStore();
const tab = makeTab({ placement: "window", windowDirty: true, content: "", baselineContent: "" });
store._debugInsert(tab);
const id = store.promoteFromModal({
...makeTab(),
remotePath: "/etc//nginx/./nginx.conf",
});
assert.equal(id, tab.id);
assert.equal(store.getTab(id), tab);
assert.equal(store.isDirty(id), true);
assert.equal(store.getTabs().length, 1);
});
test("updateContent stores content and viewState; dirty flag derives from baseline", () => {
const store = new EditorTabStore();
store._debugInsert(makeTab());
+107 -5
View File
@@ -2,6 +2,7 @@ import { useCallback, useMemo, useSyncExternalStore } from "react";
import type * as Monaco from "monaco-editor";
import { activeTabStore, fromEditorTabId, isEditorTabId } from "./activeTabStore";
import type { EditorTabPlacement, EditorWindowTabSnapshot } from "./editorWindowTypes";
// POSIX-style normalization: collapse "/./" and duplicate slashes, not ".." (remote paths
// may contain semantic ".." segments we don't want to resolve client-side).
@@ -23,6 +24,7 @@ export interface EditorTab {
sftpTabId: string;
/** Stable endpoint id; used to verify the session is still the one we opened against. */
hostId: string;
hostLabel?: string;
remotePath: string;
fileName: string;
languageId: string;
@@ -32,12 +34,16 @@ export interface EditorTab {
viewState: Monaco.editor.ICodeEditorViewState | null;
savingState: EditorSavingState;
saveError: string | null;
placement?: EditorTabPlacement;
windowDirty?: boolean;
}
type Listener = () => void;
let idCounter = 0;
const genId = (): EditorTabId => `edt_${Date.now().toString(36)}_${(++idCounter).toString(36)}`;
const genId = (): EditorTabId => `edt_${crypto.randomUUID()}`;
export const tabIsDirty = (tab: EditorTab): boolean =>
tab.placement === "window" ? tab.windowDirty === true : tab.content !== tab.baselineContent;
export class EditorTabStore {
private tabs: EditorTab[] = [];
@@ -79,7 +85,82 @@ export class EditorTabStore {
};
isDirty = (id: EditorTabId): boolean => {
const t = this.getTab(id);
return !!t && t.content !== t.baselineContent;
return !!t && tabIsDirty(t);
};
listByOwner = (owner: { sessionId?: string; sftpTabId?: string }): EditorTab[] =>
this.tabs.filter((t) => this.tabMatchesOwner(t, owner));
markDetached = (id: EditorTabId, dirty: boolean, releaseContent = true): void => {
this.patch(id, {
placement: "window",
windowDirty: dirty,
...(releaseContent ? { content: "", baselineContent: "", viewState: null } : {}),
});
this.notifyStructural();
};
setWindowDirty = (id: EditorTabId, dirty: boolean): void => {
const tab = this.getTab(id);
if (!tab || tab.placement !== "window" || tab.windowDirty === dirty) return;
this.patch(id, { windowDirty: dirty });
};
upsertFromSnapshot = (
snapshot: EditorWindowTabSnapshot,
placement: EditorTabPlacement = "tab",
): EditorTabId => {
const existingById = snapshot.editorId ? this.getTab(snapshot.editorId) : undefined;
const normalized = normalizePath(snapshot.remotePath);
const existing = existingById ?? this.tabs.find(
(t) => t.sessionId === snapshot.sessionId && normalizePath(t.remotePath) === normalized,
);
const next: Partial<EditorTab> = {
sessionId: snapshot.sessionId,
sftpTabId: snapshot.sftpTabId,
hostId: snapshot.hostId,
hostLabel: snapshot.hostLabel,
remotePath: snapshot.remotePath,
fileName: snapshot.fileName,
languageId: snapshot.languageId,
content: placement === "window" ? "" : snapshot.content,
baselineContent: placement === "window" ? "" : snapshot.baselineContent,
wordWrap: snapshot.wordWrap,
viewState: placement === "window" ? null : snapshot.viewState as EditorTab["viewState"],
placement,
windowDirty: placement === "window"
? snapshot.content !== snapshot.baselineContent
: false,
savingState: "idle",
saveError: null,
};
if (existing) {
this.patch(existing.id, next);
if (existing.placement !== placement) this.notifyStructural();
return existing.id;
}
const tab: EditorTab = {
id: snapshot.editorId || this.makeId(),
kind: "editor",
sessionId: snapshot.sessionId,
sftpTabId: snapshot.sftpTabId,
hostId: snapshot.hostId,
hostLabel: snapshot.hostLabel,
remotePath: snapshot.remotePath,
fileName: snapshot.fileName,
languageId: snapshot.languageId,
content: next.content ?? "",
baselineContent: next.baselineContent ?? "",
wordWrap: snapshot.wordWrap,
viewState: (next.viewState ?? null) as EditorTab["viewState"],
savingState: "idle",
saveError: null,
placement,
windowDirty: next.windowDirty === true,
};
this.tabs = [...this.tabs, tab];
this.notifyStructural();
return tab.id;
};
updateContent = (
@@ -91,6 +172,12 @@ export class EditorTabStore {
};
markSaved = (id: EditorTabId, newBaseline: string) => {
const tab = this.getTab(id);
if (tab?.placement === "window") {
// Only the detached renderer has the current content. Wait for its dirty report.
this.patch(id, { savingState: "idle", saveError: null });
return;
}
this.patch(id, { baselineContent: newBaseline, savingState: "idle", saveError: null });
};
@@ -174,17 +261,26 @@ export class EditorTabStore {
baselineContent: string;
wordWrap: boolean;
viewState: Monaco.editor.ICodeEditorViewState | null;
placement?: EditorTabPlacement;
}): EditorTabId => {
const normalized = normalizePath(snapshot.remotePath);
const existing = this.tabs.find(
(t) => t.sessionId === snapshot.sessionId && normalizePath(t.remotePath) === normalized,
);
if (existing) {
if (existing.placement === "window") {
if (snapshot.content !== snapshot.baselineContent) {
throw new Error("This file is already open in the editor window. Your changes are still in this dialog. Dock or close the existing editor before transferring these changes.");
}
return existing.id;
}
this.patch(existing.id, {
content: snapshot.content,
baselineContent: snapshot.baselineContent,
wordWrap: snapshot.wordWrap,
viewState: snapshot.viewState,
placement: snapshot.placement ?? existing.placement ?? "tab",
windowDirty: false,
// keep languageId/hostId/fileName stable; they shouldn't change for the same path
});
return existing.id;
@@ -204,6 +300,8 @@ export class EditorTabStore {
viewState: snapshot.viewState,
savingState: "idle",
saveError: null,
placement: snapshot.placement ?? "tab",
windowDirty: false,
};
this.tabs = [...this.tabs, tab];
this.notifyStructural();
@@ -221,9 +319,9 @@ export class EditorTabStore {
saveTab?: (tabId: EditorTabId) => Promise<void>,
onCloseTab?: (tabId: EditorTabId) => void,
): Promise<boolean> => {
const matching = this.tabs.filter((t) => this.tabMatchesOwner(t, owner));
const matching = this.tabs.filter((t) => this.tabMatchesOwner(t, owner) && t.placement !== "window");
for (const tab of matching) {
const dirty = tab.content !== tab.baselineContent;
const dirty = tabIsDirty(tab);
if (!dirty) {
onCloseTab?.(tab.id);
this.close(tab.id);
@@ -332,9 +430,11 @@ export type EditorTabChrome = Pick<
| 'sessionId'
| 'sftpTabId'
| 'hostId'
| 'hostLabel'
| 'remotePath'
| 'fileName'
| 'languageId'
| 'placement'
>;
const projectEditorTabChrome = (tab: EditorTab): EditorTabChrome => ({
@@ -343,9 +443,11 @@ const projectEditorTabChrome = (tab: EditorTab): EditorTabChrome => ({
sessionId: tab.sessionId,
sftpTabId: tab.sftpTabId,
hostId: tab.hostId,
hostLabel: tab.hostLabel,
remotePath: tab.remotePath,
fileName: tab.fileName,
languageId: tab.languageId,
placement: tab.placement ?? "tab",
});
export const useHasEditorTabForSessions = (
@@ -0,0 +1,147 @@
import assert from "node:assert/strict";
import test from "node:test";
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge.ts";
import { editorTabStore } from "./editorTabStore.ts";
import { dockDetachedEditorTab, installEditorWindowSourceListeners, popOutEditorTab, saveDetachedEditorTab } from "./editorWindowClient.ts";
import { registerEditorSftpWriterScoped } from "./editorSftpBridge.ts";
import { releaseEditorTabSaveCoordinator } from "./editorTabSave.ts";
import type { EditorWindowSaveRequest, EditorWindowSaveResult } from "./editorWindowTypes.ts";
const snapshot = {
editorId: "save-test", sessionId: "conn", sftpTabId: "pane", hostId: "host",
remotePath: "/file", fileName: "file", languageId: "plaintext",
content: "v2", baselineContent: "v1", wordWrap: false, viewState: null,
};
for (const latestContent of ["v3", "v2", "v1"]) {
test(`detached save reports current dirty state after editing to ${latestContent}`, async (t) => {
editorTabStore.upsertFromSnapshot(snapshot);
t.after(() => editorTabStore.close(snapshot.editorId));
let finish!: (result: { ok: boolean }) => void;
const pending = new Promise<{ ok: boolean }>((resolve) => { finish = resolve; });
const reports: boolean[] = [];
t.mock.method(netcattyBridge, "get", () => ({
saveEditorWindowTab: (request: { content: string }) => {
assert.equal(request.content, "v2");
return pending;
},
reportEditorWindowDirty: ({ dirty }: { dirty: boolean }) => reports.push(dirty),
}));
const saving = saveDetachedEditorTab(snapshot.editorId);
editorTabStore.updateContent(snapshot.editorId, latestContent, null);
finish({ ok: true });
assert.deepEqual(await saving, { ok: true });
assert.equal(editorTabStore.getTab(snapshot.editorId)?.baselineContent, "v2");
assert.equal(editorTabStore.getTab(snapshot.editorId)?.content, latestContent);
assert.deepEqual(reports, [latestContent !== "v2"]);
});
}
test("source save receipt preserves dirty until the detached renderer reports it", async (t) => {
editorTabStore.upsertFromSnapshot(snapshot, "window");
t.after(() => {
editorTabStore.close(snapshot.editorId);
releaseEditorTabSaveCoordinator(snapshot.editorId);
});
let finish!: () => void;
const pending = new Promise<void>((resolve) => { finish = resolve; });
t.after(registerEditorSftpWriterScoped(async () => { await pending; return "conn"; }));
let onSave!: (request: EditorWindowSaveRequest) => Promise<void>;
let onDirty!: (payload: { editorId: string; dirty: boolean }) => void;
const receipts: EditorWindowSaveResult[] = [];
t.mock.method(netcattyBridge, "get", () => ({
onEditorWindowSaveRequest: (cb: typeof onSave) => { onSave = cb; return () => {}; },
onEditorWindowDirtyChanged: (cb: typeof onDirty) => { onDirty = cb; return () => {}; },
reportEditorWindowSaveResult: (result: EditorWindowSaveResult) => receipts.push(result),
}));
t.after(installEditorWindowSourceListeners());
const saving = onSave({ ...snapshot, requestId: "request" });
onDirty({ editorId: snapshot.editorId, dirty: true });
finish();
await saving;
assert.equal(receipts[0]?.ok, true);
assert.equal(editorTabStore.isDirty(snapshot.editorId), true);
onDirty({ editorId: snapshot.editorId, dirty: false });
assert.equal(editorTabStore.isDirty(snapshot.editorId), false);
});
for (const outcome of ["accepted", "rejected", "failed"] as const) {
test(`popout retains source contents until the receiver ${outcome}`, async (t) => {
editorTabStore.upsertFromSnapshot(snapshot);
t.after(() => editorTabStore.close(snapshot.editorId));
let resolve!: (result: { success: boolean }) => void;
let reject!: (error: Error) => void;
const receipt = new Promise<{ success: boolean }>((yes, no) => { resolve = yes; reject = no; });
t.mock.method(netcattyBridge, "get", () => ({ openEditorWindow: () => receipt }));
const opening = popOutEditorTab(snapshot.editorId);
assert.equal(editorTabStore.getTab(snapshot.editorId)?.placement, "window");
assert.equal(editorTabStore.getTab(snapshot.editorId)?.content, "v2");
assert.equal(editorTabStore.getTab(snapshot.editorId)?.baselineContent, "v1");
if (outcome === "failed") reject(new Error("IPC disconnected"));
else resolve({ success: outcome === "accepted" });
assert.equal(await opening, outcome === "accepted");
const tab = editorTabStore.getTab(snapshot.editorId)!;
assert.equal(tab.placement, outcome === "accepted" ? "window" : "tab");
assert.equal(tab.content, outcome === "accepted" ? "" : "v2");
assert.equal(tab.baselineContent, outcome === "accepted" ? "" : "v1");
assert.equal(editorTabStore.isDirty(tab.id), true);
});
}
for (const outcome of ["accepted", "rejected", "failed"] as const) {
test(`dock freezes the current copy until ${outcome} and refuses a second transfer`, async (t) => {
editorTabStore.upsertFromSnapshot(snapshot);
editorTabStore.setSavingState(snapshot.editorId, "error", "previous save failed");
t.after(() => editorTabStore.close(snapshot.editorId));
let resolve!: (result: { success: boolean; error?: string }) => void;
let reject!: (error: Error) => void;
const receipt = new Promise<{ success: boolean; error?: string }>((yes, no) => { resolve = yes; reject = no; });
let calls = 0;
t.mock.method(netcattyBridge, "get", () => ({
dockEditorWindowTab: (sent: typeof snapshot) => {
calls++;
assert.equal(sent.content, "v2");
return receipt;
},
}));
const docking = dockDetachedEditorTab(snapshot.editorId);
assert.equal(editorTabStore.getTab(snapshot.editorId)?.savingState, "saving", "existing pane busy state makes Monaco read-only during transfer");
assert.equal((await dockDetachedEditorTab(snapshot.editorId)).success, false);
assert.equal(calls, 1);
if (outcome === "failed") reject(new Error("IPC disconnected"));
else resolve({ success: outcome === "accepted", error: "Source unavailable" });
assert.equal((await docking).success, outcome === "accepted");
const tab = editorTabStore.getTab(snapshot.editorId)!;
assert.equal(tab.content, "v2");
assert.equal(tab.baselineContent, "v1");
assert.equal(tab.savingState, outcome === "accepted" ? "saving" : "error", "success stays frozen until the caller closes; failure restores interaction");
if (outcome !== "accepted") {
assert.equal(tab.saveError, "previous save failed");
editorTabStore.updateContent(snapshot.editorId, "v3", null);
assert.equal(editorTabStore.getTab(snapshot.editorId)?.content, "v3");
}
});
}
test("dock does not take over an in-progress save", async (t) => {
editorTabStore.upsertFromSnapshot(snapshot);
editorTabStore.setSavingState(snapshot.editorId, "saving");
t.after(() => editorTabStore.close(snapshot.editorId));
let calls = 0;
t.mock.method(netcattyBridge, "get", () => ({ dockEditorWindowTab: async () => { calls++; return { success: true }; } }));
assert.equal((await dockDetachedEditorTab(snapshot.editorId)).success, false);
assert.equal(calls, 0);
assert.equal(editorTabStore.getTab(snapshot.editorId)?.savingState, "saving");
});
test("a closing destination rejects a popout without releasing source content", async (t) => {
editorTabStore.upsertFromSnapshot(snapshot);
t.after(() => editorTabStore.close(snapshot.editorId));
t.mock.method(netcattyBridge, "get", () => ({ openEditorWindow: async () => ({ success: false, error: "Editor window is closing" }) }));
assert.equal(await popOutEditorTab(snapshot.editorId), false);
const tab = editorTabStore.getTab(snapshot.editorId)!;
assert.equal(tab.placement, "tab");
assert.equal(tab.content, "v2");
assert.equal(tab.baselineContent, "v1");
assert.equal(editorTabStore.isDirty(tab.id), true);
});
+283
View File
@@ -0,0 +1,283 @@
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge";
import { saveEditorTab } from "./editorTabSave";
import {
editorTabStore,
tabIsDirty,
type EditorTab,
type EditorTabId,
} from "./editorTabStore";
import type { EditorWindowTabSnapshot } from "./editorWindowTypes";
import { activeTabStore, toEditorTabId } from "./activeTabStore";
export function toEditorWindowSnapshot(
tab: EditorTab,
hostLabel?: string,
): EditorWindowTabSnapshot {
return {
editorId: tab.id,
sessionId: tab.sessionId,
sftpTabId: tab.sftpTabId,
hostId: tab.hostId,
hostLabel: hostLabel || tab.hostLabel,
remotePath: tab.remotePath,
fileName: tab.fileName,
languageId: tab.languageId,
content: tab.content,
baselineContent: tab.baselineContent,
wordWrap: tab.wordWrap,
viewState: tab.viewState,
};
}
export async function openEditorInWindow(snapshot: EditorWindowTabSnapshot): Promise<boolean> {
const bridge = netcattyBridge.get();
if (!bridge?.openEditorWindow) return false;
const result = await bridge.openEditorWindow(snapshot);
return result?.success === true;
}
export async function focusEditorInWindow(editorId: EditorTabId): Promise<boolean> {
const result = await netcattyBridge.get()?.focusEditorWindow?.(editorId);
return result?.success === true;
}
export async function popOutEditorTab(
tabId: EditorTabId,
hostLabel?: string,
): Promise<boolean> {
const tab = editorTabStore.getTab(tabId);
if (!tab) return false;
if (tab.placement === "window") {
return focusEditorInWindow(tabId);
}
const dirty = tabIsDirty(tab);
const snapshot = toEditorWindowSnapshot(tab, hostLabel || tab.hostLabel);
// Hide the source editor during transfer, but retain its only owned copy
// until the destination has acknowledged installing the tab.
editorTabStore.markDetached(tabId, dirty, false);
const ok = await openEditorInWindow(snapshot).catch(() => false);
if (!ok) {
if (editorTabStore.getTab(tabId)) editorTabStore.upsertFromSnapshot(snapshot, "tab");
return false;
}
editorTabStore.markDetached(tabId, dirty);
const activeId = activeTabStore.getActiveTabId();
if (activeId === toEditorTabId(tabId)) {
activeTabStore.setActiveTabId("vault");
}
return true;
}
export async function closeOwnedEditorWindowTabs(
owner: { sessionId?: string; sftpTabId?: string },
options?: { force?: boolean },
): Promise<{ cancelled: boolean; closedIds: string[] }> {
const matching = editorTabStore.listByOwner(owner).filter((tab) => tab.placement === "window");
if (matching.length === 0) return { cancelled: false, closedIds: [] };
const result = await netcattyBridge.get()?.closeEditorWindowTabs?.({
editorIds: matching.map((tab) => tab.id),
force: options?.force === true,
});
const closedIds = result?.closedIds ?? [];
for (const id of closedIds) editorTabStore.close(id);
if (!result || result.success === false) {
return { cancelled: options?.force !== true, closedIds };
}
if (result.cancelled !== true) {
for (const tab of matching) editorTabStore.close(tab.id);
}
return {
cancelled: result.cancelled === true,
closedIds,
};
}
export async function confirmCloseOwnedEditors(
owner: { sessionId?: string; sftpTabId?: string },
promptChoice: (tab: EditorTab) => Promise<"save" | "discard" | "cancel">,
saveTab?: (tabId: EditorTabId) => Promise<void>,
onCloseTab?: (tabId: EditorTabId) => void,
): Promise<boolean> {
const windowClose = await closeOwnedEditorWindowTabs(owner, { force: false });
if (windowClose.cancelled) return false;
return editorTabStore.confirmCloseByOwner(owner, promptChoice, saveTab, onCloseTab);
}
export function forceCloseOwnedEditors(owners: {
sessionIds?: readonly string[];
sftpTabIds?: readonly string[];
}): EditorTabId[] {
const matching = editorTabStore.getTabs().filter((tab) => {
if (tab.placement !== "window") return false;
if (owners.sessionIds?.includes(tab.sessionId)) return true;
if (owners.sftpTabIds?.includes(tab.sftpTabId)) return true;
return false;
});
if (matching.length > 0) {
void netcattyBridge.get()?.closeEditorWindowTabs?.({
editorIds: matching.map((tab) => tab.id),
force: true,
});
}
return editorTabStore.forceCloseByOwners(owners);
}
export function notifyEditorWindowSessionRemap(fromSessionId: string, toSessionId: string): void {
if (!fromSessionId || !toSessionId || fromSessionId === toSessionId) return;
netcattyBridge.get()?.remapEditorWindowSession?.({ fromSessionId, toSessionId });
}
export async function saveDetachedEditorTab(tabId: EditorTabId): Promise<{ ok: boolean; error?: string }> {
const current = editorTabStore.getTab(tabId);
if (!current) return { ok: false, error: "Editor tab closed before save completed" };
editorTabStore.setSavingState(tabId, "saving");
const result = await netcattyBridge.get()?.saveEditorWindowTab?.({
editorId: current.id,
sessionId: current.sessionId,
sftpTabId: current.sftpTabId,
hostId: current.hostId,
remotePath: current.remotePath,
content: current.content,
});
if (result?.ok) {
editorTabStore.markSaved(tabId, current.content);
netcattyBridge.get()?.reportEditorWindowDirty?.({
editorId: tabId,
dirty: editorTabStore.isDirty(tabId),
});
return { ok: true };
}
const error = result?.error || "Save failed";
editorTabStore.setSavingState(tabId, "error", error);
return { ok: false, error };
}
export async function dockDetachedEditorTab(tabId: EditorTabId): Promise<{ success: boolean; error?: string }> {
const tab = editorTabStore.getTab(tabId);
if (!tab) return { success: false, error: "Editor tab closed" };
if (tab.savingState === "saving") return { success: false, error: "Editor tab is busy" };
// Keep the snapshot's owner read-only until receipt. Success stays busy
// until the caller removes this copy; failure restores its previous state.
editorTabStore.setSavingState(tabId, "saving");
let accepted = false;
try {
const result = await netcattyBridge.get()?.dockEditorWindowTab?.(
toEditorWindowSnapshot(tab, tab.hostLabel),
);
if (result?.success) {
accepted = true;
return { success: true };
}
return { success: false, error: result?.error || "Failed to dock editor tab" };
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : "Failed to dock editor tab" };
} finally {
if (!accepted) editorTabStore.setSavingState(tabId, tab.savingState, tab.saveError);
}
}
export function reportDetachedEditorDirty(tabId: EditorTabId, dirty: boolean): void {
netcattyBridge.get()?.reportEditorWindowDirty?.({ editorId: tabId, dirty });
}
export function reportDetachedEditorTabsClosed(editorIds: string[]): void {
if (editorIds.length === 0) return;
netcattyBridge.get()?.reportEditorWindowTabsClosed?.({ editorIds });
}
export function subscribeEditorWindowCloseTabs(
cb: (payload: import("./editorWindowTypes").EditorWindowCloseTabsRequest) => void | Promise<void>,
): () => void {
return netcattyBridge.get()?.onEditorWindowCloseTabs?.(cb) ?? (() => {});
}
export function reportEditorWindowCloseTabsResult(
payload: import("./editorWindowTypes").EditorWindowCloseTabsResult,
): void {
netcattyBridge.get()?.reportEditorWindowCloseTabsResult?.(payload);
}
export function subscribeEditorWindowDirtyCheck(cb: () => boolean): () => void {
const bridge = netcattyBridge.get();
if (!bridge?.onCheckDirtyEditors) return () => {};
return bridge.onCheckDirtyEditors(() => {
let hasDirty = false;
try {
hasDirty = cb() === true;
} catch {
hasDirty = false;
}
try {
bridge.reportDirtyEditorsResult?.(hasDirty);
} catch {
// ignore
}
});
}
export function installEditorWindowSourceListeners(): () => void {
const bridge = netcattyBridge.get();
if (!bridge) return () => {};
const unsubSave = bridge.onEditorWindowSaveRequest?.(async (payload) => {
try {
const tab = editorTabStore.getTab(payload.editorId);
if (!tab) {
bridge.reportEditorWindowSaveResult?.({
requestId: payload.requestId,
ok: false,
error: "Editor tab closed before save completed",
});
return;
}
if (tab.sessionId !== payload.sessionId) {
editorTabStore.remapSessionId(tab.sessionId, payload.sessionId);
}
const ok = await saveEditorTab(payload.editorId, payload.content);
const latest = editorTabStore.getTab(payload.editorId);
bridge.reportEditorWindowSaveResult?.({
requestId: payload.requestId,
ok,
liveConnectionId: latest?.sessionId,
error: ok ? undefined : (latest?.saveError || "Save failed"),
});
} catch (err) {
bridge.reportEditorWindowSaveResult?.({
requestId: payload.requestId,
ok: false,
error: err instanceof Error ? err.message : "Save failed",
});
}
}) ?? (() => {});
const unsubDock = bridge.onEditorWindowDockRequest?.((payload) => {
try {
const id = editorTabStore.upsertFromSnapshot(payload, "tab");
activeTabStore.setActiveTabId(toEditorTabId(id));
bridge.reportEditorWindowDockResult?.({ requestId: payload.requestId, ok: true });
} catch (err) {
bridge.reportEditorWindowDockResult?.({
requestId: payload.requestId,
ok: false,
error: err instanceof Error ? err.message : "Failed to dock",
});
}
}) ?? (() => {});
const unsubDirty = bridge.onEditorWindowDirtyChanged?.((payload) => {
editorTabStore.setWindowDirty(payload.editorId, payload.dirty === true);
}) ?? (() => {});
const unsubClosed = bridge.onEditorWindowTabsClosed?.((payload) => {
for (const editorId of payload.editorIds ?? []) {
editorTabStore.close(editorId);
}
}) ?? (() => {});
return () => {
unsubSave();
unsubDock();
unsubDirty();
unsubClosed();
};
}
+45
View File
@@ -0,0 +1,45 @@
export type EditorTabPlacement = "tab" | "window";
export interface EditorWindowTabSnapshot {
editorId: string;
sessionId: string;
sftpTabId: string;
hostId: string;
hostLabel?: string;
remotePath: string;
fileName: string;
languageId: string;
content: string;
baselineContent: string;
wordWrap: boolean;
viewState: unknown;
}
export interface EditorWindowSaveRequest {
requestId: string;
editorId: string;
sessionId: string;
sftpTabId: string;
hostId: string;
remotePath: string;
content: string;
}
export interface EditorWindowSaveResult {
requestId: string;
ok: boolean;
liveConnectionId?: string;
error?: string;
}
export interface EditorWindowCloseTabsRequest {
requestId: string;
editorIds: string[];
force?: boolean;
}
export interface EditorWindowCloseTabsResult {
requestId: string;
cancelled: boolean;
closedIds: string[];
}
@@ -8,10 +8,20 @@ import {
shouldRevealTerminalPopupOnExit,
} from "./resolveTerminalSessionExitIntent.ts";
test("normal backend exited events close the session tab", () => {
test("backend exited events close the session tab only when auto-close is enabled", () => {
assert.deepEqual(
resolveTerminalSessionExitIntent({ reason: "exited", exitCode: 0 }, true),
{ kind: "closeSession" },
);
});
test("omitted auto-close setting keeps the session tab on a clean exit", () => {
// The fallback for callers that have not loaded the terminal setting yet:
// keep the tab so a jump host / bastion reaping a session (a clean exit)
// never silently deletes the user's scrollback.
assert.deepEqual(
resolveTerminalSessionExitIntent({ reason: "exited", exitCode: 0 }),
{ kind: "closeSession" },
{ kind: "markDisconnected" },
);
});
@@ -57,11 +67,19 @@ test("backend closed events keep the tab and mark it disconnected", () => {
);
});
test("administrative exit-signal disconnects keep the tab", () => {
assert.deepEqual(
resolveTerminalSessionExitIntent({ reason: "signaled", exitSignal: "SIGHUP" }, true),
{ kind: "markDisconnected" },
);
});
test("terminal popup only auto-closes after clean command exit", () => {
assert.equal(shouldCloseTerminalPopupOnExit({ reason: "exited", exitCode: 0 }), true);
assert.equal(shouldCloseTerminalPopupOnExit({ reason: "exited", exitCode: 1 }), false);
assert.equal(shouldCloseTerminalPopupOnExit({ reason: "error", error: "connection reset" }), false);
assert.equal(shouldCloseTerminalPopupOnExit({ reason: "closed", exitCode: 0 }), false);
assert.equal(shouldCloseTerminalPopupOnExit({ reason: "signaled", exitSignal: "SIGHUP" }), false);
});
test("disabled auto-close keeps command and attached terminal popups open", () => {
@@ -133,6 +151,17 @@ test("exit messages show the reported code for a real remote exit", () => {
);
});
test("an administrative exit-signal renders as a remote signal, not a clean code", () => {
assert.equal(
formatSessionClosedExitMessage({ reason: "signaled", exitSignal: "SIGHUP" }),
"\r\n[session closed by remote signal]",
);
assert.equal(
formatSessionClosedExitMessage({ reason: "closed", exitSignal: "SIGTERM" }),
"\r\n[session closed by remote signal]",
);
});
test("a channel closed without exit-status or exit-signal must not render as code 0", () => {
assert.equal(
formatSessionClosedExitMessage({
@@ -150,3 +179,25 @@ test("exit messages without a reported code stay plain instead of showing a sent
);
assert.equal(formatSessionClosedExitMessage({}), "\r\n[session closed]");
});
test("infrastructure teardowns must not render a numeric code as a clean exit (#3631)", () => {
// A terminal-worker exit broadcast carries the worker's own exit status
// (graceful drain = 0). It belongs to the infrastructure failure, not to
// any remote shell, so it must never read as "session closed (code 0)".
assert.equal(
formatSessionClosedExitMessage({
reason: "error",
exitCode: 0,
error: "Terminal worker exited with code 0",
}),
"\r\n[session closed unexpectedly]",
);
assert.equal(
formatSessionClosedExitMessage({ reason: "error", exitCode: 1 }),
"\r\n[session closed unexpectedly]",
);
assert.equal(
formatSessionClosedExitMessage({ reason: "error" }),
"\r\n[session closed unexpectedly]",
);
});
@@ -4,7 +4,10 @@ export type TerminalSessionExitEvent = {
exitCode?: number;
signal?: number;
error?: string;
reason?: "exited" | "error" | "timeout" | "closed";
reason?: "exited" | "error" | "timeout" | "closed" | "signaled";
// ssh2 exit-signal name ("SIGHUP", "SIGTERM", ...). Set when the server
// terminated the session process. Distinct from a transport "closed".
exitSignal?: string;
// True when the remote closed the channel without sending exit-status or
// exit-signal (#3591): no numeric exit code exists, and the renderer shows
// a distinct message instead of a misleading "code 0".
@@ -16,6 +19,13 @@ export function formatSessionClosedExitMessage(evt: TerminalSessionExitEvent): s
if (evt?.remoteClosedWithoutExitStatus) {
return "\r\n[session closed by remote (no exit status)]";
}
if (evt?.reason === "signaled" || (typeof evt?.exitSignal === "string" && evt.exitSignal.trim().length > 0)) {
return "\r\n[session closed by remote signal]";
}
// Infrastructure exit codes do not describe the remote shell's status.
if (evt?.reason === "error") {
return "\r\n[session closed unexpectedly]";
}
return `\r\n[session closed${evt?.exitCode !== undefined ? ` (code ${evt.exitCode})` : ""}]`;
}
@@ -34,7 +44,11 @@ function isConfirmedCleanExit(evt: TerminalSessionExitEvent): boolean {
export function resolveTerminalSessionExitIntent(
evt: TerminalSessionExitEvent,
autoCloseOnExit = true,
// Keep-tab is the safe default: a caller that has not loaded the terminal
// setting yet must not silently delete a session tab. Remote graceful
// channel closes (e.g. a jump host reaping an idle session) are reported
// as a clean exit, so an unavailable setting must not re-enable closing.
autoCloseOnExit = false,
): TerminalSessionExitIntent {
if (autoCloseOnExit && isConfirmedCleanExit(evt)) {
return { kind: "closeSession" };
@@ -0,0 +1,790 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
collectSessionSearchFields,
type SessionHistorySearchMessage,
} from "../../domain/sessionHistorySearch.ts";
import {
PINYIN_CACHE_MAX_CHARS,
getPinyinCacheStatsForTests,
resetPinyinCacheForTests,
} from "../../lib/searchMatcher.ts";
import { createSessionHistorySearch } from "./sessionHistorySearch.ts";
// One instance for the whole file: it keeps the pre-factory semantics of every
// test sharing (and clearing) a single cache.
const { clearSessionHistorySearchCache, filterSessionHistory } = createSessionHistorySearch();
// Fixture messages stay loosely typed: persisted sessions carry far richer
// shapes (role, ids, payload fields, …) than the minimal searchable shapes,
// and extra properties must remain structurally harmless.
function createSession(
id: string,
title: string,
messages: Array<Record<string, unknown>>,
) {
return {
id,
title,
messages: messages.map((message, index) => ({
id: `${id}-m${index}`,
role: "user",
content: "",
timestamp: index,
...message,
})) as SessionHistorySearchMessage[],
};
}
test("filterSessionHistory matches titles and keeps order when query is blank", () => {
const sessions = [
createSession("a", "nginx restart", []),
createSession("b", "disk cleanup", []),
];
for (const query of ["", " \t\n"]) {
assert.deepEqual(filterSessionHistory(sessions, query), sessions);
}
});
test("filterSessionHistory handles special characters through the shared matcher", () => {
const sessions = [
createSession("a", "Ops", [{ content: "check [prod].json at /var/log/nginx" }]),
createSession("b", "Deploy", [{ content: "ship it" }]),
];
assert.deepEqual(filterSessionHistory(sessions, "[prod].json"), [sessions[0]]);
assert.deepEqual(filterSessionHistory(sessions, "/var/log/nginx"), [sessions[0]]);
assert.deepEqual(filterSessionHistory(sessions, "[](){}"), []);
});
test("filterSessionHistory matches message content of user and assistant turns", () => {
const sessions = [
createSession("a", "Debugging", [
{ role: "user", content: "why is sshd down on prod-3?" },
{ role: "assistant", content: "let me check the journal" },
]),
createSession("b", "Deploy notes", [
{ role: "user", content: "ship release 1.2" },
]),
];
assert.deepEqual(filterSessionHistory(sessions, "sshd down"), [sessions[0]]);
assert.deepEqual(filterSessionHistory(sessions, "1.2"), [sessions[1]]);
assert.deepEqual(filterSessionHistory(sessions, "nothing here"), []);
});
test("filterSessionHistory ignores hidden system messages", () => {
const sessions = [
createSession("a", "Ops", [
{ role: "system", content: "shared hidden prompt with marker text" },
{ role: "user", content: "all clear" },
]),
createSession("b", "Deploy", [
{ role: "system", content: "shared hidden prompt with marker text" },
{ role: "user", content: "ship it" },
]),
];
// A query found only inside system messages must not surface the sessions:
// the reopened conversation hides those messages, so a match would display
// no matching text.
assert.deepEqual(filterSessionHistory(sessions, "marker text"), []);
});
test("filterSessionHistory searches thinking and tool call/result content", () => {
const sessions = [
createSession("a", "Untitled", [
{ role: "assistant", thinking: "the certificate expired" },
]),
createSession("b", "Ops", [
{
role: "assistant",
toolCalls: [{ name: "systemctl", id: "t1", arguments: {} }],
toolResults: [{ toolCallId: "t1", content: "nginx failed to bind port" }],
},
]),
];
assert.deepEqual(filterSessionHistory(sessions, "certificate"), [sessions[0]]);
assert.deepEqual(filterSessionHistory(sessions, "systemctl"), [sessions[1]]);
assert.deepEqual(filterSessionHistory(sessions, "bind port"), [sessions[1]]);
});
test("filterSessionHistory matches text stored only in tool call arguments", () => {
const sessions = [
createSession("a", "Ops", [
{
role: "assistant",
toolCalls: [{
name: "shell",
id: "t1",
arguments: { command: "systemctl restart nginx" },
}],
},
]),
createSession("b", "Deploy", [
{
role: "assistant",
toolCalls: [{ name: "file", id: "t2", arguments: {} }],
},
]),
];
assert.deepEqual(filterSessionHistory(sessions, "nginx"), [sessions[0]]);
assert.deepEqual(filterSessionHistory(sessions, "restart nginx"), [sessions[0]]);
assert.deepEqual(filterSessionHistory(sessions, "nothing here"), []);
const fields = collectSessionSearchFields(sessions[0]);
assert.ok(fields.some((field) => field.includes("systemctl restart nginx")));
});
test("filterSessionHistory matches persisted agent activities", () => {
const sessions = [
createSession("a", "Research", [
{
role: "assistant",
agentActivities: [
{ id: "1", type: "web_search", status: "completed", query: "rust async runtime benchmarks" },
],
},
]),
createSession("b", "Refactor", [
{
role: "assistant",
agentActivities: [
{
id: "2",
type: "file_change",
status: "completed",
changes: [
{ path: "src/sessionStore.ts", kind: "update" },
{ path: "src/sessionStore.test.ts", kind: "add" },
],
},
],
},
]),
createSession("c", "Plan", [
{
role: "assistant",
agentActivities: [
{
id: "3",
type: "plan_update",
status: "completed",
items: [
{ text: "extract search collector", completed: true },
{ text: "wire into drawer", completed: false },
],
},
{ id: "4", type: "warning", status: "completed", message: "rate limit hit" },
],
},
]),
];
assert.deepEqual(filterSessionHistory(sessions, "async runtime"), [sessions[0]]);
assert.deepEqual(filterSessionHistory(sessions, "sessionStore.test"), [sessions[1]]);
assert.deepEqual(filterSessionHistory(sessions, "extract search collector"), [sessions[2]]);
assert.deepEqual(filterSessionHistory(sessions, "rate limit"), [sessions[2]]);
assert.deepEqual(filterSessionHistory(sessions, "nothing here"), []);
const fields = collectSessionSearchFields(sessions[2]);
assert.ok(fields.some((field) => field.includes("extract search collector")));
assert.ok(fields.some((field) => field.includes("rate limit hit")));
});
test("filterSessionHistory matches persisted error messages and attachment labels", () => {
const sessions = [
createSession("a", "Untitled", [
{
role: "assistant",
content: "",
errorInfo: { type: "provider", message: "upstream rate limit exceeded", retryable: true },
},
]),
createSession("b", "Deploy", [
{
role: "user",
content: "please summarize",
attachments: [
{ base64Data: "", mediaType: "text/plain", filename: "k8s-manifest.yaml" },
{ base64Data: "", mediaType: "text/markdown", vaultNoteTitle: "Postgres runbook" },
],
},
]),
createSession("c", "Legacy", [
{
role: "user",
content: "see attached",
images: [
{ base64Data: "", mediaType: "image/png", filename: "screenshot.png" },
],
},
]),
createSession("d", "Payloads", [
{
role: "user",
content: "",
attachments: [
{ base64Data: "AAAA", mediaType: "image/png", terminalSelection: true },
],
},
]),
];
assert.deepEqual(filterSessionHistory(sessions, "rate limit exceeded"), [sessions[0]]);
assert.deepEqual(filterSessionHistory(sessions, "k8s-manifest"), [sessions[1]]);
assert.deepEqual(filterSessionHistory(sessions, "postgres runbook"), [sessions[1]]);
assert.deepEqual(filterSessionHistory(sessions, "screenshot.png"), [sessions[2]]);
// Attachment base64 payloads and unlabeled attachments are not indexed.
assert.deepEqual(filterSessionHistory(sessions, "AAAA"), []);
assert.deepEqual(filterSessionHistory(sessions, "nothing here"), []);
const fields = collectSessionSearchFields(sessions[1]);
assert.ok(fields.some((field) => field.includes("k8s-manifest.yaml")));
assert.ok(fields.some((field) => field.includes("Postgres runbook")));
assert.ok(!fields.some((field) => field === "AAAA"));
});
test("budget exhaustion inside a message keeps the newest tool calls searchable", () => {
// One assistant message bearing enough tool calls (each argument capped at
// 2,000 chars) to exhaust the remaining session budget: iteration must be
// newest-first so the latest calls (appended at the array end) stay
// searchable instead of the oldest ones winning the budget.
const session = createSession("a", "Ops", [
{
role: "assistant",
content: "y".repeat(20_000),
toolCalls: Array.from({ length: 25 }, (_, i) => ({
name: `call-${i}`,
id: `t${i}`,
arguments: { command: `marker-args-${i} ${"z".repeat(20_000)}` },
})),
},
]);
const fields = collectSessionSearchFields(session);
// The newest call (index 24, appended last) stays searchable along with the
// rest of the recent burst; the oldest call is the one dropped.
assert.ok(fields.some((field) => field.includes("marker-args-24")));
assert.ok(fields.some((field) => field.includes("marker-args-19")));
assert.ok(!fields.some((field) => field.includes("marker-args-0")));
assert.deepEqual(filterSessionHistory([session], "marker-args-24"), [session]);
// Retained call fields are output in chronological order after the
// newest-first collection is reversed back (call-10 before call-19).
const call10 = fields.findIndex((field) => field.includes("call-10"));
const call19 = fields.findIndex((field) => field.includes("call-19"));
assert.ok(call10 !== -1 && call19 !== -1 && call10 < call19);
});
test("later attachment labels stay searchable when the label list overflows the cap", () => {
// The joined labels of these attachments exceed the 2,000-character field
// cap; feeding labels individually keeps the later (newest) ones findable.
const session = createSession("a", "Deploy", [
{
role: "user",
content: "please summarize",
attachments: [
{ base64Data: "", mediaType: "text/plain", filename: `pad-${"a".repeat(2_100)}` },
{ base64Data: "", mediaType: "text/markdown", filename: "latest-run.log" },
],
},
]);
const fields = collectSessionSearchFields(session);
assert.ok(fields.some((field) => field.includes("latest-run.log")));
assert.deepEqual(filterSessionHistory([session], "latest-run"), [session]);
});
test("collectSessionSearchFields skips empty content and caps very long fields", () => {
const session = createSession("a", " ", [
{ role: "user", content: " " },
{ role: "assistant", content: "x".repeat(25_000) },
]);
const fields = collectSessionSearchFields(session);
assert.equal(fields.length, 1);
assert.equal(fields[0].length, 20_000);
});
test("filterSessionHistory matches CJK titles via the shared pinyin matcher", () => {
const sessions = [
createSession("a", "重启生产服务器", []),
createSession("b", "backup", []),
];
assert.deepEqual(filterSessionHistory(sessions, "chongqi"), [sessions[0]]);
assert.deepEqual(filterSessionHistory(sessions, "重启"), [sessions[0]]);
});
test("filterSessionHistory indexes the displayed fallback for untitled sessions", () => {
// An empty persisted title is displayed as the localized "Untitled" label;
// searching for that label must find the session instead of hiding it.
const sessions = [
createSession("a", "", [{ content: "nginx notes" }]),
createSession("b", "nginx restart", []),
];
// Without the fallback the blank title contributes nothing.
assert.deepEqual(filterSessionHistory(sessions, "untitled"), []);
assert.deepEqual(filterSessionHistory(sessions, "untitled", { untitledLabel: "Untitled" }), [sessions[0]]);
// Titled sessions are unaffected by the fallback label.
assert.deepEqual(filterSessionHistory(sessions, "restart"), [sessions[1]]);
// Localized (CJK) fallback labels match through title pinyin too.
assert.deepEqual(filterSessionHistory(sessions, "无标题", { untitledLabel: "无标题" }), [sessions[0]]);
assert.deepEqual(filterSessionHistory(sessions, "wbt", { untitledLabel: "无标题" }), [sessions[0]]);
const fields = collectSessionSearchFields(sessions[0], "Untitled");
assert.deepEqual(fields, ["Untitled", "nginx notes"]);
});
test("filterSessionHistory lets tokens span title pinyin and message text", () => {
const sessions = [
createSession("a", "重启服务器", [
{ role: "user", content: "restarted nginx without errors" },
]),
];
const combined = filterSessionHistory(sessions, "chongqi nginx");
assert.deepEqual(combined, [sessions[0]]);
// Message-only pinyin still stays out of the expensive fallback.
assert.deepEqual(filterSessionHistory(sessions, "chongqi"), [sessions[0]]);
assert.deepEqual(
filterSessionHistory(sessions, "failing nginx"),
[],
);
});
test("search field trimming stays inside the field and remaining session limits", () => {
const fieldLimited = createSession("a", "Ops", [
{ content: " ".repeat(20_000) + "outside-field" },
{ content: " recent-match " + " ".repeat(1_000_000) },
]);
assert.deepEqual(collectSessionSearchFields(fieldLimited), ["Ops", "recent-match"]);
assert.deepEqual(filterSessionHistory([fieldLimited], "recent-match"), [fieldLimited]);
assert.deepEqual(filterSessionHistory([fieldLimited], "outside-field"), []);
// Title + three full fields leave 3,997 characters for the oldest message.
// Trimming must not reach past that window to discover more content.
const sessionLimited = createSession("b", "Ops", [
{ content: " ".repeat(3_997) + "outside-session" },
...Array.from({ length: 3 }, () => ({ content: "x".repeat(20_000) })),
]);
assert.equal(collectSessionSearchFields(sessionLimited).join("").length, 60_003);
assert.deepEqual(filterSessionHistory([sessionLimited], "outside-session"), []);
});
test("filterSessionHistory keeps the pinyin fallback on titles only", () => {
const sessions = [
createSession("a", "Ops", [
{ role: "user", content: "重启生产服务器" },
]),
];
// Pinyin transliteration of long message content is skipped; literal CJK
// queries still match message text.
assert.deepEqual(filterSessionHistory(sessions, "重启"), [sessions[0]]);
assert.deepEqual(filterSessionHistory(sessions, "chongqi"), []);
});
test("collectSessionSearchFields bounds the total searchable text per session", () => {
const session = createSession("a", "big session", Array.from({ length: 40 }, (_, i) => ({
role: "user" as const,
content: `chunk-${i}-${"y".repeat(19_000)}`,
})));
const fields = collectSessionSearchFields(session);
const total = fields.reduce((sum, field) => sum + field.length, 0);
assert.ok(total <= 64_000, `total ${total} exceeds session cap`);
// Title is indexed first so it always lands in the bounded window.
assert.equal(fields[0], "big session");
});
test("collectSessionSearchFields keeps recent messages searchable under the cap", () => {
// Four 20,000-char messages exceed the 64 KB cap; the budget must be
// allocated newest-first so the most recent message remains searchable.
const session = createSession("a", "big session", [0, 1, 2, 3].map((i) => ({
role: "user" as const,
content: `${"x".repeat(19_900)} marker-${i}`,
})));
const fields = collectSessionSearchFields(session);
assert.equal(fields[0], "big session");
// Oldest messages are dropped, the newest is retained.
assert.ok(!fields.some((field) => field.includes("marker-0")));
assert.ok(fields.some((field) => field.includes("marker-3")));
// Output order stays chronological (marker-1 before marker-2).
const marker1 = fields.findIndex((field) => field.includes("marker-1"));
const marker2 = fields.findIndex((field) => field.includes("marker-2"));
assert.ok(marker1 !== -1 && marker2 !== -1 && marker1 < marker2);
});
test("collectSessionSearchFields truncates a field to the remaining session budget", () => {
// Four 19,000-char messages plus the title overflow the 64 KB cap; the last
// collected (oldest) message must be truncated to exactly the remaining
// budget instead of being discarded wholesale.
const session = createSession("a", "big session", [0, 1, 2, 3].map(() => ({
role: "user" as const,
content: "a".repeat(19_000),
})));
const fields = collectSessionSearchFields(session);
const total = fields.reduce((sum, field) => sum + field.length, 0);
assert.ok(total <= 64_000, `total ${total} exceeds session cap`);
assert.equal(total, 64_000);
// The oldest message fills the leftover budget with its freshest-available
// head instead of being dropped entirely.
assert.ok(fields.some((field) => field.length === 64_000 - (11 + 3 * 19_000)));
assert.ok(fields.some((field) => field.startsWith("aaa")));
});
test("huge tool call arguments are serialized within the field cap without full materialization", () => {
const blob = "x".repeat(5_000_000);
const sessions = [
createSession("a", "Ops", [
{
role: "assistant",
toolCalls: [{
// Retained calls can carry uncapped payloads (e.g. `sftp_write_file`
// content); serialization must stop at the field cap instead of
// stringifying the whole argument object on every keystroke.
name: "sftp_write_file",
id: "t1",
arguments: { path: "/tmp/report.md", content: blob },
}],
},
]),
];
const fields = collectSessionSearchFields(sessions[0]);
const serialized = fields.find((field) => field.includes("/tmp/report.md"));
assert.ok(serialized);
// The serialized field (plus the tool name) must land under the total
// per-session budget cap, i.e. no multi-megabyte field is ever emitted.
assert.ok(serialized!.length <= 2_000);
assert.ok(serialized!.includes("/tmp/report.md"));
// Short payloads still serialize fully and stay searchable.
const searchable = createSession("b", "Ops", [
{
role: "assistant",
toolCalls: [{
name: "shell",
id: "t2",
arguments: { command: "systemctl restart nginx" },
}],
},
]);
assert.ok(
collectSessionSearchFields(searchable).some((field) => field.includes('"command":"systemctl restart nginx"')),
);
});
test("wide tool call argument objects and long property names stay within the cap", () => {
// A wide object: thousands of own properties must be iterated lazily
// instead of materializing the full entries array on every keystroke.
const wide: Record<string, unknown> = {};
for (let i = 0; i < 5_000; i++) wide[`key-${i}`] = `value-${i}`;
// An unusually large property name must be capped before it is escaped.
const longKey = "k".repeat(50_000);
const sessions = [
createSession("a", "Ops", [
{
role: "assistant",
toolCalls: [{
name: "shell",
id: "t1",
arguments: { command: "systemctl restart nginx", [longKey]: "ignored", ...wide },
}],
},
]),
];
const fields = collectSessionSearchFields(sessions[0]);
const serialized = fields.find((field) => field.includes("systemctl restart nginx"));
assert.ok(serialized);
assert.ok(serialized!.length <= 2_000);
// The oversized key (and the wide object past the cap) never materialize:
// the long key is truncated before escaping.
assert.ok(!fields.some((field) => field.length > 2_000));
});
test("filterSessionHistory matches persisted status text", () => {
const sessions = [
createSession("a", "Untitled", [
{ role: "assistant", content: "", statusText: "generating diff for src/main.ts" },
]),
createSession("b", "Deploy", [
{ role: "user", content: "ship it" },
]),
];
assert.deepEqual(filterSessionHistory(sessions, "generating diff"), [sessions[0]]);
assert.deepEqual(filterSessionHistory(sessions, "src/main.ts"), [sessions[0]]);
assert.deepEqual(filterSessionHistory(sessions, "nothing here"), []);
const fields = collectSessionSearchFields(sessions[0]);
assert.ok(fields.some((field) => field.includes("generating diff for src/main.ts")));
});
// Regression: bounds enforcement must run once per scan, not on every cache
// miss. Evicting per miss makes each rebuild drop the index of a later
// session in the same scan, so any history longer than the entry cap
// cycle-evicts the whole cache and rebuilds every corpus on every keystroke.
test("filterSessionHistory keeps index-cache hits for histories larger than the cache cap", () => {
const sessionCount = 80;
const sessions = Array.from({ length: sessionCount }, (_, i) =>
createSession(`s${i}`, `session ${i}`, [{ content: `body ${i} deploy` }]));
// Observe corpus (re)builds: a cache hit serves the prepared haystack
// without touching `messages`, while a rebuild collects the fields again.
const readSessions = new Set<string>();
for (const session of sessions) {
const messages = session.messages;
Object.defineProperty(session, "messages", {
get() {
readSessions.add(session.id);
return messages;
},
configurable: true,
});
}
filterSessionHistory(sessions, "deploy");
readSessions.clear();
const second = filterSessionHistory(sessions, "deploy");
// End-of-scan eviction only prunes the per-scan overshoot: the indexes of
// every session retained by the last prune are cache hits. Per-miss
// eviction would have rebuilt all 80 sessions here.
const rebuilt = readSessions.size;
assert.ok(rebuilt <= sessionCount - 64, `expected no cache thrash, rebuilt ${rebuilt}`);
assert.deepEqual(second, sessions);
// Eviction must retain the FRONT of the ranked list (the sessions the next
// scan visits first) and rebuild only the oldest tail above the entry cap —
// evicting the Map's first key would drop the head and thrash it instead.
const rebuiltIds = [...readSessions].sort();
assert.deepEqual(
rebuiltIds,
sessions.slice(-(sessionCount - 64)).map((s) => s.id).sort(),
);
});
// Regression: hard-rejecting NEW entries once the entry cap is saturated by
// live sessions froze the cache's membership. New sessions are prepended to
// the ranked list, so every subsequently created conversation (and its
// immutable streaming updates) would rebuild its index on every keystroke
// while stale lower-ranked entries stayed cached — and the end-of-scan prune
// could not correct it because the cache was already within both bounds.
test("filterSessionHistory admits a newly created session after the entry cap is saturated", () => {
const initial = Array.from({ length: 64 }, (_, i) =>
createSession(`s${i}`, `session ${i}`, [{ content: `body ${i} deploy` }]));
filterSessionHistory(initial, "deploy");
// A newly created session is prepended: it ranks above every cached entry.
const created = createSession("fresh", "fresh session", [{ content: "body fresh deploy" }]);
const sessions = [created, ...initial];
// Observe corpus (re)builds as in the thrash regression test above.
const readSessions = new Set<string>();
for (const session of sessions) {
const messages = session.messages;
Object.defineProperty(session, "messages", {
get() {
readSessions.add(session.id);
return messages;
},
configurable: true,
});
}
// First scan over the new list: "fresh" misses (its index was never cached)
// and must be admitted; the prune evicts the lowest-ranked tail instead.
filterSessionHistory(sessions, "deploy");
readSessions.clear();
const third = filterSessionHistory(sessions, "deploy");
assert.deepEqual(third, sessions);
// The fresh, highest-ranked session stays cached; only the evicted tail
// above the entry cap is rebuilt.
assert.ok(!readSessions.has("fresh"), `expected fresh session to be cached, rebuilt: ${[...readSessions]}`);
assert.deepEqual([...readSessions], ["s63"]);
});
// Regression: the entry cap must be enforced at cache-admission time, not only
// by the end-of-scan prune. When misses are small (empty/short searchable
// content), the char budget consumes almost no headroom per insert, so
// overshooting the entry cap mid-scan and letting the prune trim it made a
// single keystroke over a very long history build and transiently retain one
// index + Map entry per live session. Admission must instead evict the stale
// lowest-ranked untouched tail to make room (the same entry the prune would
// otherwise drop moments later), keeping the cache within the entry cap
// throughout the scan.
test("filterSessionHistory evicts the stale tail at admission instead of overshooting the entry cap", () => {
const initial = Array.from({ length: 64 }, (_, i) =>
createSession(`s${i}`, `session ${i}`, [{ content: `body ${i} deploy` }]));
filterSessionHistory(initial, "deploy");
// A newly created session is prepended: it ranks above every cached entry.
const created = createSession("fresh", "fresh session", [{ content: "body fresh deploy" }]);
const sessions = [created, ...initial];
// Observe corpus (re)builds as in the regression tests above.
const readSessions = new Set<string>();
for (const session of sessions) {
const messages = session.messages;
Object.defineProperty(session, "messages", {
get() {
readSessions.add(session.id);
return messages;
},
configurable: true,
});
}
// The single scan over the prepended list must admit "fresh" by evicting
// the stalest cached tail entry ("s63") during the scan: under the old
// overshoot-then-prune behavior "s63" would stay cached until the prune,
// so it would not be rebuilt in this same scan at all. Rebuilding it here
// is the (bounded, one-entry) cost of never exceeding the entry cap
// mid-scan.
filterSessionHistory(sessions, "deploy");
assert.deepEqual([...readSessions].sort(), ["fresh", "s63"]);
// Recovery is unchanged: the fresh session stays cached, and only the
// tail displaced below the entry cap rebuilds on the next keystroke.
readSessions.clear();
const next = filterSessionHistory(sessions, "deploy");
assert.deepEqual(next, sessions);
assert.ok(!readSessions.has("fresh"), `expected fresh session to be cached, rebuilt: ${[...readSessions]}`);
assert.deepEqual([...readSessions], ["s63"]);
});
// Regression: a hard-reject at the CHAR-budget gate froze cache membership
// just like the earlier entry-cap bug. With ~20 large sessions (each near the
// 64,000-character corpus cap) already consuming nearly the 4M-character
// cache budget, a newly prepended high-ranked session was rejected without
// evicting anything, and the end-of-scan prune — seeing a cache within both
// bounds — kept the stale lower-ranked entries: the new large conversation
// then rebuilt its index on every search keystroke.
test("filterSessionHistory admits a newly created session after the char budget is saturated", () => {
// Corpus exactly 64,000 chars (title + 4 x 15,999-char messages), each
// index retaining ~192K chars (normalized fields + haystack + compact
// haystack): 20 fill ~3.84M of the 4M budget; a 21st would overflow it.
const createLargeSession = (id: string) =>
createSession(id, "sess", [0, 1, 2, 3].map(() => ({ content: "x".repeat(15_999) })));
const initial = Array.from({ length: 20 }, (_, i) => createLargeSession(`s${i}`));
filterSessionHistory(initial, "x");
// A newly created large session is prepended: it ranks above every cached
// entry, and its index can only fit if the stale tail is evicted.
const created = createLargeSession("fresh");
const sessions = [created, ...initial];
// Observe corpus (re)builds as in the regression tests above.
const readSessions = new Set<string>();
for (const session of sessions) {
const messages = session.messages;
Object.defineProperty(session, "messages", {
get() {
readSessions.add(session.id);
return messages;
},
configurable: true,
});
}
// First scan over the new list: "fresh" misses (its index was never cached)
// and must be admitted by evicting the stalest cached tail entry.
filterSessionHistory(sessions, "x");
readSessions.clear();
const second = filterSessionHistory(sessions, "x");
assert.deepEqual(second, sessions);
// The fresh, highest-ranked session stays cached; only the evicted stalest
// tail session (which no longer fits the char budget) rebuilds.
assert.ok(!readSessions.has("fresh"), `expected fresh session to be cached, rebuilt: ${[...readSessions]}`);
assert.deepEqual([...readSessions], ["s19"]);
// And the steady state holds on subsequent scans too.
readSessions.clear();
filterSessionHistory(sessions, "x");
assert.deepEqual([...readSessions], ["s19"]);
});
test("filterSessionHistory blank-query path prunes cache entries for sessions that left the list", () => {
const kept = createSession("keep", "nginx restart", [{ content: "reload nginx" }]);
const removed = createSession("remove", "disk cleanup", [{ content: "rm old logs" }]);
filterSessionHistory([kept, removed], "nginx");
// Deleting a session then clearing the query goes through the blank path,
// which must prune the cache against the remaining list.
filterSessionHistory([kept], "");
// Subsequent searches are unaffected: the surviving session stays cached
// (its cached index is still there) and matching still works.
assert.deepEqual(filterSessionHistory([kept], "nginx"), [kept]);
assert.deepEqual(filterSessionHistory([kept], "nothing here"), []);
// The removed session's index is gone entirely (no match via a stale cache).
assert.deepEqual(filterSessionHistory([kept, removed], "rm old logs"), [removed]);
});
test("clearSessionHistorySearchCache releases cached indexes without breaking later searches", () => {
const session = createSession("a", "nginx restart", [{ content: "reload nginx" }]);
filterSessionHistory([session], "nginx");
clearSessionHistorySearchCache();
// After the clear, the next search rebuilds from scratch and still matches.
assert.deepEqual(filterSessionHistory([session], "nginx"), [session]);
assert.deepEqual(filterSessionHistory([session], ""), [session]);
});
test("independent search instances own isolated caches", () => {
const first = createSessionHistorySearch();
const second = createSessionHistorySearch();
const session = createSession("iso-1", "isolated cache", [{ content: "lighthouse" }]);
assert.deepEqual(first.filterSessionHistory([session], "lighthouse"), [session]);
// Clearing one instance must not disturb the other's state: both stay fully
// functional afterwards (each store is private to its instance).
second.clearSessionHistorySearchCache();
assert.deepEqual(second.filterSessionHistory([session], "lighthouse"), [session]);
assert.deepEqual(first.filterSessionHistory([session], "lighthouse"), [session]);
});
test("filterSessionHistory caps the query and matches on its capped prefix", () => {
const session = createSession("a", "nginx restart", [{ content: "reload nginx" }]);
// A pasted multi-megabyte query must not be re-normalized per session: it is
// capped via truncateQueryForMatch (MAX_SEARCH_QUERY_LENGTH) at this scan's
// input and normalized once per scan, so matching falls back to the capped
// prefix (which still spans tokens here).
const longQuery = `${"nginx ".repeat(200_000)}zzz-impossible-tail`;
assert.deepEqual(filterSessionHistory([session], longQuery), [session]);
// The uncapped-looking but normal query still matches exactly.
assert.deepEqual(filterSessionHistory([session], "zzz-impossible-tail"), []);
});
// Keep the author's long-title rescan protection local to history. Shared
// search must still match complete fields, including later joined fields.
test("history pinyin cache preserves long titles across same-order rescans", () => {
resetPinyinCacheForTests();
const sessions = Array.from({ length: 17 }, (_, index) =>
createSession(`rescan-${index}`, `标题${index}${"重启".repeat(10_000)}`, []));
for (let pass = 0; pass < 2; pass += 1) {
assert.deepEqual(filterSessionHistory(sessions, "unmatched-latin"), []);
}
const stats = getPinyinCacheStatsForTests();
assert.equal(stats.size, sessions.length);
assert.ok(stats.chars <= PINYIN_CACHE_MAX_CHARS);
resetPinyinCacheForTests();
});
test("history alone bounds title pinyin while retaining literal title and body matches", () => {
const sessions = [
createSession("pinyin-head", `服务器${"a".repeat(256)}`, [{ content: "nginx" }]),
createSession("pinyin-tail", `${"a".repeat(256)}服务器`, [{ content: "nginx" }]),
];
assert.deepEqual(filterSessionHistory(sessions, "fuwuqi nginx"), [sessions[0]]);
assert.deepEqual(filterSessionHistory(sessions, "服务器 nginx"), sessions);
});
+382
View File
@@ -0,0 +1,382 @@
import {
getSessionSearchTitle,
matchesSessionSearchIndex,
prepareSessionSearchIndex,
type SessionHistorySearchTarget,
type SessionSearchIndex,
} from '../../domain/sessionHistorySearch';
import { prepareSearchQuery, truncateQueryForMatch } from '../../lib/searchMatcher';
/**
* Memory bounds for the per-session search index cache. `filterSessionHistory`
* runs on every search keystroke, but `pruneSessionsForStorage` only bounds the
* persisted copy — `useAIState` retains hundreds of live sessions in memory,
* and rebuilding plus re-normalizing each session's (up to 64,000-character)
* haystack per keystroke scans tens of megabytes and freezes the renderer.
*
* The raw collected fields are NOT retained (only the prepared search fields),
* and
* the cache holds at most `MAX_SESSION_SEARCH_INDEX_CACHE_ENTRIES` entries
* within a `MAX_SESSION_SEARCH_INDEX_CACHE_CHARS` character budget: an
* unbounded cache would let one entry per live session (several normalized
* copies of each session's searchable corpus) permanently pin hundreds of
* megabytes in a long-running process. Both bounds hold after every scan's
* prune and are enforced eagerly on every insert (see
* `canCacheSessionSearchIndex`): when either bound lacks headroom, the gate
* first evicts the cache's stale, lowest-ranked untouched tail (entries the
* in-flight scan has not yet visited — they rank below every visited session,
* so this can never cycle-evict an entry the same scan still needs); if no
* such entry remains, the insert is skipped — the miss is then rebuilt on the
* next search, exactly what the end-of-scan prune would have done to it.
* Skipping is only reachable for misses ranked below every cached entry, so
* cache membership never freezes for freshly prepended high-ranked sessions:
* at their (earliest) visit nothing is touched yet and the stale tail yields.
*/
const MAX_SESSION_SEARCH_INDEX_CACHE_ENTRIES = 64;
const MAX_SESSION_SEARCH_INDEX_CACHE_CHARS = 4_000_000;
/**
* Mutable cache state owned by exactly one search client (one mounted history
* drawer). Keeping this out of module scope means several concurrently mounted
* drawers (one per terminal tab) never share a cache: each drawer's prune only
* drops entries no longer in that drawer's scoped session list, and one
* drawer's unmount releases only its own store (previously a shared
* module-level cache was discarded for every drawer on any unmount and pruned
* against whichever drawer scanned last).
*/
export type SessionHistorySearchStore = {
/**
* Sessions in state are updated immutably (`{ ...s, messages: next }`), so
* object identity is a reliable cache key: each session's normalized/compact
* haystacks are built once and reused until that session's object changes.
* A changed display title (e.g. a localized fallback switch) forces a rebuild.
*/
cache: Map<SessionHistorySearchTarget, SessionSearchIndex>;
/**
* Session keys the in-flight `filterSessionHistory` scan has already visited
* (re-appended cache hits or inserted misses). `canCacheSessionSearchIndex`
* never evicts a touched key: it either ranks above the current insert (it was
* visited earlier in this newest-first scan) or it is the current session
* itself (whose stale entry a rebuild is about to replace), and evicting either
* would cycle-evict entries the same scan still needs.
*/
scanTouched: Set<SessionHistorySearchTarget>;
/**
* Running total of the cache's retained characters. Maintained at every
* mutation site so the eager insert gate in `canCacheSessionSearchIndex` stays
* O(1) per miss instead of re-summing the whole cache on every keystroke
* (mid-scan overshoot of the entry cap would otherwise make that sum O(n²)
* across a scan).
*/
cacheChars: number;
};
/** Retained characters of an index (the prepared haystacks; raw fields are dropped). */
function indexRetainedChars(index: SessionSearchIndex): number {
// `normalizedFields`, `haystack` and `haystackCompact` are all retained
// (`haystack` is `normalizedFields` joined, `haystackCompact` is derived
// from `haystack`), so their combined length approximates the cache's
// per-entry memory cost. Counting only the joined haystacks would
// undercount by roughly a full copy of the corpus and let the char budget
// retain ~50% more than advertised.
let total = index.prepared.haystack.length + index.prepared.haystackCompact.length;
for (const field of index.prepared.normalizedFields) total += field.length;
return total;
}
/**
* Whether a freshly built index may be inserted into the cache without
* pushing either bound past its cap. Both bounds must fit eagerly — the prune
* that re-enforces them runs once per scan, so admitting every prepared index
* would let a single search over hundreds/thousands of uncached sessions
* transiently pin the whole corpus (several normalized haystacks per session)
* or hold one index and Map entry per live session — for empty/short sessions
* the char budget consumes little room, so the entry cap would be enforced
* only after the complete scan — before pruning, briefly allocating memory
* for the whole scanned list.
*
* Rather than rejecting inserts outright (which froze cache membership the
* same way a hard entry-count gate would), lack of headroom in either bound
* first evicts the cache's stale tail — the lowest-ranked entries this scan
* has not yet visited. Because the scan visits the ranked list newest-first,
* every untouched entry ranks below the current insert, so evicting any of
* them never drops an index a later visit of the same scan still needs (no
* cycle thrash) and always preserves the highest-ranked cached prefix. Only
* if no untouched entry remains does the insert get skipped — that is
* reachable only for misses ranked below every cached entry, which the
* end-of-scan prune would have evicted from the Map's back anyway, so the
* skip changes no steady-state membership: the index is simply rebuilt on
* the next search.
*
* The bounds check is applied to NEW entries only: cache hits merely
* re-append an existing entry (no growth), and a rebuild replaces the same
* session's entry (no entry-count growth; the char delta against the
* replaced index is checked).
*/
function canCacheSessionSearchIndex(
store: SessionHistorySearchStore,
previous: SessionSearchIndex | undefined,
index: SessionSearchIndex,
): boolean {
const deltaChars = indexRetainedChars(index) - (previous ? indexRetainedChars(previous) : 0);
// A rebuild replaces its own entry (no entry-count growth); a new entry
// grows the Map by one.
const growsEntries = !previous;
if (
deltaChars <= 0
&& (!growsEntries || store.cache.size < MAX_SESSION_SEARCH_INDEX_CACHE_ENTRIES)
) {
return true;
}
if (
store.cacheChars + deltaChars <= MAX_SESSION_SEARCH_INDEX_CACHE_CHARS
&& (!growsEntries || store.cache.size < MAX_SESSION_SEARCH_INDEX_CACHE_ENTRIES)
) {
return true;
}
// Not enough headroom: evict the lowest-ranked untouched entries. Mid-scan
// the Map's order is old-insertion-order front to back with this scan's
// touched keys re-appended at the end, so scanning the keys back-to-front
// and skipping touched ones reaches the untouched (stalest, lowest-ranked)
// entries first.
let entriesToFree = growsEntries
? store.cache.size + 1 - MAX_SESSION_SEARCH_INDEX_CACHE_ENTRIES
: 0;
const keysInInsertionOrder = [...store.cache.keys()];
for (let i = keysInInsertionOrder.length - 1; i >= 0; i--) {
if (entriesToFree <= 0
&& store.cacheChars + deltaChars <= MAX_SESSION_SEARCH_INDEX_CACHE_CHARS) {
break;
}
const key = keysInInsertionOrder[i];
if (store.scanTouched.has(key)) continue;
const evicted = store.cache.get(key);
if (!evicted) continue;
store.cacheChars -= indexRetainedChars(evicted);
store.cache.delete(key);
if (growsEntries) entriesToFree--;
}
return store.cacheChars + deltaChars <= MAX_SESSION_SEARCH_INDEX_CACHE_CHARS
&& (!growsEntries || store.cache.size < MAX_SESSION_SEARCH_INDEX_CACHE_ENTRIES);
}
function getSessionSearchIndex(
store: SessionHistorySearchStore,
session: SessionHistorySearchTarget,
untitledLabel: string,
): SessionSearchIndex {
const indexedTitle = getSessionSearchTitle(session, untitledLabel);
const cached = store.cache.get(session);
if (cached && cached.displayTitle === indexedTitle) {
// Re-append on every visit so the cache's insertion order mirrors the
// latest scan's visitation order (the ranked, newest-first session list);
// `pruneSessionSearchIndexCache` relies on that order when evicting.
store.cache.delete(session);
store.cache.set(session, cached);
store.scanTouched.add(session);
return cached;
}
const index = prepareSessionSearchIndex(session, untitledLabel);
// Mark the session touched before the gate so the gate's admission eviction
// can never pick this session's own (stale) entry — the rebuild below is
// about to replace it, and evicting it would corrupt the running char total.
store.scanTouched.add(session);
if (canCacheSessionSearchIndex(store, cached, index)) {
store.cache.set(session, index);
// Keep the eager gate's running total in sync (also covers the
// display-title-change rebuild, which replaces an existing entry).
store.cacheChars += indexRetainedChars(index) - (cached ? indexRetainedChars(cached) : 0);
}
return index;
}
/**
* Eviction runs once per full scan (at the end of `filterSessionHistory`), not
* on every cache miss: evicting during the scan would make each miss drop the
* index of a later session in the same scan, so any history longer than the
* entry cap would cycle-evict the whole cache and rebuild every session's
* haystack on every keystroke. Deferring eviction keeps every retained entry
* usable within a scan while still bounding the cache — first by dropping
* indexes for sessions no longer in the scanned list, then by evicting the
* lowest-ranked entries (the oldest tail of the newest-first ranked list)
* until both bounds hold, preserving the front of the ranked list.
*
* In practice the eviction loop is cheap: both bounds are enforced eagerly
* on every insert (see `canCacheSessionSearchIndex`), so the cache never
* overshoots the entry cap or char budget mid-scan — the prune only drops
* indexes for sessions that left the scanned list and re-syncs the running
* char total.
*/
function pruneSessionSearchIndexCache(
store: SessionHistorySearchStore,
sessions: readonly SessionHistorySearchTarget[],
): void {
const live = new Set<SessionHistorySearchTarget>(sessions);
for (const key of [...store.cache.keys()]) {
if (!live.has(key)) store.cache.delete(key);
}
let retained = 0;
for (const cachedIndex of store.cache.values()) {
retained += indexRetainedChars(cachedIndex);
}
// Re-sync the eager gate's running total: the sum above is authoritative
// here (once per scan), so any drift accumulated mid-scan is corrected.
store.cacheChars = retained;
// Evict from the END of the Map until both bounds hold. Every scan visits
// the full ranked session list newest-first and `getSessionSearchIndex`
// re-appends each visited entry, so after a scan the Map's insertion order
// mirrors visitation order: the highest-ranked (newest) sessions sit at the
// FRONT and the oldest tail at the BACK. Evicting the front (classic LRU on
// Map order) would therefore drop the newest, highest-ranked sessions and
// retain the stalest tail — every subsequent keystroke would rebuild every
// session above the cache cap. Evicting the back instead preserves the
// front of the ranked list. The single remaining entry is always kept, even
// if it alone exceeds the char budget (a single entry is bounded by ~192K
// chars by the collector caps).
// Snapshot the keys once: prune only deletes (never inserts), so popping
// from this array visits keys exactly in Map order, back to front.
const keysInInsertionOrder = [...store.cache.keys()];
while (
store.cache.size > MAX_SESSION_SEARCH_INDEX_CACHE_ENTRIES
|| retained > MAX_SESSION_SEARCH_INDEX_CACHE_CHARS
) {
// A single entry can never push the cache past the char budget (it is
// bounded by ~192K chars by the collector caps), so keep the last entry.
if (store.cache.size <= 1) break;
const evictKey = keysInInsertionOrder.pop();
if (evictKey === undefined) break;
const evicted = store.cache.get(evictKey);
if (evicted) retained -= indexRetainedChars(evicted);
store.cache.delete(evictKey);
}
store.cacheChars = retained;
}
/**
* Release every cached index and touched-session reference for one store
* (invoked for the drawer's unmount lifecycle via the per-drawer search
* instance). Cache keys are the session objects themselves, so clearing fully
* drops all strong session references — the retained haystack characters
* become garbage together with their sessions.
*/
function clearSessionHistorySearchStore(store: SessionHistorySearchStore): void {
store.cache.clear();
store.scanTouched.clear();
store.cacheChars = 0;
}
export type SessionHistorySearchOptions = {
/**
* Localized fallback label rendered by the drawer for sessions with an
* empty persisted title (e.g. `t('ai.chat.untitled')`); indexed as the
* displayed title so searching for the visible label finds the session.
*/
untitledLabel?: string;
};
function filterSessionHistoryStore<T extends SessionHistorySearchTarget>(
store: SessionHistorySearchStore,
sessions: readonly T[],
query: string,
options: SessionHistorySearchOptions = {},
): T[] {
const trimmed = query.trim();
if (!trimmed) {
// The blank-query path skips the scan entirely; release any stale
// scan-touched state so it cannot pin visited sessions (and their
// message/attachment memory) while the drawer is open without a query.
// Normally this set is already empty — the nonblank scan clears it in its
// `finally` — but clearing here too keeps every entry path leak-free.
store.scanTouched.clear();
// Cache keys are the session objects themselves, so an entry for a
// deleted/replaced session strongly retains that session (and its full
// message/attachment payloads). Prune against the current list here too:
// the blank-query path may run while the drawer stays open after
// deletions, and a later nonblank search that would prune otherwise may
// never happen (e.g. the user just closes the drawer).
pruneSessionSearchIndexCache(store, sessions);
return [...sessions];
}
const untitledLabel = options.untitledLabel ?? '';
// Bound and prepare the query once per scan: a pasted-in multi-megabyte
// query would otherwise be re-normalized (NFKC), tokenized and compacted
// inside `matchesSessionSearchIndex` for every session, freezing the
// renderer. The cap (`truncateQueryForMatch`, `MAX_SEARCH_QUERY_LENGTH`) is
// applied here at this scan's input — not inside the shared `prepareSearchQuery`
// — so the meaning of over-limit queries stays local to session history and
// the normalized forms are computed a single time, letting the per-session
// work below only do `includes` scans over the cached haystacks.
const preparedQuery = prepareSearchQuery(truncateQueryForMatch(trimmed));
// A new scan starts with no visited sessions: the touched set records only
// this scan's visits so `canCacheSessionSearchIndex` can never mistake a
// previous scan's keys for keys the current scan still needs.
store.scanTouched.clear();
try {
// Drop entries still held by sessions that left the scan list (e.g. the
// history shrank or session objects changed since the previous scan). Those
// entries are never visited by this scan — the end-of-scan prune would
// remove them too — but dropping them up front keeps their characters
// available to this scan's inserts (via the eager char gate). Cheap: one
// Set plus one pass over the cached keys.
const currentLive = new Set<SessionHistorySearchTarget>(sessions);
for (const key of [...store.cache.keys()]) {
if (!currentLive.has(key)) {
const removed = store.cache.get(key);
if (removed) store.cacheChars -= indexRetainedChars(removed);
store.cache.delete(key);
}
}
// Scan all sessions first (a miss admits its index into the cache —
// evicting this scan's untouched stale tail whenever either bound lacks
// headroom, and skipping the insert only when no untouched entry remains),
// then prune entries that fell out of the scan list at the end of the
// scan.
const result = sessions.filter((session) => {
const index = getSessionSearchIndex(store, session, untitledLabel);
return matchesSessionSearchIndex(preparedQuery, index);
});
// Prune against the full scanned list (not the filtered result): sessions
// that merely did not match this query must keep their cached indexes.
pruneSessionSearchIndexCache(store, sessions);
return result;
} finally {
// The touched set only guards against cycle-eviction within the current
// scan; once the scan ends (even if it throws), release its strong
// references to session objects so deleted/replaced sessions and their
// large message/attachment payloads aren't retained until the next
// nonblank search.
store.scanTouched.clear();
}
}
/**
* One stateful search instance per consumer (one mounted session history
* drawer). The cache lives in the returned instance's store instead of module
* scope, so concurrently mounted drawers (one per terminal tab) never share
* state: an unmount of one drawer clears only that drawer's indexes, and each
* drawer's prune is scoped to its own session list.
*/
export type SessionHistorySearch = {
filterSessionHistory: <T extends SessionHistorySearchTarget>(
sessions: readonly T[],
query: string,
options?: SessionHistorySearchOptions,
) => T[];
clearSessionHistorySearchCache: () => void;
};
export function createSessionHistorySearch(): SessionHistorySearch {
const store: SessionHistorySearchStore = {
cache: new Map(),
scanTouched: new Set(),
cacheChars: 0,
};
return {
filterSessionHistory(sessions, query, options = {}) {
return filterSessionHistoryStore(store, sessions, query, options);
},
clearSessionHistorySearchCache() {
clearSessionHistorySearchStore(store);
},
};
}
@@ -209,3 +209,13 @@ test("restore terminal cwd setting participates in cross-window settings sync",
assert.match(ipcSyncSource, /setRestoreTerminalCwdState/);
assert.match(ipcSyncSource, /key === STORAGE_KEY_RESTORE_TERMINAL_CWD/);
});
test("editor route keeps IPC/storage synchronization while disabling system effects", () => {
const source = readFileSync(new URL("../../index.tsx", import.meta.url), "utf8");
const expression = source.match(/const settingsOptions =\s*([^;]+);/s)?.[1];
assert.ok(expression, "renderer settings options must be explicit");
const resolveOptions = new Function("isPeerSessionWindow", "isEditorWindow", `return (${expression});`);
assert.deepEqual(resolveOptions(false, true), { enableSystemEffects: false });
assert.deepEqual(resolveOptions(true, false), { enableSettingsSync: false, enableSystemEffects: false });
assert.equal(resolveOptions(false, false), undefined);
});
@@ -9,9 +9,14 @@ import {
resolveReadableForegroundForHsl,
resolveThemeAccentForeground,
migrateIncomingTerminalFontId,
migrateMaterializedAutoCloseOnExitDefault,
} from "./settingsStateDefaults.ts";
import { TERMINAL_FONT_AUTO } from "../../infrastructure/config/fonts.ts";
import { STORAGE_KEY_TERM_FONT_FAMILY } from "../../infrastructure/config/storageKeys.ts";
import {
STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED,
STORAGE_KEY_TERM_FONT_FAMILY,
STORAGE_KEY_TERM_SETTINGS,
} from "../../infrastructure/config/storageKeys.ts";
import type { UiThemeTokens } from "../../infrastructure/config/uiThemes.ts";
function installMemoryLocalStorage(): Map<string, string> {
@@ -140,3 +145,72 @@ test("app surface theme vars isolate non-terminal pages from active terminal chr
"--ring": "270 70% 45%",
});
});
test("migrateMaterializedAutoCloseOnExitDefault resets the materialized default-on once", () => {
const store = installMemoryLocalStorage();
try {
store.set(
STORAGE_KEY_TERM_SETTINGS,
JSON.stringify({ scrollback: 3000, autoCloseOnExit: true }),
);
migrateMaterializedAutoCloseOnExitDefault();
const migrated = JSON.parse(store.get(STORAGE_KEY_TERM_SETTINGS)!);
assert.equal(migrated.autoCloseOnExit, false);
// Other saved settings pass through untouched.
assert.equal(migrated.scrollback, 3000);
assert.equal(store.get(STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED), "true");
} finally {
delete (globalThis as { localStorage?: unknown }).localStorage;
}
});
test("migrateMaterializedAutoCloseOnExitDefault runs once and respects later explicit edits", () => {
const store = installMemoryLocalStorage();
try {
store.set(
STORAGE_KEY_TERM_SETTINGS,
JSON.stringify({ autoCloseOnExit: true }),
);
migrateMaterializedAutoCloseOnExitDefault();
assert.equal(
JSON.parse(store.get(STORAGE_KEY_TERM_SETTINGS)!).autoCloseOnExit,
false,
);
// The user re-enables after upgrading: the migration must not undo it again.
store.set(
STORAGE_KEY_TERM_SETTINGS,
JSON.stringify({ autoCloseOnExit: true }),
);
migrateMaterializedAutoCloseOnExitDefault();
assert.equal(
JSON.parse(store.get(STORAGE_KEY_TERM_SETTINGS)!).autoCloseOnExit,
true,
);
} finally {
delete (globalThis as { localStorage?: unknown }).localStorage;
}
});
test("migrateMaterializedAutoCloseOnExitDefault leaves fresh installs and explicit-off alone", () => {
const store = installMemoryLocalStorage();
try {
// Fresh install: no saved settings at all.
migrateMaterializedAutoCloseOnExitDefault();
assert.equal(store.has(STORAGE_KEY_TERM_SETTINGS), false);
assert.equal(store.get(STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED), "true");
// Explicit-off (or any settings blob without the key) is never rewritten.
delete (globalThis as { localStorage?: unknown }).localStorage;
const store2 = installMemoryLocalStorage();
store2.set(
STORAGE_KEY_TERM_SETTINGS,
JSON.stringify({ scrollback: 500 }),
);
migrateMaterializedAutoCloseOnExitDefault();
assert.deepEqual(JSON.parse(store2.get(STORAGE_KEY_TERM_SETTINGS)!), {
scrollback: 500,
});
} finally {
delete (globalThis as { localStorage?: unknown }).localStorage;
}
});
+29 -1
View File
@@ -1,5 +1,9 @@
import type { HotkeyScheme, SessionLogFormat, TerminalSettings } from '../../domain/models';
import { STORAGE_KEY_TERM_FONT_FAMILY } from '../../infrastructure/config/storageKeys';
import {
STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED,
STORAGE_KEY_TERM_FONT_FAMILY,
STORAGE_KEY_TERM_SETTINGS,
} from '../../infrastructure/config/storageKeys';
import {
isDeprecatedPrimaryFontId,
TERMINAL_FONT_AUTO,
@@ -63,6 +67,30 @@ export function migrateIncomingTerminalFontId(raw: string | null | undefined): s
}
return raw;
}
/**
* One-time migration for the autoCloseOnExit default flip (tabs now survive
* session exits by default; the old default-on silently deleted tabs whenever
* a bastion or idle-harvesting server reported a clean exit). Builds before
* the flip shipped autoCloseOnExit: true as the default and materialized it
* into STORAGE_KEY_TERM_SETTINGS on any settings write, so most upgraders
* carry a value they never chose. Leaving it in place would make the new
* default unreachable for them. Reset that materialized `true` exactly once
* and persist the marker; afterwards every stored value is treated as the
* user's own choice and is respected. An inbound cloud or backup payload that
* still carries the pre-migration materialized true (no
* autoCloseOnExitDefaultMigrated flag) is rewritten to false so sync cannot
* resurrect auto-close. The cost: a user who deliberately enabled auto-close
* before this release must re-enable it once.
*/
export function migrateMaterializedAutoCloseOnExitDefault(): void {
if (localStorageAdapter.readBoolean(STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED)) return;
const stored = localStorageAdapter.read<Partial<TerminalSettings>>(STORAGE_KEY_TERM_SETTINGS);
if (stored?.autoCloseOnExit === true) {
localStorageAdapter.write(STORAGE_KEY_TERM_SETTINGS, { ...stored, autoCloseOnExit: false });
}
localStorageAdapter.writeBoolean(STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED, true);
}
// Auto-detect default hotkey scheme based on platform
export const DEFAULT_HOTKEY_SCHEME: HotkeyScheme =
typeof navigator !== 'undefined' && /Mac|iPhone|iPad|iPod/i.test(navigator.platform)
@@ -1,7 +1,7 @@
import test from "node:test";
import assert from "node:assert/strict";
import { UploadController } from "../../../lib/uploadService";
import { UploadController, uploadEntriesDirect } from "../../../lib/uploadService";
import {
cancelExternalUploadRuntime,
getExternalUploadController,
@@ -37,3 +37,64 @@ test("external upload controls survive their originating panel unmount", async (
assert.equal(getExternalUploadController("folder-1"), undefined);
assert.equal(getExternalUploadController("child-1"), undefined);
});
for (const selectedFile of ["a.bin", "c.bin"]) {
test(`cancelling independent ${selectedFile} preserves its active and queued upload siblings`, async (t) => {
resetExternalUploadRuntimeForTests();
t.after(resetExternalUploadRuntimeForTests);
const controller = new UploadController();
const cancelled: string[] = [];
const stagedCancelled: string[] = [];
const ids = new Map<string, string>();
const pending = new Map<string, (cancelled?: boolean) => void>();
const started: string[] = [];
const upload = uploadEntriesDirect(
["a.bin", "b.bin", "c.bin"].map((name) => ({
file: null, relativePath: name, isDirectory: false, localPath: `/local/${name}`, size: 8,
})),
{
targetPath: "/remote", sftpId: "fixture", isLocal: false,
fileTransferConcurrency: 2, joinPath: (base, name) => `${base}/${name}`,
callbacks: {
onTaskCreated: (task) => {
ids.set(task.fileName, task.id);
registerExternalUploadController(task.id, controller, "file");
},
},
bridge: {
mkdirSftp: async () => {},
cancelStagedUploadFile: async (id) => { stagedCancelled.push(id); },
cancelTransfer: async (id) => { cancelled.push(id); pending.get(id)?.(true); },
startStreamTransfer: ({ transferId, sourcePath }) => new Promise((resolve) => {
started.push(sourcePath);
pending.set(transferId, (cancelled = false) => resolve({ transferId, cancelled }));
}),
},
},
controller,
);
t.after(async () => {
await controller.cancel();
for (const finish of pending.values()) finish(true);
await upload;
});
await new Promise(setImmediate);
assert.deepEqual(started, ["/local/a.bin", "/local/b.bin"]);
const selectedId = ids.get(selectedFile)!;
await cancelExternalUploadRuntime(selectedId);
assert.deepEqual(cancelled, [selectedId]);
assert.deepEqual(stagedCancelled, [selectedId]);
assert.equal(controller.isCancelled(), false);
await new Promise(setImmediate);
assert.deepEqual(started, selectedFile === "a.bin"
? ["/local/a.bin", "/local/b.bin", "/local/c.bin"]
: ["/local/a.bin", "/local/b.bin"]);
for (const [id, finish] of pending) if (id !== selectedId) finish();
const results = (await upload).sort((a, b) => a.fileName.localeCompare(b.fileName));
assert.deepEqual(results, ["a.bin", "b.bin", "c.bin"].map((fileName) => (
fileName === selectedFile
? { fileName, success: false, cancelled: true }
: { fileName, success: true }
)));
});
}
@@ -5,11 +5,13 @@ import type { UploadController } from "../../../lib/uploadService";
// terminal only removes the view; the global transfer runtime can still cancel
// the upload, and the upload's own finally block releases these entries.
const controllersByTaskId = new Map<string, UploadController>();
const scopesByTaskId = new Map<string, "file" | "batch">();
const taskIdsByController = new Map<UploadController, Set<string>>();
export function registerExternalUploadController(
taskId: string,
controller: UploadController,
scope: "file" | "batch" = "batch",
): void {
if (!taskId) return;
const previous = controllersByTaskId.get(taskId);
@@ -19,6 +21,7 @@ export function registerExternalUploadController(
if (previousIds?.size === 0) taskIdsByController.delete(previous);
}
controllersByTaskId.set(taskId, controller);
scopesByTaskId.set(taskId, scope);
const taskIds = taskIdsByController.get(controller) ?? new Set<string>();
taskIds.add(taskId);
taskIdsByController.set(controller, taskIds);
@@ -30,6 +33,7 @@ export function unregisterExternalUploadController(controller: UploadController)
for (const taskId of taskIds) {
if (controllersByTaskId.get(taskId) === controller) {
controllersByTaskId.delete(taskId);
scopesByTaskId.delete(taskId);
}
}
taskIdsByController.delete(controller);
@@ -40,6 +44,10 @@ export function getExternalUploadController(taskId: string): UploadController |
}
export async function cancelExternalUploadRuntime(taskId?: string): Promise<boolean> {
if (taskId && scopesByTaskId.get(taskId) === "file") {
await controllersByTaskId.get(taskId)?.cancelFile(taskId);
return true;
}
const controllers = taskId
? [controllersByTaskId.get(taskId)].filter((value): value is UploadController => !!value)
: [...taskIdsByController.keys()];
@@ -50,5 +58,6 @@ export async function cancelExternalUploadRuntime(taskId?: string): Promise<bool
export function resetExternalUploadRuntimeForTests(): void {
controllersByTaskId.clear();
scopesByTaskId.clear();
taskIdsByController.clear();
}
@@ -520,3 +520,24 @@ test("folder resume releases a completed child compacted while pause was drainin
assert.equal(resumed.handled, true);
assert.equal(isTransferPauseLatched(child.id), false, "the worker still waits on this child even though its history row is gone");
});
test("late pause acknowledgement cannot restart a cancelling stream before settlement", async (t) => {
const { markTransferCancelledTree, resetTransferCancelLatchesForTests } = await import("./transferCancelLatch");
const { bumpTransferControlEpoch } = await import("./transferControlEpoch");
const { releaseTransferPauseTree } = await import("./transferPauseLatch");
t.after(() => { resetTransferCancelLatchesForTests(); resetTransferPauseLatchesForTests(); });
let finishPause!: (value: { success: boolean }) => void;
let resumes = 0;
const { host } = createHost([makeTask("cancel-during-pause")], () => ({
pauseTransfer: () => new Promise((resolve) => { finishPause = resolve; }),
resumeTransfer: async () => { resumes += 1; return { success: true }; },
}));
const pause = softPauseTransfer(host, "cancel-during-pause");
markTransferCancelledTree("cancel-during-pause");
bumpTransferControlEpoch("cancel-during-pause");
releaseTransferPauseTree("cancel-during-pause");
finishPause({ success: true });
await pause;
assert.equal(resumes, 0);
});
@@ -24,6 +24,7 @@ import {
latchTransferPauseTree,
releaseTransferPauseTree,
} from "./transferPauseLatch";
import { isTransferOrRootCancelled } from "./transferCancelLatch";
import { isTransferWalkInFlight } from "./transferWalkRegistry";
export type TransferControlBridge = {
@@ -72,7 +73,7 @@ export function reconcileSupersededControls(
requestedAction: "pause" | "resume",
controlTaskId = taskId,
): void {
if (!isTransferControlEpochCurrent(controlTaskId, epoch)) return;
if (!isTransferControlEpochCurrent(controlTaskId, epoch) || isTransferOrRootCancelled(taskId)) return;
const task = host.getTasks().find((candidate) => candidate.id === taskId);
if (!task || ["completed", "cancelled", "failed"].includes(task.status)) return;
const apply = (id: string, descendants: string[], action: "pause" | "resume" | "cancel") => {
@@ -234,7 +235,7 @@ export async function softPauseTransfer(
const undoObsoletePause = async (id: string) => {
const live = host.getTasks().find((candidate) => candidate.id === taskId);
if (!live || ["completed", "cancelled", "failed", "interrupted"].includes(live.status)) return;
if (isTransferOrRootPauseLatched(taskId, id)) return;
if (isTransferOrRootPauseLatched(taskId, id) || isTransferOrRootCancelled(taskId, id)) return;
try { await bridge!.resumeTransfer?.(id); } catch { /* best-effort */ }
};
const pauseOne = async (id: string) => {
@@ -0,0 +1,46 @@
import type { TransferTask } from "../../../domain/models";
import { isValidDirectoryResumeCheckpoint } from "../../../domain/sftpDirectoryCheckpoint";
/** Apply only after backend cancellation AND the old walk have settled. */
export function settleCancelledTransferTree(
tasks: readonly TransferTask[],
rootId: string,
cancelledIds: ReadonlySet<string>,
failedIds: ReadonlySet<string>,
retainedTasks: readonly TransferTask[] = [],
): TransferTask[] {
const existingTasks = new Map(tasks.map((task) => [task.id, task]));
// Use observed terminal outcomes rather than stale pre-cancel snapshots.
// Missing completed children already covered by the parent stay compacted;
// cancelled/failed exceptions still need their rows for the recovery walk.
const settledTasks = failedIds.size > 0
? [...tasks, ...retainedTasks.filter((task) => {
if (existingTasks.has(task.id)) return false;
const checkpoint = task.parentTaskId
? existingTasks.get(task.parentTaskId)?.directoryResumeCheckpoint
: undefined;
return !(task.status === "completed"
&& isValidDirectoryResumeCheckpoint(checkpoint)
&& Number.isSafeInteger(task.directoryEntryIndex)
&& (task.directoryEntryIndex ?? -1) >= 0
&& task.directoryEntryIndex! < checkpoint.coveredEntries);
})]
: tasks;
return settledTasks.map((task) => {
// Natural completion/failure wins a race with Cancel. Rows cancelled by
// this attempt can be re-admitted only when the whole tree needs recovery.
if (!cancelledIds.has(task.id) || task.status === "completed" || task.status === "failed") return task;
const failed = failedIds.has(task.id) || (task.id === rootId && failedIds.size > 0);
const recoverable = failedIds.size > 0;
return {
...task,
status: failed ? "attention" : recoverable ? "interrupted" : "cancelled",
error: failed ? "Could not cancel transfer. Please try again." : undefined,
reconnectRequired: recoverable,
endTime: recoverable ? undefined : Date.now(),
speed: 0,
phase: undefined,
conflict: undefined,
};
});
}
@@ -0,0 +1,375 @@
import assert from "node:assert/strict";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import type { TransferTask } from "../../../domain/models";
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
import { createSftpTransferCenterStore } from "../sftpTransferCenterStore";
import { globalSftpTransferScheduler } from "./globalTransferScheduler";
import { isTransferOrRootCancelled, resetTransferCancelLatchesForTests } from "./transferCancelLatch";
import { createTransferRuntime, resetTransferRuntimeRunsForTests } from "./transferRuntime";
import { resetTransferWalkRegistryForTests } from "./transferWalkRegistry";
const task = (id: string, extra: Partial<TransferTask> = {}): TransferTask => ({
id, fileName: id, sourcePath: `/source/${id}`, targetPath: `/target/${id}`,
sourceConnectionId: "local", targetConnectionId: "fixture", direction: "upload",
status: "transferring", totalBytes: 8, transferredBytes: 0, speed: 0,
startTime: 1, isDirectory: false, resumable: true, ...extra,
});
const tick = () => new Promise<void>((resolve) => setImmediate(resolve));
test("failed cancellation waits for the old walk, then Resume restores scheduler-rejected file bytes", async (t) => {
const root = await mkdtemp(join(tmpdir(), "netcatty-cancel-"));
const originalGet = netcattyBridge.get;
t.after(async () => {
netcattyBridge.get = originalGet;
resetTransferCancelLatchesForTests();
resetTransferWalkRegistryForTests();
resetTransferRuntimeRunsForTests();
await rm(root, { recursive: true, force: true });
});
const store = createSftpTransferCenterStore();
const runtime = createTransferRuntime(store);
const ids = ["active", "queued-1", "queued-2"];
const expected = ids.map((id) => Buffer.from(`bytes-of-${id}`));
const children = ids.map((id) => task(id, { parentTaskId: "folder", targetPath: join(root, id) }));
store.publishOwner("closed-panel", [task("folder", { isDirectory: true, targetHostId: "fixture-host" }), ...children]);
let release!: () => void;
const held = new Promise<void>((resolve) => { release = resolve; });
t.after(() => release());
let softResumeCalls = 0;
let pauseCalls = 0;
netcattyBridge.get = () => ({
cancelTransfer: async (id: string) => ({ success: id !== "active" }),
pauseTransfer: async () => { pauseCalls += 1; return { success: true }; },
clearPendingTransferCancel: async () => undefined,
resumeTransfer: async () => { softResumeCalls += 1; return { success: true }; },
} as unknown as ReturnType<typeof netcattyBridge.get>);
let starts = 0;
const walking = runtime.runWalk("folder", async () => {
await Promise.all(children.map((child, index) => globalSftpTransferScheduler.run(
"closed-panel", child.id, ["cancel-fixture"], () => 1, async () => {
starts += 1;
await held;
await writeFile(child.targetPath, expected[index]);
store.patchTask(child.id, { status: "completed" });
},
).catch(() => store.patchTask(child.id, { status: "cancelled" }))));
store.patchTask("folder", { status: "cancelled" });
});
await tick();
assert.equal(starts, 1);
let cancelSettled = false;
const cancelling = store.cancel("folder").then(() => { cancelSettled = true; });
const resuming = store.resume("folder");
await tick();
assert.equal(cancelSettled, true, "failed cancel must return promptly so the user can retry");
assert.equal(store.getTask("folder")?.status, "attention");
assert.equal(isTransferOrRootCancelled("folder"), true);
assert.equal(softResumeCalls, 0, "must not unlatch the old walk");
await store.pause("folder");
assert.equal(pauseCalls, 0, "a late row-level Pause must not park the cancelling walk");
let freshWalks = 0;
store.setDedicatedResumeHandler(async () => {
freshWalks += 1;
assert.equal(runtime.isWalkInFlight("folder"), false);
for (let index = 0; index < children.length; index += 1) {
const child = children[index];
if (store.getTask(child.id)?.status === "completed") continue;
assert.equal(store.admitTaskRun(child), "ready");
await writeFile(child.targetPath, expected[index]);
store.patchTask(child.id, { status: "completed" });
}
return { success: true };
});
release();
await Promise.all([walking, cancelling, resuming]);
assert.equal(freshWalks, 1);
assert.equal(starts, 1, "cancelled queue callbacks cannot silently count as transferred files");
assert.equal(store.getTask("folder")?.status, "completed");
for (let index = 0; index < children.length; index += 1) {
assert.deepEqual(await readFile(children[index].targetPath), expected[index]);
}
});
test("failed ownerless compression cancellation retries the same backend", async (t) => {
const originalGet = netcattyBridge.get;
t.after(() => { netcattyBridge.get = originalGet; resetTransferCancelLatchesForTests(); });
const store = createSftpTransferCenterStore();
const calls: string[] = [];
netcattyBridge.get = () => ({
cancelCompressedUpload: async (id: string) => {
calls.push(`compressed:${id}`);
return { success: calls.length > 1 };
},
cancelTransfer: async (id: string) => { calls.push(`stream:${id}`); return { success: true }; },
} as unknown as ReturnType<typeof netcattyBridge.get>);
store.publishOwner("closed-panel", [task("archive", { controlKind: "compressed-upload" })]);
await store.cancel("archive");
assert.equal(store.getTask("archive")?.status, "attention");
await store.cancel("archive");
assert.deepEqual(calls, ["compressed:archive", "compressed:archive"]);
assert.equal(store.getTask("archive")?.status, "cancelled");
});
test("late completion survives cancellation, while an unrelated paused batch peer stays paused", async (t) => {
const originalGet = netcattyBridge.get;
t.after(() => { netcattyBridge.get = originalGet; resetTransferCancelLatchesForTests(); });
const store = createSftpTransferCenterStore();
const calls: string[] = [];
store.publishOwner("gone", [task("selected", { batchId: "batch" }), task("peer", { batchId: "batch", status: "paused" })]);
netcattyBridge.get = () => ({
cancelTransfer: async (id: string) => {
calls.push(id);
store.patchTask(id, { status: "completed" });
return { success: true };
},
} as unknown as ReturnType<typeof netcattyBridge.get>);
await store.cancel("selected");
assert.deepEqual(calls, ["selected"]);
assert.equal(store.getTask("selected")?.status, "completed");
assert.equal(store.getTask("peer")?.status, "paused");
});
test("Resume cannot revive a tree pre-latched by an upcoming Cancel all batch", async (t) => {
const { markTransferCancelledTree } = await import("./transferCancelLatch");
t.after(resetTransferCancelLatchesForTests);
const store = createSftpTransferCenterStore();
store.publishOwner("gone", [task("waiting-cancel-batch", { status: "paused", isDirectory: true })]);
markTransferCancelledTree("waiting-cancel-batch");
await store.resume("waiting-cancel-batch");
assert.equal(store.getTask("waiting-cancel-batch")?.status, "paused");
assert.equal(isTransferOrRootCancelled("waiting-cancel-batch"), true);
});
test("failed cancel can be retried while its old walk is still running", async (t) => {
const originalGet = netcattyBridge.get;
t.after(() => { netcattyBridge.get = originalGet; resetTransferCancelLatchesForTests(); });
const store = createSftpTransferCenterStore();
const runtime = createTransferRuntime(store);
store.publishOwner("gone", [task("retry-live-cancel", { isDirectory: true })]);
let release!: () => void;
const held = new Promise<void>((resolve) => { release = resolve; });
t.after(() => release());
const walking = runtime.runWalk("retry-live-cancel", async () => { await held; });
let attempts = 0;
netcattyBridge.get = () => ({
cancelTransfer: async () => {
attempts += 1;
if (attempts === 1) return { success: false };
release();
return { success: true };
},
cleanupTransferArtifacts: async () => undefined,
} as unknown as ReturnType<typeof netcattyBridge.get>);
await store.cancel("retry-live-cancel");
assert.equal(runtime.isWalkInFlight("retry-live-cancel"), true);
assert.equal(store.getTask("retry-live-cancel")?.status, "attention");
const resume = store.resume("retry-live-cancel");
await store.cancel("retry-live-cancel");
await Promise.all([walking, resume]);
assert.equal(attempts, 2, "second cancel must reach the backend before natural completion");
assert.equal(store.getTask("retry-live-cancel")?.status, "cancelled", "new successful cancel owns final outcome");
});
test("large orphan recovery clears pending cancellations in bounded concurrent batches", async (t) => {
const originalGet = netcattyBridge.get;
t.after(() => { netcattyBridge.get = originalGet; resetTransferCancelLatchesForTests(); });
const store = createSftpTransferCenterStore();
store.publishOwner("gone", [task("cleanup-root", { isDirectory: true }),
...Array.from({ length: 2200 }, (_, index) => task(`cleanup-${index}`, { parentTaskId: "cleanup-root" }))]);
let active = 0;
let peak = 0;
let cleared = 0;
netcattyBridge.get = () => ({
cancelTransfer: async (id: string) => ({ success: id !== "cleanup-0" }),
clearPendingTransferCancel: async () => {
active += 1;
peak = Math.max(peak, active);
await new Promise<void>((resolve) => setImmediate(resolve));
active -= 1;
cleared += 1;
},
} as unknown as ReturnType<typeof netcattyBridge.get>);
await store.cancel("cleanup-root");
assert.equal(cleared, 2201);
assert.ok(peak > 1, "recovery must not serialize one IPC per row");
assert.ok(peak <= 32, "recovery uses the cancellation batch bound");
});
for (const completeDuring of ["backend-cancel", "walk-settlement"] as const) {
test(`failed cancellation preserves compacted completion during ${completeDuring} on dedicated resume`, async (t) => {
const { sftpTransferCenterStore: store } = await import("../sftpTransferCenterStore");
const { createDirectoryEntryIdentity } = await import("../../../domain/sftpDirectoryCheckpoint");
const { resumeTransferWithDedicatedSession, resetDedicatedSessionOpenGateForTests } =
await import("./dedicatedTransferResume");
const originalGet = netcattyBridge.get;
const previousStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
Object.defineProperty(globalThis, "localStorage", {
configurable: true,
value: { getItem: () => null, setItem: () => {}, removeItem: () => {} },
});
const parent = task(`compacted-cancel-${completeDuring}`, {
sourcePath: "/source/folder", targetPath: "/target/folder", targetHostId: "fixture-host",
isDirectory: true, progressMode: "files", totalBytes: 3,
});
const children = ["completed", "cancelled", "cancel-failed"].map((name, index) => {
const child = task(`${parent.id}-${name}`, {
parentTaskId: parent.id, fileName: name,
sourcePath: `${parent.sourcePath}/${name}`, targetPath: `${parent.targetPath}/${name}`,
sourceLastModified: 1, directoryEntryIndex: index,
});
return { ...child, directoryEntryIdentity: createDirectoryEntryIdentity({
sourcePath: child.sourcePath, targetPath: child.targetPath, size: child.totalBytes, lastModified: 1,
}) };
});
// Match the sorted traversal order so dedicated resume validates the actual checkpoint.
children.sort((a, b) => a.sourcePath.localeCompare(b.sourcePath));
children.forEach((child, index) => { child.directoryEntryIndex = index; });
const completed = children.find((child) => child.fileName === "completed")!;
const cancelled = children.find((child) => child.fileName === "cancelled")!;
const failed = children.find((child) => child.fileName === "cancel-failed")!;
const runtime = createTransferRuntime(store);
let release!: () => void;
const held = new Promise<void>((resolve) => { release = resolve; });
let walking: Promise<unknown> | undefined;
t.after(async () => {
release();
await walking;
store.setDedicatedResumeHandler(null);
store.patchTask(parent.id, { status: "completed" });
store.dismiss(parent.id);
netcattyBridge.get = originalGet;
resetTransferCancelLatchesForTests();
resetTransferWalkRegistryForTests();
resetTransferRuntimeRunsForTests();
resetDedicatedSessionOpenGateForTests();
if (previousStorage) Object.defineProperty(globalThis, "localStorage", previousStorage);
else Reflect.deleteProperty(globalThis, "localStorage");
});
const uploads: string[] = [];
const progress: number[] = [];
netcattyBridge.get = () => ({
cancelTransfer: async (id: string) => {
if (completeDuring === "backend-cancel" && id === completed.id) {
store.ingestBackgroundEvent({ type: "completed", transferId: id, transferred: 8, totalBytes: 8 });
}
return { success: id !== failed.id };
},
clearPendingTransferCancel: async () => undefined,
openSftp: async () => "reconnected-sftp", closeSftp: async () => {},
listLocalTree: async () => children.map((child) => ({
localPath: child.sourcePath, relativePath: child.fileName,
type: "file", size: 8, lastModified: 1,
})),
mkdirSftp: async () => {},
statLocal: async () => ({ size: 8, lastModified: 1 }),
startStreamTransfer: async (options: { sourcePath: string; checkpointBytes: number }) => {
if (options.sourcePath === cancelled.sourcePath) assert.equal(options.checkpointBytes, 4);
uploads.push(options.sourcePath);
return {};
},
} as unknown as ReturnType<typeof netcattyBridge.get>);
store.publishOwner("closed-panel", [parent, ...children]);
walking = runtime.runWalk(parent.id, async () => {
await held;
if (completeDuring === "walk-settlement") {
store.ingestBackgroundEvent({ type: "completed", transferId: completed.id, transferred: 8, totalBytes: 8 });
}
store.patchTask(cancelled.id, { status: "cancelled", checkpointBytes: 4 });
store.patchTask(parent.id, { status: "cancelled" });
});
await store.cancel(parent.id);
assert.equal(runtime.isWalkInFlight(parent.id), true);
store.setDedicatedResumeHandler(async (root) => {
assert.equal(root.directoryResumeCheckpoint?.completedEntries, 1);
assert.equal(store.getTask(completed.id), undefined, "the finished child stays compacted");
assert.equal(store.getTask(cancelled.id)?.status, "interrupted");
assert.equal(store.getTask(failed.id)?.status, "attention");
return resumeTransferWithDedicatedSession(root, {
hosts: [{ id: "fixture-host", label: "fixture", hostname: "fixture", port: 22,
username: "test", authMethod: "password", protocol: "ssh", tags: [], os: "linux" }],
keys: [], identities: [],
}, (value) => { progress.push(value.transferred); }, {
children: store.getSnapshot().tasks.filter((child) => child.parentTaskId === parent.id),
onChildUpdate: (child) => { store.upsertTasks([child]); },
onDirectoryCheckpointUpdate: (checkpoint) => store.patchTask(parent.id, { directoryResumeCheckpoint: checkpoint }),
});
});
const resuming = store.resume(parent.id);
release();
await Promise.all([walking, resuming]);
assert.deepEqual(uploads.sort(), [cancelled.sourcePath, failed.sourcePath].sort(),
"only cancelled and failed-to-cancel children should upload again");
assert.equal(store.getTask(parent.id)?.status, "completed");
assert.ok(progress.every((count) => count <= 3), "compacted completion must count exactly once");
assert.equal(progress.at(-1), 3);
});
}
for (const rejects of [false, true]) {
test(`failed live compression cancellation resumes the same compression job: rejects=${rejects}`, async (t) => {
const originalGet = netcattyBridge.get;
t.after(() => { netcattyBridge.get = originalGet; resetTransferCancelLatchesForTests(); });
const store = createSftpTransferCenterStore();
const calls: string[] = [];
netcattyBridge.get = () => ({
cancelCompressedUpload: async () => {
if (rejects) throw new Error("Cancel IPC failed");
return { success: false };
},
resumeCompressedUpload: async () => { calls.push("compressed"); return { success: true }; },
resumeTransfer: async () => { calls.push("generic"); return { success: false, reason: "not active" }; },
clearPendingTransferCancel: async () => {},
} as unknown as ReturnType<typeof netcattyBridge.get>);
store.setDedicatedResumeHandler(async () => { calls.push("dedicated"); return { success: true }; });
store.publishOwner("closed-panel", [task("live-compressed", {
isDirectory: true, controlKind: "compressed-upload", reconnectRequired: false,
})]);
await store.cancel("live-compressed");
assert.equal(store.getTask("live-compressed")?.status, "attention");
await store.resume("live-compressed");
assert.deepEqual(calls, ["compressed"], "failed cancellation must not start a second destination writer");
assert.equal(store.getTask("live-compressed")?.status, "transferring");
});
}
for (const hasConflict of [false, true]) {
test(`restored compressed attention uses reconnect and preserves conflict gating: conflict=${hasConflict}`, async (t) => {
const { serializeSftpTransferCenter } = await import("../../../domain/sftpTransferCenter");
const { listGloballyResumableTransferIds } = await import("../../../domain/sftpTransferActions");
const { restoreSftpTransferHistoryCooperatively } = await import("./transferHistoryRestoreMigration");
const originalGet = netcattyBridge.get;
t.after(() => { netcattyBridge.get = originalGet; });
const saved = task("restored-compressed", {
status: "attention", isDirectory: true, controlKind: "compressed-upload", reconnectRequired: false,
error: "Could not cancel the compressed upload.",
...(hasConflict ? { conflict: {
transferId: "restored-compressed", fileName: "folder", sourcePath: "/source/folder", targetPath: "/target/folder",
isDirectory: true, existingSize: 8, newSize: 8, existingModified: 1, newModified: 1,
} } : {}),
});
const raw = serializeSftpTransferCenter([saved]);
const restored = createSftpTransferCenterStore({ read: () => raw, write: () => {} });
const cooperative = await restoreSftpTransferHistoryCooperatively(JSON.stringify({ version: 1, tasks: [saved] }));
assert.equal(restored.getTask(saved.id)?.reconnectRequired, true);
assert.equal(cooperative.tasks[0]?.reconnectRequired, true);
assert.equal(restored.getTask(saved.id)?.status, "attention");
assert.deepEqual(restored.getTask(saved.id)?.conflict, saved.conflict);
assert.equal(saved.reconnectRequired, false, "saving must not reclassify the current live row");
const calls: string[] = [];
netcattyBridge.get = () => ({
resumeCompressedUpload: async () => { calls.push("compressed"); return { success: true }; },
clearPendingTransferCancel: async () => {},
} as unknown as ReturnType<typeof netcattyBridge.get>);
restored.setDedicatedResumeHandler(async () => { calls.push("dedicated"); return { success: true }; });
const resumeIds = listGloballyResumableTransferIds(restored.getSnapshot().tasks);
assert.deepEqual(resumeIds, hasConflict ? [] : [saved.id]);
for (const id of resumeIds) await restored.resume(id);
assert.deepEqual(calls, hasConflict ? [] : ["dedicated"]);
});
}
@@ -972,13 +972,10 @@ export function useSftpDirectoryTransferOps({
if (isTransferCancelledError(err)) {
// Keep cancelled status; do not rethrow — other workers must finish
// and the parent should not become a clean completed tree.
setTransfers((prev) =>
prev.map((t) =>
t.id === fileId
? { ...t, status: "cancelled" as TransferStatus, error: undefined, endTime: Date.now() }
: t,
),
);
setTransfers((prev) => prev.map((task) =>
task.id === fileId
? { ...task, status: "cancelled" as TransferStatus, error: undefined, endTime: Date.now() }
: task));
errors.push(err instanceof Error ? err : new Error(message));
return;
}
@@ -51,6 +51,7 @@ export interface TransferRuntime {
* walk is dead / reconnectRequired. Single external operation.
*/
resume(taskId: string): Promise<void>;
/** Wait for backend cancellation requests; Resume remains fenced on walk settlement. */
cancel(taskId: string): Promise<void>;
/**
+14 -11
View File
@@ -71,21 +71,24 @@ export function useSftpTransferTaskOps({
const bridge = netcattyBridge.get();
const cancelTransferAtBackend = bridge?.cancelTransfer;
const cancelCompressedUpload = bridge?.cancelCompressedUpload;
if (!cancelTransferAtBackend && !cancelCompressedUpload) return;
if (!cancelTransferAtBackend && !cancelCompressedUpload) return [];
await Promise.all(
Array.from(idsToCancel).map(async (id) => {
const failed: string[] = [];
const ids = [...idsToCancel];
for (let offset = 0; offset < ids.length; offset += 32) {
await Promise.all(ids.slice(offset, offset + 32).map(async (id) => {
const candidate = currentTransfers.find((task) => task.id === id);
const compressed = candidate?.controlKind === "compressed-upload";
const operation = compressed
? cancelCompressedUpload?.(id)
: cancelTransferAtBackend?.(id);
const results = operation ? await Promise.allSettled([operation]) : [];
if (results.some((result) => result.status === "rejected")) {
logger.warn("Failed to cancel one or more transfer backends");
try {
const result = await (compressed ? cancelCompressedUpload?.(id) : cancelTransferAtBackend?.(id));
if (result && result.success === false) failed.push(id);
} catch {
failed.push(id);
}
}),
);
}));
}
if (failed.length > 0) logger.warn("Failed to cancel one or more transfer backends");
return failed;
}, [activeChildIdsRef, cancelledTasksRef, transfersRef]);
const markBatchStopped = useCallback(
+15 -1
View File
@@ -6,6 +6,7 @@
*/
const inFlightRootIds = new Set<string>();
const settlementWaiters = new Map<string, Set<() => void>>();
export function registerTransferWalk(rootTaskId: string): void {
inFlightRootIds.add(rootTaskId);
@@ -13,15 +14,28 @@ export function registerTransferWalk(rootTaskId: string): void {
export function unregisterTransferWalk(rootTaskId: string): void {
inFlightRootIds.delete(rootTaskId);
const waiters = settlementWaiters.get(rootTaskId);
settlementWaiters.delete(rootTaskId);
for (const resolve of waiters ?? []) resolve();
}
export function isTransferWalkInFlight(rootTaskId: string): boolean {
return inFlightRootIds.has(rootTaskId);
}
/** Wait for the current walk to finish; cancellation must not resume a dying walk. */
export function waitForTransferWalkSettled(rootTaskId: string): Promise<void> {
if (!inFlightRootIds.has(rootTaskId)) return Promise.resolve();
return new Promise((resolve) => {
const waiters = settlementWaiters.get(rootTaskId) ?? new Set();
waiters.add(resolve);
settlementWaiters.set(rootTaskId, waiters);
});
}
/** Test helper. */
export function resetTransferWalkRegistryForTests(): void {
inFlightRootIds.clear();
for (const id of inFlightRootIds) unregisterTransferWalk(id);
}
export function listTransferWalksForTests(): string[] {
@@ -79,6 +79,7 @@ import { localStorageAdapter } from "../../../infrastructure/persistence/localSt
import { STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY } from "../../../infrastructure/config/storageKeys";
import { sftpTransferCenterStore } from "../sftpTransferCenterStore";
import { editorTabStore } from "../editorTabStore";
import { notifyEditorWindowSessionRemap } from "../editorWindowClient";
import {
resolveUploadStreamTargetSftpId,
} from "../../../domain/sftpDedicatedStreamPolicy";
@@ -200,8 +201,8 @@ export const useSftpExternalOperations = (
return { sftpId, release: () => {} };
}, [ensureRemoteSftpId, getActivePane, getPaneByConnectionId, getPaneByTabId, getSideByTabId, sftpSessionsRef]);
const registerUploadController = useCallback((taskId: string, controller: UploadController) => {
registerExternalUploadController(taskId, controller);
const registerUploadController = useCallback((taskId: string, controller: UploadController, scope: "file" | "batch" = "batch") => {
registerExternalUploadController(taskId, controller, scope);
}, []);
const unregisterUploadController = useCallback((controller: UploadController) => {
@@ -221,7 +222,7 @@ export const useSftpExternalOperations = (
callbacks.onScanningProgress?.(taskId, progress);
},
onTaskCreated: (task) => {
registerUploadController(task.id, controller);
registerUploadController(task.id, controller, !task.isDirectory && !task.parentTaskId ? "file" : "batch");
if (task.parentTaskId) {
registerUploadController(task.parentTaskId, controller);
}
@@ -443,6 +444,7 @@ export const useSftpExternalOperations = (
if (liveConnectionId !== connectionId) {
editorTabStore.remapSessionId(connectionId, liveConnectionId);
notifyEditorWindowSessionRemap(connectionId, liveConnectionId);
}
const bridge = netcattyBridge.get();
@@ -561,3 +561,105 @@ test("files past the preflight bound stay un-statted after conflict resolution",
restore();
}
});
test("owned cancelled directory settles recoverable children without dropping completed bytes", async () => {
const { writeFile, readFile, mkdtemp, rm } = await import("node:fs/promises");
const { tmpdir } = await import("node:os");
const { join } = await import("node:path");
const { transferRuntime } = await import("./transferRuntime");
const root = await mkdtemp(join(tmpdir(), "netcatty-owner-cancel-"));
const completedPath = join(root, "completed.txt");
await writeFile(completedPath, "completed bytes must remain");
const ownerId = "cancel-owner-large";
const rootId = "cancel-root-large";
const base = {
id: rootId, fileName: "folder", sourcePath: "/opt/folder", targetPath: root,
sourceConnectionId: "remote-conn", targetConnectionId: "local-conn",
sourceHostId: "host-1", direction: "download" as const, status: "transferring" as const,
totalBytes: 2201, transferredBytes: 1, speed: 0, startTime: 1, isDirectory: true,
};
const children = Array.from({ length: 2200 }, (_, index) => ({
...base, id: `cancel-child-${index}`, parentTaskId: rootId, isDirectory: false,
sourcePath: `/opt/folder/${index}`, targetPath: join(root, String(index)),
totalBytes: 10, transferredBytes: 4, status: "queued" as const,
}));
sftpTransferCenterStore.publishOwner(ownerId, [base, ...children, {
...base, id: "cancel-completed", parentTaskId: rootId, isDirectory: false,
sourcePath: "/opt/folder/completed.txt", targetPath: completedPath, status: "completed",
}]);
let active = 0;
let peak = 0;
let clearing = 0;
let clearPeak = 0;
let clearCount = 0;
const cleaned: string[] = [];
const restore = installGlobals({
cancelTransfer: async (id: string) => {
assert.notEqual(id, "cancel-completed");
active += 1;
peak = Math.max(peak, active);
await new Promise((resolve) => setImmediate(resolve));
active -= 1;
return { success: id !== "cancel-child-0" };
},
clearPendingTransferCancel: async () => {
clearing += 1;
clearPeak = Math.max(clearPeak, clearing);
await new Promise((resolve) => setImmediate(resolve));
clearing -= 1;
clearCount += 1;
},
cleanupTransferArtifacts: async ({ transferId }: { transferId: string }) => { cleaned.push(transferId); },
});
let ops: ReturnType<typeof useSftpTransfers> | undefined;
let renderer: ReactTestRenderer | undefined;
function Probe() {
ops = useSftpTransfers({
ownerId,
getActivePane: (side) => makePane(side),
getPaneByConnectionId: () => null, getTabByConnectionId: () => null,
updateTab: () => undefined, refresh: async () => undefined,
clearCacheForConnection: () => undefined, handleSessionError: () => undefined,
sftpSessionsRef: { current: new Map([["remote-conn", "sftp-remote"]]) },
connectionCacheKeyMapRef: { current: new Map() },
listLocalFiles: async () => [], listRemoteFiles: async () => [],
});
return null;
}
let release!: () => void;
const held = new Promise<void>((resolve) => { release = resolve; });
try {
await act(async () => { renderer = create(React.createElement(Probe)); });
const walking = transferRuntime.runWalk(rootId, async () => {
await held;
// Old scheduler callbacks settle as cancelled before the outcome is applied.
sftpTransferCenterStore.publishOwner(ownerId, sftpTransferCenterStore.getOwnerTasks(ownerId).map((task) =>
task.status === "completed" ? task : {
...task, status: "cancelled",
checkpointBytes: task.id === "cancel-child-1" ? 6 : task.checkpointBytes,
}));
});
await act(async () => {
const cancellation = ops!.cancelTransfer(rootId);
await new Promise((resolve) => setImmediate(resolve));
assert.equal(transferRuntime.isWalkInFlight(rootId), true);
release();
await Promise.all([walking, cancellation]);
});
assert.ok(peak <= 32, `bounded cancellation IPC, got ${peak}`);
assert.equal(clearCount, 2201);
assert.ok(clearPeak > 1 && clearPeak <= 32, `bounded concurrent cleanup, got ${clearPeak}`);
assert.equal(sftpTransferCenterStore.getTask(rootId)?.status, "attention");
assert.equal(sftpTransferCenterStore.getTask("cancel-child-0")?.status, "attention");
const remaining = sftpTransferCenterStore.getTask("cancel-child-1");
assert.equal(remaining?.status, "interrupted", "evicted cancelled children remain recoverable");
assert.equal(remaining?.checkpointBytes, 6, "retain the terminal checkpoint, not the pre-cancel snapshot");
assert.deepEqual(cleaned, [], "no stage cleanup during partial cancellation recovery");
assert.equal(await readFile(completedPath, "utf8"), "completed bytes must remain");
} finally {
release();
await act(async () => { renderer?.unmount(); });
restore();
await rm(root, { recursive: true, force: true });
}
});
+52 -64
View File
@@ -1418,77 +1418,66 @@ export const useSftpTransfers = ({
releasePausedTransfer(transferId);
globalSftpTransferScheduler.cancel(transferId);
// Cancel parent + remove child tasks
const childIdsToCancel = new Set<string>();
const childrenToCleanup: TransferTask[] = [];
for (const t of transfersRef.current) {
if (t.parentTaskId === transferId && !["completed", "cancelled", "failed"].includes(t.status)) {
childIdsToCancel.add(t.id);
childrenToCleanup.push(t);
}
// Keep child rows and checkpoints until the old walk has stopped. A
// partial backend failure needs a fresh re-walk, including queued jobs
// the scheduler has already rejected and will never run again.
const children = transfersRef.current.filter((task) =>
task.parentTaskId === transferId
&& !["completed", "cancelled", "failed"].includes(task.status));
const childIds = new Set([
...children.map((task) => task.id),
...(activeChildIdsRef.current.get(transferId) ?? []),
]);
for (const id of childIds) {
cancelledTasksRef.current.add(id);
releasePausedTransfer(id);
globalSftpTransferScheduler.cancel(id);
}
for (const cid of activeChildIdsRef.current.get(transferId) ?? []) {
if (!childIdsToCancel.has(cid)) {
childIdsToCancel.add(cid);
if (taskToCancel) {
childrenToCleanup.push({
...taskToCancel,
id: cid,
parentTaskId: transferId,
});
}
}
}
for (const cid of childIdsToCancel) {
cancelledTasksRef.current.add(cid);
markTransferCancelled(cid);
globalSftpTransferScheduler.cancel(cid);
}
markTransferCancelledTree(transferId, [...childIdsToCancel]);
// Keep refs in sync immediately so same-turn resolveConflict/resume sees cancel.
const nextTransfers = transfersRef.current
.filter((t) => t.parentTaskId !== transferId)
.map((t) =>
t.id === transferId
? { ...t, status: "cancelled" as TransferStatus, endTime: Date.now(), conflict: undefined }
: t,
);
setTransfers(nextTransfers);
conflictsRef.current = conflictsRef.current.filter((c) => c.transferId !== transferId && !childIdsToCancel.has(c.transferId));
markTransferCancelledTree(transferId, [...childIds]);
conflictsRef.current = conflictsRef.current.filter((conflict) =>
conflict.transferId !== transferId && !childIds.has(conflict.transferId));
setConflicts(conflictsRef.current);
await cancelBackendTransfers([transferId, ...childIdsToCancel]);
if (taskToCancel) await cleanupTaskArtifacts(taskToCancel);
// Child stages are keyed by per-file transferId — clean each known child.
for (const child of childrenToCleanup) {
try {
await cleanupTaskArtifacts(child);
} catch {
// best-effort
}
}
const ids = new Set([transferId, ...childIds]);
const retainedTasks = (taskToCancel ? [taskToCancel, ...children] : children)
.map((task) => ({ task, ...sftpTransferCenterStore.observeTaskSettlement(task) }));
const failedIds = new Set(await cancelBackendTransfers([...ids]));
await sftpTransferCenterStore.settleCancellation(
transferId, ids, failedIds, retainedTasks,
async () => {
setTransfers(sftpTransferCenterStore.getOwnerTasks(ownerId));
for (const id of ids) clearCancelledTask(id);
clearTransferCancelledTree(transferId, [...childIds]);
if (failedIds.size > 0) {
// Preserve stages while any writer could not be cancelled. The
// fresh recovery walk reuses ids only after this settlement.
const recoveryIds = [...ids];
for (let offset = 0; offset < recoveryIds.length; offset += 32) {
await Promise.all(recoveryIds.slice(offset, offset + 32).map(async (id) => {
try { await netcattyBridge.get()?.clearPendingTransferCancel?.(id); } catch { /* best-effort */ }
}));
}
return;
}
if (taskToCancel && sftpTransferCenterStore.getTask(transferId)?.status === "cancelled") {
try { await cleanupTaskArtifacts(taskToCancel); } catch { /* best-effort */ }
}
for (const child of children) {
if (sftpTransferCenterStore.getTask(child.id)?.status === "completed") continue;
try { await cleanupTaskArtifacts(child); } catch { /* best-effort */ }
}
},
);
},
[cancelBackendTransfers, cleanupTaskArtifacts, releasePausedTransfer, setTransfers],
[cancelBackendTransfers, clearCancelledTask, cleanupTaskArtifacts, ownerId, releasePausedTransfer, setTransfers],
);
// Soft pause/resume: single TransferRuntime entry (store soft-control +
// dedicated hard reconnect). No panel-local soft-control dual path.
// All controls go through the process-global runtime, including cancellation
// settlement. A panel must never clear cancellation latches on Resume.
const resumeTransfer = useCallback(async (transferId: string) => {
// Clear sticky child cancel latches so re-walk can retry same child ids.
clearCancelledTask(transferId);
for (const child of transfersRef.current) {
if (child.parentTaskId === transferId) clearCancelledTask(child.id);
}
for (const childId of activeChildIdsRef.current.get(transferId) ?? []) {
clearCancelledTask(childId);
}
clearTransferCancelledTree(
transferId,
[...(activeChildIdsRef.current.get(transferId) ?? [])],
);
await transferRuntime.resume(transferId);
}, [clearCancelledTask]);
}, []);
const prioritizeTransfer = useCallback((transferId: string) => {
globalSftpTransferScheduler.prioritize(transferId);
@@ -2265,7 +2254,6 @@ export const useSftpTransfers = ({
await transferRuntime.pause(taskId);
},
resume: async (taskId: string) => {
// Use resumeTransfer so cancel latches are cleared before soft/hard resume.
await resumeTransfer(taskId);
},
cancel: cancelTransfer,
@@ -2290,7 +2278,7 @@ export const useSftpTransfers = ({
activeTransfersCount,
startTransfer,
downloadToLocal,
cancelTransfer,
cancelTransfer: transferRuntime.cancel,
// Single process-level control surface (TransferRuntime).
pauseTransfer: async (transferId: string) => {
await transferRuntime.pause(transferId);
@@ -1622,6 +1622,46 @@ test("orphan compressed upload resume and cancel keep using the compression job"
assert.equal(store.getSnapshot().tasks[0]?.status, "cancelled");
});
test("compressed upload completing during cancel IPC keeps its completed paint", async (t) => {
const calls: string[] = [];
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
t.after(() => {
if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow);
else Reflect.deleteProperty(globalThis, "window");
});
let storeRef: ReturnType<typeof createSftpTransferCenterStore> | undefined;
Object.defineProperty(globalThis, "window", {
configurable: true,
value: {
netcatty: {
// Inactive-compression responses report success even after the upload
// already finished; the completion repaint must survive the cancel.
cancelCompressedUpload: async (id: string) => {
calls.push(`cancel:${id}`);
storeRef?.ingestBackgroundEvent({ type: "completed", transferId: id });
return { success: true };
},
},
},
});
const store = createSftpTransferCenterStore();
storeRef = store;
store.publishOwner("closed-panel", [{
...makeTask("compressed-race", "transferring"),
fileName: "done (compressed)",
isDirectory: true,
phase: "compressing",
controlKind: "compressed-upload",
} as TransferTask]);
await store.cancel("compressed-race");
assert.deepEqual(calls, ["cancel:compressed-race"]);
assert.equal(store.getSnapshot().tasks[0]?.status, "completed");
});
test("orphan cancel marks process-global cancel so surviving walks stop", async (t) => {
const {
isTransferCancelledFlag,
@@ -1650,6 +1690,10 @@ test("orphan cancel marks process-global cancel so surviving walks stop", async
}]);
await store.cancel("walk-1");
assert.equal(isTransferCancelledFlag("walk-1"), true);
const resuming = store.resume("walk-1");
unregisterTransferWalk("walk-1");
await resuming;
assert.equal(isTransferCancelledFlag("walk-1"), false);
assert.equal(store.getSnapshot().tasks.find((row) => row.id === "walk-1")?.status, "cancelled");
});
@@ -2706,6 +2750,39 @@ test("an interrupted task without its old controller can still be cancelled", as
assert.equal(store.getSnapshot().tasks[0]?.status, "cancelled");
});
test("orphan cancel keeps failed work visible and leaves completed children alone", async (t) => {
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
t.after(() => {
if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow);
else Reflect.deleteProperty(globalThis, "window");
});
const cancelledIds: string[] = [];
Object.defineProperty(globalThis, "window", {
configurable: true,
value: { netcatty: {
cancelTransfer: async (id: string) => {
cancelledIds.push(id);
return { success: id !== "unfinished" };
},
} },
});
const store = createSftpTransferCenterStore();
store.publishOwner("closed-panel", [
{ ...makeTask("directory", "paused"), isDirectory: true },
{ ...makeTask("finished", "completed"), parentTaskId: "directory" },
{ ...makeTask("unfinished", "queued"), parentTaskId: "directory" },
]);
const finishedBeforeCancel = store.getTask("finished");
await store.cancel("directory");
assert.deepEqual(cancelledIds, ["directory", "unfinished"]);
assert.equal(store.getTask("finished")?.status, finishedBeforeCancel?.status);
assert.equal(store.getTask("unfinished")?.status, "attention");
assert.equal(store.getTask("directory")?.status, "attention");
assert.match(store.getTask("directory")?.error ?? "", /Could not cancel/);
});
test("concurrent resume clicks adopt a task only once", async () => {
let adoptCount = 0;
const store = createSftpTransferCenterStore();
@@ -3867,3 +3944,66 @@ for (const outcome of ["rejected", "stream-gone"] as const) {
assert.equal(resumeCalls, 1, "obsolete resume must not issue another resume after the held run ends");
});
}
test("partial cancel failure resets recovered folder children so the re-walk can re-admit them", async (t) => {
const { netcattyBridge } = await import("../../infrastructure/services/netcattyBridge");
const { resetTransferCancelLatchesForTests, isTransferOrRootCancelled } =
await import("./sftp/transferCancelLatch");
const { resetTransferWalkRegistryForTests } = await import("./sftp/transferWalkRegistry");
const originalGet = netcattyBridge.get;
resetTransferCancelLatchesForTests();
resetTransferWalkRegistryForTests();
t.after(() => {
netcattyBridge.get = originalGet;
resetTransferCancelLatchesForTests();
resetTransferWalkRegistryForTests();
});
// Only child2 fails to cancel; the folder must stay recoverable via Resume.
let failChild2 = true;
netcattyBridge.get = () => ({
cancelTransfer: async (id: string) => ({ success: id !== "dir-child-2" || !failChild2 }),
cleanupTransferArtifacts: async () => ({}),
} as unknown as ReturnType<typeof netcattyBridge.get>);
const store = createSftpTransferCenterStore();
const child = (id: string): TransferTask => ({
...makeTask(id),
parentTaskId: "dir-root",
isDirectory: false,
transferredBytes: 4,
directoryEntryIndex: 0,
directoryEntryIdentity: "a".repeat(64),
});
store.publishOwner("gone-panel", [
{ ...makeTask("dir-root"), isDirectory: true, transferredBytes: 10, totalBytes: 20, directoryEntryIndex: 0 },
child("dir-child-1"),
child("dir-child-2"),
]);
await store.cancel("dir-root");
const rows = store.getSnapshot().tasks;
const parent = rows.find((row) => row.id === "dir-root");
const child1 = rows.find((row) => row.id === "dir-child-1");
const child2 = rows.find((row) => row.id === "dir-child-2");
assert.equal(parent?.status, "attention");
assert.equal(parent?.error, "Could not cancel transfer. Please try again.");
// A failed backend cancellation keeps the folder recoverable: successfully
// cancelled siblings must not settle as terminal "cancelled", or admitTaskRun
// would reject the recovery re-walk and leave the destination incomplete.
assert.equal(child1?.status, "interrupted");
assert.equal(child1?.error, undefined);
assert.equal(child2?.status, "attention");
assert.equal(isTransferOrRootCancelled("dir-root"), false, "recovery latch must be cleared");
// The directory re-walk (Resume) can re-admit the reset sibling.
const incoming = { ...child("dir-child-1"), directoryEntryIndex: 0, directoryEntryIdentity: "a".repeat(64) };
assert.equal(store.admitTaskRun(incoming), "ready");
assert.equal(store.getTask("dir-child-1")?.status, "transferring");
// Without any cancellation failure the tree still settles fully cancelled.
failChild2 = false;
await store.cancel("dir-root");
const afterFullCancel = store.getSnapshot().tasks;
for (const id of ["dir-root", "dir-child-1", "dir-child-2"]) {
assert.equal(afterFullCancel.find((row) => row.id === id)?.status, "cancelled");
}
});
+133 -34
View File
@@ -26,8 +26,10 @@ import {
isTransferControlEpochCurrent,
settleTransferControlEpochTree,
} from "./sftp/transferControlEpoch";
import { isTransferWalkInFlight } from "./sftp/transferWalkRegistry";
import { isTransferWalkInFlight, waitForTransferWalkSettled } from "./sftp/transferWalkRegistry";
import { settleCancelledTransferTree } from "./sftp/transferCancelOutcome";
import {
clearTransferCancelledTree,
isTransferOrRootCancelled,
markTransferCancelledTree,
settleTransferCancelTree,
@@ -93,6 +95,12 @@ export type DedicatedTransferResumeHandler = (task: TransferTask) => Promise<{
resetCheckpoint?: boolean;
}>;
interface RetainedCancellationTask {
task: TransferTask;
read(): TransferTask | undefined;
dispose(): void;
}
export interface SftpTransferCenterStore {
subscribe(listener: Listener): () => void;
/**
@@ -121,7 +129,10 @@ export interface SftpTransferCenterStore {
isResuming(taskId: string): boolean;
pause(taskId: string): Promise<void>;
resume(taskId: string): Promise<void>;
/** Wait for backend cancellation requests; live walks settle separately. */
cancel(taskId: string): Promise<void>;
/** Publish one cancellation outcome after the old execution has settled. */
settleCancellation(taskId: string, taskIds: ReadonlySet<string>, failedIds: ReadonlySet<string>, retainedTasks: readonly RetainedCancellationTask[], onSettled: () => Promise<void>): Promise<void>;
retry(taskId: string): Promise<void>;
prioritize(taskId: string): Promise<void>;
dismiss(taskId: string): void;
@@ -464,6 +475,8 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
let persistenceDirty = false;
let persistenceTimer: ReturnType<typeof setTimeout> | null = null;
const resumeInvocations = new Map<string, Promise<void>>();
const cancelInvocations = new Map<string, Promise<void>>();
const cancelSettlements = new Map<string, Promise<void>>();
// Ephemeral control intent: never serialize this or change bridge lifecycle epochs.
const resumeRequests = new Map<string, symbol>();
const resumeListeners = new Set<Listener>();
@@ -818,8 +831,7 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
// Compressed soft-control is process-global: do not gate on a live panel owner.
const liveCompressedJob = task?.controlKind === "compressed-upload"
&& task.reconnectRequired !== true
&& task.status !== "interrupted"
&& task.status !== "attention";
&& task.status !== "interrupted";
if (liveCompressedJob && (action === "pause" || action === "resume" || action === "cancel")) {
const bridge = netcattyBridge.get();
if (action === "pause") {
@@ -879,8 +891,17 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
if (!result.success && result.reason) notify.warning(result.reason, "SFTP");
return;
}
const result = await (bridge?.cancelCompressedUpload?.(taskId)
?? { success: false });
const result = await Promise.resolve(bridge?.cancelCompressedUpload?.(taskId)
?? { success: false }).catch(() => ({ success: false }));
// The upload may finish while this cancel is still awaiting IPC: its
// completion event has already repainted the row terminal. The new
// inactive-compression response still reports success, so recheck the
// latest row before applying the cancellation result — otherwise Cancel
// all misreports a successfully completed upload as cancelled.
const latestAfterCancelIpc = tasks.find((candidate) => candidate.id === taskId);
if (latestAfterCancelIpc && ["completed", "failed", "cancelled"].includes(latestAfterCancelIpc.status)) {
return;
}
if (!result.success) {
const reason = "Could not cancel the compressed upload.";
tasks = tasks.map((candidate) => candidate.id === taskId ? {
@@ -889,6 +910,13 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
error: reason,
speed: 0,
} : candidate);
if (isTransferOrRootCancelled(taskId)) {
// The row is kept in attention for recovery instead of settling as
// cancelled. Drop the pre-installed cancellation latch now, or a
// later Resume is rejected outright by admitTaskRun's cancelled-root
// check (compressed uploads have no runWalk settlement to clear it).
clearTransferCancelledTree(taskId);
}
emit();
notify.warning(reason, "SFTP");
return;
@@ -1301,9 +1329,9 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
emit();
}
}
if (!controller && action === "cancel" && task && ["paused", "interrupted", "attention", "pending", "queued", "transferring", "pausing"].includes(task.status)) {
if (!controller && action === "cancel" && task && !["completed", "cancelled"].includes(task.status)) {
const childIds = tasks
.filter((candidate) => candidate.parentTaskId === taskId)
.filter((candidate) => candidate.parentTaskId === taskId && !["completed", "cancelled", "failed"].includes(candidate.status))
.map((candidate) => candidate.id);
// Stop surviving processTransfer walks immediately (panel-local
// cancelledTasksRef is gone after unmount).
@@ -1315,33 +1343,43 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
} catch {
// best-effort
}
try {
await netcattyBridge.get()?.cancelTransfer?.(taskId);
for (const childId of childIds) {
try { await netcattyBridge.get()?.cancelTransfer?.(childId); } catch { /* best-effort */ }
const failedIds = new Set<string>();
const cancelIds = [taskId, ...childIds];
const cancelledIdSet = new Set(cancelIds);
const retainedTasks = tasks.filter((candidate) => cancelledIdSet.has(candidate.id))
.map((task) => ({ task, ...store.observeTaskSettlement(task) }));
for (let offset = 0; offset < cancelIds.length; offset += 32) {
await Promise.all(cancelIds.slice(offset, offset + 32).map(async (id) => {
try {
const result: unknown = await netcattyBridge.get()?.cancelTransfer?.(id);
if (result && typeof result === "object" && "success" in result && result.success === false) {
failedIds.add(id);
}
} catch {
failedIds.add(id);
}
}));
}
await store.settleCancellation(taskId, cancelledIdSet, failedIds, retainedTasks, async () => {
clearTransferCancelledTree(taskId, childIds);
if (failedIds.size > 0) {
const recoveryIds = cancelIds;
for (let offset = 0; offset < recoveryIds.length; offset += 32) {
await Promise.all(recoveryIds.slice(offset, offset + 32).map(async (id) => {
try { await netcattyBridge.get()?.clearPendingTransferCancel?.(id); } catch { /* best-effort */ }
}));
}
} else if (tasks.find((candidate) => candidate.id === taskId)?.status === "cancelled") {
try {
await netcattyBridge.get()?.cleanupTransferArtifacts?.({
transferId: taskId,
sourcePath: task.sourcePath,
targetPath: task.targetPath,
stagedTargetPath: task.stagedTargetPath,
});
} catch { /* best-effort */ }
}
} catch {
// Best-effort backend cancel when the owning panel is gone / no window.
}
try {
await netcattyBridge.get()?.cleanupTransferArtifacts?.({
transferId: taskId,
sourcePath: task.sourcePath,
targetPath: task.targetPath,
stagedTargetPath: task.stagedTargetPath,
});
} catch {
// best-effort temp/.part cleanup
}
const cancelIds = new Set([taskId, ...childIds]);
tasks = tasks.map((candidate) => cancelIds.has(candidate.id) ? {
...candidate,
status: "cancelled",
error: undefined,
endTime: Date.now(),
speed: 0,
conflict: undefined,
} : candidate);
});
emit();
return;
}
@@ -1794,10 +1832,28 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
&& ["paused", "interrupted", "attention", "pending", "queued"].includes(task.status);
},
pause(taskId) {
if (cancelInvocations.has(taskId) || isTransferOrRootCancelled(taskId)) return Promise.resolve();
clearResumeRequest(taskId);
return invoke(taskId, "pause");
},
async resume(taskId) {
// A cancelled scheduler job cannot be soft-resumed. Wait for the cancel
// outcome and fresh re-walk rather than unlatching the old walk.
const cancelling = cancelInvocations.get(taskId);
if (cancelling) {
await cancelling;
const task = tasks.find((candidate) => candidate.id === taskId);
if (!task || ["completed", "cancelled", "failed"].includes(task.status)) return;
}
// Failed cancellation reports immediately, but its old walk must still
// settle before Resume can start a fresh scan. A retried Cancel may have
// replaced the awaited outcome, so drain the latest settlement as well.
while (cancelSettlements.has(taskId)) await cancelSettlements.get(taskId);
const afterCancellation = tasks.find((candidate) => candidate.id === taskId);
if (!afterCancellation || ["completed", "cancelled"].includes(afterCancellation.status)) return;
// Cancel all latches every selected tree before its bounded IPC batches.
// A row-level Resume must not revive a tree whose batch has not run yet.
if (isTransferOrRootCancelled(taskId)) return;
const request = Symbol("resume");
resumeRequests.set(taskId, request);
notifyResume();
@@ -1913,9 +1969,52 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
if (resumeRequests.get(taskId) === request) clearResumeRequest(taskId);
}
},
async settleCancellation(taskId, taskIds, failedIds, retainedTasks, onSettled) {
const walkStillRunning = isTransferWalkInFlight(taskId);
const settlement = (async () => {
await waitForTransferWalkSettled(taskId);
// A later cancel attempt owns the outcome, even if this older attempt
// failed. Never repaint a successful retry as a failed cancellation.
if (cancelSettlements.get(taskId) !== settlement) return;
tasks = settleCancelledTransferTree(
tasks, taskId, taskIds, failedIds,
retainedTasks.map((retained) => retained.read() ?? retained.task),
);
await onSettled();
emit();
})().finally(() => {
for (const retained of retainedTasks) retained.dispose();
if (cancelSettlements.get(taskId) === settlement) cancelSettlements.delete(taskId);
});
cancelSettlements.set(taskId, settlement);
if (walkStillRunning) {
if (failedIds.size > 0) {
tasks = tasks.map((task) => task.id === taskId
&& !["completed", "failed"].includes(task.status)
? { ...task, status: "attention", error: "Could not cancel transfer. Please try again.", speed: 0 }
: task);
emit();
}
// Backend requests are done: let Cancel all dispatch its next batch.
// Resume, final status, and cleanup still wait on this settlement.
void settlement.catch(() => {});
return;
}
await settlement;
},
cancel(taskId) {
clearResumeRequest(taskId);
return invoke(taskId, "cancel");
const existing = cancelInvocations.get(taskId);
if (existing) return existing;
bumpTransferControlEpoch(taskId);
for (const task of tasks) {
if (task.parentTaskId === taskId) bumpTransferControlEpoch(task.id);
}
const running = invoke(taskId, "cancel").finally(() => {
if (cancelInvocations.get(taskId) === running) cancelInvocations.delete(taskId);
});
cancelInvocations.set(taskId, running);
return running;
},
async retry(taskId) {
const ownerId = findOwner(taskId);
@@ -58,6 +58,52 @@ test("normal shell exits do not auto reconnect", () => {
);
});
test("administrative idle, timeout, and exit-signal disconnects do not auto reconnect", () => {
for (const evt of [
{ reason: "timeout" as const },
{ reason: "timeout" as const, exitCode: 0 },
{ reason: "timeout" as const, error: "Connection timeout to example.com" },
{ reason: "signaled" as const, exitSignal: "SIGHUP" },
{ reason: "signaled" as const, exitSignal: "SIGTERM" },
{ reason: "signaled" as const },
// A path that still says "closed" but plumbed the exit-signal is the same kick.
{ reason: "closed" as const, exitSignal: "SIGUSR2" },
{ reason: "closed" as const, exitSignal: " SIGHUP " },
]) {
assert.equal(
shouldAutoReconnectAfterExit({
evt,
host: sshHost,
terminalSettings: { sshAutoReconnectEnabled: true },
hasEverConnected: true,
}),
false,
);
}
});
test("transport drops still auto reconnect", () => {
for (const evt of [
{ reason: "closed" as const },
{ reason: "error" as const, error: "connection reset" },
{ reason: "closed" as const, remoteClosedWithoutExitStatus: true },
{ reason: "closed" as const, exitSignal: "" },
{ reason: "closed" as const, exitSignal: " " },
{ reason: "error" as const, error: "read ECONNRESET", exitSignal: "" },
{ reason: "error" as const, error: "read ECONNRESET", exitSignal: "SIGHUP" },
]) {
assert.equal(
shouldAutoReconnectAfterExit({
evt,
host: sshHost,
terminalSettings: { sshAutoReconnectEnabled: true },
hasEverConnected: true,
}),
true,
);
}
});
test("auto reconnect ignores protocols that already have different lifecycle semantics", () => {
const variants = [
{ protocol: "local" as const, hostname: "localhost" },
+10 -1
View File
@@ -40,7 +40,16 @@ export function shouldAutoReconnectAfterExit({
if (!hasEverConnected) return false;
if (!isTerminalAutoReconnectEnabled(terminalSettings)) return false;
if (!isAutoReconnectableSshHost(host)) return false;
return evt.reason !== "exited";
// "exited" is a shell exit. "timeout" is an administrative idle/TMOUT logout
// (or a connect timeout). "signaled" — or a plumbed exit-signal on an
// otherwise "closed" end — is a server kick (idle policy, SIGHUP, SIGTERM).
// Reconnecting would fight that policy. Transport "error" and bare "closed"
// still retry.
if (evt.reason === "error") return true;
if (evt.reason === "exited" || evt.reason === "timeout" || evt.reason === "signaled") {
return false;
}
return typeof evt.exitSignal !== "string" || evt.exitSignal.trim().length === 0;
}
export function shouldContinueAutoReconnectAfterFailure({
+33
View File
@@ -86,6 +86,11 @@ import {
retargetWorkspaceActiveChatAfterMemberLoss,
seedWorkspaceAIActiveSessionFromMembers,
} from '../../domain/workspaceAiScopeHandoff';
import {
buildUndoLastTurnBranch,
type UndoLastTurnRestoredDraft,
} from '../../domain/aiUndoLastTurn';
function providerPatchIsNoop(
current: ProviderConfig,
@@ -697,6 +702,32 @@ export function useAIState() {
return session;
}, [defaultAgentId, persistSessions, setActiveSessionId]);
// Non-destructive "undo last turn": branch the session at the boundary
// before the latest user message and keep the original in history intact.
// Returns the new branch id plus the undone user message for the composer.
const undoLastTurnInSession = useCallback(async (sessionId: string): Promise<{
sessionId: string;
restored: UndoLastTurnRestoredDraft;
} | null> => {
const nextId = `ai_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
const source = sessionsRef.current.find(s => s.id === sessionId);
if (!source) return null;
const result = buildUndoLastTurnBranch(source, { newId: nextId, now: Date.now() });
if (!result) return null;
const branched = result.session;
setSessionsRaw(prev => {
// Keep both recently used chats within the existing history limit.
const next = [branched, { ...source, updatedAt: branched.updatedAt }, ...prev.filter(session => session.id !== source.id)];
setLatestAISessionsSnapshot(next);
persistSessions(next);
return next;
});
const scopeKey = `${branched.scope.type}:${branched.scope.targetId ?? ''}`;
setActiveSessionId(scopeKey, branched.id);
return { sessionId: branched.id, restored: result.restored };
}, [persistSessions, setActiveSessionId]);
const deleteSession = useCallback((sessionId: string, scopeKey?: string) => {
cleanupDeletedAIChatSessions([sessionId]);
if (persistTimerRef.current) {
@@ -1275,6 +1306,7 @@ export function useAIState() {
addDraftFiles,
removeDraftFile,
createSession,
undoLastTurnInSession,
deleteSession,
deleteSessionsByTarget,
updateSessionTitle,
@@ -1335,6 +1367,7 @@ export function useAIState() {
addDraftFiles,
removeDraftFile,
createSession,
undoLastTurnInSession,
deleteSession,
deleteSessionsByTarget,
updateSessionTitle,
@@ -0,0 +1,194 @@
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, UploadedFile } 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';
const SCOPE_KEY = 'terminal:terminal-a';
const SOURCE_SESSION: AISession = {
id: 'chat-source',
title: 'Undo source',
agentId: 'catty',
scope: { type: 'terminal', targetId: 'terminal-a' },
messages: [
{ id: 'user-1', role: 'user', content: 'first prompt', timestamp: 1 },
{ id: 'assistant-1', role: 'assistant', content: 'first answer', timestamp: 2 },
{
id: 'user-2',
role: 'user',
content: 'bad last prompt',
timestamp: 3,
attachments: [
{ base64Data: 'QUJD', mediaType: 'text/plain', filename: 'note.txt', lineCount: 2 },
],
},
{ id: 'assistant-2', role: 'assistant', content: 'bad answer', timestamp: 4 },
],
createdAt: 1,
updatedAt: 4,
};
type HookCapture = {
ai: ReturnType<typeof useAIState> | null;
sessions: AISession[];
activeSessionIdMap: Record<string, string | null>;
};
function UndoHarness({ capture }: { capture: HookCapture }) {
capture.ai = useAIState();
const store = useAISessionsStore();
capture.sessions = store.sessions as AISession[];
capture.activeSessionIdMap = store.activeSessionIdMap as Record<string, string | null>;
return null;
}
async function setupAiState(sessions: AISession[]) {
const dom = new JSDOM('<!doctype html><html><body></body></html>', {
url: 'http://localhost',
});
Object.defineProperty(globalThis, 'window', { configurable: true, value: dom.window });
Object.defineProperty(globalThis, 'document', { configurable: true, value: dom.window.document });
Object.defineProperty(globalThis, 'localStorage', { configurable: true, value: dom.window.localStorage });
Object.defineProperty(globalThis, 'CustomEvent', { configurable: true, value: dom.window.CustomEvent });
Object.defineProperty(globalThis, 'Event', { configurable: true, value: dom.window.Event });
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { configurable: true, value: true });
setLatestAISessionsSnapshot(sessions);
dom.window.localStorage.setItem('netcatty_ai_sessions_v1', JSON.stringify(sessions));
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
const capture: HookCapture = { ai: null, sessions: [], activeSessionIdMap: {} };
await act(async () => {
root.render(React.createElement(UndoHarness, { capture }));
});
return { dom, container, root, capture };
}
test('undoLastTurnInSession branches and restores the last user turn', async () => {
const { dom, container, root, capture } = await setupAiState([SOURCE_SESSION]);
assert.ok(capture.ai);
let result: Awaited<ReturnType<typeof runUndo>> | null = null;
async function runUndo() {
return capture.ai!.undoLastTurnInSession('chat-source');
}
await act(async () => {
result = await runUndo();
});
assert.ok(result);
assert.notEqual(result!.sessionId, 'chat-source');
const sessions = capture.sessions;
const branch = sessions.find((session) => session.id === result!.sessionId);
assert.ok(branch);
// Branch keeps the conversation up to the boundary before the last turn.
assert.deepEqual(
branch!.messages.map((message) => message.content),
['first prompt', 'first answer'],
);
// Original session stays intact (non-destructive).
const original = sessions.find((session) => session.id === 'chat-source');
assert.equal(original?.messages.length, 4);
// Branch becomes the active session for the scope.
assert.equal(capture.activeSessionIdMap[SCOPE_KEY], result!.sessionId);
const attachments = result!.restored.attachments as UploadedFile[];
assert.equal(result!.restored.text, 'bad last prompt');
assert.equal(attachments.length, 1);
assert.equal(attachments[0].filename, 'note.txt');
assert.equal(attachments[0].dataUrl, 'data:text/plain;base64,QUJD');
await act(async () => root.unmount());
container.remove();
dom.window.close();
});
test('undoLastTurnInSession returns null when there is nothing to undo', async () => {
const emptySession: AISession = {
...SOURCE_SESSION,
id: 'chat-empty',
messages: [],
};
const { dom, container, root, capture } = await setupAiState([emptySession]);
assert.ok(capture.ai);
let result: unknown;
await act(async () => {
result = await capture.ai!.undoLastTurnInSession('chat-empty');
});
assert.equal(result, null);
await act(async () => root.unmount());
container.remove();
dom.window.close();
});
test('undo copies chat only, preserving original outputs and job ownership', async () => {
const runtime = getAgentRuntime();
const outputStore = runtime.getToolOutputStore('chat-source');
const handle = outputStore.store({
chatSessionId: 'chat-source', capabilityId: 'terminal.exec', content: 'original saved output',
});
const stateStore = runtime.getSessionStateStore();
stateStore.updateFromToolResult('chat-source', 'terminal_start',
{ sessionId: 'terminal-a', command: 'long-running-command' },
JSON.stringify({ jobId: 'original-job', status: 'running', nextOffset: 7 }), false);
const source = structuredClone(SOURCE_SESSION);
source.messages[1].content = `Preview with handleId=${handle.id}`;
const { dom, container, root, capture } = await setupAiState([source]);
const original = JSON.stringify(source);
let result: { sessionId: string } | null = null;
await act(async () => { result = await capture.ai!.undoLastTurnInSession(source.id); });
assert.ok(result);
assert.equal(outputStore.read({ handleId: handle.id }, result!.sessionId), null);
assert.equal(outputStore.read({ handleId: handle.id }, source.id), 'original saved output');
assert.deepEqual(stateStore.get(result!.sessionId).activeJobs, {});
assert.ok(stateStore.get(source.id).activeJobs['original-job']);
assert.equal(JSON.stringify({ ...capture.sessions.find(item => item.id === source.id), updatedAt: source.updatedAt }), original);
assert.ok(capture.sessions.find(item => item.id === result!.sessionId)!.messages[1].content.includes('Preview'));
stateStore.clear(source.id);
outputStore.prune(source.id);
await act(async () => root.unmount());
container.remove(); dom.window.close();
});
for (const newerCount of [49, 50]) {
test(`undo retains an old source and its branch after reload with ${newerCount} newer chats`, async () => {
const newer = Array.from({ length: newerCount }, (_, index) => ({
...structuredClone(SOURCE_SESSION), id: `history-${index}`, updatedAt: 100 + index,
}));
const { dom, container, root, capture } = await setupAiState([...newer, SOURCE_SESSION]);
let branchId: string | undefined;
await act(async () => {
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);
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.
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));
await act(async () => reloaded.root.unmount());
reloaded.container.remove(); reloaded.dom.window.close();
});
}
+74
View File
@@ -0,0 +1,74 @@
import { useCallback, useEffect, useState } from "react";
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge";
import { editorTabStore } from "./editorTabStore";
import type { EditorWindowTabSnapshot } from "./editorWindowTypes";
import type { EditorTabId } from "./editorTabStore";
export function useEditorWindowRuntime() {
const [activeTabId, setActiveTabId] = useState<EditorTabId | null>(
() => editorTabStore.getTabs()[editorTabStore.getTabs().length - 1]?.id ?? null,
);
const closeWindow = useCallback(async () => {
await netcattyBridge.get()?.windowClose?.();
}, []);
const setWindowTitle = useCallback(async (title: string) => {
await netcattyBridge.get()?.setWindowTitle?.(title);
}, []);
useEffect(() => {
const bridge = netcattyBridge.get();
if (!bridge?.onEditorWindowOpenTab) return () => {};
return bridge.onEditorWindowOpenTab((snapshot: EditorWindowTabSnapshot) => {
const existing = editorTabStore.getTab(snapshot.editorId)
?? editorTabStore.getTabs().find((tab) => (
tab.sessionId === snapshot.sessionId && tab.remotePath === snapshot.remotePath
));
if (existing) {
if (existing.sessionId !== snapshot.sessionId) {
editorTabStore.remapSessionId(existing.sessionId, snapshot.sessionId);
}
if (existing.sftpTabId !== snapshot.sftpTabId || existing.hostLabel !== snapshot.hostLabel) {
editorTabStore.upsertFromSnapshot({
...snapshot,
content: existing.content,
baselineContent: existing.baselineContent,
viewState: existing.viewState,
wordWrap: existing.wordWrap,
languageId: existing.languageId,
}, "tab");
}
setActiveTabId(existing.id);
return;
}
const id = editorTabStore.upsertFromSnapshot(snapshot, "tab");
setActiveTabId(id);
});
}, []);
useEffect(() => {
const bridge = netcattyBridge.get();
if (!bridge?.onEditorWindowActivateTab) return () => {};
return bridge.onEditorWindowActivateTab((payload) => {
if (payload?.editorId && editorTabStore.getTab(payload.editorId)) {
setActiveTabId(payload.editorId);
}
});
}, []);
useEffect(() => {
const bridge = netcattyBridge.get();
if (!bridge?.onEditorWindowRemapSession) return () => {};
return bridge.onEditorWindowRemapSession((payload) => {
editorTabStore.remapSessionId(payload.fromSessionId, payload.toSessionId);
});
}, []);
return {
activeTabId,
setActiveTabId,
closeWindow,
setWindowTitle,
};
}
@@ -1,5 +1,8 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { createSessionIdleManager } from '../../electron/bridges/mcpServerBridge/sessionIdleManager.cjs';
import { STORAGE_KEY_AI_SESSION_IDLE_TIMEOUT_MINUTES } from '../../infrastructure/config/storageKeys';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
import {
EXTERNAL_MCP_RUNTIME_STATUS_POLL_MS,
bumpExternalMcpEnableGenerationForTests,
@@ -18,6 +21,7 @@ import {
resetExternalMcpStartupSyncOnceForTests,
shouldStartExternalMcpOnStartup,
shouldWaitForExternalMcpStartupReady,
syncExternalMcpConfig,
syncExternalMcpStartupState,
syncExternalMcpStartupStateOnce,
waitForExternalMcpStartupReady,
@@ -63,10 +67,44 @@ describe('useExternalMcpToggleState helpers', () => {
assert.equal(normalizeExternalMcpIdleTimeoutMinutes(null), 10);
assert.equal(normalizeExternalMcpIdleTimeoutMinutes(0), 1);
assert.equal(normalizeExternalMcpIdleTimeoutMinutes(99999), 24 * 60);
assert.equal(normalizeSessionIdleTimeoutMinutes(null), 30);
assert.equal(normalizeSessionIdleTimeoutMinutes(null), 8 * 60);
assert.equal(normalizeSessionIdleTimeoutMinutes(0), 1);
});
for (const savedMinutes of [null, 30]) {
it(`syncs the session idle timeout without replacing saved value ${savedMinutes}`, async () => {
const restore = installMemoryLocalStorage();
const manager = createSessionIdleManager();
try {
assert.equal(manager.getTimeoutMinutes(), 8 * 60, 'main-process default');
if (savedMinutes !== null) {
localStorageAdapter.writeNumber(STORAGE_KEY_AI_SESSION_IDLE_TIMEOUT_MINUTES, savedMinutes);
}
const expected = savedMinutes ?? manager.getTimeoutMinutes();
const received: number[] = [];
const bridge = {
externalMcpSetConfig: (config: { sessionIdleTimeoutMinutes: number }) => {
received.push(config.sessionIdleTimeoutMinutes);
manager.setTimeoutMinutes(config.sessionIdleTimeoutMinutes);
},
};
await syncExternalMcpStartupState(bridge);
assert.equal(manager.getTimeoutMinutes(), expected, 'startup keeps the intended timeout');
syncExternalMcpConfig(bridge);
assert.deepEqual(received, [expected, expected]);
assert.equal(manager.getTimeoutMinutes(), expected, 'config sync keeps the intended timeout');
assert.equal(
localStorageAdapter.readNumber(STORAGE_KEY_AI_SESSION_IDLE_TIMEOUT_MINUTES),
savedMinutes,
'sync does not migrate or overwrite the stored preference',
);
} finally {
manager.clearAll();
restore();
}
});
}
it('only starts on launch for persistent+enabled', () => {
assert.equal(shouldStartExternalMcpOnStartup({ enabled: true, mode: 'persistent' }), true);
assert.equal(shouldStartExternalMcpOnStartup({ enabled: true, mode: 'temporary' }), false);
@@ -18,7 +18,7 @@ export type ExternalMcpMode = 'temporary' | 'persistent';
const DEFAULT_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES = 10;
const MIN_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES = 1;
const MAX_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES = 24 * 60;
const DEFAULT_SESSION_IDLE_TIMEOUT_MINUTES = 30;
const DEFAULT_SESSION_IDLE_TIMEOUT_MINUTES = 480;
/** Keep top-bar / settings switch state aligned with runtime auto-disable. */
export const EXTERNAL_MCP_RUNTIME_STATUS_POLL_MS = 3000;
@@ -0,0 +1,147 @@
import assert from "node:assert/strict";
import test from "node:test";
import React from "react";
import { act, create, type ReactTestRenderer } from "react-test-renderer";
import type { TransferTask } from "../../domain/models";
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge";
import { sftpTransferCenterStore as store } from "./sftpTransferCenterStore";
import { useGlobalSftpTransferActions } from "./useGlobalSftpTransferActions";
import { isTransferOrRootCancelled, resetTransferCancelLatchesForTests } from "./sftp/transferCancelLatch";
import { resetTransferRuntimeRunsForTests, transferRuntime } from "./sftp/transferRuntime";
import { resetTransferWalkRegistryForTests } from "./sftp/transferWalkRegistry";
test("Cancel all releases a skipped late failure so one Retry executes while the old walk unwinds", async (t) => {
const previousAct = (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT;
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
const originalGet = netcattyBridge.get;
const tasks: TransferTask[] = Array.from({ length: 33 }, (_, index) => ({
id: `late-failure-${index}`, fileName: `${index}.bin`,
sourcePath: `/source/${index}`, targetPath: `/target/${index}`,
sourceConnectionId: "local", targetConnectionId: "remote", targetHostId: "host",
direction: "upload", status: "transferring", totalBytes: 8, transferredBytes: 0,
speed: 0, startTime: Date.now() + index, isDirectory: false, resumable: true,
}));
const later = tasks[32];
let release!: () => void;
const held = new Promise<void>((resolve) => { release = resolve; });
let renderer: ReactTestRenderer | undefined;
let actions: ReturnType<typeof useGlobalSftpTransferActions> | undefined;
function Probe() {
actions = useGlobalSftpTransferActions(store.getSnapshot().tasks);
return null;
}
store.publishOwner("closed-panel", tasks);
const walking = transferRuntime.runWalk(later.id, async () => { await held; });
t.after(async () => {
release();
await walking;
await act(async () => { renderer?.unmount(); });
store.setDedicatedResumeHandler(null);
for (const task of tasks) store.dismiss(task.id);
netcattyBridge.get = originalGet;
resetTransferCancelLatchesForTests();
resetTransferWalkRegistryForTests();
resetTransferRuntimeRunsForTests();
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: previousAct });
});
const cancellations: string[] = [];
netcattyBridge.get = () => ({
cancelTransfer: async (id: string) => {
cancellations.push(id);
if (id === tasks[0].id) store.patchTask(later.id, { status: "failed", error: "Connection lost" });
return { success: true };
},
resumeTransfer: async () => ({ success: false, reason: "Transfer is no longer active" }),
clearPendingTransferCancel: async () => {}, cleanupTransferArtifacts: async () => {},
} as unknown as ReturnType<typeof netcattyBridge.get>);
let retries = 0;
store.setDedicatedResumeHandler(async () => { retries += 1; return { success: true }; });
await act(async () => { renderer = create(React.createElement(Probe)); });
await act(async () => { await actions!.cancelAll(); });
assert.equal(cancellations.length, 32, "the late failure must be skipped by the second batch");
assert.equal(store.getTask(later.id)?.status, "failed");
assert.equal(store.getTask(later.id)?.error, "Connection lost");
assert.equal(transferRuntime.isWalkInFlight(later.id), true);
await store.retry(later.id);
assert.equal(retries, 1, "one Retry must reach recovery instead of being swallowed by the pre-latch");
assert.equal(store.getTask(later.id)?.status, "completed");
assert.equal(isTransferOrRootCancelled(later.id), false);
});
for (const firstCancelSucceeds of [true, false]) {
test(`Cancel all dispatches later batches before walk settlement: success=${firstCancelSucceeds}`, async (t) => {
const previousAct = (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT;
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
const originalGet = netcattyBridge.get;
const tasks: TransferTask[] = Array.from({ length: 33 }, (_, index) => ({
id: `dispatch-${firstCancelSucceeds}-${index}`, fileName: `${index}.bin`,
sourcePath: `/source/${index}`, targetPath: `/target/${index}`,
sourceConnectionId: "local", targetConnectionId: "remote", targetHostId: "host",
direction: "upload", status: "transferring", totalBytes: 8, transferredBytes: 0,
speed: 0, startTime: Date.now() + index, isDirectory: false, resumable: true,
}));
let release!: () => void;
const held = new Promise<void>((resolve) => { release = resolve; });
let renderer: ReactTestRenderer | undefined;
let actions: ReturnType<typeof useGlobalSftpTransferActions> | undefined;
let cancelling: Promise<void> | undefined;
function Probe() {
actions = useGlobalSftpTransferActions(store.getSnapshot().tasks);
return null;
}
store.publishOwner("closed-panel", tasks);
const first = tasks[0].id;
const walking = transferRuntime.runWalk(first, async () => { await held; });
t.after(async () => {
release();
await walking;
await act(async () => { await cancelling; renderer?.unmount(); });
store.setDedicatedResumeHandler(null);
for (const task of tasks) store.dismiss(task.id);
netcattyBridge.get = originalGet;
resetTransferCancelLatchesForTests();
resetTransferWalkRegistryForTests();
resetTransferRuntimeRunsForTests();
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: previousAct });
});
const calls: string[] = [];
const cleaned: string[] = [];
let active = 0;
let peak = 0;
let recoveryCalls = 0;
netcattyBridge.get = () => ({
cancelTransfer: async (id: string) => {
calls.push(id);
peak = Math.max(peak, ++active);
await new Promise((resolve) => setImmediate(resolve));
active -= 1;
return { success: id !== first || firstCancelSucceeds };
},
clearPendingTransferCancel: async () => {},
cleanupTransferArtifacts: async ({ transferId }: { transferId: string }) => { cleaned.push(transferId); },
} as unknown as ReturnType<typeof netcattyBridge.get>);
store.setDedicatedResumeHandler(async () => { recoveryCalls += 1; return { success: true }; });
await act(async () => { renderer = create(React.createElement(Probe)); });
await act(async () => {
cancelling = actions!.cancelAll();
for (let tick = 0; tick < 5; tick += 1) await new Promise((resolve) => setImmediate(resolve));
});
assert.equal(calls.length, 33, "the later active stream must receive cancellation before the first walk finishes");
assert.ok(peak > 1 && peak <= 32, "backend cancellation remains bounded");
assert.equal(transferRuntime.isWalkInFlight(first), true);
assert.equal(isTransferOrRootCancelled(first), true, "dispatch completion must not clear the old walk latch");
assert.equal(cleaned.includes(first), false, "artifact cleanup still waits for settlement");
let resumeFinished = false;
const resuming = store.resume(first).then(() => { resumeFinished = true; });
await new Promise((resolve) => setImmediate(resolve));
assert.equal(resumeFinished, false, "Resume must still wait for the cancelling walk");
assert.equal(recoveryCalls, 0);
release();
await Promise.all([walking, cancelling, resuming]);
assert.equal(recoveryCalls, firstCancelSucceeds ? 0 : 1);
assert.equal(store.getTask(first)?.status, firstCancelSucceeds ? "cancelled" : "completed");
});
}
@@ -1,28 +1,95 @@
import { useCallback, useMemo } from "react";
import { useCallback, useMemo, useRef, useState } from "react";
import type { TransferTask } from "../../domain/models";
import {
getGlobalTransferBatchEligibility,
listGloballyCancellableTransferIds,
listGloballyPausableTransferIds,
listGloballyResumableTransferIds,
} from "../../domain/sftpTransferActions";
import { sftpTransferCenterStore } from "./sftpTransferCenterStore";
import { globalSftpTransferScheduler } from "./sftp/globalTransferScheduler";
import { clearTransferCancelledTree, markTransferCancelledTree } from "./sftp/transferCancelLatch";
import { transferRuntime } from "./sftp/transferRuntime";
export function useGlobalSftpTransferActions(tasks: readonly TransferTask[]) {
const cancellingRef = useRef(false);
const [isCancelling, setIsCancelling] = useState(false);
const batchEligibility = useMemo(
() => getGlobalTransferBatchEligibility(tasks),
[tasks],
);
const pauseAll = useCallback(() => {
// Ignore clicks racing a cancelAll batch: re-enabling these tasks would
// restart work cancelAll has already pre-cancelled at the scheduler.
if (cancellingRef.current) return;
for (const taskId of listGloballyPausableTransferIds(tasks)) {
void sftpTransferCenterStore.pause(taskId);
}
}, [tasks]);
const resumeAll = useCallback(() => {
if (cancellingRef.current) return;
for (const taskId of listGloballyResumableTransferIds(tasks)) {
void sftpTransferCenterStore.resume(taskId);
}
}, [tasks]);
return { batchEligibility, pauseAll, resumeAll };
const cancelAll = useCallback(async () => {
if (cancellingRef.current) return;
cancellingRef.current = true;
setIsCancelling(true);
const currentTasks = transferRuntime.getSnapshot().tasks;
const ids = listGloballyCancellableTransferIds(currentTasks);
try {
// Stop queued work before yielding to IPC, including children of folder
// transfers. Otherwise later batches could start while earlier ones stop.
const selected = new Set(ids);
const children = new Map<string, string[]>();
for (const task of currentTasks) {
if (!task.parentTaskId || !selected.has(task.parentTaskId)
|| ["completed", "failed", "cancelled"].includes(task.status)) continue;
const idsForParent = children.get(task.parentTaskId) ?? [];
idsForParent.push(task.id);
children.set(task.parentTaskId, idsForParent);
globalSftpTransferScheduler.cancel(task.id);
}
for (const id of ids) {
markTransferCancelledTree(id, children.get(id));
globalSftpTransferScheduler.cancel(id);
}
// Bound IPC work for large queues while active transfers stop promptly.
for (let offset = 0; offset < ids.length; offset += 32) {
await Promise.all(ids.slice(offset, offset + 32).map(async (taskId) => {
const current = transferRuntime.getTask(taskId);
// "failed" is terminal here just like the initial eligibility filter:
// a queued task can fail naturally while its cancel batch awaits IPC,
// and cancel's owner/orphan recovery paths would repaint the row as
// cancelled, hiding the real failure and its diagnostic.
if (!current || ["completed", "failed", "cancelled"].includes(current.status)) {
// No cancel invocation will own this pre-latch. Release it here so
// Retry is not discarded while the finished walk is unwinding.
clearTransferCancelledTree(taskId, children.get(taskId));
return;
}
try {
await transferRuntime.cancel(taskId);
} catch {
// The row is kept in attention for recovery, so the pre-installed
// cancellation latch must be dropped or a later Resume is rejected
// outright by admitTaskRun's cancelled-root check.
clearTransferCancelledTree(taskId, children.get(taskId));
sftpTransferCenterStore.patchTask(taskId, {
status: "attention",
error: "Could not cancel transfer. Please try again.",
});
}
}));
}
} finally {
cancellingRef.current = false;
setIsCancelling(false);
}
}, []);
return { batchEligibility, pauseAll, resumeAll, cancelAll, isCancelling };
}
@@ -0,0 +1,108 @@
import assert from "node:assert/strict";
import test, { mock } from "node:test";
import React from "react";
import { act, create, type ReactTestRenderer } from "react-test-renderer";
import type { Host } from "../../domain/models.ts";
import { useHostConnectionTest } from "./useHostConnectionTest.ts";
type HookApi = ReturnType<typeof useHostConnectionTest>;
const targetHost: Host = {
id: "host-1",
label: "Target",
hostname: "target.example.test",
port: 22,
username: "alice",
password: "secret",
tags: [],
os: "linux",
protocol: "ssh",
// Shorter than the fixed 15s test auth timeout sent to the bridge.
sshAuthReadyTimeoutSeconds: 5,
};
test("connection test countdown follows the probe timeouts and invalidates the attempt on timeout", async (t) => {
const actEnvironment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
const previousWindowDescriptor = Object.getOwnPropertyDescriptor(globalThis, "window");
let resolveProbe: (value: unknown) => void = () => {};
const cancelled: string[] = [];
const startedSessionIds: string[] = [];
const bridge = {
testConnection: (options: { sessionId: string }) => {
startedSessionIds.push(options.sessionId);
return new Promise((resolve) => {
resolveProbe = resolve;
});
},
cancelTestConnection: async (sessionId: string) => {
cancelled.push(sessionId);
return { success: true };
},
onChainProgress: () => () => {},
onHostKeyVerification: () => () => {},
respondHostKeyVerification: async () => ({ success: true }),
};
Object.defineProperty(globalThis, "window", {
configurable: true,
writable: true,
value: { netcatty: bridge },
});
mock.timers.enable({ apis: ["setTimeout", "setInterval"] });
let api: HookApi | null = null;
let renderer: ReactTestRenderer | null = null;
const Probe = () => {
api = useHostConnectionTest({ host: targetHost, hosts: [targetHost], keys: [] });
return null;
};
const current = () => {
assert.ok(api);
return api;
};
t.after(async () => {
await act(async () => {
renderer?.unmount();
});
mock.timers.reset();
if (previousWindowDescriptor) Object.defineProperty(globalThis, "window", previousWindowDescriptor);
else delete (globalThis as { window?: unknown }).window;
actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
});
await act(async () => {
renderer = create(React.createElement(Probe));
});
await act(async () => {
void current().startTest();
});
assert.equal(current().state.status, "connecting");
// 8s TCP + 15s auth sent to the bridge, plus a 2s grace.
assert.equal(current().state.timeLeft, 25);
// The host's own 5s auth timeout must not end the test early.
await act(async () => {
mock.timers.tick(6000);
});
assert.equal(current().state.status, "connecting");
assert.deepEqual(cancelled, []);
await act(async () => {
mock.timers.tick(19_000);
});
assert.equal(current().state.status, "disconnected");
assert.equal(current().state.error, "Connection timed out. Please try again.");
assert.deepEqual(cancelled, startedSessionIds, "timeout must cancel the in-flight probe");
// A late success from the cancelled probe must not overwrite the timeout.
await act(async () => {
resolveProbe({ sessionId: startedSessionIds[0], testResult: "connected" });
await Promise.resolve();
});
assert.equal(current().state.status, "disconnected");
assert.equal(current().state.error, "Connection timed out. Please try again.");
});
+329
View File
@@ -0,0 +1,329 @@
import { useCallback, useEffect, useRef, useState } from "react";
import type { GroupConfig, Host, Identity, KnownHost, ProxyProfile, SSHKey, TerminalSettings } from "../../domain/models";
import type { HostKeyInfo } from "../../domain/hostKey";
import { toHostKeyInfo } from "../../domain/hostKey";
import { createKnownHostFromHostKeyInfo } from "../../domain/knownHosts";
import {
buildHostConnectionTestPlan,
formatConnectionTestProgressLog,
resolveHostConnectionTestDeadlineMs,
type HostConnectionTestAuthOverride,
} from "../../domain/hostConnectionTest";
import { useTerminalBackend } from "./useTerminalBackend";
export type ChainProgress = {
currentHop: number;
totalHops: number;
currentHostLabel: string;
connectionPhase: string;
} | null;
export type HostKeyVerificationState = {
requestId: string;
hostKeyInfo: HostKeyInfo;
};
export type HostConnectionTestStatus = "idle" | "connecting" | "connected" | "disconnected";
export type HostConnectionTestState = {
status: HostConnectionTestStatus;
error: string | null;
needsAuth: boolean;
progressValue: number;
chainProgress: ChainProgress;
progressLogs: string[];
hostKeyVerification: HostKeyVerificationState | null;
timeLeft: number;
isAwaitingUserInput: boolean;
isCancelling: boolean;
};
export type HostConnectionTestInput = {
host: Host;
hosts: Host[];
keys: SSHKey[];
identities?: Identity[];
knownHosts?: KnownHost[];
groupConfigs?: GroupConfig[];
proxyProfiles?: ProxyProfile[];
terminalSettings?: TerminalSettings;
onAddKnownHost?: (knownHost: KnownHost) => void;
};
const messageOf = (err: unknown): string => (err instanceof Error ? err.message : String(err));
export function useHostConnectionTest(input: HostConnectionTestInput) {
const {
host,
hosts,
keys,
identities = [],
knownHosts,
groupConfigs = [],
proxyProfiles = [],
terminalSettings,
onAddKnownHost,
} = input;
const backend = useTerminalBackend();
const [status, setStatus] = useState<HostConnectionTestStatus>("idle");
const [error, setError] = useState<string | null>(null);
const [needsAuth, setNeedsAuth] = useState(false);
const [progressValue, setProgressValue] = useState(0);
const [chainProgress, setChainProgress] = useState<ChainProgress>(null);
const [progressLogs, setProgressLogs] = useState<string[]>([]);
const [hostKeyVerification, setHostKeyVerification] = useState<HostKeyVerificationState | null>(null);
const [timeLeft, setTimeLeft] = useState(0);
const [isAwaitingUserInput, setIsAwaitingUserInput] = useState(false);
const [isCancelling, setIsCancelling] = useState(false);
const sessionIdRef = useRef<string | null>(null);
const bootEpochRef = useRef(0);
const pendingHostKeyRequestIdRef = useRef<string | null>(null);
const disposedRef = useRef(false);
// Deadline of the active attempt, derived from the options actually sent to
// the bridge so the countdown matches the probe's real timeouts.
const deadlineMsRef = useRef(0);
const resetState = useCallback(() => {
setError(null);
setNeedsAuth(false);
setProgressValue(0);
setChainProgress(null);
setProgressLogs([]);
setHostKeyVerification(null);
setIsAwaitingUserInput(false);
setIsCancelling(false);
setTimeLeft(0);
pendingHostKeyRequestIdRef.current = null;
}, []);
const clearSession = useCallback(() => {
const sessionId = sessionIdRef.current;
sessionIdRef.current = null;
if (sessionId && !disposedRef.current) {
void backend.cancelTestConnection(sessionId).catch(() => {});
}
}, [backend]);
// Unmount cleanup. The per-attempt subscriptions below dispose themselves via
// their own effect returns; nothing here touches them.
useEffect(() => {
disposedRef.current = false;
return () => {
disposedRef.current = true;
};
}, []);
// Subscribe to chain progress for the active attempt.
useEffect(() => {
const unsub = backend.onChainProgress?.((sessionId, hop, total, label, phase, phaseError) => {
if (sessionId !== sessionIdRef.current) return;
if (total > 1) {
setChainProgress({
currentHop: hop,
totalHops: total,
currentHostLabel: label,
connectionPhase: phase,
});
}
setProgressLogs((prev) => [
...prev,
formatConnectionTestProgressLog({ hop, total, label, phase, error: phaseError }),
]);
const hopProgress = (hop / total) * 80 + 10;
setProgressValue((prev) => Math.max(prev, Math.min(95, hopProgress)));
setIsAwaitingUserInput(phase === "auth-attempt" && phaseError === "waiting for user input...");
});
return () => unsub?.();
}, [backend]);
// Subscribe to host-key verification for the active attempt.
useEffect(() => {
const unsub = backend.onHostKeyVerification?.((request) => {
if (request.sessionId !== sessionIdRef.current) return;
pendingHostKeyRequestIdRef.current = request.requestId;
setHostKeyVerification({
requestId: request.requestId,
hostKeyInfo: toHostKeyInfo(request),
});
});
return () => unsub?.();
}, [backend]);
const respondHostKey = useCallback(
(requestId: string, accept: boolean, addToKnownHosts: boolean, hostKeyInfo: HostKeyInfo) => {
if (accept && addToKnownHosts && onAddKnownHost) {
onAddKnownHost(createKnownHostFromHostKeyInfo(hostKeyInfo));
}
void backend.respondHostKeyVerification(requestId, accept, addToKnownHosts);
pendingHostKeyRequestIdRef.current = null;
setHostKeyVerification(null);
},
[backend, onAddKnownHost],
);
const handleHostKeyContinue = useCallback(() => {
if (!hostKeyVerification) return;
respondHostKey(hostKeyVerification.requestId, true, false, hostKeyVerification.hostKeyInfo);
}, [hostKeyVerification, respondHostKey]);
const handleHostKeyAddAndContinue = useCallback(() => {
if (!hostKeyVerification) return;
respondHostKey(hostKeyVerification.requestId, true, true, hostKeyVerification.hostKeyInfo);
}, [hostKeyVerification, respondHostKey]);
const handleHostKeyClose = useCallback(() => {
if (!hostKeyVerification) return;
respondHostKey(hostKeyVerification.requestId, false, false, hostKeyVerification.hostKeyInfo);
}, [hostKeyVerification, respondHostKey]);
const startTest = useCallback(
async (authOverride?: HostConnectionTestAuthOverride | null) => {
clearSession();
resetState();
const sessionId = crypto.randomUUID();
const bootEpoch = (bootEpochRef.current += 1);
sessionIdRef.current = sessionId;
const plan = buildHostConnectionTestPlan({
host,
hosts,
keys,
identities,
knownHosts,
groupConfigs,
proxyProfiles,
terminalSettings: terminalSettings
? {
keepaliveInterval: terminalSettings.keepaliveInterval,
keepaliveCountMax: terminalSettings.keepaliveCountMax,
verifyHostKeys: terminalSettings.verifyHostKeys,
}
: undefined,
sessionId,
bootEpoch,
authOverride,
});
if (!plan.ok) {
setError(plan.error);
setStatus("disconnected");
return;
}
if (plan.needsCredentialReentry) {
setNeedsAuth(true);
setStatus("connecting");
return;
}
deadlineMsRef.current = resolveHostConnectionTestDeadlineMs(plan.options);
setStatus("connecting");
setIsAwaitingUserInput(false);
try {
await backend.testConnection(plan.options);
if (disposedRef.current || sessionIdRef.current !== sessionId) return;
setProgressValue(100);
setChainProgress(null);
setStatus("connected");
} catch (err) {
if (disposedRef.current || sessionIdRef.current !== sessionId) return;
setError(messageOf(err));
setStatus("disconnected");
}
},
[
backend,
clearSession,
groupConfigs,
host,
hosts,
identities,
keys,
knownHosts,
proxyProfiles,
resetState,
terminalSettings,
],
);
const submitAuth = useCallback(
(auth: HostConnectionTestAuthOverride) => {
void startTest(auth);
},
[startTest],
);
const cancelTest = useCallback(() => {
setIsCancelling(true);
// Reject any pending host-key prompt so the dial fails fast, then clear
// this attempt's session. An in-flight TCP/auth dial without a prompt
// settles on its own (it is not registered as a terminal session).
if (pendingHostKeyRequestIdRef.current) {
void backend.respondHostKeyVerification(pendingHostKeyRequestIdRef.current, false, false);
pendingHostKeyRequestIdRef.current = null;
setHostKeyVerification(null);
}
clearSession();
setStatus("idle");
setNeedsAuth(false);
setError(null);
}, [backend, clearSession]);
// Connection timeout countdown + gentle progress while connecting.
useEffect(() => {
if (status !== "connecting" || needsAuth || hostKeyVerification || isAwaitingUserInput) {
return;
}
const deadlineMs = deadlineMsRef.current;
if (deadlineMs <= 0) return;
setTimeLeft(Math.ceil(deadlineMs / 1000));
const countdown = setInterval(() => {
setTimeLeft((prev) => (prev > 0 ? prev - 1 : 0));
}, 1000);
const timeout = setTimeout(() => {
// Cancel the probe and invalidate this attempt so a late success or
// failure from the bridge cannot overwrite the timeout outcome.
clearSession();
setError("Connection timed out. Please try again.");
setStatus("disconnected");
}, deadlineMs);
const prog = setInterval(() => {
setProgressValue((prev) => {
if (prev >= 95) return prev;
const remaining = 95 - prev;
return Math.min(95, prev + Math.max(1, remaining * 0.15));
});
}, 200);
return () => {
clearInterval(countdown);
clearTimeout(timeout);
clearInterval(prog);
};
}, [status, needsAuth, hostKeyVerification, isAwaitingUserInput, clearSession]);
return {
state: {
status,
error,
needsAuth,
progressValue,
chainProgress,
progressLogs,
hostKeyVerification,
timeLeft,
isAwaitingUserInput,
isCancelling,
} satisfies HostConnectionTestState,
startTest,
submitAuth,
cancelTest,
retry: () => void startTest(),
handleHostKeyContinue,
handleHostKeyAddAndContinue,
handleHostKeyClose,
};
}
@@ -0,0 +1,148 @@
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 type { Host, Identity, SSHKey, TerminalSession } from '../../domain/models';
import { updateRestoredSessionStatusState } from './sessionRestoreState';
import { resolveTerminalSessionHost } from '../../domain/terminalHostResolution';
import { createHostTerminalSession } from './sessionFactories';
import { useHostOpenSessionReuse } from './useHostOpenSessionReuse';
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const effective = (host: Host) => host;
const host = (extra: Partial<Host> = {}): Host => ({
id: 'host', hostname: 'original.invalid', username: 'user', label: 'Original', os: 'linux', tags: [], protocol: 'ssh', ...extra,
});
async function harness(initial: Host, extra: { hosts?: Host[]; keys?: SSHKey[]; identities?: Identity[]; sessions?: TerminalSession[] } = {}) {
let hosts = [initial, ...(extra.hosts ?? [])];
let keys = extra.keys ?? [];
let identities = extra.identities ?? [];
let sessions: TerminalSession[] = extra.sessions ?? [{ ...createHostTerminalSession('tab', initial), status: 'connected' }];
let find: ReturnType<typeof useHostOpenSessionReuse>;
function Harness() {
find = useHostOpenSessionReuse({ sessions, hosts, keys, identities, resolveEffectiveHost: effective });
return null;
}
let renderer: ReactTestRenderer;
await act(async () => { renderer = create(React.createElement(Harness)); });
return {
find: () => find(initial.id),
async update(input: { host?: Host; hosts?: Host[]; keys?: SSHKey[]; identities?: Identity[]; sessions?: TerminalSession[] }) {
hosts = input.hosts ?? (input.host ? [input.host, ...hosts.slice(1)] : hosts);
keys = input.keys ?? keys;
identities = input.identities ?? identities;
sessions = input.sessions ?? sessions;
await act(async () => renderer.update(React.createElement(Harness)));
},
close: () => act(async () => renderer.unmount()),
};
}
for (const [field, value] of [
['hostname', 'replacement.invalid'], ['username', 'other-user'], ['port', 2222],
['protocol', 'telnet'], ['password', 'new-password'], ['authMethod', 'key'],
['hostChain', { hostIds: ['jump'] }],
['proxyConfig', { type: 'socks5', host: 'proxy.invalid', port: 1080 }],
] as const) {
test(`a saved ${field} edit cannot reuse the old tab`, async (t) => {
const original = host({ password: 'old-password', authMethod: 'password' });
const api = await harness(original, { hosts: [host({ id: 'jump', hostname: 'jump.invalid' })] });
t.after(api.close);
assert.equal((await api.find())?.sessionId, 'tab');
await api.update({ host: { ...original, [field]: value } });
assert.equal(await api.find(), undefined);
});
}
test('renaming the target and jump host keeps the same tab eligible', async (t) => {
const original = host({ hostChain: { hostIds: ['jump'] } });
const jump = host({ id: 'jump', hostname: 'jump.invalid' });
const api = await harness(original, { hosts: [jump] });
t.after(api.close);
await api.update({ hosts: [{ ...original, label: 'Renamed', fontSize: 16, notes: 'Updated note' }, { ...jump, label: 'Renamed jump' }] });
assert.equal((await api.find())?.sessionId, 'tab');
});
test('changing a jump endpoint or its password rejects the original connection', async (t) => {
const original = host({ hostChain: { hostIds: ['jump'] } });
const jump = host({ id: 'jump', hostname: 'jump.invalid', password: 'old' });
const api = await harness(original, { hosts: [jump] });
t.after(api.close);
for (const changed of [{ ...jump, hostname: 'new-jump.invalid' }, { ...jump, password: 'new' }]) {
await api.update({ hosts: [original, changed] });
assert.equal(await api.find(), undefined);
}
});
test('rotating a referenced identity or key keeps stable IDs from masking auth changes', async (t) => {
const identity: Identity = { id: 'identity', label: 'Login', username: 'user', password: 'old', authMethod: 'password', created: 0 };
const original = host({ identityId: identity.id });
const api = await harness(original, { identities: [identity] });
t.after(api.close);
assert.equal((await api.find())?.sessionId, 'tab');
await api.update({ identities: [{ ...identity, password: 'rotated' }] });
assert.equal(await api.find(), undefined);
const key: SSHKey = { id: 'key', label: 'Key', type: 'ED25519', privateKey: 'old-key', source: 'generated', category: 'key', created: 0 };
const keyHost = host({ identityFileId: key.id, authMethod: 'key' });
const keyApi = await harness(keyHost, { keys: [key] });
t.after(keyApi.close);
assert.equal((await keyApi.find())?.sessionId, 'tab');
await keyApi.update({ keys: [{ ...key, privateKey: 'rotated-key' }] });
assert.equal(await keyApi.find(), undefined);
});
for (const [field, value] of [
['baudRate', 19200], ['dataBits', 7], ['stopBits', 2], ['parity', 'even'], ['flowControl', 'rts/cts'],
] as const) {
test(`a serial ${field} edit must not reuse the opening parameters`, async (t) => {
const original = host({ protocol: 'serial', hostname: '/dev/tty.test', username: '', serialConfig: { path: '/dev/tty.test', baudRate: 9600, dataBits: 8, stopBits: 1, parity: 'none', flowControl: 'none' } });
const api = await harness(original);
t.after(api.close);
assert.equal((await api.find())?.sessionId, 'tab');
await api.update({ host: { ...original, serialConfig: { ...original.serialConfig!, [field]: value } } });
assert.equal(await api.find(), undefined);
});
}
test('unchanged legacy serial defaults still match their opening snapshot', async (t) => {
const original = host({ protocol: 'serial', hostname: '/dev/tty.test', username: '', port: 9600 });
const api = await harness(original);
t.after(api.close);
assert.equal((await api.find())?.sessionId, 'tab');
});
test('the captured serial settings come from the opening session even if the vault was already edited', async (t) => {
const original = host({ protocol: 'serial', hostname: '/dev/tty.test', username: '', serialConfig: { path: '/dev/tty.test', baudRate: 9600 } });
const session: TerminalSession = { ...createHostTerminalSession('tab', original), status: 'connected' };
const api = await harness({ ...original, serialConfig: { ...original.serialConfig!, baudRate: 19200 } }, { sessions: [session] });
t.after(api.close);
assert.equal(await api.find(), undefined);
});
for (const changedField of ['configuration', 'credentialId'] as const) {
test(`a restored plugin tab keeps its opening ${changedField} after manual reconnect`, async (t) => {
const providerId = 'com.example.transport.connection';
const original = host({ protocol: `plugin:${providerId}`, pluginConnection: { providerId, configuration: { endpoint: 'original.invalid' }, credentialId: 'original-credential-reference' } });
const edited = { ...original, pluginConnection: { ...original.pluginConnection!, [changedField]: changedField === 'configuration' ? { endpoint: 'replacement.invalid' } : 'replacement-credential-reference' } };
const restored: TerminalSession = { ...createHostTerminalSession('tab', original), status: 'disconnected', restoreState: 'restored-disconnected' };
const api = await harness(edited, { sessions: [restored] });
t.after(api.close);
assert.equal(await api.find(), undefined);
const reconnecting = updateRestoredSessionStatusState([restored], restored.id, 'connecting');
const actual = resolveTerminalSessionHost({ session: reconnecting[0], hosts: [edited], groupConfigs: [], proxyProfiles: [], localOs: 'linux' });
assert.deepEqual(actual.pluginConnection, original.pluginConnection, 'the real terminal resolver uses the restored opening configuration');
await api.update({ sessions: reconnecting });
assert.equal(await api.find(), undefined, 'host_open must not call that old transport the edited vault connection');
});
}
test('an unchanged restored plugin configuration can be reused after manual reconnect', async (t) => {
const providerId = 'com.example.transport.connection';
const original = host({ protocol: `plugin:${providerId}`, pluginConnection: { providerId, configuration: { endpoint: 'original.invalid' } } });
const restored: TerminalSession = { ...createHostTerminalSession('tab', original), status: 'disconnected', restoreState: 'restored-disconnected' };
const api = await harness({ ...original, label: 'Renamed' }, { sessions: [restored] });
t.after(api.close);
await api.update({ sessions: updateRestoredSessionStatusState([restored], restored.id, 'connecting') });
assert.equal((await api.find())?.sessionId, 'tab');
});
@@ -0,0 +1,85 @@
import { useCallback, useLayoutEffect, useRef } from 'react';
import type { Host, Identity, SSHKey, TerminalSession } from '../../domain/models';
import { findReusableHostSession } from '../../domain/hostOpenSession';
import { buildSftpHostCredentials } from './sftp/useSftpHostCredentials';
import { buildTransferPoolKey } from './sftp/transferConnectionPool';
import { createHostTerminalSession } from './sessionFactories';
type HostOpenSessionReuseInput = {
sessions: TerminalSession[];
hosts: Host[];
keys: SSHKey[];
identities: Identity[];
resolveEffectiveHost: (host: Host) => Host;
};
/** Retain the opening connection identity, never credentials in session storage. */
export function useHostOpenSessionReuse({ sessions, hosts, keys, identities, resolveEffectiveHost }: HostOpenSessionReuseInput) {
const openingKeys = useRef(new Map<string, Promise<string | undefined>>());
const connectionKey = useCallback((host: Host) => {
// Reuse the existing credential/chain expansion and transport identity
// comparison. Presentation names and SFTP-only choices do not change a
// terminal's destination or login.
const options = buildSftpHostCredentials({
host, hosts: hosts.map(resolveEffectiveHost), keys, identities,
});
delete options.sudo;
delete options.fileProtocol;
for (const hop of options.jumpHosts ?? []) delete hop.label;
const connectionOptions = {
...options,
protocol: host.protocol || 'ssh',
moshEnabled: host.moshEnabled,
moshServerPath: host.moshServerPath,
etEnabled: host.etEnabled,
etPort: host.etPort,
pluginConnection: host.pluginConnection,
serialConfig: host.protocol === 'serial' ? createHostTerminalSession('', host).serialConfig : undefined,
agentForwarding: host.agentForwarding,
x11Forwarding: host.x11Forwarding,
};
return buildTransferPoolKey({ ...options, protocol: connectionOptions.protocol, connectionOptions });
}, [hosts, keys, identities, resolveEffectiveHost]);
useLayoutEffect(() => {
const liveIds = new Set(sessions.map((session) => session.id));
for (const id of openingKeys.current.keys()) {
if (!liveIds.has(id)) openingKeys.current.delete(id);
}
for (const session of sessions) {
if (openingKeys.current.has(session.id) || session.restoreState) continue;
const host = hosts.find((candidate) => candidate.id === session.hostId);
if (!host) continue;
try {
openingKeys.current.set(session.id, connectionKey({
...resolveEffectiveHost(host),
hostname: session.hostname,
username: session.username,
port: session.port ?? host.port,
protocol: session.protocol ?? host.protocol,
moshEnabled: session.moshEnabled ?? host.moshEnabled,
etEnabled: session.etEnabled ?? host.etEnabled,
serialConfig: session.serialConfig ?? host.serialConfig,
pluginConnection: session.pluginConnection ?? host.pluginConnection,
}).catch(() => undefined));
} catch {
openingKeys.current.set(session.id, Promise.resolve(undefined));
}
}
}, [sessions, hosts, connectionKey, resolveEffectiveHost]);
return useCallback(async (hostId: string) => {
const host = hosts.find((candidate) => candidate.id === hostId);
if (!host) return undefined;
try {
const requestedKey = await connectionKey(resolveEffectiveHost(host));
const matches = await Promise.all(sessions.map(async (session) => (
session.hostId === hostId && await openingKeys.current.get(session.id) === requestedKey ? session : undefined
)));
return findReusableHostSession(matches.filter((session) => session !== undefined), hostId);
} catch {
// An unresolved/changed credential must never select an old transport.
return undefined;
}
}, [hosts, sessions, connectionKey, resolveEffectiveHost]);
}
@@ -0,0 +1,27 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import {
parseScriptsViewMode,
SCRIPTS_VIEW_MODE_STORAGE_KEY,
} from "./useScriptsViewMode.ts";
test("scripts view mode parser only accepts 'stacked'", () => {
assert.equal(parseScriptsViewMode("stacked"), "stacked");
assert.equal(parseScriptsViewMode("list"), "list");
assert.equal(parseScriptsViewMode("grid"), "list");
assert.equal(parseScriptsViewMode(null), "list");
assert.equal(parseScriptsViewMode(""), "list");
});
test("scripts view mode hook persists the chosen mode", () => {
const source = readFileSync(new URL("./useScriptsViewMode.ts", import.meta.url), "utf8");
assert.match(source, /useStoredString/);
// The shared hook owns persistence and same-/cross-window subscriptions.
assert.match(
source,
/useStoredString\(SCRIPTS_VIEW_MODE_STORAGE_KEY, "list", isScriptsViewMode\)/,
);
assert.equal(SCRIPTS_VIEW_MODE_STORAGE_KEY.startsWith("netcatty:"), true);
});
+19
View File
@@ -0,0 +1,19 @@
import { STORAGE_KEY_SCRIPTS_SIDE_PANEL_VIEW } from "../../infrastructure/config/storageKeys";
import { useStoredString } from "./useStoredString";
/** View modes for the scripts side panel library:
* 'list' (one snippet per row, tree of packages) and 'stacked' (compact
* wrap-around chips). */
export type ScriptsViewMode = "list" | "stacked";
export const SCRIPTS_VIEW_MODE_STORAGE_KEY = STORAGE_KEY_SCRIPTS_SIDE_PANEL_VIEW;
/** Pure resolver so callers and tests share one validation rule. */
export const parseScriptsViewMode = (value: string | null): ScriptsViewMode =>
value === "stacked" ? "stacked" : "list";
const isScriptsViewMode = (value: string | null): value is ScriptsViewMode =>
value === "list" || value === "stacked";
export const useScriptsViewMode = () =>
useStoredString(SCRIPTS_VIEW_MODE_STORAGE_KEY, "list", isScriptsViewMode);
@@ -0,0 +1,59 @@
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 { SCRIPTS_VIEW_MODE_STORAGE_KEY, useScriptsViewMode } from "./useScriptsViewMode.ts";
test("script view changes follow native storage events from another window", async (t) => {
const windowA = new EventTarget();
const windowB = new EventTarget();
let activeWindow = windowA;
const data = new Map<string, string>();
const values = {
localStorage: { getItem: (key: string) => data.get(key) ?? null, setItem: (key: string, value: string) => { data.set(key, value); } },
addEventListener: (type: string, listener: EventListener) => activeWindow.addEventListener(type, listener),
removeEventListener: (type: string, listener: EventListener) => activeWindow.removeEventListener(type, listener),
IS_REACT_ACT_ENVIRONMENT: true,
};
const previous = new Map<string, PropertyDescriptor | undefined>();
for (const [key, value] of Object.entries(values)) {
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, { configurable: true, value });
}
let a!: ReturnType<typeof useScriptsViewMode>;
let b!: ReturnType<typeof useScriptsViewMode>;
function FirstWindow() { a = useScriptsViewMode(); return null; }
function SecondWindow() { b = useScriptsViewMode(); return null; }
let first!: ReactTestRenderer;
let second!: ReactTestRenderer;
t.after(async () => {
activeWindow = windowA; await act(async () => first?.unmount());
activeWindow = windowB; await act(async () => second?.unmount());
for (const [key, descriptor] of previous) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else Reflect.deleteProperty(globalThis, key);
}
});
activeWindow = windowA; await act(async () => { first = create(React.createElement(FirstWindow)); });
activeWindow = windowB; await act(async () => { second = create(React.createElement(SecondWindow)); });
assert.equal(a[0], "list"); assert.equal(b[0], "list");
// BrowserWindow A writes shared storage. Electron emits the native event
// in B, not an adapter CustomEvent from A's independent JS environment.
activeWindow = windowA; await act(async () => a[1]("stacked"));
assert.equal(data.get(SCRIPTS_VIEW_MODE_STORAGE_KEY), "stacked");
assert.equal(b[0], "list");
const notify = async (target: EventTarget, key = SCRIPTS_VIEW_MODE_STORAGE_KEY) => {
const event = new Event("storage");
Object.defineProperty(event, "key", { value: key });
await act(async () => { target.dispatchEvent(event); });
};
activeWindow = windowB; await notify(windowB);
assert.equal(b[0], "stacked");
await act(async () => b[1]("list"));
assert.equal(a[0], "stacked");
activeWindow = windowA; await notify(windowA);
assert.equal(a[0], "list");
data.set(SCRIPTS_VIEW_MODE_STORAGE_KEY, "stacked");
await notify(windowA, "unrelated");
assert.equal(a[0], "list", "unrelated preferences must not trigger an update");
});
@@ -0,0 +1,24 @@
import { useEffect, useMemo } from 'react';
import {
createSessionHistorySearch,
type SessionHistorySearch,
} from './sessionHistorySearch';
/**
* Owns one search instance per mounted session history drawer.
*
* The search index cache is bound to this instance (not module scope), so
* several concurrently mounted drawers — one per terminal tab — each keep
* their own indexes: pruning in one drawer never drops another drawer's
* entries, and the unmount below releases only this drawer's store instead of
* discarding the shared cache for every drawer. Cache keys are the session
* objects themselves, so clearing on unmount drops all strong session
* references together with the retained haystack characters.
*/
export function useSessionHistorySearch(): SessionHistorySearch {
const search = useMemo(() => createSessionHistorySearch(), []);
useEffect(() => () => {
search.clearSessionHistorySearchCache();
}, [search]);
return search;
}
+4
View File
@@ -151,6 +151,7 @@ import {
isValidUiFontId,
isValidUiThemeId,
migrateIncomingTerminalFontId,
migrateMaterializedAutoCloseOnExitDefault,
readStoredString,
serializeTerminalSettings,
type HostClickBehavior,
@@ -355,6 +356,9 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
passwordVerifier: null,
});
const [terminalSettings, setTerminalSettingsState] = useState<TerminalSettings>(() => {
// One-time reset of the pre-flip materialized autoCloseOnExit: true before
// it is ingested; afterwards stored values are the user's own and win.
migrateMaterializedAutoCloseOnExitDefault();
const stored = localStorageAdapter.read<TerminalSettings>(STORAGE_KEY_TERM_SETTINGS);
return normalizeTerminalSettings(stored);
});
+16
View File
@@ -133,6 +133,18 @@ export const useTerminalBackend = () => {
return bridge.startSSHSession(options);
}, []);
const testConnection = useCallback(async (options: NetcattySSHOptions) => {
const bridge = netcattyBridge.get();
if (!bridge?.testConnection) throw new Error("testConnection unavailable");
return bridge.testConnection(options);
}, []);
const cancelTestConnection = useCallback(async (sessionId: string) => {
const bridge = netcattyBridge.get();
if (!bridge?.cancelTestConnection) return { success: false, error: "cancelTestConnection unavailable" };
return bridge.cancelTestConnection(sessionId);
}, []);
const startTelnetSession = useCallback(async (options: Parameters<NonNullable<NetcattyBridge["startTelnetSession"]>>[0]) => {
const bridge = netcattyBridge.get();
if (!bridge?.startTelnetSession) throw new Error("startTelnetSession unavailable");
@@ -572,6 +584,8 @@ export const useTerminalBackend = () => {
execAvailable,
openExternalAvailable,
startSSHSession,
testConnection,
cancelTestConnection,
startTelnetSession,
startMoshSession,
startEtSession,
@@ -655,6 +669,8 @@ export const useTerminalBackend = () => {
execAvailable,
openExternalAvailable,
startSSHSession,
testConnection,
cancelTestConnection,
startTelnetSession,
startMoshSession,
startEtSession,
+2
View File
@@ -53,6 +53,7 @@ export interface UseVaultAgentBridgeInput {
stopTunnel: VaultAgentApiDeps['stopTunnel'];
stopRuleTunnels: VaultAgentApiDeps['stopRuleTunnels'];
openHost?: VaultAgentApiDeps['openHost'];
findExistingSessionForHost?: VaultAgentApiDeps['findExistingSessionForHost'];
closeSession?: VaultAgentApiDeps['closeSession'];
getScriptSessionMeta?: (sessionId: string) => Pick<TerminalSession, 'status' | 'customName' | 'hostLabel' | 'hostname' | 'username'> | undefined;
}
@@ -271,6 +272,7 @@ export function useVaultAgentBridge(input: UseVaultAgentBridgeInput): void {
openHost: current.openHost
? (host, isExternalMcpCall) => current.openHost!(host, isExternalMcpCall)
: undefined,
findExistingSessionForHost: current.findExistingSessionForHost,
closeSession: current.closeSession
? (sessionId) => current.closeSession!(sessionId)
: undefined,
+52 -10
View File
@@ -80,6 +80,7 @@ import {
registerNotesActions,
} from "./notesStore";
import { commitVaultNotesWrite } from "./vaultNotesPersistence";
import { rebasePendingVaultNotes } from "../../domain/vaultNotesReconciliation";
import { publishShellHistorySnapshot } from "./shellHistoryStore";
import { setVaultInitialized } from "./vaultInitStore";
import { notify } from "../notification";
@@ -306,6 +307,12 @@ export const useVaultState = () => {
const snippetsRef = useRef<Snippet[]>([]);
const groupConfigsRef = useRef<GroupConfig[]>([]);
const notesRef = useRef<VaultNote[]>([]);
const notesStorageRef = useRef<VaultNote[]>([]);
// An optimistic React echo is not a persistence acknowledgement. Keep this
// owner until that exact generation has successfully reached storage.
const notesPendingRef = useRef<VaultNote[] | null>(null);
// Failed clear/import keeps full-snapshot ownership across later local edits.
const notesPendingReplaceRef = useRef(false);
const noteGroupsRef = useRef<string[]>([]);
const notesPersistFailureNotifiedAtRef = useRef(0);
customGroupsRef.current = customGroups;
@@ -834,17 +841,37 @@ export const useVaultState = () => {
localStorageAdapter.write(STORAGE_KEY_SNIPPET_PACKAGES, data);
}, []);
const updateNotes = useCallback((data: Partial<VaultNote>[]) => {
const updateNotes = useCallback((data: Partial<VaultNote>[], options?: { replace?: boolean }) => {
const stored = normalizeVaultNotes(localStorageAdapter.read<VaultNote[]>(STORAGE_KEY_NOTES) ?? []);
const requested = normalizeVaultNotes(data);
// Explicit clear/restore keeps its existing full-snapshot semantics.
const replace = options?.replace === true || notesPendingReplaceRef.current;
const pending = replace ? requested : rebasePendingVaultNotes({
base: notesStorageRef.current,
ours: requested,
theirs: stored,
});
notesStorageRef.current = stored;
notesPendingRef.current = pending;
notesPendingReplaceRef.current = replace;
notesRef.current = pending;
const { notes: cleaned, persisted } = commitVaultNotesWrite({
data,
data: pending,
write: (key, value) => localStorageAdapter.write(key, value),
});
// Keep the in-session catalog updated so an autosave quota failure does not
// snap the editor back to stale props and discard the user's draft. Disk
// may still be behind — surface that explicitly.
notesRef.current = cleaned;
setNotes(cleaned);
publishNotesSnapshot({ notes: cleaned, noteGroups: noteGroupsRef.current });
if (notesPendingRef.current === pending) {
notesRef.current = cleaned;
if (persisted) {
notesStorageRef.current = cleaned;
notesPendingRef.current = null;
notesPendingReplaceRef.current = false;
}
setNotes(cleaned);
publishNotesSnapshot({ notes: cleaned, noteGroups: noteGroupsRef.current });
}
if (!persisted) {
const now = Date.now();
// Debounced autosave can hit quota repeatedly; avoid toast spam.
@@ -1163,7 +1190,7 @@ export const useVaultState = () => {
updateProxyProfiles([]);
updateSnippets([], { replace: true });
updateSnippetPackages([]);
updateNotes([]);
updateNotes([], { replace: true });
updateNoteGroups([]);
updateCustomGroups([]);
updateKnownHosts([]);
@@ -1534,8 +1561,10 @@ export const useVaultState = () => {
if (savedGroups) setCustomGroups(savedGroups);
if (savedSnippetPackages) setSnippetPackages(savedSnippetPackages);
if (savedNotes) {
if (savedNotes && notesPendingRef.current === null) {
const cleanedNotes = normalizeVaultNotes(savedNotes);
notesStorageRef.current = cleanedNotes;
notesRef.current = cleanedNotes;
setNotes(cleanedNotes);
localStorageAdapter.write(STORAGE_KEY_NOTES, cleanedNotes);
}
@@ -1780,8 +1809,21 @@ export const useVaultState = () => {
}
if (key === STORAGE_KEY_NOTES) {
const next = safeParse<VaultNote[]>(event.newValue) ?? [];
setNotes(normalizeVaultNotes(next));
// Like snippets, ignore queued events for a snapshot no longer on disk.
if (event.newValue !== localStorageAdapter.readString(STORAGE_KEY_NOTES)) return;
// Only a successful current write can release an outstanding replacement.
if (notesPendingReplaceRef.current) return;
const next = normalizeVaultNotes(safeParse<VaultNote[]>(event.newValue) ?? []);
const pending = notesPendingRef.current;
const visible = pending === null ? next : rebasePendingVaultNotes({
base: notesStorageRef.current,
ours: pending,
theirs: next,
});
notesStorageRef.current = next;
if (pending !== null) notesPendingRef.current = visible;
notesRef.current = visible;
setNotes(visible);
return;
}
@@ -1952,7 +1994,7 @@ export const useVaultState = () => {
}
if (payload.customGroups) updateCustomGroups(payload.customGroups);
if (payload.snippetPackages) updateSnippetPackages(payload.snippetPackages);
if (payload.notes) updateNotes(payload.notes);
if (payload.notes) updateNotes(payload.notes, { replace: true });
if (payload.noteGroups) updateNoteGroups(payload.noteGroups);
if (payload.knownHosts) updateKnownHosts(payload.knownHosts);
if (Array.isArray(payload.groupConfigs)) encryptedWrites.push(updateGroupConfigs(payload.groupConfigs));
+44
View File
@@ -1667,6 +1667,50 @@ test("terminal auto-close preference survives sync round-trip", async () => {
assert.equal(restored.autoCloseOnExit, false);
});
test("inbound sync does not resurrect the materialized auto-close default after the marker", async () => {
localStorage.setItem(storageKeys.STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED, "true");
try {
localStorage.setItem(
storageKeys.STORAGE_KEY_TERM_SETTINGS,
JSON.stringify({ autoCloseOnExit: false, scrollback: 3000 }),
);
await applySyncPayload({
hosts: [],
keys: [],
identities: [],
snippets: [],
customGroups: [],
syncedAt: 1,
settings: { terminalSettings: { autoCloseOnExit: true } },
}, { importVaultData: () => {} });
const resurrected = JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_TERM_SETTINGS)!);
assert.equal(resurrected.autoCloseOnExit, false);
assert.equal(resurrected.scrollback, 3000);
// An explicit opt-in made after the migration round-trips with a marker.
localStorage.setItem(
storageKeys.STORAGE_KEY_TERM_SETTINGS,
JSON.stringify({ autoCloseOnExit: true, scrollback: 3000 }),
);
const optedIn = buildSyncPayload(vault());
assert.equal(optedIn.settings?.autoCloseOnExitDefaultMigrated, true);
assert.equal(optedIn.settings?.terminalSettings?.autoCloseOnExit, true);
localStorage.setItem(
storageKeys.STORAGE_KEY_TERM_SETTINGS,
JSON.stringify({ autoCloseOnExit: false, scrollback: 3000 }),
);
await applySyncPayload(optedIn, { importVaultData: () => {} });
const restoredChoice = JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_TERM_SETTINGS)!);
assert.equal(restoredChoice.autoCloseOnExit, true);
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED), "true");
} finally {
localStorage.removeItem(storageKeys.STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED);
}
});
test("terminal disconnected notice preference survives sync round-trip", async () => {
localStorage.setItem(
storageKeys.STORAGE_KEY_TERM_SETTINGS,
+22
View File
@@ -25,6 +25,7 @@ import {
SYNC_PAYLOAD_ENTITY_KEYS,
SYNC_STORAGE_KEYS,
hasSyncPayloadEntityData,
reconcileInboundAutoCloseOnExit,
retainLocalHostLastConnectedAt,
sanitizeHostsForSync,
type SyncPayload,
@@ -62,6 +63,7 @@ import {
} from './state/customAccentSync';
import { isValidHslToken } from './state/settingsStateDefaults';
import {
STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED,
STORAGE_KEY_THEME,
STORAGE_KEY_UI_THEME_LIGHT,
STORAGE_KEY_UI_THEME_DARK,
@@ -505,6 +507,9 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
if (Object.keys(subset).length > 0) settings.terminalSettings = subset;
} catch { /* ignore corrupt data */ }
}
if (localStorageAdapter.readBoolean(STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED)) {
settings.autoCloseOnExitDefaultMigrated = true;
}
// Custom terminal themes
const customThemesRaw = localStorageAdapter.readString(STORAGE_KEY_CUSTOM_THEMES);
@@ -795,6 +800,23 @@ async function applySyncableSettings(
? 'disabled'
: 'paste';
}
if ('autoCloseOnExit' in settings.terminalSettings) {
merged.autoCloseOnExit = reconcileInboundAutoCloseOnExit(
settings.terminalSettings.autoCloseOnExit,
settings.autoCloseOnExitDefaultMigrated,
);
}
// A legacy true is the old default. A migrated peer's explicit choice must
// stick, so record the marker before the next local migration pass.
if (
settings.autoCloseOnExitDefaultMigrated === true
|| (
settings.terminalSettings.autoCloseOnExit === true
&& settings.autoCloseOnExitDefaultMigrated !== true
)
) {
localStorageAdapter.writeBoolean(STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED, true);
}
localStorageAdapter.writeString(STORAGE_KEY_TERM_SETTINGS, JSON.stringify(merged));
}
+44 -3
View File
@@ -1,5 +1,5 @@
import React, { type Dispatch, type SetStateAction } from 'react';
import { History, Plus } from 'lucide-react';
import { History, Plus, Undo2 } from 'lucide-react';
import type { AIPermissionMode, AISession, ChatMessage, DiscoveredAgent, ExternalAgentConfig, AgentModelPreset, ProviderConfig, UploadedFile } from '../infrastructure/ai/types';
import type { Host, VaultNote } from '../types';
import type { UserSkillOption } from './ai/userSkillsState';
@@ -55,6 +55,9 @@ interface AIChatPanelContentProps {
setInputValue: (value: string) => void;
handleSend: () => void;
handleCompact: () => void;
/** Undo last turn is offered at a safe boundary (never mid-tool / mid-compaction). */
canUndoLastTurn?: boolean;
handleUndoLastTurn?: () => void;
canCompact?: boolean;
handleSteer: () => void;
handleStop: () => void;
@@ -66,6 +69,10 @@ interface AIChatPanelContentProps {
providerDisplayName?: string;
modelDisplayName?: string;
modelCatalogWarning?: string;
/** Live SDK model catalog is loading (no built-in presets to show yet). */
modelCatalogLoading?: boolean;
/** Retry a failed SDK model catalog load from the warning banner. */
handleModelCatalogRetry?: () => void;
agentModelPresets: AgentModelPreset[];
/** Suppress the composer picker's "use custom model" action (locked model). */
allowCustomModelEntry?: boolean;
@@ -128,9 +135,12 @@ export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
setInputValue,
handleSend,
handleCompact,
canUndoLastTurn = false,
handleUndoLastTurn,
canCompact = false,
handleSteer,
handleStop,
handleModelCatalogRetry,
canSteer,
isSteering,
steerWarning,
@@ -139,6 +149,7 @@ export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
providerDisplayName,
modelDisplayName,
modelCatalogWarning,
modelCatalogLoading = false,
agentModelPresets,
allowCustomModelEntry = true,
selectedAgentModel,
@@ -226,6 +237,23 @@ export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
onManageAgents={handleOpenSettings}
/>
<div className="flex items-center gap-0.5">
{handleUndoLastTurn && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 rounded-md text-muted-foreground/62 hover:bg-white/[0.05] hover:text-foreground"
aria-label={t('ai.chat.undoLastTurn')}
disabled={sending || isStreaming || !canUndoLastTurn}
onClick={handleUndoLastTurn}
>
<Undo2 size={12} />
</Button>
</TooltipTrigger>
<TooltipContent>{t('ai.chat.undoLastTurn')}</TooltipContent>
</Tooltip>
)}
<ConversationExport
session={activeSession}
onExport={handleExport}
@@ -335,8 +363,20 @@ export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
{steerWarning}
</div>
) : modelCatalogWarning ? (
<div role="status" className="mx-3 mb-1.5 rounded-md border border-amber-500/25 bg-amber-500/10 px-2.5 py-1.5 text-[11px] leading-4 text-amber-600 dark:text-amber-400">
{modelCatalogWarning}
<div role="status" className="mx-3 mb-1.5 flex items-center gap-2 rounded-md border border-amber-500/25 bg-amber-500/10 px-2.5 py-1.5 text-[11px] leading-4 text-amber-600 dark:text-amber-400">
<span className="min-w-0 flex-1">{modelCatalogWarning}</span>
{handleModelCatalogRetry && (
<button
onClick={handleModelCatalogRetry}
className="shrink-0 rounded px-1.5 py-0.5 text-[11px] font-medium text-amber-600 underline-offset-2 transition-colors hover:bg-amber-500/10 hover:text-amber-500 hover:underline cursor-pointer dark:text-amber-400 dark:hover:text-amber-300"
>
{t('ai.chat.modelCatalogRetry')}
</button>
)}
</div>
) : modelCatalogLoading ? (
<div role="status" className="mx-3 mb-1.5 rounded-md bg-muted/40 px-2.5 py-1.5 text-[11px] leading-4 text-muted-foreground/70">
{t('ai.chat.loadingModels')}
</div>
) : null}
<ChatInput
@@ -357,6 +397,7 @@ export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
modelName={modelDisplayName}
agentName={currentAgentId === 'catty' ? 'Catty Agent' : externalAgents.find(a => a.id === currentAgentId)?.name}
modelPresets={agentModelPresets}
modelCatalogLoading={modelCatalogLoading}
allowCustomModelEntry={allowCustomModelEntry}
selectedModelId={selectedAgentModel}
onModelSelect={handleAgentModelSelect}
@@ -0,0 +1,126 @@
import assert from "node:assert/strict";
import test from "node:test";
import React from "react";
import type { AISession } from "../infrastructure/ai/types.ts";
import { MESSAGES_BY_LOCALE } from "../application/i18n/messages.ts";
import { createDomRenderer, dispatchDomEvent, installDomEnvironment } from "./test-support/renderReactDom.tsx";
test("history drawer filters, pages, clears and explains the bounded search in every locale", async () => {
const dom = installDomEnvironment();
const { SessionHistoryDrawer } = await import("./AIChatSessionHistoryDrawer.tsx");
const { I18nProvider } = await import("../application/i18n/I18nProvider.tsx");
const { TooltipProvider } = await import("./ui/tooltip.tsx");
const renderer = await createDomRenderer(dom.document);
const sessions: AISession[] = Array.from({ length: 141 }, (_, i) => ({
id: `s-${i}`,
title: `Session ${i}`,
agentId: "catty",
scope: { type: "terminal", targetId: "terminal-1" },
messages: [{
id: `m-${i}`, role: "user", timestamp: 0,
content: i === 140 ? "needle [prod].json" : "ordinary content",
}],
createdAt: 0,
updatedAt: 0,
}));
const selected: string[] = [];
const deleted: string[] = [];
let closed = 0;
const render = (items = sessions, locale = "en") => renderer.render(
<I18nProvider locale={locale}>
<TooltipProvider>
<SessionHistoryDrawer
sessions={items}
activeSessionId="s-140"
onSelect={(id) => selected.push(id)}
onDelete={(event, id) => { event.stopPropagation(); deleted.push(id); }}
onClose={() => { closed += 1; }}
/>
</TooltipProvider>
</I18nProvider>,
);
const rows = () => renderer.container.querySelectorAll('[role="button"]');
const click = async (element: Element | null | undefined) => {
assert.ok(element);
await dispatchDomEvent(element, new dom.window.MouseEvent("click", { bubbles: true }));
};
const enterQuery = async (query: string) => {
const input = renderer.container.querySelector("input");
assert.ok(input);
const setValue = Object.getOwnPropertyDescriptor(dom.window.HTMLInputElement.prototype, "value")!.set!;
setValue.call(input, query);
await dispatchDomEvent(input, new dom.window.Event("input", { bubbles: true }));
};
const loadMore = () => [...renderer.container.querySelectorAll("button")]
.find((button) => button.textContent?.includes("Load more"));
try {
await render();
assert.equal(rows().length, 80);
assert.match(loadMore()?.textContent ?? "", /61/);
await click(loadMore());
assert.equal(rows().length, 140);
await enterQuery("[prod].json");
assert.equal(rows().length, 1);
assert.match(rows()[0].textContent ?? "", /Session 140/);
assert.equal(loadMore(), undefined);
await click(rows()[0]);
await dispatchDomEvent(rows()[0], new dom.window.KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
await click(rows()[0].querySelector("button"));
assert.deepEqual(selected, ["s-140", "s-140"]);
assert.deepEqual(deleted, ["s-140"]);
// A pasted suffix must not remain visible while the matcher silently
// ignores it: the controlled input and search accept the same prefix.
const pastedQuery = "needle ".repeat(36) + "needle absent-tail";
await enterQuery(pastedQuery);
const boundedInput = renderer.container.querySelector("input")!;
assert.equal(boundedInput.value, pastedQuery.slice(0, 256));
assert.equal(boundedInput.maxLength, 256);
assert.ok(!boundedInput.value.includes("absent-tail"));
assert.equal(rows().length, 1);
assert.match(rows()[0].textContent ?? "", /Session 140/);
await enterQuery("needle absent-tail");
assert.equal(boundedInput.value, "needle absent-tail");
assert.equal(rows().length, 0, "every visible query token must affect matching");
await enterQuery("x".repeat(255) + "\u{1f63a}");
assert.equal(boundedInput.value, "x".repeat(255), "do not retain half a surrogate pair");
await enterQuery("[](){}");
assert.equal(rows().length, 0);
assert.match(renderer.container.textContent ?? "", /No matches in titles or recent retained content/);
assert.match(renderer.container.textContent ?? "", /prioritizing newer messages and entries/);
assert.match(renderer.container.textContent ?? "", /only a limited portion at the beginning is searched/);
assert.match(renderer.container.textContent ?? "", /not full-text search/);
await click(renderer.container.querySelector("input")?.parentElement?.querySelector("button"));
assert.equal(renderer.container.querySelector("input")?.value, "");
assert.equal(rows().length, 80);
assert.match(loadMore()?.textContent ?? "", /61/);
await enterQuery(" \t");
assert.equal(rows().length, 80);
await click(renderer.container.querySelector("button"));
assert.equal(closed, 1);
for (const locale of ["en", "zh-CN", "zh-TW", "es", "ru"]) {
await render(sessions, locale);
await enterQuery("no-such-content");
const messages = MESSAGES_BY_LOCALE[locale];
const input = renderer.container.querySelector("input")!;
const hint = dom.document.getElementById(input.getAttribute("aria-describedby")!);
for (const key of ["ai.chat.searchSessions", "ai.chat.searchSessionsHint", "ai.chat.noMatchingSessions"]) {
assert.ok(messages[key], `${locale} is missing ${key}`);
}
assert.equal(input.placeholder, messages["ai.chat.searchSessions"]);
assert.equal(input.getAttribute("aria-label"), messages["ai.chat.searchSessions"]);
assert.equal(hint?.textContent, messages["ai.chat.searchSessionsHint"]);
assert.ok(renderer.container.textContent?.includes(messages["ai.chat.noMatchingSessions"]));
await render([], locale);
assert.ok(renderer.container.textContent?.includes(messages["ai.chat.noSessions"]));
assert.ok(!renderer.container.textContent?.includes(messages["ai.chat.noMatchingSessions"]));
}
} finally {
await renderer.unmount();
dom.cleanup();
}
});
+64 -5
View File
@@ -1,11 +1,14 @@
import React, { useEffect, useState } from 'react';
import { Trash2, X } from 'lucide-react';
import React, { useEffect, useId, useRef, useState } from 'react';
import { Search, Trash2, X } from 'lucide-react';
import type { AISession } from '../infrastructure/ai/types';
import { useI18n } from '../application/i18n/I18nProvider';
import { cn } from '../lib/utils';
import { MAX_SEARCH_QUERY_LENGTH, truncateQueryForMatch } from '../lib/searchMatcher';
import { Input } from './ui/input';
import { ScrollArea } from './ui/scroll-area';
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
import { SESSION_HISTORY_ROW_CLASSNAMES } from './ai/sessionHistoryLayout';
import { useSessionHistorySearch } from '../application/state/useSessionHistorySearch';
// -------------------------------------------------------------------
// Session History Drawer
@@ -31,13 +34,28 @@ export const SessionHistoryDrawer: React.FC<SessionHistoryDrawerProps> = ({
}) => {
const { t } = useI18n();
const [renderCount, setRenderCount] = useState(SESSION_RENDER_BATCH);
const [searchQuery, setSearchQuery] = useState('');
const searchHintId = useId();
const searchInputRef = useRef<HTMLInputElement>(null);
// One cache instance per mounted drawer: caches of concurrently mounted
// drawers stay independent (no shared state to prune or discard across tabs).
const search = useSessionHistorySearch();
const filteredSessions = React.useMemo(
// The localized fallback below is displayed for untitled sessions
// (row label), so it must be indexed for search as the displayed title.
() => search.filterSessionHistory(sessions, searchQuery, { untitledLabel: t('ai.chat.untitled') }),
[sessions, searchQuery, t, search],
);
// Reset the render batch when the list scope or query changes so matching
// sessions are never hidden behind stale paging.
useEffect(() => {
setRenderCount(SESSION_RENDER_BATCH);
}, [sessions]);
}, [sessions, searchQuery]);
const displayedSessions = sessions.slice(0, renderCount);
const hiddenSessionCount = Math.max(0, sessions.length - renderCount);
const displayedSessions = filteredSessions.slice(0, renderCount);
const hiddenSessionCount = Math.max(0, filteredSessions.length - renderCount);
return (
<div className="flex-1 flex flex-col min-h-0">
@@ -50,6 +68,41 @@ export const SessionHistoryDrawer: React.FC<SessionHistoryDrawerProps> = ({
<X size={14} />
</button>
</div>
<div className="px-3 py-2 shrink-0 border-b border-border/30">
<div className="relative">
<Search
size={13}
className="absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground/50 pointer-events-none"
/>
<Input
ref={searchInputRef}
type="text"
value={searchQuery}
maxLength={MAX_SEARCH_QUERY_LENGTH}
placeholder={t('ai.chat.searchSessions')}
aria-label={t('ai.chat.searchSessions')}
aria-describedby={searchHintId}
onChange={(event) => setSearchQuery(truncateQueryForMatch(event.target.value))}
className="h-8 pl-8 pr-7 text-[12px]"
/>
{searchQuery && (
<button
type="button"
aria-label={t('ai.chat.clearSessionSearch')}
onClick={() => {
setSearchQuery('');
searchInputRef.current?.focus();
}}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground/50 hover:text-muted-foreground transition-colors cursor-pointer"
>
<X size={12} />
</button>
)}
</div>
<p id={searchHintId} className="mt-1.5 text-[11px] text-muted-foreground/60">
{t('ai.chat.searchSessionsHint')}
</p>
</div>
<ScrollArea className="flex-1">
<div className="px-3">
{sessions.length === 0 ? (
@@ -58,6 +111,12 @@ export const SessionHistoryDrawer: React.FC<SessionHistoryDrawerProps> = ({
{t('ai.chat.noSessions')}
</p>
</div>
) : filteredSessions.length === 0 ? (
<div className="py-12 text-center">
<p className="text-[13px] text-muted-foreground/40">
{t('ai.chat.noMatchingSessions')}
</p>
</div>
) : (
<>
{hiddenSessionCount > 0 && (
@@ -120,7 +120,9 @@ test('send preflight aborts after the panel unmounts or the scope changes', () =
test('send preflight locks header agent and new-chat controls', () => {
const panel = readFileSync(new URL('./AIChatSidePanel.tsx', import.meta.url), 'utf8');
const content = readFileSync(new URL('./AIChatPanelContent.tsx', import.meta.url), 'utf8');
assert.match(panel, /sending=\{isSending\}/);
// `sending` also covers an in-flight undo-last-turn so the header controls
// (and a quick send) stay locked while the branch materializes.
assert.match(panel, /sending=\{isSending \|\| isUndoingLastTurn\}/);
assert.match(content, /disabled=\{sending\}/);
const selector = readFileSync(new URL('./ai/AgentSelector.tsx', import.meta.url), 'utf8');
assert.match(selector, /if \(parked \|\| disabled\) setOpen\(false\)/);
+182 -5
View File
@@ -65,6 +65,7 @@ import {
} from '../application/state/useAIChatStreaming';
import { getScopedHistorySessions } from './ai/scopedHistorySessions';
import { resolveInheritedAIActiveSessionId } from '../domain/aiWorkspaceScopeInherit';
import { resolveUndoLastTurnBoundary } from '../domain/aiUndoLastTurn';
import { aiSessionIdSetEqual, exactScopeAISessionsEqual } from '../domain/aiSessionsForScope';
import { buildExternalAgentHistoryMessagesForBridge } from './ai/externalAgentHistory';
import { canSendWithAgent, findEnabledExternalAgent } from './ai/agentSendEligibility';
@@ -269,6 +270,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
addDraftFiles,
removeDraftFile,
createSession,
undoLastTurnInSession,
deleteSession,
updateSessionTitle,
updateSessionExternalSessionId,
@@ -319,8 +321,17 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
const [showHistory, setShowHistory] = useState(false);
const [isSending, setIsSending] = useState(false);
// Undo-last-turn is still
// running. Unlike the send flow this is not reflected in `isSending`, so it
// is tracked separately to block sends until the branch settles: a turn
// submitted meanwhile would run against the (soon hidden) source session.
const [isUndoingLastTurn, setIsUndoingLastTurn] = useState(false);
const [runtimeAgentModelPresets, setRuntimeAgentModelPresets] = useState<Record<string, { cacheKey: string; models: AgentModelPreset[] }>>({});
const [runtimeModelWarnings, setRuntimeModelWarnings] = useState<Record<string, { cacheKey: string; message: string }>>({});
// Tracks SDK runtime model catalogs currently loading (per agent scope):
// while an OpenCode catalog is pending we must not flash the built-in
// OPENCODE_MODEL_PRESETS for providers the user never configured (#3584).
const [runtimeModelLoading, setRuntimeModelLoading] = useState<Record<string, { cacheKey: string }>>({});
const [steerWarnings, setSteerWarnings] = useState<Record<string, SteerWarning>>({});
const [steeringSessionId, setSteeringSessionId] = useState<string | null>(null);
const [userSkillOptions, setUserSkillOptions] = useState<UserSkillOption[]>([]);
@@ -817,6 +828,78 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
&& Boolean(effectiveActiveProvider)
&& Boolean(effectiveActiveModelId.trim());
// Undo last turn: offered only at a safe boundary (never inside a compacted
// prefix, never between an assistant tool call and its result).
const canUndoLastTurn = useMemo(() => Boolean(
activeSession
&& !isStreaming
&& resolveUndoLastTurnBoundary(
activeSession.messages,
activeSession.contextCompaction?.compactedMessageCount ?? 0,
) != null,
), [activeSession, isStreaming]);
const undoInFlightRef = useRef(false);
const handleUndoLastTurn = useCallback(async () => {
const sessionId = activeSessionRef.current?.id;
if (!sessionId || !undoLastTurnInSession) return;
if (isStreaming || isAIChatSessionStreaming(sessionId)) return;
// Serialize: while a branch is still being created, `canUndoLastTurn` stays stale, so a quick second
// click must not start a parallel undo of the same session — that would
// publish duplicate branches and restore the same prompt twice.
if (undoInFlightRef.current) return;
undoInFlightRef.current = true;
setIsUndoingLastTurn(true);
try {
const result = await undoLastTurnInSession(sessionId);
if (!result) return;
ensureScopeDraft(currentAgentId);
// Text typed since the last flush lives only in the live composer buffer
// (pendingComposerTextRef/currentDraftRef), not in the persisted draft;
// merge from the live buffer so it is not lost, then drop the pending
// buffer so the later flushDraftText() cannot overwrite the merged text.
const liveDraft = currentDraftRef.current;
updateScopeDraft(currentAgentId, (draft) => {
const liveText = liveDraft?.text ?? draft.text;
const liveSkillSlugs = liveDraft?.selectedUserSkillSlugs ?? draft.selectedUserSkillSlugs;
return {
...draft,
// Keep anything the user is still typing; the undone prompt goes first.
text: liveText.trim()
? `${result.restored.text}\n\n${liveText}`
: result.restored.text,
attachments: [
...result.restored.attachments,
...(liveDraft?.attachments ?? draft.attachments),
],
// The undone turn was selected with these pills; keep any pills the
// user picked since the send on top of them.
selectedUserSkillSlugs: [
...result.restored.selectedUserSkillSlugs,
...liveSkillSlugs.filter((slug) => !result.restored.selectedUserSkillSlugs.includes(slug)),
],
};
});
discardPendingComposerText();
showScopeSessionView(result.sessionId);
// Tool side effects live outside the conversation and cannot be rolled back.
toast.info(t('ai.chat.undoLastTurnNotice'));
} finally {
undoInFlightRef.current = false;
setIsUndoingLastTurn(false);
}
}, [
discardPendingComposerText,
ensureScopeDraft,
isStreaming,
showScopeSessionView,
t,
undoLastTurnInSession,
updateScopeDraft,
currentAgentId,
]);
const providersRef = useRef(providers);
providersRef.current = providers;
@@ -933,6 +1016,14 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
: { ...prev, [agentId]: { cacheKey: target.cacheKey, models: runtimePresets } }
));
}
// Another path (e.g. send preflight) applied the catalog before the
// deferred idle callback scheduled in the visibility effect: clear its
// stale pending flag so the composer stops claiming models are loading.
setRuntimeModelLoading((prev) => {
if (!prev[agentId] || prev[agentId].cacheKey !== target.cacheKey) return prev;
const { [agentId]: _removed, ...rest } = prev;
return rest;
});
const normalizedStoredModelId = normalizeStoredAgentModelSelection(
storedModelId,
@@ -950,7 +1041,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
) {
setAgentModel(agentId, catalog.currentModelId);
}
}, [setAgentModel, buildExternalAgentRuntimeModelTarget, collectCustomModelIds]);
}, [setAgentModel, setRuntimeModelLoading, buildExternalAgentRuntimeModelTarget, collectCustomModelIds]);
const loadSdkRuntimeModelCatalog = useCallback((
target: SdkRuntimeModelTarget,
@@ -1029,17 +1120,46 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
// even when the user never selected OpenCode (#2184). Manual refresh still
// passes force via the model selector path. Defer the network refresh so
// expand → first type does not wait on CLI model listing.
const clearRuntimeModelLoading = () => {
setRuntimeModelLoading((prev) => {
if (!prev[target.agentId] || prev[target.agentId].cacheKey !== target.cacheKey) return prev;
const { [target.agentId]: _removed, ...rest } = prev;
return rest;
});
};
let cancelled = false;
let idleFired = false;
// Mark the catalog pending as soon as the refresh is scheduled: otherwise
// `runtimeModelLoading` stays false for the whole idle delay (and longer
// while the composer stays focused), so the built-in OPENCODE_MODEL_PRESETS
// remain selectable the entire time (#3584). Only do this on a cache miss:
// when a cached catalog was already applied above, the runtime presets are
// available, so a pending flag would keep the composer banner/picker in the
// "loading" state indefinitely while the idle callback is deferred.
if (!cached) {
setRuntimeModelLoading((prev) => (
prev[target.agentId]?.cacheKey === target.cacheKey ? prev : { ...prev, [target.agentId]: { cacheKey: target.cacheKey } }
));
}
const cancelIdle = scheduleWhenAiComposerIdle(() => {
idleFired = true;
void loadSdkRuntimeModelCatalog(target).then((catalog) => {
if (cancelled || !catalog) return;
applySdkRuntimeModelCatalog(target, catalog, { adoptCurrentModel: true });
}).finally(() => {
// Always clear, even after cancellation: a stale flag would keep the
// composer in the loading state forever.
clearRuntimeModelLoading();
});
});
return () => {
cancelled = true;
cancelIdle();
// If the idle callback never fired, the load never started and nothing
// would clear the pending flag — do it here. In-flight loads clear their
// own flag in `.finally`, so leave them alone.
if (!idleFired) clearRuntimeModelLoading();
};
}, [
isVisible,
@@ -1075,6 +1195,22 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
collectCustomModelIds(currentAgentId, runtimePresets),
);
}
// While an OpenCode catalog load is pending, do not flash the built-in
// OPENCODE_MODEL_PRESETS: they enumerate well-known gateway providers the
// user may never have configured, so for a few seconds every switch looks
// like a broken default model (#3584). Show only the user's own custom
// model ids until the live catalog resolves (or a retryable warning
// appears in the composer banner).
if (
target
&& getExternalAgentSdkBackend(currentAgentConfig) === 'opencode'
&& runtimeModelLoading[currentAgentId]?.cacheKey === target.cacheKey
) {
return appendComposerCustomModelPresets(
[],
collectCustomModelIds(currentAgentId, []),
);
}
const presets = getAgentModelPresets(
currentAgentConfig?.command,
getExternalAgentSdkBackend(currentAgentConfig),
@@ -1092,6 +1228,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
currentAgentConfig,
currentAgentId,
runtimeAgentModelPresets,
runtimeModelLoading,
buildExternalAgentRuntimeModelTarget,
hasCodexCustomConfig,
codexConfigModel,
@@ -1119,14 +1256,39 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
const inputAgentId = activeSession?.agentId ?? currentDraft?.agentId ?? currentAgentId;
const canSendCurrentAgent = useMemo(
() => !isSending && canSendWithAgent(inputAgentId, externalAgents),
[inputAgentId, externalAgents, isSending],
() => !isSending && !isUndoingLastTurn && canSendWithAgent(inputAgentId, externalAgents),
[inputAgentId, externalAgents, isSending, isUndoingLastTurn],
);
const handleAgentModelSelect = useCallback((modelId: string) => {
setAgentModel(currentAgentId, modelId);
}, [currentAgentId, setAgentModel]);
// Retry a failed SDK runtime model catalog load (composer warning banner).
// Failures are never cached, so a forced refresh re-queries the CLI/daemon.
const handleRetrySdkModelCatalog = useCallback(() => {
const target = buildExternalAgentRuntimeModelTarget(currentAgentConfig);
if (!target) return;
setRuntimeModelLoading((prev) => (
prev[target.agentId]?.cacheKey === target.cacheKey ? prev : { ...prev, [target.agentId]: { cacheKey: target.cacheKey } }
));
void loadSdkRuntimeModelCatalog(target, { force: true }).then((catalog) => {
if (!catalog) return;
applySdkRuntimeModelCatalog(target, catalog, { adoptCurrentModel: true });
}).finally(() => {
setRuntimeModelLoading((prev) => {
if (!prev[target.agentId] || prev[target.agentId].cacheKey !== target.cacheKey) return prev;
const { [target.agentId]: _removed, ...rest } = prev;
return rest;
});
});
}, [
currentAgentConfig,
buildExternalAgentRuntimeModelTarget,
loadSdkRuntimeModelCatalog,
applySdkRuntimeModelCatalog,
]);
const selectedCattyThinking = agentThinkingMap.catty;
const handleCattyThinkingSelect = useCallback((level: string) => {
setAgentThinking('catty', level);
@@ -1231,6 +1393,10 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
}));
const hasInlineTextAttachments = attachments.some(isInlineTextAttachment);
if ((!trimmed && !hasInlineTextAttachments) || isStreaming) return;
// While undo-last-turn is creating the branch, the target is still the source; a
// turn submitted now would run in the soon-hidden source session, so
// block it until the branch settles.
if (isUndoingLastTurn) return;
// Note-only sends (empty text + a mentioned note) still need a usable
// session title: fall back to the first mentioned note's title so these
// conversations don't all show up as "Untitled" in history.
@@ -1355,6 +1521,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
addMessageToSession(sessionId, {
id: generateId(), role: 'user', content: trimmed,
...(attachments.length > 0 ? { attachments } : {}),
...(selectedSkillSlugs.length > 0 ? { selectedUserSkillSlugs: selectedSkillSlugs } : {}),
timestamp: Date.now(),
});
addMessageToSession(sessionId, { id: generateId(), role: 'assistant', content: t('ai.chat.noProvider'), timestamp: Date.now() });
@@ -1369,6 +1536,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
addMessageToSession(sessionId, {
id: generateId(), role: 'user', content: trimmed,
...(attachments.length > 0 ? { attachments } : {}),
...(selectedSkillSlugs.length > 0 ? { selectedUserSkillSlugs: selectedSkillSlugs } : {}),
timestamp: Date.now(),
});
addMessageToSession(sessionId, { id: generateId(), role: 'assistant', content: t('ai.chat.noProviderModel'), timestamp: Date.now() });
@@ -1382,6 +1550,8 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
addMessageToSession(sessionId, {
id: generateId(), role: 'user', content: trimmed,
...(attachments.length > 0 ? { attachments } : {}),
// Kept on the user message so undo last turn can restore the pills.
...(selectedSkillSlugs.length > 0 ? { selectedUserSkillSlugs: selectedSkillSlugs } : {}),
timestamp: Date.now(),
});
clearScopeDraft({ keepPendingText: keepPendingAfterSend() });
@@ -1463,7 +1633,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
}
}, [
validateNoteMentions, loadCodexConfigModel,
isStreaming, activeProvider, effectiveActiveProvider, effectiveActiveModelId, selectedCattyThinking, scopeKey, currentAgentId,
isStreaming, isUndoingLastTurn, activeProvider, effectiveActiveProvider, effectiveActiveModelId, selectedCattyThinking, scopeKey, currentAgentId,
activeModelId, externalAgents,
createSession, addMessageToSession, updateMessageById, updateLastMessage,
setStreamingForScope,
@@ -1751,7 +1921,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
>
<AIChatPanelContent
parked={!isVisible}
sending={isSending}
sending={isSending || isUndoingLastTurn}
t={t}
currentAgentId={currentAgentId}
externalAgents={externalAgents}
@@ -1781,6 +1951,8 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
setInputValue={setInputValue}
handleSend={handleSend}
handleCompact={handleCompact}
canUndoLastTurn={canUndoLastTurn}
handleUndoLastTurn={undoLastTurnInSession ? handleUndoLastTurn : undefined}
handleSteer={handleSteer}
handleStop={handleStop}
canSteer={canSteerCurrentTurn}
@@ -1794,6 +1966,11 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
&& runtimeModelWarnings[currentAgentId].cacheKey === buildExternalAgentRuntimeModelTarget(currentAgentConfig)?.cacheKey
? runtimeModelWarnings[currentAgentId].message
: undefined}
modelCatalogLoading={
getExternalAgentSdkBackend(currentAgentConfig) === 'opencode'
&& runtimeModelLoading[currentAgentId]?.cacheKey === buildExternalAgentRuntimeModelTarget(currentAgentConfig)?.cacheKey
}
handleModelCatalogRetry={handleRetrySdkModelCatalog}
agentModelPresets={agentModelPresets}
// A managed Codex config's `model` field overrides every selection on
// send, so the manual-entry action would be a silent no-op.
+4
View File
@@ -37,6 +37,10 @@ export interface AIChatSidePanelProps {
addDraftFiles: (scopeKey: string, fallbackAgentId: string, inputFiles: File[]) => Promise<void>;
removeDraftFile: (scopeKey: string, fallbackAgentId: string, fileId: string) => void;
createSession: (scope: AISessionScope, agentId?: string) => AISession;
/** Non-destructively branch before the last user turn; returns the branch id + restored composer content. */
undoLastTurnInSession?: (
sessionId: string,
) => Promise<{ sessionId: string; restored: { text: string; attachments: import('../infrastructure/ai/types').UploadedFile[]; selectedUserSkillSlugs: string[] } } | null>;
deleteSession: (sessionId: string, scopeKey?: string) => void;
updateSessionTitle: (sessionId: string, title: string) => void;
updateSessionExternalSessionId: (sessionId: string, externalSessionId: string | undefined) => void;
+17 -2
View File
@@ -2,6 +2,7 @@ import assert from "node:assert/strict";
import test from "node:test";
import type { TransferTask } from "../domain/models";
import { listGloballyCancellableTransferIds } from "../domain/sftpTransferActions";
import {
buildGlobalTransferProgressDisplay,
getGlobalConflictActionPresentation,
@@ -65,14 +66,28 @@ test("batch actions are disabled when no task can accept them", () => {
assert.deepEqual(getGlobalTransferBatchEligibility([
task("done", "completed"),
task("cancelled", "cancelled"),
]), { pausableCount: 0, resumableCount: 0 });
]), { cancellableCount: 0, pausableCount: 0, resumableCount: 0 });
assert.deepEqual(getGlobalTransferBatchEligibility([
task("running", "transferring"),
task("waiting", "queued"),
task("paused", "paused"),
{ ...task("failed", "failed"), checkpointBytes: 4 },
]), { pausableCount: 2, resumableCount: 2 });
]), { cancellableCount: 3, pausableCount: 2, resumableCount: 2 });
});
test("cancel all selects unfinished top-level tasks without touching completed files", () => {
const statuses: TransferTask["status"][] = [
"pending", "queued", "transferring", "pausing", "paused", "interrupted", "attention",
];
const tasks = [
...statuses.map((status) => task(status, status)),
task("completed", "completed"),
task("failed", "failed"),
task("cancelled", "cancelled"),
{ ...task("child", "queued"), parentTaskId: "pending" },
];
assert.deepEqual(listGloballyCancellableTransferIds(tasks), statuses);
});
test("paused transfer errors remain visible instead of looking safely paused", () => {
+7 -4
View File
@@ -837,7 +837,7 @@ export function GlobalSftpTransferCenter() {
.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0) || b.startTime - a.startTime), [bucket, snapshot.tasks]);
const { visible, collapsed } = splitBackgroundTransfers(bucketTasks);
const displayed = showBackground ? [...visible, ...collapsed] : visible;
const { batchEligibility, pauseAll, resumeAll } = useGlobalSftpTransferActions(snapshot.tasks);
const { batchEligibility, pauseAll, resumeAll, cancelAll, isCancelling } = useGlobalSftpTransferActions(snapshot.tasks);
return (
<Popover open={open} onOpenChange={setOpen}>
@@ -877,13 +877,16 @@ export function GlobalSftpTransferCenter() {
<div className="min-w-0 pr-2 text-sm font-semibold">
{t("sftp.transferCenter.title")}
</div>
<div className="flex items-center gap-1">
<Button variant="ghost" size="sm" className="h-7 text-xs" onClick={pauseAll} disabled={batchEligibility.pausableCount === 0}>
<div className="flex flex-wrap items-center justify-end gap-1">
<Button variant="ghost" size="sm" className="h-7 text-xs" onClick={pauseAll} disabled={isCancelling || batchEligibility.pausableCount === 0}>
<Pause size={12} className="mr-1" />{t("sftp.transferCenter.pauseAll")}
</Button>
<Button variant="ghost" size="sm" className="h-7 text-xs" onClick={resumeAll} disabled={batchEligibility.resumableCount === 0}>
<Button variant="ghost" size="sm" className="h-7 text-xs" onClick={resumeAll} disabled={isCancelling || batchEligibility.resumableCount === 0}>
<Play size={12} className="mr-1" />{t("sftp.transferCenter.resumeAll")}
</Button>
<Button variant="ghost" size="sm" className="h-7 text-xs text-destructive" onClick={() => { void cancelAll(); }} disabled={isCancelling || batchEligibility.cancellableCount === 0}>
<X size={12} className="mr-1" />{t("sftp.transferCenter.cancelAll")}
</Button>
</div>
</div>
+50 -3
View File
@@ -30,7 +30,7 @@ import {
resolveHostTerminalFontSize,
resolveHostTerminalThemeId,
} from "../domain/terminalAppearance";
import { EnvVar, GroupConfig, Host, Identity, ManagedSource, ProxyConfig, ProxyProfile, Snippet, SSHKey } from "../types";
import { EnvVar, GroupConfig, Host, Identity, KnownHost, ManagedSource, ProxyConfig, ProxyProfile, Snippet, SSHKey, TerminalSettings } from "../types";
import { DISTRO_COLORS, DISTRO_LOGOS } from "./DistroAvatar";
import ThemeSelectPanel from "./ThemeSelectPanel";
import {
@@ -77,6 +77,8 @@ import {
stripBuiltInConnectionFieldsForPluginHost,
} from "../domain/pluginConnection";
import { PluginConnectionSection } from "./PluginConnectionSection";
import { useHostConnectionTest } from "../application/state/useHostConnectionTest";
import { HostConnectionTestDialog } from "./host-details/HostConnectionTestDialog";
type CredentialType = "sshid" | "key" | "certificate" | "localKeyFile" | null;
type SubPanel =
@@ -111,6 +113,9 @@ interface HostDetailsPanelProps {
snippets?: Snippet[];
onSnippetsChange?: (snippets: Snippet[]) => void;
onHostsChange?: (hosts: Host[] | ((prev: Host[]) => Host[])) => void;
knownHosts?: KnownHost[];
onAddKnownHost?: (knownHost: KnownHost) => void;
terminalSettings?: TerminalSettings;
className?: string;
}
@@ -139,6 +144,9 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
snippets = [],
onSnippetsChange,
onHostsChange,
knownHosts,
onAddKnownHost,
terminalSettings,
className,
resizable,
persistWidthStorageKey,
@@ -198,6 +206,28 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
const [sshAgentStatus, setSshAgentStatus] = useState<SshAgentStatus | null>(null);
const [sshForwardingAgentStatus, setSshForwardingAgentStatus] = useState<SshAgentStatus | null>(null);
const [testOpen, setTestOpen] = useState(false);
const hostConnectionTest = useHostConnectionTest({
host: form,
hosts: allHosts,
keys: availableKeys,
identities,
knownHosts,
groupConfigs,
proxyProfiles,
terminalSettings,
onAddKnownHost,
});
const handleStartTest = () => {
setTestOpen(true);
void hostConnectionTest.startTest();
};
const handleCloseTest = () => {
setTestOpen(false);
};
useEffect(() => {
let cancelled = false;
if (form.agentForwarding || form.useSshAgent === true) {
@@ -1309,15 +1339,32 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
</Button>
)}
</AsidePanelContent>
<AsidePanelFooter>
<AsidePanelFooter className="flex flex-row gap-2">
{!pluginProtocolActive && form.protocol !== "telnet" && (
<Button
variant="outline"
className="h-10 px-4"
onClick={handleStartTest}
disabled={!canSaveHost}
>
{t("hostConnectionTest.test")}
</Button>
)}
<Button
className="w-full h-10"
className="flex-1 h-10"
onClick={handleSubmit}
disabled={!canSaveHost}
>
{t("common.save")}
</Button>
</AsidePanelFooter>
<HostConnectionTestDialog
open={testOpen}
onClose={handleCloseTest}
host={form}
keys={availableKeys}
test={hostConnectionTest}
/>
</AsidePanel>
);
};
@@ -0,0 +1,73 @@
import assert from "node:assert/strict";
import test from "node:test";
import React from "react";
import { createDomRenderer, dispatchDomEvent, installDomEnvironment, runWithAct } from "./test-support/renderReactDom.tsx";
import type { Snippet } from "../types";
test("stacked library continues loading after running a script and returning", async (t) => {
const env = installDomEnvironment();
const previous = Object.getOwnPropertyDescriptor(globalThis, "IntersectionObserver");
const previousStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
Object.defineProperty(globalThis, "localStorage", { configurable: true, value: env.window.localStorage });
const observers: Array<{ target: Element | null; intersect: () => void }> = [];
class Observer {
target: Element | null = null;
constructor(private callback: IntersectionObserverCallback) { observers.push(this); }
observe(target: Element) { this.target = target; }
disconnect() { this.target = null; }
intersect = () => {
if (this.target?.isConnected) {
this.callback([{ target: this.target, isIntersecting: true } as IntersectionObserverEntry], this as unknown as IntersectionObserver);
}
};
}
Object.defineProperty(globalThis, "IntersectionObserver", { configurable: true, value: Observer });
const { ScriptsSidePanel } = await import("./ScriptsSidePanel.tsx");
const { I18nProvider } = await import("../application/i18n/I18nProvider.tsx");
const { SCRIPTS_VIEW_MODE_STORAGE_KEY } = await import("../application/state/useScriptsViewMode.ts");
env.window.localStorage.setItem(SCRIPTS_VIEW_MODE_STORAGE_KEY, "stacked");
const renderer = await createDomRenderer(env.document);
t.after(async () => {
await renderer.unmount();
if (previous) Object.defineProperty(globalThis, "IntersectionObserver", previous);
else Reflect.deleteProperty(globalThis, "IntersectionObserver");
if (previousStorage) Object.defineProperty(globalThis, "localStorage", previousStorage);
else Reflect.deleteProperty(globalThis, "localStorage");
env.cleanup();
});
const snippets: Snippet[] = Array.from({ length: 300 }, (_, i) => ({
id: `script-${i}`, label: `Script ${String(i).padStart(3, "0")}`, command: "echo ok", kind: "script", package: "",
}));
const executed: string[] = [];
await renderer.render(
<I18nProvider locale="en"><ScriptsSidePanel snippets={snippets} packages={[]}
onSnippetClick={() => assert.fail("scripts use the existing script execution path")}
onRunScript={(snippet) => executed.push(snippet.id)} /></I18nProvider>,
);
const library = () => env.document.querySelector('[data-scripts-view="stacked"]');
const chipCount = () => library()?.querySelectorAll("button").length;
const button = (label: string) => {
const result = Array.from(env.document.querySelectorAll("button")).find((element) => element.textContent === label);
assert.ok(result, `missing button ${label}`);
return result;
};
assert.equal(chipCount(), 120);
const firstSentinel = observers.at(-1)?.target;
assert.ok(firstSentinel?.isConnected);
await dispatchDomEvent(button("Script 000"), new env.window.MouseEvent("click", { bubbles: true }));
assert.deepEqual(executed, ["script-0"]);
assert.equal(library(), null, "execution opens Running");
assert.equal(firstSentinel.isConnected, false);
await dispatchDomEvent(button("Library"), new env.window.MouseEvent("click", { bubbles: true }));
assert.equal(chipCount(), 120);
assert.ok(observers.at(-1)?.target?.isConnected, "the new sentinel must be observed");
assert.notEqual(observers.at(-1)?.target, firstSentinel);
await runWithAct(() => { observers.at(-1)?.intersect(); });
assert.equal(chipCount(), 240);
await dispatchDomEvent(button("Running"), new env.window.MouseEvent("click", { bubbles: true }));
await dispatchDomEvent(button("Library"), new env.window.MouseEvent("click", { bubbles: true }));
assert.equal(chipCount(), 240, "returning preserves the loaded window");
await runWithAct(() => { observers.at(-1)?.intersect(); });
assert.equal(chipCount(), 300);
assert.ok(button("Script 299"));
});
+28
View File
@@ -174,3 +174,31 @@ test("scripts side panel does not start a drag from a non-primary pointer", () =
assert.match(source, /isNonPrimaryPointer/);
assert.match(source, /primaryOnlyDragHandlers/);
});
test("scripts side panel offers a stacked view toggle that persists the choice", () => {
assert.match(source, /useScriptsViewMode/);
const toggle = source.match(/viewStacked[\s\S]*?\{viewMode === 'stacked' \? <List size=\{14\} \/> : <LayoutGrid size=\{14\} \/>\}/);
assert.ok(toggle, "view toggle button should swap list / stacked icons");
assert.match(source, /aria-pressed=\{viewMode === 'stacked'\}/);
// The chosen mode persists via the shared hook so it survives app restarts.
assert.match(source, /useScriptsViewMode\(\)/);
assert.match(source, /setViewMode\(viewMode === 'stacked' \? 'list' : 'stacked'\)/);
});
test("scripts side panel stacked view renders wrap-around chips and keeps run behavior", () => {
const stacked = source.match(/viewMode === 'stacked' \? \([\s\S]*?\) : \(/);
assert.ok(stacked, "stacked branch should exist in the content area");
assert.match(stacked[0], /flex flex-wrap/);
// Chips reuse the same click handler (multi-select / run / execute) as rows.
assert.match(stacked[0], /onClick=\{\(\) => handleSnippetClick\(snippet\)\}/);
// Packages stay visible as section headers with their counts.
assert.match(stacked[0], /item\.row\.name/);
assert.match(stacked[0], /item\.countLabel/);
assert.match(source, /const SnippetChip = memo/);
// The stacked chip keeps the same context menu contract (edit / delete / run).
const chip = source.match(/const SnippetChip = memo[\s\S]*?SnippetChip\.displayName/);
assert.ok(chip, "SnippetChip component should exist");
assert.match(chip[0], /ContextMenuContent/);
assert.match(chip[0], /onRunParallel/);
assert.match(chip[0], /onEdit/);
});
+315 -5
View File
@@ -15,6 +15,8 @@ import {
Expand,
FolderPlus,
Layers,
LayoutGrid,
List,
Minimize2,
Package,
Play,
@@ -27,6 +29,7 @@ import {
import React, { memo, useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
import { useI18n } from '../application/i18n/I18nProvider';
import { getScriptRecordingSnapshot, subscribeScriptRecording } from '../application/state/scriptRecordingStore.ts';
import { useScriptsViewMode } from '../application/state/useScriptsViewMode.ts';
import { VaultDeleteConfirmDialog } from './vault/VaultDeleteConfirmDialog';
import {
collectSnippetPackageTreePaths,
@@ -61,6 +64,11 @@ const toolbarIconButtonClass =
'h-7 w-7 shrink-0 flex items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors disabled:opacity-40 disabled:pointer-events-none';
const SCRIPT_ROW_HEIGHT = 34;
// The stacked view renders wrapping chips that cannot use FixedSizeVirtualList,
// so cap the initial render and grow lazily as the user scrolls to keep a huge
// library (or a broad search) from mounting every chip at once.
const STACKED_INITIAL_RENDER_COUNT = 120;
const STACKED_RENDER_BATCH = 120;
const isRootPackagePath = (path: string): boolean => {
const body = path.startsWith('/') ? path.slice(1) : path;
@@ -171,10 +179,14 @@ export function buildScriptsSidePanelRows({
snippets,
packages,
expandedPaths,
snippetsBeforeChildren = false,
}: {
snippets: Snippet[];
packages: string[];
expandedPaths: Set<string>;
/** Emit each package's own snippets before its sub-packages so flat views
* (stacked chips) keep every package's chips attached to its section. */
snippetsBeforeChildren?: boolean;
}): TreeRow[] {
const normalizedPackages = new Set(collectScriptsSidePanelPackagePaths(packages, snippets));
@@ -227,6 +239,11 @@ export function buildScriptsSidePanelRows({
const snippetsIn = (pkg: string | null): Snippet[] =>
sortByVaultOrder(snippetsByPackage.get(pkg ?? '') ?? []);
const emitSnippets = (localSnippets: Snippet[], pkg: string, depth: number) =>
localSnippets.forEach((snippet) =>
rows.push({ type: 'snippet', id: snippet.id, depth: depth + 1, snippet, packagePath: pkg }),
);
const rows: TreeRow[] = [];
const walk = (pkg: string, depth: number) => {
const children = childPackagesOf(pkg);
@@ -246,10 +263,15 @@ export function buildScriptsSidePanelRows({
});
if (!isExpanded) return;
if (snippetsBeforeChildren) {
// Flat stacked view: a package's chips must follow its own section
// header, not trail after its nested children's chips.
emitSnippets(localSnippets, pkg, depth);
children.forEach((child) => walk(child, depth + 1));
return;
}
children.forEach((child) => walk(child, depth + 1));
localSnippets.forEach((snippet) =>
rows.push({ type: 'snippet', id: snippet.id, depth: depth + 1, snippet, packagePath: pkg }),
);
emitSnippets(localSnippets, pkg, depth);
};
snippetsIn(null).forEach((snippet) =>
@@ -281,6 +303,7 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
const [search, setSearch] = useState('');
const [searchExpanded, setSearchExpanded] = useState(false);
const [subView, setSubView] = useState<'library' | 'running'>('library');
const [viewMode, setViewMode] = useScriptsViewMode();
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(new Set());
const [isMultiSelectMode, setIsMultiSelectMode] = useState(false);
const [selectedSnippetIds, setSelectedSnippetIds] = useState<Set<string>>(new Set());
@@ -291,6 +314,7 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
const [renamingPackagePath, setRenamingPackagePath] = useState('');
const [newPackageName, setNewPackageName] = useState('');
const [packageError, setPackageError] = useState('');
const [stackedRenderLimit, setStackedRenderLimit] = useState(STACKED_INITIAL_RENDER_COUNT);
const packageDialogRef = useRef<HTMLDivElement>(null);
const packageNameInputRef = useRef<HTMLInputElement>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
@@ -481,8 +505,15 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
if (!isVisible) return [];
if (searchMatches !== null) return [];
return buildScriptsSidePanelRows({ snippets, packages, expandedPaths });
}, [snippets, packages, expandedPaths, searchMatches, isVisible]);
return buildScriptsSidePanelRows({
snippets,
packages,
expandedPaths,
// Stacked chips wrap flat, so each package's chips must stay attached
// to its own section header.
snippetsBeforeChildren: viewMode === 'stacked',
});
}, [snippets, packages, expandedPaths, searchMatches, isVisible, viewMode]);
type ScriptsListItem =
| { key: string; kind: 'search'; snippet: Snippet }
@@ -515,6 +546,48 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
});
}, [rows, searchMatches, t, isVisible]);
const stackedSentinelRef = useRef<HTMLDivElement | null>(null);
// Restart the lazy window whenever the underlying result set changes
// (new search, package list changes, snippet edits, view-mode switch) — but
// NOT on package expand/collapse. Expanding/collapsing only reshapes the
// same result set; resetting there would drop the user's lazy-loaded window
// and remount the package they just toggled, forcing them to re-scroll.
// Keyed by data identity, not by listItems reference, for that reason.
// Reset during render — not in an effect — so the first render of a new
// result set never slices with the previous, potentially very large window.
const stackedResultSetKey = useMemo(() => {
if (searchMatches !== null) {
return `search:${searchMatches.map((snippet) => snippet.id).join('\u0000')}`;
}
return `tree:${viewMode}:${packages.join('\u0000')}:${snippets
.map((snippet) => snippet.id)
.join('\u0000')}`;
}, [searchMatches, viewMode, packages, snippets]);
const [prevResultSetKey, setPrevResultSetKey] = useState(stackedResultSetKey);
if (prevResultSetKey !== stackedResultSetKey) {
setPrevResultSetKey(stackedResultSetKey);
setStackedRenderLimit(STACKED_INITIAL_RENDER_COUNT);
}
const hasMoreStackedItems = viewMode === 'stacked' && stackedRenderLimit < listItems.length;
useEffect(() => {
if (subView !== 'library' || !hasMoreStackedItems) return;
const sentinel = stackedSentinelRef.current;
if (!sentinel) return;
// Re-created per batch: observing always emits an initial intersection
// callback, so short result sets that never scroll keep loading.
const observer = new IntersectionObserver((entries) => {
if (entries.some((entry) => entry.isIntersecting)) {
setStackedRenderLimit((limit) => limit + STACKED_RENDER_BATCH);
}
});
observer.observe(sentinel);
return () => observer.disconnect();
}, [hasMoreStackedItems, stackedRenderLimit, subView]);
const handleSnippetClick = useCallback(
(snippet: Snippet) => {
if (isMultiSelectMode) {
@@ -973,6 +1046,30 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
</TooltipTrigger>
<TooltipContent side="bottom">{t('snippets.searchPlaceholder')}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className={cn(
toolbarIconButtonClass,
viewMode === 'stacked' && 'bg-muted/70 text-foreground',
)}
disabled={!hasAnyContent}
aria-label={viewMode === 'stacked'
? t('scripts.sidePanel.viewList')
: t('scripts.sidePanel.viewStacked')}
aria-pressed={viewMode === 'stacked'}
onClick={() => setViewMode(viewMode === 'stacked' ? 'list' : 'stacked')}
>
{viewMode === 'stacked' ? <List size={14} /> : <LayoutGrid size={14} />}
</button>
</TooltipTrigger>
<TooltipContent side="bottom">
{viewMode === 'stacked'
? t('scripts.sidePanel.viewList')
: t('scripts.sidePanel.viewStacked')}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
@@ -1120,6 +1217,107 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
<div className="px-3 py-4 text-xs text-muted-foreground italic text-center">
{t('common.noResultsFound')}
</div>
) : viewMode === 'stacked' ? (
<div
className="h-full overflow-auto px-2 py-1.5"
data-scripts-view="stacked"
>
<div className="flex flex-wrap items-center content-start gap-1.5">
{listItems.slice(0, stackedRenderLimit).map((item) => {
if (item.kind === 'package') {
// Mirror the list view's PackageRow context menu so stacked
// mode keeps rename/delete reachable without switching views.
const canMutatePackages = Boolean(onPackagesChange && onSnippetsChange);
const header = (
<button
key={item.key}
type="button"
onClick={() => togglePackage(item.row.path)}
aria-expanded={item.row.isExpanded}
className="w-full basis-full flex items-center gap-1.5 pt-1.5 pb-0.5 text-left rounded-sm hover:bg-accent/40 transition-colors"
style={{ paddingLeft: 8 + Math.min(item.row.depth, 4) * 10 }}
>
<ChevronRight
size={11}
className={cn(
'shrink-0 text-muted-foreground transition-transform',
item.row.isExpanded && 'rotate-90',
!item.row.hasChildren && 'opacity-0',
)}
/>
<Package size={11} className="shrink-0 text-primary/80" />
<span className="min-w-0 truncate text-[10px] font-medium text-muted-foreground">
{item.row.name}
</span>
<span className="shrink-0 text-[10px] text-muted-foreground tabular-nums">
{item.countLabel}
</span>
</button>
);
if (!canMutatePackages) {
return header;
}
return (
<ContextMenu key={item.key}>
<ContextMenuTrigger asChild>
{header}
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem
onClick={() => openRenamePackageDialog(item.row.path)}
>
<Edit2 className="mr-2 h-4 w-4" /> {t('common.rename')}
</ContextMenuItem>
<ContextMenuItem
className="text-destructive"
onClick={() => requestDeletePackage(item.row.path)}
>
<Trash2 className="mr-2 h-4 w-4" /> {t('action.delete')}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
}
const snippet = item.kind === 'search' ? item.snippet : item.row.snippet;
const isScript = isScriptSnippet(snippet);
// Search results drop the package headers, so surface each
// snippet's package on the chip itself to keep results
// distinguishable (labels are not unique across packages).
const chipSubtitle = item.kind === 'search'
? (snippet.package || t('terminal.toolbar.library'))
: undefined;
return (
<SnippetChip
key={item.key}
snippet={snippet}
subtitle={chipSubtitle}
selected={selectedSnippetIds.has(snippet.id)}
multiSelect={isMultiSelectMode}
onClick={() => handleSnippetClick(snippet)}
onEdit={() => handleEditSnippet(snippet)}
onDelete={() => handleDeleteSnippet(snippet.id)}
onCopyCommand={() => handleCopySnippetCommand(snippet)}
copyCommandLabel={t('scripts.actions.copyCommand')}
onRunParallel={onRunScriptOnWorkspace
? () => onRunScriptOnWorkspace(snippet, 'parallel')
: undefined}
onRunSequential={isScript && onRunScriptOnWorkspace
? () => onRunScriptOnWorkspace(snippet, 'sequential')
: undefined}
runParallelLabel={isScript
? t('scripts.actions.runParallel')
: t('scripts.actions.runOnAllTabs')}
runSequentialLabel={t('scripts.actions.runSequential')}
editLabel={t('action.edit')}
deleteLabel={t('action.delete')}
/>
);
})}
</div>
{hasMoreStackedItems ? (
<div ref={stackedSentinelRef} className="h-px w-full" aria-hidden="true" />
) : null}
</div>
) : (
<FixedSizeVirtualList
className="h-full"
@@ -1644,5 +1842,117 @@ const SnippetRow = memo<SnippetRowProps>(({
));
SnippetRow.displayName = 'SnippetRow';
interface SnippetChipProps {
snippet: Snippet;
/** Optional package context shown on the chip (used by stacked search
* results, where the package headers are dropped). */
subtitle?: string;
selected?: boolean;
multiSelect?: boolean;
onClick: () => void;
onEdit: () => void;
onDelete: () => void;
onRunParallel?: () => void;
onRunSequential?: () => void;
onCopyCommand?: () => void;
copyCommandLabel?: string;
runParallelLabel?: string;
runSequentialLabel?: string;
editLabel: string;
deleteLabel: string;
}
/**
* Compact chip for the stacked (wrap) view. Shares the same click → run /
* execute and right-click context menu as the list rows; only the layout is
* denser. Drag reordering stays list-view-only.
*/
const SnippetChip = memo<SnippetChipProps>(({
snippet,
subtitle,
selected = false,
multiSelect = false,
onClick,
onEdit,
onDelete,
onRunParallel,
onRunSequential,
onCopyCommand,
copyCommandLabel,
runParallelLabel,
runSequentialLabel,
editLabel,
deleteLabel,
}) => (
<ContextMenu>
{/* Radix ContextMenuTrigger needs a real DOM element (Tooltip.Root renders
none), so mirror SnippetRow and wrap the tooltip in a plain div. */}
<ContextMenuTrigger asChild>
<div className="min-w-0 max-w-full">
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={onClick}
aria-pressed={multiSelect ? selected : undefined}
className={cn(
'h-6 max-w-full min-w-0 px-2 rounded-md border border-border/60 bg-muted/40 hover:bg-accent/60 text-[11px] flex items-center gap-1 transition-colors',
selected && 'bg-primary/10 border-primary/40 hover:bg-primary/15',
)}
>
{multiSelect ? (
<CheckSquare
size={11}
className={cn('shrink-0', selected ? 'text-primary' : 'text-muted-foreground/70')}
/>
) : isScriptSnippet(snippet) ? (
<Play size={11} className="shrink-0 text-primary" />
) : (
<Zap size={11} className="shrink-0 text-muted-foreground" />
)}
<span className="min-w-0 truncate">{snippet.label}</span>
{subtitle ? (
<>
<span aria-hidden="true" className="shrink-0 text-muted-foreground/50">·</span>
<span className="min-w-0 truncate text-[10px] text-muted-foreground/80">
{subtitle}
</span>
</>
) : null}
</button>
</TooltipTrigger>
<TooltipContent side="right" align="start">
<SnippetCommandTooltipContent label={snippet.label} command={snippet.command} />
</TooltipContent>
</Tooltip>
</div>
</ContextMenuTrigger>
<ContextMenuContent>
{onRunParallel ? (
<ContextMenuItem onClick={onRunParallel}>
<Layers className="mr-2 h-4 w-4" /> {runParallelLabel}
</ContextMenuItem>
) : null}
{onRunSequential ? (
<ContextMenuItem onClick={onRunSequential}>
<Layers className="mr-2 h-4 w-4" /> {runSequentialLabel}
</ContextMenuItem>
) : null}
{onCopyCommand ? (
<ContextMenuItem onClick={onCopyCommand}>
<Copy className="mr-2 h-4 w-4" /> {copyCommandLabel}
</ContextMenuItem>
) : null}
<ContextMenuItem onClick={onEdit}>
<Edit2 className="mr-2 h-4 w-4" /> {editLabel}
</ContextMenuItem>
<ContextMenuItem className="text-destructive" onClick={onDelete}>
<Trash2 className="mr-2 h-4 w-4" /> {deleteLabel}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
));
SnippetChip.displayName = 'SnippetChip';
export const ScriptsSidePanel = memo(ScriptsSidePanelInner);
ScriptsSidePanel.displayName = 'ScriptsSidePanel';
+4 -1
View File
@@ -36,6 +36,7 @@ import {
editorTabStore,
useEditorTabPresenceRevision,
} from "../application/state/editorTabStore";
import { forceCloseOwnedEditors } from "../application/state/editorWindowClient";
import { releaseEditorTabSaveCoordinator } from "../application/state/editorTabSave";
import { useSftpBackend } from "../application/state/useSftpBackend";
import { useSftpFileAssociations } from "../application/state/useSftpFileAssociations";
@@ -534,7 +535,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
if (id) ownedSessionIds.push(id);
}
if (ownedSessionIds.length === 0 && ownedSftpTabIds.length === 0) return;
const closed = editorTabStore.forceCloseByOwners({
const closed = forceCloseOwnedEditors({
sessionIds: ownedSessionIds,
sftpTabIds: ownedSftpTabIds,
});
@@ -1607,6 +1608,7 @@ const SftpSidePanelInteractiveBody: React.FC<SftpSidePanelInteractiveBodyProps>
setFileOpenerTarget,
handleSaveTextFile,
onPromoteToTab,
onPopOut,
handleFileOpenerSelect,
handleSelectSystemApp,
} = useSftpViewPaneCallbacks({
@@ -2058,6 +2060,7 @@ const SftpSidePanelInteractiveBody: React.FC<SftpSidePanelInteractiveBodyProps>
handleFileOpenerSelect={handleFileOpenerSelect}
handleSelectSystemApp={handleSelectSystemApp}
onPromoteToTab={onPromoteToTab}
onPopOut={onPopOut}
onRequestTerminalFocus={onRequestTerminalFocus}
t={t}
/>
+2
View File
@@ -410,6 +410,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
setFileOpenerTarget,
handleSaveTextFile,
onPromoteToTab,
onPopOut,
handleFileOpenerSelect,
handleSelectSystemApp,
} = useSftpViewPaneCallbacks({
@@ -797,6 +798,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
handleFileOpenerSelect={handleFileOpenerSelect}
handleSelectSystemApp={handleSelectSystemApp}
onPromoteToTab={onPromoteToTab}
onPopOut={onPopOut}
t={t}
/>
</div>
+3 -1
View File
@@ -669,7 +669,9 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
const handleSessionExit = useCallback((sessionId: string, evt: TerminalSessionExitEvent) => {
const intent = resolveTerminalSessionExitIntent(
evt,
terminalSettings?.autoCloseOnExit ?? true,
// No terminal settings loaded yet -> do not close the tab; auto-close is
// opt-in via Settings -> Terminal -> Behavior (DEFAULT_TERMINAL_SETTINGS).
terminalSettings?.autoCloseOnExit ?? false,
);
if (intent.kind === "closeSession") {
handleCloseSession(sessionId);
Loaded 100 of 244 files, more files were not shown because too many files have changed in this diff. Show more