Compare commits

...
Author SHA1 Message Date
bincxz e57ec0b476 fix: show the effective device mode in host system details 2026-09-07 16:15:18 +08:00
bincxz 999f85763f fix: detect remote systems after Mosh becomes ready 2026-09-07 16:05:15 +08:00
bincxz 70a68900ce fix: retain detected systems for temporary terminal hosts 2026-09-07 15:51:12 +08:00
bincxz 361dc6117f fix: unify host system detection and simplify host details 2026-09-07 15:40:33 +08:00
fe2f14f032 feat(ai): add Mention Note context picker for Vault Notes (#3290)
* fix(#3289): automated AI fix

* fix: address Codex review on PR #3290

* fix: address Codex review on PR #3290

* fix: address Codex review on PR #3290

* fix: address Codex review on PR #3290

* fix: address Codex review on PR #3290

* fix: address Codex review on PR #3290

* fix: address Codex review on PR #3290

* fix: address Codex review on PR #3290

* fix: address Codex review on PR #3290

* fix: address Codex review on PR #3290

* fix: address Codex review on PR #3290

* fix: address Codex review on PR #3290

* fix: address Codex review on PR #3290

* fix: address Codex review on PR #3290

* fix: address Codex review on PR #3290

* fix: address Codex review on PR #3290

* fix: address Codex review on PR #3290

* fix: address Codex review on PR #3290

* fix: address Codex review on PR #3290

* fix: address Codex review on PR #3290

* fix: address Codex review on PR #3290

* fix: address Codex review on PR #3290

* fix(ai): apply note attachment budgets synchronously

* fix(ai): reuse normalized note search fields while typing

* fix(ai): retain draft state binding in panel host

* fix(ai): reject carriage returns in note references

* fix(ai): protect continuation before pruning note payloads

* refactor(ai): keep note mentions as lightweight references

* fix(ai): gate note references on reader availability

* fix(ai): retain note references ahead of replay prose

* refactor(ai): share one instruction across note references

* fix(ai): keep accepted note references within replay space

* Bound agent note reads and add resumable literal search

* Preserve compact note requests and announce picker selection

* Retain mixed attachment context in compact note replay

* Keep imported long note identities readable

* Keep note picker selection and untitled labels consistent

---------

Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-09-07 14:32:08 +08:00
1a9c57b0a9 fix(terminal): keep reading position across fit resize and settled output (#3300)
* fix(#3299): automated AI fix

* fix: address Codex review on PR #3300

* fix: address Codex review on PR #3300

* fix: address Codex review on PR #3300

* fix: address Codex review on PR #3300

* fix: address Codex review on PR #3300

* fix: address Codex review on PR #3300

* fix: address Codex review on PR #3300

* fix: address Codex review on PR #3300

* fix: address Codex review on PR #3300

* fix: address Codex review on PR #3300

* fix: address Codex review on PR #3300

* fix: address Codex review on PR #3300

* fix: address Codex review on PR #3300

* fix: address Codex review on PR #3300

* fix: address Codex review on PR #3300

* fix: address Codex review on PR #3300

* fix: address Codex review on PR #3300

* fix: address Codex review on PR #3300

* fix: address Codex review on PR #3300

* fix: address Codex review on PR #3300

* fix: address Codex review on PR #3300

* fix: address Codex review on PR #3300

* fix: address Codex review on PR #3300

* fix(terminal): defer fit until synchronized output settles

---------

Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-09-07 11:46:23 +08:00
058727b0d9 fix(ssh): refresh login on new sessions and reconnects (#3294)
* fix(#3293): automated AI fix

* fix(ssh): refresh new sessions and discard reconnect source reuse

---------

Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-09-07 11:41:48 +08:00
陈大猫andnetcatty-bot 35ed0b6dbd fix(terminal): preserve row boundaries in alternate history preview (#3297)
* fix(terminal): preserve row boundaries in alternate history preview

* fix(terminal): retain columns across history row moves

* fix(terminal): keep cursor-only moves empty and overwrite whole glyphs

* fix: address Codex review on PR #3297

* fix: address Codex review on PR #3297

* fix: address Codex review on PR #3297

* fix: address Codex review on PR #3297

* fix: address Codex review on PR #3297

* fix(terminal): consume unprintable wide glyphs in history capture

* fix(terminal): preserve deferred wrap between display chunks

* fix: address Codex review on PR #3297

* fix: address Codex review on PR #3297

* fix: address Codex review on PR #3297

* fix: address Codex review on PR #3297

* fix: address Codex review on PR #3297

* fix: address Codex review on PR #3297

* fix: address Codex review on PR #3297

* fix: address Codex review on PR #3297

* fix: address Codex review on PR #3297

* fix: address Codex review on PR #3297

* fix: address Codex review on PR #3297

* fix: address Codex review on PR #3297

* fix(terminal): retain blank rows when reflowing history

* fix: address Codex review on PR #3297

* fix: address Codex review on PR #3297

* fix: address Codex review on PR #3297

* fix: address Codex review on PR #3297

* fix(terminal): refresh cached history after resize trims text

* fix(terminal): synchronize history with actual resized buffer

* fix(terminal): preserve final wide glyph during nonwrapping overflow

* fix(terminal): restore saved cursor modes in history

---------

Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
2026-09-07 11:11:10 +08:00
陈大猫 ab80d04039 fix(ci): allow exact research helper paths in Claude runs (#3306) 2026-09-07 10:30:46 +08:00
陈大猫 aaac962179 fix(sftp): share pending resume feedback across transfer views (#3305) 2026-09-07 10:30:42 +08:00
陈大猫 5d61462b20 fix(terminal): keep full-width history preview rows visible (#3304)
* fix(terminal): align history preview with rendered cell dimensions

* fix(terminal): fit wide history rows without changing copied text

* fix(terminal): select every history preview row
2026-09-07 10:30:38 +08:00
陈大猫 8cc8d938b8 fix(ai): preserve user text during request compression (#3303) 2026-09-07 10:30:35 +08:00
陈大猫andnetcatty-bot 1babda6fa9 fix(sync): resume auto-sync after first master-key setup (#3302)
* fix(sync): retry peer unlock when the shared password becomes available

* fix(sync): retain deliberate locks after availability notifications

* fix(sync): preserve deliberate locks after peer password events

* fix(sync): retain deliberate locks across consumer remounts

* fix: address Codex review on PR #3302

* fix: address Codex review on PR #3302

* fix: address Codex review on PR #3302

* fix: address Codex review on PR #3302

* test(sync): cover pending unlock owner handoff

* fix(sync): distinguish stale unlocks from invalid passwords

* fix(sync): retry completed auto unlock after consumers remount

* fix(sync): share auto unlock retry generation across consumers

---------

Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
2026-09-07 10:30:30 +08:00
陈大猫 0eebc77b1b fix(ssh): resolve watchdog PID without an intermediate shell (#3301) 2026-09-07 10:30:26 +08:00
陈大猫andnetcatty-bot 7069bdb1c8 fix(ai): keep live shell probes out of Bash history (#3298)
* fix(ai): keep live shell probes out of Bash history

* fix(ai): bypass custom eval when cleaning probe history

* fix(ai): clean Bash probes without external shell detection

* fix(ai): keep probe cleanup nonfatal in errexit shells

* fix(ai): bypass custom builtin definitions in history cleanup

* fix: address Codex review on PR #3298

* fix(ai): verify history cleanup across dispatcher fallbacks

* fix: address Codex review on PR #3298

* fix: address Codex review on PR #3298

* fix: address Codex review on PR #3298

* fix: address Codex review on PR #3298

* fix: address Codex review on PR #3298

* fix(ai): deliver shell probes without input overruns

* fix: address Codex review on PR #3298

* fix(ai): suspend wait timers during paced terminal input

* fix(ai): bound long command assignments for canonical terminals

* fix(ai): clear canonical pending input without a control command

---------

Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
2026-09-07 10:30:22 +08:00
陈大猫 10ad14223f fix(sftp): isolate side panel snapshots across tab switches (#3296) 2026-09-07 10:30:15 +08:00
陈大猫 f7f0d804af fix(settings): restore AI detection after effect replay (#3295) 2026-09-07 10:30:11 +08:00
陈大猫 4ba196510d fix(terminal): avoid duplicate broadcast keypress text (#3292) 2026-09-07 10:30:07 +08:00
陈大猫 fd54339367 fix(sftp): settle direct folder downloads after pause and resume (#3291) 2026-09-07 10:30:03 +08:00
github-actions[bot]andgithub-actions[bot] 28b9a8db89 chore: update star history charts [skip ci] (#3307)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-09-07 00:10:36 +00:00
cabdcdf31c fix(logs): adaptive light/dark theme for HTML session logs (#3264)
* fix(#3263): automated AI fix

* fix(logs): keep adaptive HTML colors readable

* fix(logs): adapt extended foreground colors to light pages

* perf(logs): calculate light foreground colors without iteration

---------

Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-09-06 15:11:13 +08:00
陈大猫andnetcatty-bot e2a9e54a90 fix(sftp): preserve concurrent files during local publication (#3286)
* fix(sftp): preserve concurrent files during local publication

* fix(sftp): preserve metadata during fallback restoration

* fix: address Codex review on PR #3286

* fix(sftp): prepare timestamps before publishing local downloads

* fix: address Codex review on PR #3286

* fix(sftp): preserve file access through restricted-mode publication

* fix(sftp): retry timestamps when preparation fails

* fix(sftp): verify unreadable destination recovery before replacement

---------

Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
2026-09-06 14:23:15 +08:00
陈大猫 c9d22b3b4d fix(sftp): preserve latest pause across delayed transfer controls (#3284)
* fix(sftp): preserve newer pause decisions across delayed transfer controls

* fix(sftp): stop obsolete recovery after held transfers settle

* fix(sftp): treat superseded cross-window controls as no-ops

* fix(sftp): preserve latest folder control in pause watcher

* fix(sftp): reconcile superseded controls across windows

* test(sftp): expect authoritative action on superseded pause

* fix(sftp): distinguish mixed resume outcomes and IPC failures

* docs(sftp): publish audit findings and validation boundaries

* docs(sftp): record final publication and restart validation

* fix(sftp): reflect partial resumes and reconcile remote watcher controls

* fix(sftp): preserve resolved resume failures in partial folders
2026-09-06 14:13:49 +08:00
陈大猫 2dea078c6d fix(sftp): preserve shared SSH transport when channel opens abort (#3288) 2026-09-06 13:29:08 +08:00
陈大猫 446e82180b fix(sftp): settle folder children before history compaction removes their rows (#3287)
* fix(sftp): observe folder child completion before history compaction

* fix(sftp): carry current child identity through recovery events

* fix(sftp): admit recovered children before waiting for completion

* test(sftp): keep independent audit regressions mergeable

* fix(sftp): resume retained pauses without bypassing newer controls
2026-09-06 13:29:05 +08:00
陈大猫 2eaddf0baf fix(sftp): refuse ambiguous or replaced resume endpoints (#3285) 2026-09-06 13:29:02 +08:00
陈大猫 7b964ead21 perf(terminal): skip painting inactive terminal panes (#3283)
* perf(terminal): stop painting inactive panes without pausing output

* fix(terminal): clear cached split offsets when parking panes
2026-09-06 10:56:35 +08:00
Atirnaandbincxz 0ea8fa4943 fix(port-forwarding): reconnect auto-start rules after inactive status (#3279)
* fix(port-forwarding): reconnect auto-start rules after inactive status

* fix(port-forwarding): honor current auto-start setting during reconnect

* fix(port-forwarding): ignore retries from replaced connections

* fix(port-forwarding): preserve exhausted retries after close

* fix(port-forwarding): schedule one retry per failed connection

* fix(port-forwarding): retain pending retry status after repeated errors

---------

Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-09-05 23:01:41 +08:00
7228af73bf fix(ai): keep agent wrapper out of bash history to stop arrow-key residue (#3267)
* fix(#3265): automated AI fix

* fix: address Codex review on PR #3267

* fix(ai): reliably remove wrapper history entries

* fix(ai): avoid interactive zsh history expansion in cleanup

---------

Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-09-05 22:38:03 +08:00
HLAIA 3626601eef fix(terminal): accept WindowsApps execution aliases in shell discovery (#3281) 2026-09-05 22:27:26 +08:00
81ec019892 fix(ai): detect the active terminal shell before command execution (#3262)
* fix(#3261): automated AI fix

* fix: address Codex review on PR #3262

* fix: address Codex review on PR #3262

* fix: address Codex review on PR #3262

* fix: address Codex review on PR #3262

* fix: address Codex review on PR #3262

* fix: address Codex review on PR #3262

* fix: address Codex review on PR #3262

* fix: address Codex review on PR #3262

* fix: address Codex review on PR #3262

* fix: address Codex review on PR #3262

* fix: address Codex review on PR #3262

* fix(ai): probe the active PTY shell before choosing command syntax

* fix(ai): keep shell probing invisible and tolerate unavailable sh

* fix(ai): separate probe output when terminal echo is disabled

* fix(ai): retain hidden probe lines through delayed prompt flushes

* fix(ci): use Debian security endpoint for arm64 dependencies

* fix(ci): fall back between official Debian security mirrors

* fix(ci): retain snapshot fallback for missing Bullseye packages

* fix(ai): hide delayed partial probe completion markers

* fix(ai): bound multiline probe prompt suppression by job lifecycle

---------

Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-09-05 14:55:57 +08:00
陈大猫andnetcatty-bot 1151b31a42 fix: prevent Notes autocomplete popup clipping (#3277)
* fix: keep note autocomplete popups outside clipped code blocks

* fix: address Codex review on PR #3277

* fix: constrain note completion width to its pane

* fix: retain owning pane colors on note tooltips

---------

Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
2026-09-05 14:31:28 +08:00
20b12a861c fix(terminal): color the viewport in-frame on bulk keyword-highlight bypass (#3272)
* fix(#3271): automated AI fix

* fix: address Codex review on PR #3272

* fix: address Codex review on PR #3272

* fix: address Codex review on PR #3272

* fix: address Codex review on PR #3272

* fix: address Codex review on PR #3272

* fix: address Codex review on PR #3272

* fix(terminal): defer viewport coloring during history replay

* fix(terminal): skip extra bulk repaint while reading history

---------

Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-09-05 13:13:56 +08:00
陈大猫 f70f6e4a5f Merge pull request #3275 from Meow-Emperor/feature/broadcast-mode-for-multiple-tabs
Feature/broadcast mode for multiple tabs
2026-09-05 13:04:43 +08:00
Netcatty-Botandnetcatty-bot a789ec1b9d fix(#3266): automated AI fix (#3268)
Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
2026-09-05 12:57:30 +08:00
bincxz 2d9890a92e fix: disarm global broadcast when orphan group disappears 2026-09-05 12:53:39 +08:00
Meow-Emperor 1ef7b026a6 Enable global broadcast for orphan terminal tabs
Allow global broadcast hotkeys for visible orphan sessions and centralize target resolution with coverage for workspace, global, and direct-target modes.
2026-09-05 01:06:36 +08:00
Meow-Emperor f9eded887f Fix global broadcast for orphan sessions
Keep stored boolean updates pure and propagate global broadcast state through terminal panes so orphan sessions can toggle and receive broadcasts correctly.
2026-09-05 00:11:52 +08:00
Meow-Emperor 701e35dac0 feat: 支持多标签直接启用广播模式
- 新增全局广播状态管理(globalBroadcastEnabled)
- 修改广播按钮显示逻辑:工作区内或孤立标签数 ≥2 时显示
- 修改广播输入处理:支持工作区广播和全局广播两种模式
- 工作区广播优先级高于全局广播,两种模式互不干扰
- 孤立标签(未加入工作区)可独立启用全局广播功能
2026-09-04 21:08:04 +08:00
陈大猫 09cb03c88c Merge pull request #3246 from binaricat/ai/issue-3244-33598498608
fix(window): restore main window position when a secondary display returns after lock
2026-09-03 15:23:09 +08:00
JasonWen 5c5fa4f27e fix(ai): make the command blocklist shell-aware
Select default command-safety rules from the live session shell so legal
PowerShell subexpressions are allowed and PowerShell-native dangerous
commands are blocked. Untouched pre-shell-aware lists gain the new
defaults on read.

Closes #3256
2026-09-03 15:21:45 +08:00
bincxz c91a57f315 fix(windows): drop stale recovery after a post-unlock user move 2026-09-03 15:13:01 +08:00
bincxz 81c3feba0b fix(windows): retry display restore when unlock follows a locked relocation 2026-09-03 15:07:28 +08:00
bincxz 1be02e5c01 fix(windows): scope display recovery to lock and sleep
The original bug is: lock + display sleep moves a secondary-display window
to the primary, and unlock never puts it back. Review follow-ups had grown
a general display-identity machine far beyond that.

Restore only while the session is locked/asleep, or shortly after unlock.
Keep user moves authoritative. Drop Win+Shift destination tracking and
multi-unknown-display correlation.
2026-09-03 14:59:28 +08:00
bincxz 41a35824aa fix(windows): preserve recovery through late display ordering 2026-09-03 14:47:18 +08:00
bincxz 438a5f0652 fix(windows): retry resolved display recovery 2026-09-03 14:30:54 +08:00
bincxz 16b2a20220 fix(windows): preserve explicit display intent 2026-09-03 14:22:40 +08:00
bincxz 2f4726c8ce fix(windows): harden display recovery ordering 2026-09-03 14:06:40 +08:00
陈大猫 c9b40d98c2 Merge pull request #3245 from binaricat/feat/3240-sftp-ux-and-folder-scan
feat(sftp): bookmarks, compact list, dual-pane entry, and faster folder scans
2026-09-03 14:06:23 +08:00
bincxz 9fca48a927 fix(windows): safely assign transient display changes 2026-09-03 13:56:09 +08:00
JasonWenandbincxz 3411d7f08b fix(ai): avoid idle PowerShell clear corruption (#3253)
* fix(ai): avoid idle PowerShell clear corruption

* fix(ai): preserve safe PowerShell input clearing

* fix(ai): support all PowerShell edit modes

* fix(ai): clear PowerShell Vi suffix input

* fix(ai): clear multiline PowerShell input

* fix(ai): support legacy PowerShell Vi mode

* fix(ai): wait for PowerShell prompt before next exec

* fix(ai): guard delayed prompt recovery

---------

Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-09-03 13:48:43 +08:00
bincxz 82e62c8c1d fix(sftp): preserve bookmark scope in actions 2026-09-03 13:42:51 +08:00
bincxz 48780fcd09 fix(sftp): route bookmark changes by scope 2026-09-03 13:36:14 +08:00
bincxz 116e70a206 fix(windows): correlate replaced display payloads 2026-09-03 13:34:31 +08:00
bincxz 56e593898c fix(windows): harden display recovery ordering 2026-09-03 13:26:57 +08:00
bincxz 636f6c29ab fix(windows): preserve split display recovery bursts 2026-09-03 13:17:19 +08:00
bincxz d2e584f00d fix(windows): preserve post-return placement 2026-09-03 13:07:37 +08:00
bincxz 0b8df00e31 fix(sftp): guard replacement and focus fallback 2026-09-03 13:04:58 +08:00
bincxz dd9577ce75 fix(sftp): fail closed and restore bookmark focus 2026-09-03 12:59:13 +08:00
bincxz 8becc81e7a fix(windows): harden delayed display recovery 2026-09-03 12:57:14 +08:00
bincxz 6b6b37c7a1 fix(sftp): isolate bookmark removal confirmation 2026-09-03 12:44:11 +08:00
bincxz d62dfac977 fix(windows): distinguish display recovery intent 2026-09-03 12:38:25 +08:00
bincxz cfbda2a23f fix(sftp): confirm current bookmark removal 2026-09-03 12:26:02 +08:00
bincxz d2ed328907 fix(windows): preserve display recovery intent 2026-09-03 12:18:57 +08:00
bincxz 52ec1edab8 fix(sftp): use in-app bookmark removal dialog 2026-09-03 12:14:27 +08:00
陈大猫 f3f89edad5 Merge pull request #3251 from binaricat/ai/issue-3249-33607924553
fix(ai): run Responses API turns stateless to stop rs_ item replay errors
2026-09-03 11:55:10 +08:00
bincxz 3e03f439ec fix: safely correlate returning displays 2026-09-03 11:53:13 +08:00
陈大猫 5a05da0b4a Merge pull request #3260 from binaricat/ai/issue-3259-33708519760
fix(vault): sync managed ssh_config with latest host edits
2026-09-03 11:45:52 +08:00
bincxz efaaaebd29 fix(ai): distinguish Chat history during API switches 2026-09-03 11:38:48 +08:00
bincxz 969da6aa88 fix(sftp): apply saved folder upload concurrency 2026-09-03 11:38:38 +08:00
bincxz 8bf63ed89e fix: follow stabilized returning displays 2026-09-03 11:36:41 +08:00
bincxz 2308d133c2 fix(ai): preserve Chat tool history on Responses switch 2026-09-03 11:35:39 +08:00
bincxz d607c81b5b fix(ai): handle metadata-free Responses model switches 2026-09-03 11:31:53 +08:00
bincxz 4fc97ce96e fix(sftp): honor concurrency for ordinary uploads 2026-09-03 11:29:18 +08:00
bincxz f828e0d479 fix(ai): classify Responses replay metadata precisely 2026-09-03 11:27:53 +08:00
bincxz 3162c2c375 fix: preserve unknown display recovery 2026-09-03 11:25:11 +08:00
bincxz e7b59ad768 fix(ai): trim stored history at tool-safe boundaries 2026-09-03 11:20:12 +08:00
bincxz 1de1221707 fix: preserve delayed display recovery 2026-09-03 11:17:04 +08:00
bincxz b285200798 fix(ai): keep compaction boundaries aligned after trimming 2026-09-03 11:15:28 +08:00
bincxz 02f5729ab7 fix(ai): discard compacted replay ciphertext 2026-09-03 11:11:14 +08:00
陈大猫 74c1cd063b Merge pull request #3250 from binaricat/ai/issue-3248-33607677408
fix(workspace): auto-name merged tab workspaces from both tab labels
2026-09-03 11:08:35 +08:00
bincxz 044a95b831 test: protect manual moves after display return 2026-09-03 11:07:23 +08:00
bincxz 8e05923ca2 fix(ai): scope legacy replay pruning to Responses 2026-09-03 11:05:44 +08:00
bincxz 73ed3e02ed fix: retry recovery after display stabilization 2026-09-03 11:04:21 +08:00
bincxz c0a46cc926 fix(ai): sanitize cross-model replay and compaction 2026-09-03 11:02:08 +08:00
bincxz b2c72d607b fix(sftp): add accessible bookmark reordering 2026-09-03 10:58:14 +08:00
netcatty-bot b83f3ee17a fix(#3259): automated AI fix 2026-09-03 02:56:54 +00:00
陈大猫 689fcfd4e4 Merge pull request #3257 from JasonWenTheFox/fix/windows-host-os-metadata
fix(vault): make host OS metadata settable end to end
2026-09-03 10:56:13 +08:00
bincxz 0fb15b99c5 fix: match displays across transient identifiers 2026-09-03 10:55:07 +08:00
bincxz c6c769acc3 perf(ai): prune continuations in one pass 2026-09-03 10:54:19 +08:00
bincxz 0b8ba35da7 fix(sftp): restore split view for host entry 2026-09-03 10:53:45 +08:00
bincxz 47bef3c9d9 fix(vault): expose host operating system in editor 2026-09-03 10:51:45 +08:00
bincxz 2faa30d79b fix(sftp): apply shared transfer limit everywhere 2026-09-03 10:50:48 +08:00
bincxz d19ddce4ff fix(ai): preserve newest continuation within long chats 2026-09-03 10:49:19 +08:00
bincxz ae26219fdd fix(sftp): align transfer concurrency defaults 2026-09-03 10:46:58 +08:00
bincxz 35076fea5d fix: limit display recovery to Windows 2026-09-03 10:45:18 +08:00
bincxz 192c7043c1 fix: handle transient display identifiers 2026-09-03 10:43:20 +08:00
bincxz 2ff47eafce fix(ai): prioritize active chat continuation 2026-09-03 10:40:49 +08:00
bincxz 2538da9891 fix: distinguish manual moves after unlock 2026-09-03 10:40:35 +08:00
bincxz 7440e66ea8 fix(ai): retain newest replay data when pruning chats 2026-09-03 10:37:21 +08:00
bincxz e84ef25720 fix(sftp): complete issue 3240 workflows 2026-09-03 10:35:09 +08:00
bincxz 56deb86151 fix: honor immediate post-unlock window moves 2026-09-03 10:32:32 +08:00
bincxz 2189f6c820 test(workspace): cover merged title boundaries 2026-09-03 10:31:47 +08:00
bincxz 7e3960f561 fix(ai): preserve newest chat under tight quotas 2026-09-03 10:29:06 +08:00
bincxz acb29e0791 fix(ai): make session quota retries converge 2026-09-03 10:26:43 +08:00
bincxz a6683e23ab fix: respect post-unlock window moves 2026-09-03 10:26:09 +08:00
bincxz d350f2746a test(workspace): cover merged title lifecycle 2026-09-03 10:24:41 +08:00
bincxz 89dab1394d fix(ai): keep stateless Responses replay data intact 2026-09-03 10:22:29 +08:00
bincxz 0326881d85 fix: preserve display recovery through lock teardown 2026-09-03 10:20:37 +08:00
JW 309fe73492 fix(vault): make host OS metadata settable end to end
Hosts were hardcoded to os 'linux' at every creation and update entry
point, so Windows hosts were always reported as linux by the AI
environment (get_environment) and nothing could correct them.

- buildVaultHostFromDraft parses draft.os with a strict allowlist
  (linux/windows/macos, common aliases included) and defaults to linux
- applyVaultHostUpdate accepts os with the same validation
- the AI host.update field list and the MCP vault host tool schemas
  expose os so agents can set and correct it
2026-09-03 03:41:10 +08:00
netcatty-bot cc0fc9ee19 fix: address Codex review on PR #3246 2026-09-02 16:09:27 +00:00
netcatty-bot c628cb233c fix: address Codex review on PR #3246 2026-09-02 15:39:40 +00:00
netcatty-bot 5f3a1eaed6 fix: address Codex review on PR #3246 2026-09-02 15:19:14 +00:00
netcatty-bot 5d10f09a0a fix: address Codex review on PR #3246 2026-09-02 14:58:20 +00:00
netcatty-bot 4abbdd9e48 fix: address Codex review on PR #3246 2026-09-02 14:28:20 +00:00
netcatty-bot 6ff65334d4 fix: address Codex review on PR #3246 2026-09-02 13:57:51 +00:00
netcatty-bot 2c23f445ac fix: address Codex review on PR #3246 2026-09-02 13:35:57 +00:00
netcatty-bot e3f56204b2 fix: address Codex review on PR #3246 2026-09-02 13:14:48 +00:00
netcatty-bot dcc3e0718f fix: address Codex review on PR #3246 2026-09-02 12:47:36 +00:00
netcatty-bot 3936d8b7f3 fix: address Codex review on PR #3251 2026-09-02 12:07:00 +00:00
netcatty-bot b0481e7da0 fix: address Codex review on PR #3246 2026-09-02 12:04:59 +00:00
netcatty-bot e911792287 fix: address Codex review on PR #3246 2026-09-02 11:46:01 +00:00
陈大猫 ceb89d65aa Merge pull request #3247 from binaricat/codex/fix-2974-shift-enter-negotiation
[Bug] Preserve modified Enter keys in local Windows TUIs
2026-09-02 19:36:04 +08:00
netcatty-bot 1025d93ae3 fix: address Codex review on PR #3251 2026-09-02 11:35:00 +00:00
netcatty-bot 3b6f185866 fix: address Codex review on PR #3246 2026-09-02 11:17:07 +00:00
netcatty-bot 8d4fde0340 fix: address Codex review on PR #3251 2026-09-02 10:58:59 +00:00
netcatty-bot 9c61e46635 fix: address Codex review on PR #3246 2026-09-02 10:56:43 +00:00
netcatty-bot f4987550f1 fix: address Codex review on PR #3246 2026-09-02 10:31:01 +00:00
netcatty-bot 6ad422b1ea fix: address Codex review on PR #3251 2026-09-02 10:30:39 +00:00
netcatty-bot 24b8d8e05d fix: address Codex review on PR #3250 2026-09-02 10:28:40 +00:00
netcatty-bot 2487e954ee fix: address Codex review on PR #3251 2026-09-02 10:03:22 +00:00
netcatty-bot f66c098c9b fix: address Codex review on PR #3250 2026-09-02 09:59:49 +00:00
netcatty-bot 8332aa2dbd fix: address Codex review on PR #3246 2026-09-02 09:58:58 +00:00
netcatty-bot 908cc687cb fix: address Codex review on PR #3251 2026-09-02 09:37:34 +00:00
netcatty-bot 47667e80f5 fix: address Codex review on PR #3250 2026-09-02 09:36:22 +00:00
netcatty-bot da8b13471f fix: address Codex review on PR #3246 2026-09-02 09:35:02 +00:00
netcatty-bot 2d6183c440 fix: address Codex review on PR #3251 2026-09-02 09:07:42 +00:00
netcatty-bot a352ca3836 fix: address Codex review on PR #3250 2026-09-02 09:02:06 +00:00
netcatty-bot 4fad9b8dcf fix: address Codex review on PR #3246 2026-09-02 08:59:57 +00:00
netcatty-bot 2569c6c3c4 fix: address Codex review on PR #3246 2026-09-02 08:40:57 +00:00
netcatty-bot 0ecd06b2f6 fix(#3249): automated AI fix 2026-09-02 08:33:19 +00:00
netcatty-bot 1f52f6f399 fix(#3248): automated AI fix 2026-09-02 08:30:19 +00:00
netcatty-bot 606c53d7fa fix: address Codex review on PR #3246 2026-09-02 08:05:18 +00:00
netcatty-bot 0556e493f8 fix: address Codex review on PR #3246 2026-09-02 07:39:15 +00:00
bincxz 4435f763bd fix(sftp): ignore IME composition Enter when renaming bookmarks
CJK IMEs use Enter to accept a candidate. Do not treat that keydown as
a rename submit while composition is active.
2026-09-02 15:24:53 +08:00
bincxz 5ae1df350d fix(sftp): rename bookmarks inline instead of window.prompt
Electron throws on window.prompt, so Manage → Rename did nothing.
Use an in-list input with Enter/blur commit and Escape cancel.
2026-09-02 15:17:13 +08:00
bincxz 74b77ddec1 fix(sftp): always plan Open SFTP instead of guessing first visit
Empty pane arrays are not a first-visit signal; they also happen after
the user closes every tab. Route every Open SFTP request through the
planner, and do not forceNewTab on an empty side.
2026-09-02 15:07:11 +08:00
bincxz 2084ede4db fix(sftp): restore local-left after all left SFTP tabs are closed
The empty-left shortcut was racing first-visit auto-connect, but it also
fired after the user closed every left tab. Open SFTP then skipped the
planner and could replace the right pane. Only skip the planner when
both sides are still empty.
2026-09-02 14:59:14 +08:00
netcatty-bot ae59c32888 fix: address Codex review on PR #3246 2026-09-02 06:58:09 +00:00
bincxz 1f75c20446 fix(sftp): reconnect the matching dead dual-pane tab
Keep host identity on failed panes and prefer that tab over an unrelated
idle pane so Open SFTP does not revive the wrong connection.
2026-09-02 14:47:04 +08:00
bincxz 7062dcb4a4 fix(sftp): reconnect failed dual-pane tabs from Open SFTP
A matching hostId in error or disconnected state is not a live session.
Treat only connected/connecting panes as reusable so Open SFTP reconnects
in place instead of selecting a dead tab.
2026-09-02 14:43:40 +08:00
netcatty-bot 0a3d001f47 fix(#3244): automated AI fix 2026-09-02 06:38:18 +00:00
bincxz 26b9027d41 fix(sftp): keep ordinary directory identities in local-tree cycle detection
Skipping realpath on plain directories also dropped them from the ancestor
set, so a symlink back to a non-root ancestor duplicated that subtree.
Compose the resolved parent path with the child name instead, and still
realpath directory links.
2026-09-02 14:35:30 +08:00
bincxz 25b79a6947 feat(sftp): tighten bookmarks, compact list, dual-pane entry, and folder scans
Address the SFTP UX list in #3240: wider bookmark management with
confirm/reorder/rename, a compact file-list density, a host-list shortcut
into the existing local/remote dual pane, faster local folder discovery,
and higher small-file upload concurrency.

Tree view already supports double-click to enter directories, so that
item is left as-is.
2026-09-02 14:26:14 +08:00
281 changed files with 18316 additions and 1435 deletions

No files matched your search

+2
View File
@@ -877,6 +877,7 @@ jobs:
--bare -p --permission-mode dontAsk \
--settings "$HOME/.claude/settings.json" \
--allowedTools "Read" "Bash(web-search *)" "Bash(web-fetch *)" \
"Bash($research_dir/web-search *)" "Bash($research_dir/web-fetch *)" \
--disallowedTools "WebSearch" "WebFetch" "Edit" "Write" "NotebookEdit" \
--output-format stream-json --verbose --model "$AI_MODEL" \
"$prompt"
@@ -1695,6 +1696,7 @@ jobs:
--bare -p --permission-mode dontAsk \
--settings "$HOME/.claude/settings.json" \
--allowedTools "Read" "Bash(web-search *)" "Bash(web-fetch *)" \
"Bash($research_dir/web-search *)" "Bash($research_dir/web-fetch *)" \
--disallowedTools "WebSearch" "WebFetch" "Edit" "Write" "NotebookEdit" \
--output-format stream-json --verbose --model "$AI_MODEL" \
"$prompt"
+5
View File
@@ -695,6 +695,11 @@ jobs:
shell: bash
run: |
set -euo pipefail
# Security CDN endpoints can lack files advertised in their indexes.
# Fall back per file, with the final Bullseye security snapshot for packages only.
# Live signed indexes still select versions and verify every download.
printf 'http://deb.debian.org/debian-security\tpriority:1\nhttp://security.debian.org/debian-security\tpriority:2\nhttp://snapshot.debian.org/archive/debian-security/20260901T000000Z/\tpriority:3 type:deb\n' > /etc/apt/security-mirrors.txt
sed -i 's|http://deb.debian.org/debian-security|mirror+file:/etc/apt/security-mirrors.txt|g' /etc/apt/sources.list
apt-get -o Acquire::Retries=4 update
apt-get -o Acquire::Retries=4 install -y curl build-essential python3 git libfuse2 file rpm \
libarchive-tools \
+3
View File
@@ -52,6 +52,9 @@ jobs:
- name: Test
run: npm test
- name: Test terminal background rendering
run: xvfb-run -a ./node_modules/.bin/electron --no-sandbox --use-angle=swiftshader --enable-unsafe-swiftshader scripts/terminal-background-rendering.live.test.cjs
- name: Test terminal keyword highlight performance
env:
NETCATTY_TERMINAL_PERF_SHOW_WINDOW: "1"
@@ -315,6 +315,30 @@ test('pane zoom hotkey delegates to the active in-app magnification surface', ()
assert.equal(toggles, 1);
});
test('broadcast hotkey toggles global mode for an active orphan tab', () => {
let globalToggles = 0;
let workspaceToggles = 0;
executeHotkeyActionImpl(() => ({
activeTabStore: { getActiveTabId: () => 'orphan-1' },
editorTabs: [],
orderedTabs: ['orphan-1', 'orphan-2'],
settings: { showSftpTab: true, shellOnlyTabNumberShortcuts: false },
toEditorTabId: (id: string) => id,
sessions: [
{ id: 'orphan-1' },
{ id: 'orphan-2' },
],
workspaces: [],
canUseGlobalBroadcast: true,
toggleBroadcast: () => { workspaceToggles += 1; },
toggleGlobalBroadcast: () => { globalToggles += 1; },
}), 'broadcast', {} as KeyboardEvent);
assert.equal(globalToggles, 1);
assert.equal(workspaceToggles, 0);
});
test('move-focus shortcut cannot send input behind a magnified pane', () => {
let moveCalls = 0;
executeHotkeyActionImpl(() => ({
+7 -2
View File
@@ -722,7 +722,7 @@ export async function closeTabsBatchImpl(getCtx: AppContextGetter, targetIds: st
}
export function executeHotkeyActionImpl(getCtx: AppContextGetter, action: string, e: KeyboardEvent) {
const { IS_DEV, MOVE_FOCUS_DEBOUNCE_MS, activeTabStore, addConnectionLogRef, closePluginViewTab, closeSession, closeTabInFlightRef, closeWorkspace, collectSessionIds, confirmIfBusyLocalTerminal, createLocalTerminalWithCurrentShell, editorTabs, fromEditorTabId, handleOpenSettingsRef, handleRequestCloseEditorTabRef, isEditorTabId, isPluginViewTabId, isQuickSwitcherOpen, lastMoveFocusTimeRef, moveFocusInWorkspace, orderedTabs, resolveCloseIntent, resolveSnippetsShortcutIntent, sessions, setActiveTabId, setAddToWorkspaceDialog, setIsQuickSwitcherOpen, setNavigateToSection, settings, sftpPaneMagnificationRef, splitSessionWithCurrentShell, systemInfoRef, terminalPaneMagnificationRef, toEditorTabId, toggleBroadcast, toggleScriptsSidePanelRef, toggleSidePanelRef, workspaces } = getCtx();
const { IS_DEV, MOVE_FOCUS_DEBOUNCE_MS, activeTabStore, addConnectionLogRef, canUseGlobalBroadcast, closePluginViewTab, closeSession, closeTabInFlightRef, closeWorkspace, collectSessionIds, confirmIfBusyLocalTerminal, createLocalTerminalWithCurrentShell, editorTabs, fromEditorTabId, handleOpenSettingsRef, handleRequestCloseEditorTabRef, isEditorTabId, isPluginViewTabId, isQuickSwitcherOpen, lastMoveFocusTimeRef, moveFocusInWorkspace, orderedTabs, resolveCloseIntent, resolveSnippetsShortcutIntent, sessions, setActiveTabId, setAddToWorkspaceDialog, setIsQuickSwitcherOpen, setNavigateToSection, settings, sftpPaneMagnificationRef, splitSessionWithCurrentShell, systemInfoRef, terminalPaneMagnificationRef, toEditorTabId, toggleBroadcast, toggleGlobalBroadcast, toggleScriptsSidePanelRef, toggleSidePanelRef, workspaces } = getCtx();
{
const shortcutTabs = buildNumberShortcutTabTargets({
showSftpTab: settings.showSftpTab ?? true,
@@ -916,11 +916,16 @@ export function executeHotkeyActionImpl(getCtx: AppContextGetter, action: string
toggleSidePanelRef.current?.();
break;
case 'broadcast': {
// Toggle broadcast mode for the active workspace
// Workspace tabs keep their local broadcast state; orphan tabs share
// the global state when at least two visible orphan tabs exist.
const currentId = activeTabStore.getActiveTabId();
const activeWs = workspaces.find(w => w.id === currentId);
if (activeWs) {
toggleBroadcast(activeWs.id);
} else if (sessions.some((session: TerminalSession) => (
session.id === currentId && !session.workspaceId
)) && canUseGlobalBroadcast) {
toggleGlobalBroadcast();
}
break;
}
@@ -225,3 +225,9 @@ test('AppShell uses default memo (store-driven), not always-rerender comparator'
assert.match(appShellSource, /memo\s*\(\s*AppShellView\s*\)/);
assert.doesNotMatch(appShellSource, /memo\s*\(\s*AppShellView\s*,\s*\(\s*\)\s*=>\s*false\s*\)/);
});
test('terminal system detection routes through the owner of temporary hosts', () => {
const sideEffects = readFileSync(join(here, 'AppSideEffects.tsx'), 'utf8');
assert.match(hostSources['TerminalHost.tsx'], /updateHostDistro: handlers\.updateTerminalHostDistro/);
assert.match(sideEffects, /if \(ephemeralHostIds\.has\(hostId\)\) \{\s*setEphemeralHosts\([\s\S]*?applyEphemeralHostDistroUpdate[\s\S]*?return;\s*\}\s*updateHostDistro\(hostId, distro\)/);
});
+12 -1
View File
@@ -43,7 +43,7 @@ import { materializeHostProxyProfile } from '../../domain/proxyProfiles';
import { buildSshDeepLinkConnectionHost, buildSshDeepLinkEphemeralHost, buildSshDeepLinkEphemeralHostFromSaved, buildSshDeepLinkHostDraft, findSshDeepLinkHost, parseSshDeepLink } from '../../domain/sshDeepLink';
import { buildTelnetDeepLinkConnectionHost, buildTelnetDeepLinkEphemeralHostFromSaved, buildTelnetDeepLinkOpenHost, findTelnetDeepLinkHost, materializeTelnetDeepLinkMatchHost, parseTelnetDeepLink } from '../../domain/telnetDeepLink';
import { buildJmsDeepLinkEphemeralHost, isSupportedJmsProtocol, parseJmsDeepLink } from '../../domain/jmsDeepLink';
import { applyEphemeralHostsUpdate, splitHostsUpdateByEphemeral } from '../../domain/ephemeralHosts';
import { applyEphemeralHostDistroUpdate, applyEphemeralHostsUpdate, splitHostsUpdateByEphemeral } from '../../domain/ephemeralHosts';
import { resolveHostAuth } from '../../domain/sshAuth';
import { isEncryptedCredentialPlaceholder, stripSyncPayloadEncryptedCredentials } from '../../domain/credentials';
import {
@@ -202,6 +202,7 @@ export function AppSideEffects() {
addConnectionLog,
updateConnectionLog,
updateHostLastConnected,
updateHostDistro,
importDataFromString,
readPersistedHosts,
groupConfigs,
@@ -1751,6 +1752,14 @@ export function AppSideEffects() {
updateHosts(vaultHosts);
}, [ephemeralHostIds, updateHosts]);
const updateTerminalHostDistro = useCallback((hostId: string, distro: string) => {
if (ephemeralHostIds.has(hostId)) {
setEphemeralHosts((previous) => applyEphemeralHostDistroUpdate(previous, hostId, distro));
return;
}
updateHostDistro(hostId, distro);
}, [ephemeralHostIds, updateHostDistro]);
// Wrapper to create serial session with logging
const handleConnectSerial = useCallback((config: SerialConfig, options?: { charset?: string }) => {
const { username, hostname } = systemInfoRef.current;
@@ -1886,6 +1895,7 @@ export function AppSideEffects() {
handleTerminalDataCapture,
handleUpdateHostFromTerminal,
updateTerminalHosts,
updateTerminalHostDistro,
runSnippet: handleRunSnippet,
splitSessionWithCurrentShell,
toggleScriptsSidePanelRef,
@@ -1962,6 +1972,7 @@ export function AppSideEffects() {
handleTerminalDataCapture,
handleUpdateHostFromTerminal,
updateTerminalHosts,
updateTerminalHostDistro,
handleRunSnippet,
splitSessionWithCurrentShell,
handleEndSessionDrag,
+4 -1
View File
@@ -268,7 +268,7 @@ function AppViewInner({ domains }: AppViewProps) {
handleEndSessionDrag, handleFollowAppTerminalThemeChange, handleHostConnectWithProtocolCheck, handleHotkeyAction, handleKeyboardInteractiveCancel, handleKeyboardInteractiveSubmit,
handleOpenHostFromVaultNote, handleOpenQuickSwitcher, handleOpenSettings, handleOpenVaultHostFromChat, handleOpenVaultNoteFromChat, handleOpenVaultSectionFromChat, handleOpenVaultSnippetFromChat, handleRootContextMenu, handlePassphraseCancel, handlePassphraseSkip, handlePassphraseSubmit, handleProtocolSelect,
handleRequestCloseEditorTabRef, handleSessionStatusChange, handleSyncNowManual, handleTerminalDataCapture, handleUpdateHostFromTerminal,
hostById, hosts, terminalHosts, updateTerminalHosts, hotkeyScheme, identities, importOrReuseKey, isBroadcastEnabled, isCreateWorkspaceOpen, isMacClient, isQuickSwitcherOpen,
hostById, hosts, terminalHosts, updateTerminalHosts, hotkeyScheme, identities, importOrReuseKey, isBroadcastEnabled, isGlobalBroadcastEnabled, onToggleGlobalBroadcast, canUseGlobalBroadcast, isCreateWorkspaceOpen, isMacClient, isQuickSwitcherOpen,
keyBindings, keyboardInteractiveQueue, keys, logViews, managedSources, navigateToSection, openLogView, openNoteRequest, orderedTabsWithEditors, orphanSessions,
passphraseQueue, protocolSelectHost, proxyProfiles, portForwardingRules, quickResults, quickSearch, removeSessionFromWorkspace, reorderWorkTabs, reorderWorkspaceSessions,
resolveEmptyVaultConflict, resolveSessionAppearance, runSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionLogsTimestampsEnabled, sessionRenameTarget, sshDebugLogsEnabled,
@@ -737,6 +737,9 @@ function AppViewInner({ domains }: AppViewProps) {
onCreateLocalTerminal={handleCreateLocalTerminal}
isBroadcastEnabled={isBroadcastEnabled}
onToggleBroadcast={toggleBroadcast}
isGlobalBroadcastEnabled={isGlobalBroadcastEnabled}
onToggleGlobalBroadcast={onToggleGlobalBroadcast}
canUseGlobalBroadcast={canUseGlobalBroadcast}
updateHosts={updateTerminalHosts}
updateSnippets={updateSnippets}
updateSnippetPackages={updateSnippetPackages}
+5 -4
View File
@@ -20,7 +20,6 @@ import {
import { useThemeRuntime, useTerminalAppearanceInjection } from '../../state/useThemeRuntime';
import {
useVaultSnapshot,
useVaultSnapshotActions,
} from '../../state/vaultSnapshotStore';
import { getAppHandlers, subscribeAppHandlers } from '../appHandlersBridge';
import { publishAppShellDomainSlice } from '../appShellPropsStore';
@@ -37,7 +36,6 @@ export function TerminalHost() {
const session = useSessionSnapshot();
const sessionActions = useSessionSnapshotActions();
const vault = useVaultSnapshot();
const vaultActions = useVaultSnapshotActions();
const terminalSettings = useTerminalSettingsStore();
const terminalSettingsActions = useTerminalSettingsActions();
const {
@@ -207,6 +205,8 @@ export function TerminalHost() {
updateTerminalHosts: handlers.updateTerminalHosts,
hotkeyScheme: terminalSettings.hotkeyScheme,
isBroadcastEnabled: sessionActions?.isBroadcastEnabled,
isGlobalBroadcastEnabled: sessionActions?.isGlobalBroadcastEnabled,
canUseGlobalBroadcast: sessionActions?.canUseGlobalBroadcast,
keyBindings: terminalSettings.keyBindings,
openNoteRequest: local.openNoteRequest,
portForwardingRules: local.portForwardingRules,
@@ -238,12 +238,14 @@ export function TerminalHost() {
terminalSettings: terminalSettings.terminalSettings,
terminalThemeId: terminalSettings.terminalThemeId,
toggleBroadcast: sessionActions?.toggleBroadcast,
toggleGlobalBroadcast: sessionActions?.toggleGlobalBroadcast,
onToggleGlobalBroadcast: sessionActions?.toggleGlobalBroadcast,
toggleScriptsSidePanelRef: handlers.toggleScriptsSidePanelRef,
toggleSidePanelRef: handlers.toggleSidePanelRef,
terminalPaneMagnificationRef: handlers.terminalPaneMagnificationRef,
sftpPaneMagnificationRef: handlers.sftpPaneMagnificationRef,
toggleWorkspaceViewMode: sessionActions?.toggleWorkspaceViewMode,
updateHostDistro: vaultActions?.updateHostDistro,
updateHostDistro: handlers.updateTerminalHostDistro,
updateSplitSizes: sessionActions?.updateSplitSizes,
updateSessionFontSize: sessionActions?.updateSessionFontSize,
updateSessionRestoreCwd: sessionActions?.updateSessionRestoreCwd,
@@ -271,7 +273,6 @@ export function TerminalHost() {
terminalHosts,
terminalSettings,
terminalSettingsActions,
vaultActions,
]);
useLayoutEffect(() => {
@@ -70,6 +70,9 @@ export function SessionPublisher({ persistSessionRestore, children }: SessionPub
reorderTabs,
toggleBroadcast,
isBroadcastEnabled,
toggleGlobalBroadcast,
isGlobalBroadcastEnabled,
canUseGlobalBroadcast,
copySession,
copyWorkspace,
createSessionFromCloneSource,
@@ -147,6 +150,9 @@ export function SessionPublisher({ persistSessionRestore, children }: SessionPub
reorderTabs,
toggleBroadcast,
isBroadcastEnabled,
toggleGlobalBroadcast,
isGlobalBroadcastEnabled,
canUseGlobalBroadcast,
copySession,
copyWorkspace,
createSessionFromCloneSource,
@@ -175,8 +181,10 @@ export function SessionPublisher({ persistSessionRestore, children }: SessionPub
getOrderedWorkTabs,
getSessionRestoreCwd,
isBroadcastEnabled,
isGlobalBroadcastEnabled,
moveFocusInWorkspace,
openLogView,
canUseGlobalBroadcast,
removeSessionFromWorkspace,
renameSessionInline,
reorderTabs,
@@ -193,6 +201,7 @@ export function SessionPublisher({ persistSessionRestore, children }: SessionPub
submitSessionRename,
submitWorkspaceRename,
toggleBroadcast,
toggleGlobalBroadcast,
toggleWorkspaceViewMode,
updateSessionCodingCliProvider,
updateSessionDynamicTitle,
+7
View File
@@ -497,6 +497,13 @@ export const enAiMessages: Messages = {
'ai.chat.menuFiles': 'Files',
'ai.chat.menuImage': 'Image',
'ai.chat.menuMentionHost': 'Mention Host',
'ai.chat.menuMentionNote': 'Mention Note',
'ai.chat.mentionNoteSearch': 'Search notes…',
'ai.chat.mentionNoteEmpty': 'No matching notes',
'ai.chat.mentionNoteUnavailable': 'This agent cannot read Vault notes in the current connection mode.',
'ai.chat.mentionNoteTooMany': 'These notes cannot all be referenced together. Please select fewer notes.',
'ai.chat.mentionNoteInvalid': '"{{title}}" could not be attached: the note has an invalid identifier.',
'ai.chat.untitledNote': 'Untitled note',
'ai.chat.menuUserSkills': 'User Skills',
'ai.chat.menuSlashCommands': 'Slash Commands',
'ai.chat.slashCommands': 'Slash commands',
+1
View File
@@ -1082,6 +1082,7 @@ Highlight the focused split pane:
// Vault hosts header/actions
'vault.hosts.search.placeholder': 'Find a host or ssh user@hostname / ssh -p 2222 user@hostname...',
'vault.hosts.connect': 'Connect',
'vault.hosts.openSftp': 'Open SFTP',
'vault.view.grid': 'Grid',
'vault.view.list': 'List',
'vault.view.tree': 'Tree',
+20
View File
@@ -273,6 +273,12 @@ export const enVaultMessages: Messages = {
'sftp.bookmark.addGlobal': '+Global',
'sftp.bookmark.addGlobalTooltip': 'Save as global bookmark (shared across all hosts)',
'sftp.bookmark.empty': 'No bookmarks yet',
'sftp.bookmark.removeConfirm': 'Remove bookmark {path}?',
'sftp.bookmark.manage': 'Manage',
'sftp.bookmark.rename': 'Rename bookmark',
'sftp.bookmark.done': 'Done',
'sftp.bookmark.moveUp': 'Move {label} up',
'sftp.bookmark.moveDown': 'Move {label} down',
'sftp.columns.name': 'Name',
'sftp.columns.modified': 'Modified',
'sftp.columns.size': 'Size',
@@ -313,6 +319,10 @@ export const enVaultMessages: Messages = {
'sftp.viewMode.tree': 'Tree view',
'sftp.viewMode.switchToList': 'Switch to list view',
'sftp.viewMode.switchToTree': 'Switch to tree view',
'sftp.listDensity.compact': 'Compact list',
'sftp.listDensity.comfortable': 'Comfortable list',
'sftp.listDensity.switchToCompact': 'Switch to compact list',
'sftp.listDensity.switchToComfortable': 'Switch to comfortable list',
'sftp.tree.loadError': 'Failed to load directory',
'sftp.tree.loading': 'Loading...',
'sftp.kind.folder': 'Folder',
@@ -700,6 +710,16 @@ export const enVaultMessages: Messages = {
'hostDetails.section.address': 'Address',
'hostDetails.hostname.placeholder': 'IP or Hostname',
'hostDetails.section.general': 'General',
'hostDetails.os.title': 'System identification',
'hostDetails.os.desc': 'Prefer automatic detection. Override only when the detected system is incorrect. Network Device Mode is configured separately.',
'hostDetails.os.auto': 'Automatic',
'hostDetails.os.unknown': 'Other / unknown',
'hostDetails.os.selection': 'System selection',
'hostDetails.os.current': 'Current system',
'hostDetails.os.freebsd': 'FreeBSD',
'hostDetails.os.linux': 'Linux',
'hostDetails.os.windows': 'Windows',
'hostDetails.os.macos': 'macOS',
'hostDetails.section.sftp': 'SFTP Settings',
'hostDetails.sftp.fileProtocol': 'File Protocol',
'hostDetails.sftp.fileProtocol.desc': 'Auto tries SFTP, then falls back to SCP mode when the SFTP subsystem is unavailable (e.g. some NAS). Force SCP for hosts that only support SCP.',
+7
View File
@@ -497,6 +497,13 @@ export const esAiMessages: Messages = {
'ai.chat.menuFiles': 'Archivos',
'ai.chat.menuImage': 'Imagen',
'ai.chat.menuMentionHost': 'Mencionar host',
'ai.chat.menuMentionNote': 'Mencionar nota',
'ai.chat.mentionNoteSearch': 'Buscar notas…',
'ai.chat.mentionNoteEmpty': 'Sin notas coincidentes',
'ai.chat.mentionNoteUnavailable': 'Este agente no puede leer notas de la bóveda en el modo de conexión actual.',
'ai.chat.mentionNoteTooMany': 'No se pueden referenciar todas estas notas juntas. Selecciona menos notas.',
'ai.chat.mentionNoteInvalid': 'No se pudo adjuntar «{{title}}»: la nota tiene un identificador no válido.',
'ai.chat.untitledNote': 'Nota sin título',
'ai.chat.menuUserSkills': 'Habilidades de usuario',
'ai.chat.menuSlashCommands': 'Comandos de barra',
'ai.chat.slashCommands': 'Comandos de barra',
+1
View File
@@ -1040,6 +1040,7 @@ Resalta el panel dividido enfocado:
// Vault hosts header/actions
'vault.hosts.search.placeholder': 'Busca un host o escribe ssh usuario@hostname / ssh -p 2222 usuario@hostname...',
'vault.hosts.connect': 'Conectar',
'vault.hosts.openSftp': 'Abrir SFTP',
'vault.view.grid': 'Cuadrícula',
'vault.view.list': 'Lista',
'vault.view.tree': 'Árbol',
+20
View File
@@ -273,6 +273,12 @@ export const esVaultMessages: Messages = {
'sftp.bookmark.addGlobal': '+Global',
'sftp.bookmark.addGlobalTooltip': 'Guardar como marcador global (compartido entre todos los hosts)',
'sftp.bookmark.empty': 'Aún no hay marcadores',
'sftp.bookmark.removeConfirm': '¿Quitar el marcador {path}?',
'sftp.bookmark.manage': 'Administrar',
'sftp.bookmark.rename': 'Renombrar marcador',
'sftp.bookmark.done': 'Listo',
'sftp.bookmark.moveUp': 'Mover {label} hacia arriba',
'sftp.bookmark.moveDown': 'Mover {label} hacia abajo',
'sftp.columns.name': 'Nombre',
'sftp.columns.modified': 'Modificado',
'sftp.columns.size': 'Tamaño',
@@ -313,6 +319,10 @@ export const esVaultMessages: Messages = {
'sftp.viewMode.tree': 'Vista de árbol',
'sftp.viewMode.switchToList': 'Cambiar a vista de lista',
'sftp.viewMode.switchToTree': 'Cambiar a vista de árbol',
'sftp.listDensity.compact': 'Lista compacta',
'sftp.listDensity.comfortable': 'Lista cómoda',
'sftp.listDensity.switchToCompact': 'Cambiar a lista compacta',
'sftp.listDensity.switchToComfortable': 'Cambiar a lista cómoda',
'sftp.tree.loadError': 'No se pudo cargar el directorio',
'sftp.tree.loading': 'Cargando...',
'sftp.kind.folder': 'Carpeta',
@@ -700,6 +710,16 @@ export const esVaultMessages: Messages = {
'hostDetails.section.address': 'Dirección',
'hostDetails.hostname.placeholder': 'IP o nombre de host',
'hostDetails.section.general': 'General',
'hostDetails.os.title': 'Identificación del sistema',
'hostDetails.os.desc': 'Usa la detección automática. Selecciona un sistema solo si la detección es incorrecta. El modo de dispositivo de red se configura por separado.',
'hostDetails.os.auto': 'Automático',
'hostDetails.os.unknown': 'Otro / desconocido',
'hostDetails.os.selection': 'Selección del sistema',
'hostDetails.os.current': 'Sistema actual',
'hostDetails.os.freebsd': 'FreeBSD',
'hostDetails.os.linux': 'Linux',
'hostDetails.os.windows': 'Windows',
'hostDetails.os.macos': 'macOS',
'hostDetails.section.sftp': 'Configuración de SFTP',
'hostDetails.sftp.fileProtocol': 'Protocolo de archivos',
'hostDetails.sftp.fileProtocol.desc': 'Auto intenta SFTP y luego recurre al modo SCP cuando el subsistema SFTP no está disponible (p. ej., algunos NAS). Fuerza SCP para hosts que solo admiten SCP.',
+7
View File
@@ -449,6 +449,13 @@ export const ruAiMessages: Messages = {
'ai.chat.menuFiles': 'Файлы',
'ai.chat.menuImage': 'Изображение',
'ai.chat.menuMentionHost': 'Упомянуть хост',
'ai.chat.menuMentionNote': 'Упомянуть заметку',
'ai.chat.mentionNoteSearch': 'Поиск заметок…',
'ai.chat.mentionNoteEmpty': 'Нет подходящих заметок',
'ai.chat.mentionNoteUnavailable': 'Этот агент не может читать заметки хранилища в текущем режиме подключения.',
'ai.chat.mentionNoteTooMany': 'Невозможно сослаться на все эти заметки одновременно. Выберите меньше заметок.',
'ai.chat.mentionNoteInvalid': '«{{title}}» не удалось прикрепить: у заметки недопустимый идентификатор.',
'ai.chat.untitledNote': 'Заметка без названия',
'ai.chat.menuUserSkills': 'Пользовательские навыки',
'ai.chat.menuSlashCommands': 'Команды /',
'ai.chat.slashCommands': 'Команды /',
+21
View File
@@ -95,6 +95,7 @@ export const ruVaultMessages: Messages = {
// Vault hosts header/actions
'vault.hosts.search.placeholder': 'Найти хост или ssh user@hostname / ssh -p 2222 user@hostname...',
'vault.hosts.connect': 'Подключиться',
'vault.hosts.openSftp': 'Открыть SFTP',
'vault.view.grid': 'Сетка',
'vault.view.list': 'Список',
'vault.view.tree': 'Дерево',
@@ -310,6 +311,12 @@ export const ruVaultMessages: Messages = {
'sftp.bookmark.addGlobal': '+Глобальная',
'sftp.bookmark.addGlobalTooltip': 'Сохранить как глобальную закладку (общую для всех хостов)',
'sftp.bookmark.empty': 'Пока нет закладок',
'sftp.bookmark.removeConfirm': 'Удалить закладку {path}?',
'sftp.bookmark.manage': 'Управление',
'sftp.bookmark.rename': 'Переименовать закладку',
'sftp.bookmark.done': 'Готово',
'sftp.bookmark.moveUp': 'Переместить {label} вверх',
'sftp.bookmark.moveDown': 'Переместить {label} вниз',
'sftp.columns.name': 'Имя',
'sftp.columns.modified': 'Изменён',
'sftp.columns.size': 'Размер',
@@ -350,6 +357,10 @@ export const ruVaultMessages: Messages = {
'sftp.viewMode.tree': 'Дерево',
'sftp.viewMode.switchToList': 'Переключиться на список',
'sftp.viewMode.switchToTree': 'Переключиться на дерево',
'sftp.listDensity.compact': 'Компактный список',
'sftp.listDensity.comfortable': 'Обычный список',
'sftp.listDensity.switchToCompact': 'Переключиться на компактный список',
'sftp.listDensity.switchToComfortable': 'Переключиться на обычный список',
'sftp.tree.loadError': 'Не удалось загрузить каталог',
'sftp.tree.loading': 'Загрузка...',
'sftp.kind.folder': 'Папка',
@@ -736,6 +747,16 @@ export const ruVaultMessages: Messages = {
'hostDetails.section.address': 'Адрес',
'hostDetails.hostname.placeholder': 'IP или имя хоста',
'hostDetails.section.general': 'Общие',
'hostDetails.os.title': 'Определение системы',
'hostDetails.os.desc': 'Используйте автоматическое определение. Укажите систему вручную только при ошибке. Режим сетевого устройства настраивается отдельно.',
'hostDetails.os.auto': 'Автоматически',
'hostDetails.os.unknown': 'Другая / неизвестно',
'hostDetails.os.selection': 'Выбор системы',
'hostDetails.os.current': 'Текущая система',
'hostDetails.os.freebsd': 'FreeBSD',
'hostDetails.os.linux': 'Linux',
'hostDetails.os.windows': 'Windows',
'hostDetails.os.macos': 'macOS',
'hostDetails.section.sftp': 'Настройки SFTP',
'hostDetails.sftp.fileProtocol': 'Протокол файлов',
'hostDetails.sftp.fileProtocol.desc': 'Авто сначала пробует SFTP, затем SCP-режим, если подсистема SFTP недоступна. Принудительный SCP — для хостов только с SCP.',
+7
View File
@@ -497,6 +497,13 @@ export const zhCNAiMessages: Messages = {
'ai.chat.menuFiles': '文件',
'ai.chat.menuImage': '图片',
'ai.chat.menuMentionHost': '提及主机',
'ai.chat.menuMentionNote': '提及笔记',
'ai.chat.mentionNoteSearch': '搜索笔记…',
'ai.chat.mentionNoteEmpty': '没有匹配的笔记',
'ai.chat.mentionNoteUnavailable': '当前连接方式下,此 AI 无法读取保险箱笔记。',
'ai.chat.mentionNoteTooMany': '这些笔记无法一起引用,请减少选择。',
'ai.chat.mentionNoteInvalid': '无法附加「{{title}}」:笔记的标识符无效。',
'ai.chat.untitledNote': '未命名笔记',
'ai.chat.menuUserSkills': '用户 Skills',
'ai.chat.menuSlashCommands': '快捷命令',
'ai.chat.slashCommands': '快捷命令',
+11
View File
@@ -718,6 +718,7 @@ export const zhCNCoreMessages: Messages = {
// Vault hosts header/actions
'vault.hosts.search.placeholder': '查找主机或 ssh user@hostname / ssh -p 2222 user@hostname…',
'vault.hosts.connect': '连接',
'vault.hosts.openSftp': '打开 SFTP',
'vault.view.grid': '网格',
'vault.view.list': '列表',
'vault.view.tree': '树形',
@@ -903,6 +904,12 @@ export const zhCNCoreMessages: Messages = {
'sftp.bookmark.addGlobal': '+全局',
'sftp.bookmark.addGlobalTooltip': '保存为全局收藏(所有主机共享)',
'sftp.bookmark.empty': '暂无收藏路径',
'sftp.bookmark.removeConfirm': '删除收藏 {path}?',
'sftp.bookmark.manage': '管理',
'sftp.bookmark.rename': '重命名收藏',
'sftp.bookmark.done': '完成',
'sftp.bookmark.moveUp': '上移 {label}',
'sftp.bookmark.moveDown': '下移 {label}',
'sftp.columns.name': '名称',
'sftp.columns.configure': '选择显示的列',
'sftp.columns.modified': '修改时间',
@@ -943,6 +950,10 @@ export const zhCNCoreMessages: Messages = {
'sftp.viewMode.tree': '树形视图',
'sftp.viewMode.switchToList': '切换到列表视图',
'sftp.viewMode.switchToTree': '切换到树形视图',
'sftp.listDensity.compact': '紧凑列表',
'sftp.listDensity.comfortable': '宽松列表',
'sftp.listDensity.switchToCompact': '切换到紧凑列表',
'sftp.listDensity.switchToComfortable': '切换到宽松列表',
'sftp.tree.loadError': '加载目录失败',
'sftp.tree.loading': '加载中...',
'sftp.kind.folder': '文件夹',
+10
View File
@@ -133,6 +133,16 @@ export const zhCNVaultMessages: Messages = {
'hostDetails.section.address': '地址',
'hostDetails.hostname.placeholder': 'IP 或 主机名',
'hostDetails.section.general': '通用',
'hostDetails.os.title': '系统识别',
'hostDetails.os.desc': '默认自动识别,仅在识别错误时手动指定。网络设备模式单独设置。',
'hostDetails.os.auto': '自动识别',
'hostDetails.os.unknown': '其他 / 未知',
'hostDetails.os.selection': '识别方式',
'hostDetails.os.current': '当前系统',
'hostDetails.os.freebsd': 'FreeBSD',
'hostDetails.os.linux': 'Linux',
'hostDetails.os.windows': 'Windows',
'hostDetails.os.macos': 'macOS',
'hostDetails.section.sftp': 'SFTP 设置',
'hostDetails.sftp.fileProtocol': '文件协议',
'hostDetails.sftp.fileProtocol.desc': '自动优先 SFTP;当服务器无 SFTP 子系统时回退到 SCP 模式(如部分 NAS)。仅支持 SCP 的主机可强制 SCP。',
+7
View File
@@ -496,6 +496,13 @@ export const zhTWAiMessages: Messages = {
'ai.chat.menuFiles': '檔案',
'ai.chat.menuImage': '圖片',
'ai.chat.menuMentionHost': '提及主機',
'ai.chat.menuMentionNote': '提及筆記',
'ai.chat.mentionNoteSearch': '搜尋筆記…',
'ai.chat.mentionNoteEmpty': '沒有符合的筆記',
'ai.chat.mentionNoteUnavailable': '目前連線方式下,此 AI 無法讀取保險箱筆記。',
'ai.chat.mentionNoteTooMany': '這些筆記無法一起引用,請減少選擇。',
'ai.chat.mentionNoteInvalid': '無法附加「{{title}}」:筆記的識別碼無效。',
'ai.chat.untitledNote': '未命名筆記',
'ai.chat.menuUserSkills': '使用者 Skills',
'ai.chat.menuSlashCommands': '快捷指令',
'ai.chat.slashCommands': '快捷指令',
+11
View File
@@ -721,6 +721,7 @@ export const zhTWCoreMessages: Messages = {
// Vault hosts header/actions
'vault.hosts.search.placeholder': '查詢主機或 ssh user@hostname / ssh -p 2222 user@hostname…',
'vault.hosts.connect': '連線',
'vault.hosts.openSftp': '開啟 SFTP',
'vault.view.grid': '網格',
'vault.view.list': '列表',
'vault.view.tree': '樹形',
@@ -906,6 +907,12 @@ export const zhTWCoreMessages: Messages = {
'sftp.bookmark.addGlobal': '+全域',
'sftp.bookmark.addGlobalTooltip': '儲存為全域收藏(所有主機共享)',
'sftp.bookmark.empty': '暫無收藏路徑',
'sftp.bookmark.removeConfirm': '刪除收藏 {path}?',
'sftp.bookmark.manage': '管理',
'sftp.bookmark.rename': '重新命名收藏',
'sftp.bookmark.done': '完成',
'sftp.bookmark.moveUp': '上移 {label}',
'sftp.bookmark.moveDown': '下移 {label}',
'sftp.columns.name': '名稱',
'sftp.columns.configure': '選擇顯示的欄位',
'sftp.columns.modified': '修改時間',
@@ -946,6 +953,10 @@ export const zhTWCoreMessages: Messages = {
'sftp.viewMode.tree': '樹形檢視',
'sftp.viewMode.switchToList': '切換到列表檢視',
'sftp.viewMode.switchToTree': '切換到樹形檢視',
'sftp.listDensity.compact': '緊湊列表',
'sftp.listDensity.comfortable': '寬鬆列表',
'sftp.listDensity.switchToCompact': '切換到緊湊列表',
'sftp.listDensity.switchToComfortable': '切換到寬鬆列表',
'sftp.tree.loadError': '載入目錄失敗',
'sftp.tree.loading': '載入中...',
'sftp.kind.folder': '資料夾',
+10
View File
@@ -132,6 +132,16 @@ export const zhTWVaultMessages: Messages = {
'hostDetails.section.address': '位址',
'hostDetails.hostname.placeholder': 'IP 或 主機名',
'hostDetails.section.general': '通用',
'hostDetails.os.title': '系統識別',
'hostDetails.os.desc': '預設自動辨識,僅在辨識錯誤時手動指定。網路裝置模式另行設定。',
'hostDetails.os.auto': '自動辨識',
'hostDetails.os.unknown': '其他 / 未知',
'hostDetails.os.selection': '辨識方式',
'hostDetails.os.current': '目前系統',
'hostDetails.os.freebsd': 'FreeBSD',
'hostDetails.os.linux': 'Linux',
'hostDetails.os.windows': 'Windows',
'hostDetails.os.macos': 'macOS',
'hostDetails.section.sftp': 'SFTP 設定',
'hostDetails.sftp.fileProtocol': '檔案協定',
'hostDetails.sftp.fileProtocol.desc': '自動優先 SFTP;當伺服器無 SFTP 子系統時改用 SCP 模式(如部分 NAS)。僅支援 SCP 的主機可強制 SCP。',
+464
View File
@@ -1,5 +1,6 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { AISession } from '../../infrastructure/ai/types';
import {
cleanupClosedTerminalSessions,
cleanupDeletedAIChatSessions,
@@ -47,3 +48,466 @@ test('orphan cleanup keeps durable Catty output while explicit deletion removes
});
}
});
function makeSession(id: string, updatedAt: number, messages: unknown[]): AISession {
return {
id,
title: id,
agentId: 'agent',
scope: { type: 'terminal', targetId: 't' },
messages: messages as never,
createdAt: updatedAt,
updatedAt,
} as never;
}
test('serializeSessionsForStorage strips oldest ciphertext before dropping visible sessions', async () => {
const { serializeSessionsForStorage } = await import('./aiStateSnapshots');
const ciphertext = 'gAAAA'.repeat(50000); // ~250 KB of ciphertext per message
const messages = () => [{
id: 'm',
role: 'assistant' as const,
content: 'hello',
timestamp: 0,
providerContinuation: {
reasoningParts: [{ text: '', providerOptions: { openai: { reasoningEncryptedContent: ciphertext } } }],
},
}];
const sessions = [
makeSession('newest', 3, messages()),
makeSession('older', 2, messages()),
makeSession('oldest', 1, messages()),
];
const hasCiphertext = (result: { sessions: AISession[] }) =>
result.sessions.some(s => s.messages.some(m =>
m.providerContinuation?.reasoningParts?.some(p => typeof p.providerOptions?.openai?.reasoningEncryptedContent === 'string')));
// Removing replay-only ciphertext from the oldest session fits the budget,
// so every visible chat remains available after restart.
const withOldestCiphertextStripped = serializeSessionsForStorage(sessions, 600 * 1024);
assert.deepEqual(
withOldestCiphertextStripped.sessions.map(s => s.id),
['newest', 'older', 'oldest'],
);
assert.equal(hasCiphertext(withOldestCiphertextStripped), true);
assert.ok(withOldestCiphertextStripped.json.length <= 600 * 1024);
// Tight budget that even a single session exceeds: ciphertext stripped but
// the visible conversation content survives.
const withCiphertextStripped = serializeSessionsForStorage(sessions, 220 * 1024);
assert.ok(withCiphertextStripped.json.length <= 220 * 1024);
assert.equal(hasCiphertext(withCiphertextStripped), false);
assert.deepEqual(withCiphertextStripped.sessions.map(s => s.id), ['newest', 'older', 'oldest']);
assert.equal(withCiphertextStripped.sessions[0].messages[0].content, 'hello');
});
test('serializeSessionsForStorage keeps new ciphertext when an oversized old chat must be dropped', async () => {
const { serializeSessionsForStorage } = await import('./aiStateSnapshots');
const ciphertext = 'gAAAA'.repeat(20 * 1024); // ~100 KB
const newest = makeSession('newest', 2, [{
id: 'new-message',
role: 'assistant',
content: 'new visible chat',
timestamp: 2,
providerContinuation: {
source: { providerConfigId: 'p', providerType: 'openai', modelId: 'm' },
reasoningParts: [{ text: '', providerOptions: { openai: { reasoningEncryptedContent: ciphertext } } }],
},
}]);
const oversizedOld = makeSession('oldest', 1, [{
id: 'old-message',
role: 'user',
content: 'x'.repeat(300 * 1024),
timestamp: 1,
}]);
const result = serializeSessionsForStorage([oversizedOld, newest], 200 * 1024);
assert.deepEqual(result.sessions.map(session => session.id), ['newest']);
assert.equal(
result.sessions[0].messages[0].providerContinuation
?.reasoningParts?.[0].providerOptions?.openai?.reasoningEncryptedContent,
ciphertext,
);
assert.ok(result.json.length <= 200 * 1024);
});
test('serializeSessionsForStorage prioritizes a usable newest chat over older visible history', async () => {
const { serializeSessionsForStorage } = await import('./aiStateSnapshots');
const ciphertext = 'gAAAA'.repeat(20 * 1024); // ~100 KB
const newest = makeSession('newest', 2, [{
id: 'new-message',
role: 'assistant',
content: 'new visible chat',
timestamp: 2,
providerContinuation: {
source: { providerConfigId: 'p', providerType: 'openai', modelId: 'm' },
reasoningParts: [{ text: '', providerOptions: { openai: { reasoningEncryptedContent: ciphertext } } }],
},
}]);
const olderVisible = makeSession('oldest', 1, [{
id: 'old-message',
role: 'user',
content: 'x'.repeat(100 * 1024),
timestamp: 1,
}]);
const result = serializeSessionsForStorage([olderVisible, newest], 150 * 1024);
assert.deepEqual(result.sessions.map(session => session.id), ['newest']);
assert.equal(
result.sessions[0].messages[0].providerContinuation
?.reasoningParts?.[0].providerOptions?.openai?.reasoningEncryptedContent,
ciphertext,
);
assert.ok(result.json.length <= 150 * 1024);
});
test('serializeSessionsForStorage keeps the newest replayable turn in one oversized chat', async () => {
const { serializeSessionsForStorage } = await import('./aiStateSnapshots');
const ciphertext = 'gAAAA'.repeat(20 * 1024); // ~100 KB per reasoning turn
const reasoning = (itemId: string) => ({
source: { providerConfigId: 'p', providerType: 'openai', modelId: 'm' },
reasoningParts: [{
text: '',
providerOptions: { openai: { itemId, reasoningEncryptedContent: ciphertext } },
}],
});
const session = makeSession('active', 1, [
{
id: 'assistant-old',
role: 'assistant',
content: 'old turn',
timestamp: 1,
providerContinuation: reasoning('rs_old'),
toolCalls: [{ id: 'call-old', name: 'terminal_execute', arguments: { command: 'pwd' } }],
},
{
id: 'tool-old',
role: 'tool',
content: '',
timestamp: 2,
toolResults: [{ toolCallId: 'call-old', content: '/tmp' }],
},
{
id: 'assistant-new',
role: 'assistant',
content: 'new turn',
timestamp: 3,
providerContinuation: reasoning('rs_new'),
toolCalls: [{ id: 'call-new', name: 'terminal_execute', arguments: { command: 'ls' } }],
},
{
id: 'tool-new',
role: 'tool',
content: '',
timestamp: 4,
toolResults: [{ toolCallId: 'call-new', content: 'file.txt' }],
},
]);
const result = serializeSessionsForStorage([session], 150 * 1024);
assert.ok(result.json.length <= 150 * 1024);
assert.deepEqual(result.sessions[0].messages.map(message => message.id), [
'assistant-old',
'tool-old',
'assistant-new',
'tool-new',
]);
assert.equal(
result.sessions[0].messages[0].providerContinuation
?.reasoningParts?.[0].providerOptions?.openai?.reasoningEncryptedContent,
undefined,
);
assert.equal(
result.sessions[0].messages[2].providerContinuation
?.reasoningParts?.[0].providerOptions?.openai?.reasoningEncryptedContent,
ciphertext,
);
});
test('serializeSessionsForStorage drops compacted ciphertext before protecting the newest chat', async () => {
const { serializeSessionsForStorage } = await import('./aiStateSnapshots');
const ciphertext = 'gAAAA'.repeat(20 * 1024); // ~100 KB per reasoning turn
const compactedMessages = Array.from({ length: 17 }, (_, index) => ({
id: `compacted-${index}`,
role: 'assistant' as const,
content: `compacted turn ${index}`,
timestamp: index,
providerContinuation: {
reasoningParts: [{
text: '',
providerOptions: {
openai: { itemId: `rs_${index}`, reasoningEncryptedContent: ciphertext },
},
}],
},
}));
const newest = {
...makeSession('newest', 2, [
...compactedMessages,
{
id: 'recent',
role: 'assistant',
content: 'recent turn',
timestamp: 18,
providerContinuation: {
reasoningParts: [{
text: '',
providerOptions: {
openai: { itemId: 'rs_recent', reasoningEncryptedContent: ciphertext },
},
}],
},
},
]),
contextCompaction: {
summary: 'The first 17 turns were summarized.',
compactedMessageCount: 17,
},
};
const olderVisible = makeSession('older', 1, [{
id: 'older-visible',
role: 'user',
content: 'x'.repeat(600 * 1024),
timestamp: 1,
}]);
const result = serializeSessionsForStorage([olderVisible, newest]);
assert.deepEqual(result.sessions.map(session => session.id), ['newest', 'older']);
assert.ok(result.json.length <= 2 * 1024 * 1024);
const persistedNewest = result.sessions[0];
for (const message of persistedNewest.messages.slice(0, 17)) {
assert.equal(
message.providerContinuation
?.reasoningParts?.[0].providerOptions?.openai?.reasoningEncryptedContent,
undefined,
);
}
assert.equal(
persistedNewest.messages[17].providerContinuation
?.reasoningParts?.[0].providerOptions?.openai?.reasoningEncryptedContent,
ciphertext,
);
});
test('serializeSessionsForStorage shifts the compaction boundary when trimming old messages', async () => {
const { serializeSessionsForStorage } = await import('./aiStateSnapshots');
const ciphertext = 'enc'.repeat(100);
const messages = Array.from({ length: 250 }, (_, index) => ({
id: `message-${index}`,
role: 'assistant' as const,
content: `turn ${index}`,
timestamp: index,
providerContinuation: {
reasoningParts: [{
text: '',
providerOptions: {
openai: { itemId: `rs_${index}`, reasoningEncryptedContent: ciphertext },
},
}],
},
}));
const session = {
...makeSession('trimmed', 1, messages),
contextCompaction: {
summary: 'The first 100 messages were summarized.',
compactedMessageCount: 100,
},
};
const result = serializeSessionsForStorage([session]);
const persisted = result.sessions[0];
assert.equal(persisted.messages.length, 200);
assert.equal(persisted.messages[0].id, 'message-50');
assert.equal(persisted.contextCompaction?.compactedMessageCount, 50);
assert.equal(
persisted.messages[49].providerContinuation
?.reasoningParts?.[0].providerOptions?.openai?.reasoningEncryptedContent,
undefined,
);
assert.equal(
persisted.messages[50].providerContinuation
?.reasoningParts?.[0].providerOptions?.openai?.reasoningEncryptedContent,
ciphertext,
);
});
test('serializeSessionsForStorage does not trim between a tool call and its result', async () => {
const { serializeSessionsForStorage } = await import('./aiStateSnapshots');
const messages = Array.from({ length: 250 }, (_, index) => ({
id: `message-${index}`,
role: 'user' as const,
content: `turn ${index}`,
timestamp: index,
}));
messages[49] = {
id: 'assistant-call',
role: 'assistant',
content: 'Running it.',
timestamp: 49,
toolCalls: [{ id: 'call-1', name: 'terminal_execute', arguments: { command: 'pwd' } }],
} as never;
messages[50] = {
id: 'tool-result',
role: 'tool',
content: '',
timestamp: 50,
toolResults: [{ toolCallId: 'call-1', content: '/tmp' }],
} as never;
const session = {
...makeSession('tool-boundary', 1, messages),
contextCompaction: {
summary: 'The first 10 messages were summarized.',
compactedMessageCount: 10,
},
};
const result = serializeSessionsForStorage([session]);
const persisted = result.sessions[0];
assert.equal(persisted.messages.length, 201);
assert.equal(persisted.messages[0].id, 'assistant-call');
assert.equal(persisted.messages[1].id, 'tool-result');
assert.equal(persisted.contextCompaction?.compactedMessageCount, 0);
});
test('writeSessionsForStorage retries below nominal budgets after a quota failure', async () => {
const { writeSessionsForStorage } = await import('./aiStateSnapshots');
const writes: string[] = [];
let stored: string | undefined;
const previousLocalStorage = globalThis.localStorage;
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: {
getItem: () => null,
setItem: (_key: string, value: string) => {
writes.push(value);
if (value.length > 350 * 1024) {
throw new DOMException('quota exceeded', 'QuotaExceededError');
}
stored = value;
},
removeItem: () => {},
},
});
try {
// The retry loop must attempt progressively smaller payloads (here the
// ciphertext stripping at the tighter budget) before reporting failure.
const huge = 'x'.repeat(260 * 1024);
const ciphertext = 'gAAAA'.repeat(30 * 1024); // ~150 KB
const sessions = [makeSession('s', 1, [{
id: 'm',
role: 'user' as const,
content: huge,
timestamp: 0,
providerContinuation: {
reasoningParts: [{ text: '', providerOptions: { openai: { reasoningEncryptedContent: ciphertext } } }],
},
}])];
assert.equal(writeSessionsForStorage(sessions), true);
assert.equal(writes.length, 2);
assert.equal(stored, writes[1]);
assert.ok(writes[writes.length - 1].length < writes[0].length);
} finally {
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: previousLocalStorage,
});
}
});
test('writeSessionsForStorage reduces several small sessions to fit a sub-512 KB quota', async () => {
const { writeSessionsForStorage } = await import('./aiStateSnapshots');
const writes: string[] = [];
let stored: string | undefined;
const previousLocalStorage = globalThis.localStorage;
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: {
getItem: () => null,
setItem: (_key: string, value: string) => {
writes.push(value);
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: 'user' as const,
content: 'x'.repeat(40 * 1024),
timestamp: index,
}],
));
assert.equal(writeSessionsForStorage(sessions), true);
assert.ok(writes[0].length > 300 * 1024);
assert.ok(stored);
assert.ok(stored.length <= 300 * 1024);
assert.ok((JSON.parse(stored) as AISession[]).length < sessions.length);
} 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[] = [];
let stored: string | undefined;
const previousLocalStorage = globalThis.localStorage;
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: {
getItem: () => null,
setItem: (_key: string, value: string) => {
writes.push(value);
if (value.length > 50 * 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: 'user' as const,
content: 'x'.repeat(40 * 1024),
timestamp: index,
}],
));
assert.equal(writeSessionsForStorage(sessions), true);
assert.equal(writes.length, 6);
assert.ok(stored);
const persisted = JSON.parse(stored) as AISession[];
assert.deepEqual(persisted.map(session => session.id), ['session-0']);
assert.ok(stored.length <= 50 * 1024);
} finally {
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: previousLocalStorage,
});
}
});
+256 -5
View File
@@ -10,6 +10,8 @@ import type {
AIPermissionMode,
AIToolIntegrationMode,
} from '../../infrastructure/ai/types';
import type { ProviderContinuationOptions } from '../../infrastructure/ai/providerContinuation';
import { findSafeChatMessageCompactionSplitIndex } from '../../infrastructure/ai/contextCompaction';
import {
bumpDraftMutationVersionState,
bumpDraftUploadGenerationState,
@@ -112,10 +114,7 @@ export function cleanupOrphanedAISessions(activeTargetIds: Set<string>) {
if (nextSessionCleanup.sessions !== currentSessions) {
setLatestAISessionsSnapshot(nextSessionCleanup.sessions);
localStorageAdapter.write(
STORAGE_KEY_AI_SESSIONS,
pruneSessionsForStorage(nextSessionCleanup.sessions),
);
writeSessionsForStorage(nextSessionCleanup.sessions);
emitAIStateChanged(STORAGE_KEY_AI_SESSIONS);
}
@@ -181,6 +180,91 @@ export function cleanupOrphanedAISessions(activeTargetIds: Set<string>) {
const MAX_STORED_SESSIONS = 50;
/** Maximum number of messages per session when persisting to localStorage. */
const MAX_SESSION_MESSAGES = 200;
/**
* Byte budget for the serialized sessions JSON. The localStorage quota is
* ~5-10 MB across all keys, and Responses reasoning ciphertext can add tens
* of KB per turn, so keep the sessions blob well under the quota with
* headroom for the rest of the app's storage keys.
*/
const MAX_SESSIONS_JSON_BYTES = 2 * 1024 * 1024;
/** Retry budgets used when the primary budget still fails to persist. */
const RETRY_SESSIONS_JSON_BYTES = [
1024 * 1024,
512 * 1024,
256 * 1024,
128 * 1024,
] as const;
/**
* 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.
*/
function stripReasoningEncryptedContent(
options: ProviderContinuationOptions,
): ProviderContinuationOptions | undefined {
const hasCiphertext = Object.values(options).some(
providerOptions => typeof providerOptions?.reasoningEncryptedContent === 'string',
);
if (!hasCiphertext) return options;
const stripped: ProviderContinuationOptions = {};
for (const [provider, providerOptions] of Object.entries(options)) {
const rest = { ...providerOptions };
delete rest.reasoningEncryptedContent;
if (Object.keys(rest).length) stripped[provider] = rest;
}
return Object.keys(stripped).length ? stripped : undefined;
}
function stripEncryptedReasoningFromMessage(message: AISession['messages'][number]) {
const continuation = message.providerContinuation;
if (!continuation?.reasoningParts) return message;
let changed = false;
const parts = continuation.reasoningParts.map(part => {
if (!part.providerOptions) return part;
const providerOptions = stripReasoningEncryptedContent(part.providerOptions);
if (providerOptions === part.providerOptions) return part;
changed = true;
return providerOptions ? { text: part.text, providerOptions } : { text: part.text };
});
if (!changed) return message;
return {
...message,
providerContinuation: {
...continuation,
reasoningParts: parts,
},
};
}
function stripEncryptedReasoningFromSession(session: AISession): AISession {
let changed = false;
const messages = session.messages.map(message => {
const stripped = stripEncryptedReasoningFromMessage(message);
if (stripped !== message) changed = true;
return stripped;
});
return changed ? { ...session, messages } : session;
}
function stripCompactedEncryptedReasoningFromSession(session: AISession): AISession {
const compactedMessageCount = Math.min(
session.messages.length,
Math.max(0, session.contextCompaction?.compactedMessageCount ?? 0),
);
if (compactedMessageCount === 0) return session;
let changed = false;
const messages = session.messages.map((message, index) => {
if (index >= compactedMessageCount) return message;
const stripped = stripEncryptedReasoningFromMessage(message);
if (stripped !== message) changed = true;
return stripped;
});
return changed ? { ...session, messages } : session;
}
/**
* Prune sessions before writing to localStorage to prevent hitting the
@@ -196,12 +280,179 @@ export function pruneSessionsForStorage(sessions: AISession[]): AISession[] {
const limited = sorted.slice(0, MAX_STORED_SESSIONS);
return limited.map(s => {
if (s.messages.length > MAX_SESSION_MESSAGES) {
return { ...s, messages: s.messages.slice(-MAX_SESSION_MESSAGES) };
// Do not start the retained tail with a tool result whose assistant call
// was just trimmed. Reuse the same tool-safe boundary logic as context
// compaction, even if that retains a few more than the nominal cap.
const removedMessageCount = findSafeChatMessageCompactionSplitIndex(
s.messages,
MAX_SESSION_MESSAGES,
);
const contextCompaction = s.contextCompaction
? {
...s.contextCompaction,
compactedMessageCount: Math.max(
0,
s.contextCompaction.compactedMessageCount - removedMessageCount,
),
}
: undefined;
return {
...s,
messages: s.messages.slice(removedMessageCount),
...(contextCompaction ? { contextCompaction } : {}),
};
}
return s;
});
}
/**
* 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.
*/
export function serializeSessionsForStorage(
sessions: AISession[],
budgetBytes: number = MAX_SESSIONS_JSON_BYTES,
): { json: string; sessions: AISession[] } {
const serialized = pruneSessionsForStorage(sessions).map(rawSession => {
// These messages are replaced by the durable summary before every later
// request, so their replay ciphertext can never be used again. Remove it
// before deciding whether the newest session deserves full protection;
// otherwise dead metadata can evict other visible conversations.
const session = stripCompactedEncryptedReasoningFromSession(rawSession);
const ciphertextMessages = session.messages.flatMap((message, index) => {
const strippedMessage = stripEncryptedReasoningFromMessage(message);
if (strippedMessage === message) return [];
return [{
index,
strippedMessage,
jsonLengthDelta: JSON.stringify(strippedMessage).length - JSON.stringify(message).length,
}];
});
const strippedSession = stripEncryptedReasoningFromSession(session);
const json = JSON.stringify(session);
return {
session,
json,
strippedSession,
strippedJson: strippedSession === session
? json
: JSON.stringify(strippedSession),
ciphertextMessages,
};
});
// Preserve the newest session's full continuation whenever it can fit by
// itself. That session is the one the user is most likely continuing now;
// older visible history must not make its next tool turn unreplayable.
const protectNewestContinuation = serialized.length > 0
&& serialized[0].json.length + 2 <= budgetBytes;
// Determine how many sessions can fit using the smallest representation for
// older sessions while reserving the newest session's full representation
// when possible. This also prevents an old, oversized visible chat that must
// be dropped anyway from causing newer replay ciphertext to be stripped.
let minimalLength = 2
+ serialized.reduce((total, entry, index) => (
total + (protectNewestContinuation && index === 0 ? entry.json.length : entry.strippedJson.length)
), 0)
+ Math.max(0, serialized.length - 1);
while (minimalLength > budgetBytes && serialized.length > 1) {
const removed = serialized.pop();
if (!removed) break;
minimalLength -= removed.strippedJson.length + 1;
}
// 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.
let jsonLength = 2 + serialized.reduce((total, entry) => total + entry.json.length, 0)
+ Math.max(0, serialized.length - 1);
const firstStrippableIndex = protectNewestContinuation ? 1 : 0;
for (
let index = serialized.length - 1;
index >= firstStrippableIndex && jsonLength > budgetBytes;
index -= 1
) {
const current = serialized[index];
let strippedMessageCount = 0;
while (jsonLength > budgetBytes && strippedMessageCount < current.ciphertextMessages.length) {
jsonLength += current.ciphertextMessages[strippedMessageCount].jsonLengthDelta;
strippedMessageCount += 1;
}
if (strippedMessageCount > 0) {
const strippedMessagesByIndex = new Map(
current.ciphertextMessages
.slice(0, strippedMessageCount)
.map(entry => [entry.index, entry.strippedMessage] as const),
);
const nextSession = {
...current.session,
messages: current.session.messages.map((message, messageIndex) => (
strippedMessagesByIndex.get(messageIndex) ?? message
)),
};
const nextJson = JSON.stringify(nextSession);
const projectedJsonLength = current.json.length
+ current.ciphertextMessages
.slice(0, strippedMessageCount)
.reduce((total, entry) => total + entry.jsonLengthDelta, 0);
jsonLength += nextJson.length - projectedJsonLength;
serialized[index] = {
...current,
session: nextSession,
json: nextJson,
};
}
}
return {
json: `[${serialized.map(entry => entry.json).join(',')}]`,
sessions: serialized.map(entry => entry.session),
};
}
/**
* Persist sessions to localStorage with byte-budgeted pruning and retries.
* Returns true when the write succeeded; a false result means the payload
* could not be persisted even after escalation (it stays memory-only).
*/
export function writeSessionsForStorage(sessions: AISession[]): boolean {
let previousLength = Number.POSITIVE_INFINITY;
for (const configuredBudget of [MAX_SESSIONS_JSON_BYTES, ...RETRY_SESSIONS_JSON_BYTES]) {
// A real quota failure means the next attempt must be smaller even when
// the failed payload was already below the nominal retry budget. Reduce
// materially on each bounded retry so several small sessions can be
// removed before the retry sequence is exhausted.
const reducedBudget = Number.isFinite(previousLength)
? Math.floor(previousLength * 0.75)
: configuredBudget;
const budget = Math.min(configuredBudget, reducedBudget);
if (budget < 2) break;
const candidate = serializeSessionsForStorage(sessions, budget);
if (candidate.json.length >= previousLength) continue;
if (localStorageAdapter.writeString(STORAGE_KEY_AI_SESSIONS, candidate.json)) return true;
previousLength = candidate.json.length;
}
// Last resort: attempt the smallest representation this serializer can
// produce (the newest session with replay-only ciphertext removed). A very
// small amount of shared quota may still be enough to preserve that chat.
const minimalCandidate = serializeSessionsForStorage(sessions, 0);
if (
minimalCandidate.json.length < previousLength
&& localStorageAdapter.writeString(STORAGE_KEY_AI_SESSIONS, minimalCandidate.json)
) {
return true;
}
console.warn(
'[AIState] Failed to persist AI sessions within the storage quota; recent chat history may not survive a restart.',
);
return false;
}
export let latestAISessionsSnapshot: AISession[] | null = null;
export let latestAIActiveSessionMapSnapshot: Record<string, string | null> | null = null;
export let latestAIDraftsByScopeSnapshot: DraftsByScope | null = null;
@@ -0,0 +1,36 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { migrateLegacyCommandBlocklist } from '../../domain/commandBlocklist';
import commandBlocklistTable from '../../lib/commandBlocklist.json';
const legacyDefaults = [
...commandBlocklistTable.common,
...commandBlocklistTable.posixNative,
...commandBlocklistTable.posix,
];
test('untouched legacy defaults gain the PowerShell group once', () => {
assert.deepEqual(
migrateLegacyCommandBlocklist(legacyDefaults),
[...legacyDefaults, ...commandBlocklistTable.powershell],
);
});
test('customized legacy settings preserve removed defaults', () => {
const customized = legacyDefaults.slice(1);
assert.deepEqual(migrateLegacyCommandBlocklist(customized), customized);
assert.deepEqual(migrateLegacyCommandBlocklist([]), []);
});
test('legacy settings keep user additions while gaining new defaults', () => {
const customized = [...legacyDefaults, 'company-forbidden-command'];
assert.deepEqual(
migrateLegacyCommandBlocklist(customized),
[...customized, ...commandBlocklistTable.powershell],
);
});
test('a list that already contains a PowerShell default is left unchanged', () => {
const alreadyUpgraded = [...legacyDefaults, commandBlocklistTable.powershell[0]];
assert.deepEqual(migrateLegacyCommandBlocklist(alreadyUpgraded), alreadyUpgraded);
});
@@ -0,0 +1,26 @@
import { migrateLegacyCommandBlocklist } from '../../domain/commandBlocklist';
import { DEFAULT_COMMAND_BLOCKLIST } from '../../infrastructure/ai/types';
import { STORAGE_KEY_AI_COMMAND_BLOCKLIST } from '../../infrastructure/config/storageKeys';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
export function persistCommandBlocklistSetting(blocklist: string[]): boolean {
return localStorageAdapter.write(STORAGE_KEY_AI_COMMAND_BLOCKLIST, blocklist);
}
export function readCommandBlocklistSetting(): string[] {
const stored = localStorageAdapter.read<string[]>(STORAGE_KEY_AI_COMMAND_BLOCKLIST);
if (stored != null && !Array.isArray(stored)) {
return [...DEFAULT_COMMAND_BLOCKLIST];
}
const current = stored ?? [...DEFAULT_COMMAND_BLOCKLIST];
const migrated = stored == null ? current : migrateLegacyCommandBlocklist(current);
if (
stored == null
|| migrated.length !== current.length
|| migrated.some((pattern, index) => pattern !== current[index])
) {
persistCommandBlocklistSetting(migrated);
}
return migrated;
}
@@ -68,6 +68,9 @@ export type SessionSnapshotActions = Pick<
| 'reorderTabs'
| 'toggleBroadcast'
| 'isBroadcastEnabled'
| 'toggleGlobalBroadcast'
| 'isGlobalBroadcastEnabled'
| 'canUseGlobalBroadcast'
| 'copySession'
| 'copyWorkspace'
| 'createSessionFromCloneSource'
@@ -0,0 +1,33 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
createSftpBookmark,
moveSftpBookmark,
renameSftpBookmark,
} from "./bookmarkHelpers.ts";
test("moveSftpBookmark reorders by id and ignores unknown ids", () => {
const bookmarks = [
{ id: "a", path: "/a" },
{ id: "b", path: "/b" },
{ id: "c", path: "/c" },
];
assert.deepEqual(
moveSftpBookmark(bookmarks, "c", "a").map((item) => item.id),
["c", "a", "b"],
);
assert.equal(moveSftpBookmark(bookmarks, "missing", "a"), bookmarks);
assert.equal(moveSftpBookmark(bookmarks, "a", "a"), bookmarks);
});
test("renameSftpBookmark updates a trimmed label", () => {
const bookmarks = [
createSftpBookmark("/var/www"),
createSftpBookmark("/etc"),
];
const renamed = renameSftpBookmark(bookmarks, bookmarks[0].id, " Web ");
assert.equal(renamed[0].label, "Web");
assert.equal(renamed[1].label, bookmarks[1].label);
assert.equal(renameSftpBookmark(bookmarks, bookmarks[0].id, " "), bookmarks);
});
+26
View File
@@ -21,3 +21,29 @@ export function createSftpBookmark(
...(global ? { global: true } : {}),
};
}
export function moveSftpBookmark<T extends { id: string }>(
bookmarks: T[],
fromId: string,
toId: string,
): T[] {
const from = bookmarks.findIndex((bookmark) => bookmark.id === fromId);
const to = bookmarks.findIndex((bookmark) => bookmark.id === toId);
if (from < 0 || to < 0 || from === to) return bookmarks;
const next = bookmarks.slice();
const [item] = next.splice(from, 1);
next.splice(to, 0, item);
return next;
}
export function renameSftpBookmark<T extends { id: string; label: string }>(
bookmarks: T[],
id: string,
label: string,
): T[] {
const nextLabel = label.trim();
if (!nextLabel) return bookmarks;
return bookmarks.map((bookmark) => (
bookmark.id === id ? { ...bookmark, label: nextLabel } : bookmark
));
}
@@ -39,6 +39,108 @@ const host = (id: string, label: string, hostname = label): Host => ({
protocol: "ssh",
} as Host);
for (const retainedStatus of [undefined, "interrupted", "failed", "paused"] as const) {
for (const newerPause of retainedStatus === "paused" ? [false, true] : [false]) {
const batchExistingIdentity = retainedStatus !== undefined;
test(`superseded folder child settles when its completion was compacted into the parent: retained=${retainedStatus ?? "none"}, newerPause=${newerPause}`, async (t) => {
const { sftpTransferCenterStore } = await import("../sftpTransferCenterStore");
const previousLocalStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
Object.defineProperty(globalThis, "localStorage", {
configurable: true,
value: { getItem: () => null, setItem: () => {}, removeItem: () => {} },
});
const originalGet = netcattyBridge.get;
let abort = false;
let running: Promise<unknown> | undefined;
let childId: string | undefined;
const parent: TransferTask = {
id: "superseded-compacted-folder", fileName: "folder",
sourcePath: "/local/folder", targetPath: "/remote/folder",
sourceConnectionId: "local", targetConnectionId: "expired-sftp",
targetHostId: "h1", direction: "upload", status: "transferring",
totalBytes: 1, transferredBytes: 0, speed: 0, startTime: Date.now(),
isDirectory: true, progressMode: "files", reconnectRequired: true,
};
t.after(async () => {
abort = true;
await running;
netcattyBridge.get = originalGet;
sftpTransferCenterStore.patchTask(parent.id, { status: "completed" });
sftpTransferCenterStore.dismiss(parent.id);
if (previousLocalStorage) Object.defineProperty(globalThis, "localStorage", previousLocalStorage);
else Reflect.deleteProperty(globalThis, "localStorage");
resetDedicatedSessionOpenGateForTests();
});
const persisted: TransferTask = {
...parent, id: "retained-child", parentTaskId: parent.id, isDirectory: false,
sourcePath: "/local/folder/a.bin", targetPath: "/remote/folder/a.bin",
totalBytes: 10, sourceLastModified: 1, status: retainedStatus ?? "interrupted",
directoryEntryIndex: 0, directoryEntryIdentity: "a".repeat(64),
};
const { createDedicatedResumeChildUpdateBatcher } = await import("../../app/dedicatedResumeProgress");
// Exercise the real large-history batching branch with one relevant retained row.
const childBatcher = createDedicatedResumeChildUpdateBatcher({
getTaskCount: () => 4096,
hasTask: (id) => id === persisted.id,
upsertTasks: (updates) => sftpTransferCenterStore.upsertTasks(updates),
});
sftpTransferCenterStore.publishOwner("compacted-owner", [parent, ...(batchExistingIdentity ? [persisted] : [])]);
(netcattyBridge as { get: () => unknown }).get = () => ({
openSftp: async () => "dedicated-sftp", closeSftp: async () => {},
listLocalTree: async () => [{
localPath: "/local/folder/a.bin", relativePath: "a.bin", type: "file", size: 10, lastModified: 1,
}],
mkdirSftp: async () => {},
statLocal: async () => {
if (newerPause) sftpTransferCenterStore.patchTask(persisted.id, { status: "paused", lifecycleEpoch: 9 });
return { size: 10, lastModified: 1 };
},
startStreamTransfer: async (options: {
transferId: string; parentTaskId?: string; directoryEntryIndex?: number; directoryEntryIdentity?: string;
}) => {
childId = options.transferId;
// Main-process lifecycle events carry the stream's metadata, not queued UI updates.
sftpTransferCenterStore.ingestBackgroundEvent({
type: "started", transferId: childId, lifecycleEpoch: 0,
parentTaskId: options.parentTaskId,
directoryEntryIndex: options.directoryEntryIndex,
directoryEntryIdentity: options.directoryEntryIdentity,
});
// A newer same-id owner completes before this older invocation rejoins.
sftpTransferCenterStore.ingestBackgroundEvent({
type: "completed", transferId: childId, transferred: 10, totalBytes: 10, lifecycleEpoch: 0,
});
return { superseded: true };
},
});
running = resumeTransferWithDedicatedSession(parent, {
hosts: [host("h1", "box", "1.2.3.4")], keys: [], identities: [],
}, undefined, {
children: batchExistingIdentity ? [persisted] : [], shouldAbort: () => abort,
onChildUpdate: (child) => batchExistingIdentity
? childBatcher.push(child)
: sftpTransferCenterStore.publishOwner("compacted-owner", [parent, child]),
});
const result = await Promise.race([
running,
new Promise<"still-waiting">((resolve) => setTimeout(() => resolve("still-waiting"), 450)),
]);
if (newerPause) {
assert.equal(result, "still-waiting", "a later pause must still hold fresh recovery");
assert.equal(childId, undefined, "newer paused child must not start");
assert.equal(sftpTransferCenterStore.getTask(persisted.id)?.lifecycleEpoch, 9);
return;
}
assert.notEqual(result, "still-waiting", "completed compacted child must not leave its folder waiting forever");
assert.ok(childId);
assert.equal(sftpTransferCenterStore.getTask(childId), undefined, "completed row was compacted");
assert.equal(sftpTransferCenterStore.getTask(parent.id)?.directoryResumeCheckpoint?.completedEntries, 1);
assert.notEqual(result, "still-waiting", "completed compacted child must not leave its folder waiting forever");
assert.equal((result as { success: boolean }).success, true);
});
}
}
test("resolveDirectoryResumeTargetRoot prefers staged replace path", () => {
assert.equal(
resolveDirectoryResumeTargetRoot({
@@ -97,10 +199,11 @@ test("persisted resume child lookup handles 50,000 retained rows in linear time"
assert.ok(Date.now() - startedAt < 2_000, "50k retained-child lookup should stay linear");
});
test("resolveHostForTransferEndpoint prefers id then label", () => {
test("resolveHostForTransferEndpoint requires recorded id and supports unique legacy names", () => {
const hosts = [host("id-1", "CI-Build-01", "ci-01.example")];
assert.equal(resolveHostForTransferEndpoint(hosts, "id-1", "other")?.id, "id-1");
assert.equal(resolveHostForTransferEndpoint(hosts, "missing", "CI-Build-01")?.id, "id-1");
assert.equal(resolveHostForTransferEndpoint(hosts, "missing", "CI-Build-01"), null);
assert.equal(resolveHostForTransferEndpoint(hosts, undefined, "CI-Build-01")?.id, "id-1");
assert.equal(resolveHostForTransferEndpoint(hosts, undefined, "ci-01.example")?.id, "id-1");
assert.equal(resolveHostForTransferEndpoint(hosts, "missing", "gone"), null);
});
@@ -1998,3 +2101,33 @@ test("corrupted replace-stage history cannot delete an unrelated directory", asy
resetDedicatedSessionOpenGateForTests();
}
});
for (const scenario of ["deleted-id", "ambiguous-legacy-label"] as const) {
test(`restart upload does not select a different server by ${scenario}`, async (t) => {
const originalGet = netcattyBridge.get;
t.after(() => { netcattyBridge.get = originalGet; });
const opened: string[] = [];
let uploads = 0;
(netcattyBridge as { get: () => unknown }).get = () => ({
openSftp: async (options: { hostname: string }) => { opened.push(options.hostname); return "new-channel"; },
closeSftp: async () => {},
statLocal: async () => ({ size: 100, lastModified: 123 }),
startStreamTransfer: async () => { uploads++; return { transferId: "wrong-host-resume" }; },
});
const hosts = scenario === "deleted-id"
? [host("different-id", "production", "different.example")]
: [host("first-id", "production", "first.example"), host("second-id", "production", "second.example")];
const result = await resumeTransferWithDedicatedSession({
id: "wrong-host-resume", fileName: "deployment.zip",
sourcePath: "/fixture/deployment.zip", targetPath: "/srv/deployment.zip",
sourceConnectionId: "local", targetConnectionId: "right-expired",
targetHostId: scenario === "deleted-id" ? "deleted-original-id" : undefined,
targetHostLabel: "production", direction: "upload", status: "interrupted",
totalBytes: 100, transferredBytes: 20, checkpointBytes: 20,
speed: 0, startTime: 1, isDirectory: false, reconnectRequired: true,
}, {hosts, keys: [], identities: []});
assert.equal(result.success, false, "resume must stop instead of selecting another endpoint by display name");
assert.deepEqual(opened, [], "must not authenticate to an unproven replacement host");
assert.equal(uploads, 0);
});
}
@@ -1,3 +1,4 @@
import { runTransferAndWaitForOwner } from "./waitForTransferOwner";
import type { Host, Identity, KnownHost, SSHKey, TerminalSettings, TransferTask } from "../../../domain/models";
import { validateTransferResumeSource } from "../../../domain/sftpTransferCenter";
import { STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY } from "../../../infrastructure/config/storageKeys";
@@ -95,8 +96,8 @@ export function resetDedicatedSessionOpenGateForTests(): void {
}
/**
* Resolve a vault host for a transfer endpoint. Prefer stable id; fall back to
* label/hostname when the id is stale after vault edits or older task records.
* Resolve the recorded endpoint, never a same-named replacement. Legacy tasks
* without an id may use label/hostname only when exactly one host matches.
*/
export function resolveHostForTransferEndpoint(
hosts: readonly Host[],
@@ -104,16 +105,16 @@ export function resolveHostForTransferEndpoint(
hostLabel?: string,
): Host | null {
if (hostId) {
const byId = hosts.find((host) => host.id === hostId);
if (byId) return byId;
return hosts.find((host) => host.id === hostId) ?? null;
}
const needle = (hostLabel || "").trim().toLowerCase();
if (!needle) return null;
return hosts.find((host) => {
const matches = hosts.filter((host) => {
const label = (host.label || "").trim().toLowerCase();
const hostname = (host.hostname || "").trim().toLowerCase();
return label === needle || hostname === needle;
}) ?? null;
});
return matches.length === 1 ? matches[0] : null;
}
export type OpenTransferSftpSessionOptions = {
@@ -1000,6 +1001,11 @@ async function resumeDirectoryWithDedicatedSession(
onProgress?: (progress: DedicatedResumeProgress) => void,
options?: DedicatedResumeOptions,
): Promise<DedicatedResumeResult> {
// Fresh recovery authorizes only pauses already present at its entry. Keep
// row references so a newer cross-window pause invalidates that permission.
const pausedAtResume = new Map(sftpTransferCenterStore.getSnapshot().tasks
.filter((child) => child.parentTaskId === parent.id && child.status === "paused")
.map((child) => [child.id, child]));
const bridge = netcattyBridge.get();
if (!bridge?.startStreamTransfer) {
return { success: false, error: "Transfer bridge unavailable" };
@@ -1247,8 +1253,13 @@ async function resumeDirectoryWithDedicatedSession(
if (options?.shouldAbort?.()) throw new Error("Transfer cancelled");
options?.onChildUpdate?.(childBase);
const streamResult = await bridge.startStreamTransfer!({
const streamResult = await runTransferAndWaitForOwner(childBase, () => bridge.startStreamTransfer!({
transferId: childId,
// Lifecycle events must carry the current identity even while
// large-history renderer child updates remain batched.
parentTaskId: childBase.parentTaskId,
directoryEntryIndex: childBase.directoryEntryIndex,
directoryEntryIdentity: childBase.directoryEntryIdentity,
sourcePath: file.sourcePath,
targetPath: file.targetPath,
sourceType,
@@ -1267,19 +1278,9 @@ async function resumeDirectoryWithDedicatedSession(
uploadCheckpointBytes: childBase.uploadCheckpointBytes,
sourceFingerprint: childBase.sourceFingerprint,
skipAdmission: true,
});
}), () => options?.shouldAbort?.() === true, pausedAtResume.get(childId));
if (streamResult?.superseded === true) {
for (;;) {
if (options?.shouldAbort?.()) throw new Error("Transfer cancelled");
const latest = sftpTransferCenterStore.getTask(childBase.id);
const status = latest?.status;
if (status === "completed") break;
if (status === "failed") throw new Error(latest?.error || "Transfer failed");
if (status === "cancelled") throw new Error("Transfer cancelled");
await new Promise((resolve) => setTimeout(resolve, 200));
}
} else if (streamResult?.error || streamResult?.cancelled) {
if (streamResult?.error || streamResult?.cancelled) {
throw new Error(streamResult.error || "Transfer cancelled");
}
@@ -0,0 +1,100 @@
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 { useSftpTransfers } from "./useSftpTransfers";
import { transferRuntime } from "./transferRuntime";
import { sftpTransferCenterStore } from "../sftpTransferCenterStore";
import { releaseTransferPauseTree } from "./transferPauseLatch";
for (const closePanel of [false, true]) {
test(`direct folder download resumes discovery with panel ${closePanel ? "closed" : "open"}`, async () => {
const previousWindow = globalThis.window;
const previousStorage = globalThis.localStorage;
const globals = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
const previousAct = globals.IS_REACT_ACT_ENVIRONMENT;
globals.IS_REACT_ACT_ENVIRONMENT = true;
let finishListing!: () => void;
const listingGate = new Promise<void>((resolve) => { finishListing = resolve; });
let listingStarted!: () => void;
const started = new Promise<void>((resolve) => { listingStarted = resolve; });
let listCalls = 0;
let maxCount = 0;
const unsubscribe = sftpTransferCenterStore.subscribe(() => {
const root = sftpTransferCenterStore.getOwnerTasks("direct-runtime-owner").find((row) => !row.parentTaskId);
maxCount = Math.max(maxCount, root?.transferredBytes ?? 0);
});
(globalThis as { window?: unknown }).window = { netcatty: {
mkdirLocal: async () => undefined,
statLocal: async () => undefined,
startStreamTransfer: async (options: { transferId: string }) => {
sftpTransferCenterStore.ingestBackgroundEvent({ type: "completed", transferId: options.transferId, transferred: 1, totalBytes: 1, lifecycleEpoch: 0 });
return {};
},
resumeTransfer: async () => ({ success: false, reason: "Transfer is no longer active" }),
pauseTransfer: async () => ({ success: false, reason: "Transfer is no longer active" }),
} };
(globalThis as { localStorage?: unknown }).localStorage = {
getItem: () => null, setItem: () => undefined, removeItem: () => undefined,
};
let ops: ReturnType<typeof useSftpTransfers> | undefined;
let renderer: ReactTestRenderer | undefined;
let running: Promise<unknown> | undefined;
let rootId = "";
let reconnects = 0;
sftpTransferCenterStore.setDedicatedResumeHandler(async () => {
reconnects++;
return { success: false, error: "unexpected reconnect" };
});
function Probe() {
ops = useSftpTransfers({
ownerId: "direct-runtime-owner", getActivePane: () => null,
getPaneByConnectionId: () => null, getTabByConnectionId: () => null,
updateTab: () => undefined, refresh: async () => undefined,
clearCacheForConnection: () => undefined, handleSessionError: () => undefined,
sftpSessionsRef: { current: new Map() }, connectionCacheKeyMapRef: { current: new Map() },
listLocalFiles: async () => [], listRemoteFiles: async () => {
listCalls++; listingStarted(); await listingGate;
return ["one.txt", "two.txt"].map((name) => ({ name, type: "file" as const, size: 1, sizeFormatted: "1 B", lastModified: 0, lastModifiedFormatted: "" }));
},
});
return null;
}
try {
await act(async () => { renderer = create(React.createElement(Probe)); });
assert.ok(ops);
await act(async () => {
running = ops!.downloadToLocal({ fileName: "folder", sourcePath: "/folder", targetPath: "/download/folder-runtime", sftpId: "sftp", connectionId: "ssh", sourceHostId: "host", sourceHostLabel: "Test", isDirectory: true });
await started;
});
const root = sftpTransferCenterStore.getOwnerTasks("direct-runtime-owner")[0];
assert.ok(root); rootId = root.id;
assert.equal(root.status, "transferring", "live root must be visible in In Progress");
assert.equal(transferRuntime.isWalkInFlight(rootId), true, "direct download registers before discovery");
await act(async () => { await ops!.pauseTransfer(rootId); });
assert.equal(sftpTransferCenterStore.getTask(rootId)?.status, "paused");
if (closePanel) await act(async () => { renderer?.unmount(); renderer = undefined; });
await act(async () => { await ops!.resumeTransfer(rootId); });
assert.equal(sftpTransferCenterStore.getTask(rootId)?.status, "transferring", "resume must rejoin live directory discovery even without an active child stream");
assert.equal(transferRuntime.isWalkInFlight(rootId), true);
assert.equal(reconnects, 0);
finishListing();
await act(async () => { assert.equal(await running, "completed"); });
assert.equal(sftpTransferCenterStore.getTask(rootId)?.status, "completed");
assert.equal(sftpTransferCenterStore.getTask(rootId)?.transferredBytes, 2);
assert.ok(maxCount <= 2, `completed children must be counted once, observed ${maxCount}`);
assert.equal(listCalls, 1);
assert.equal(transferRuntime.isWalkInFlight(rootId), false);
} finally {
releaseTransferPauseTree(rootId, []);
finishListing();
await act(async () => { await running; renderer?.unmount(); });
if (rootId) sftpTransferCenterStore.dismiss(rootId);
unsubscribe();
sftpTransferCenterStore.setDedicatedResumeHandler(null);
(globalThis as { window?: unknown }).window = previousWindow;
(globalThis as { localStorage?: unknown }).localStorage = previousStorage;
globals.IS_REACT_ACT_ENVIRONMENT = previousAct;
}
});
}
@@ -51,6 +51,46 @@ function createHost(initial: TransferTask[], bridge?: TransferControlHost["getBr
};
}
test("an older pause response must not resume a newer pause", async (t) => {
t.after(resetTransferPauseLatchesForTests);
let finishFirstPause!: (value: { success: boolean; lifecycleEpoch: number }) => void;
let calls = 0;
let backendPaused = true;
const { host, getTasks } = createHost([makeTask("overlapping-pause")], () => ({
pauseTransfer: async () => {
backendPaused = true;
if (++calls === 1) return new Promise((resolve) => { finishFirstPause = resolve; });
return { success: true, lifecycleEpoch: 2 };
},
resumeTransfer: async () => {
backendPaused = false;
return { success: true, lifecycleEpoch: 3 };
},
}));
const first = softPauseTransfer(host, "overlapping-pause");
await softPauseTransfer(host, "overlapping-pause");
finishFirstPause({ success: true, lifecycleEpoch: 1 });
await first;
assert.equal(getTasks()[0].status, "paused");
assert.equal(backendPaused, true, "late pause acknowledgement must not restart file writes");
});
test("a delayed resume response must not repaint a newer pause", async (t) => {
t.after(resetTransferPauseLatchesForTests);
let finishResume!: (value: { success: boolean; lifecycleEpoch: number }) => void;
const { host, getTasks } = createHost([makeTask("resume-then-pause", "paused")], () => ({
resumeTransfer: () => new Promise((resolve) => { finishResume = resolve; }),
pauseTransfer: async () => ({ success: true, lifecycleEpoch: 3 }),
}));
const resume = softResumeTransfer(host, "resume-then-pause");
await softPauseTransfer(host, "resume-then-pause");
finishResume({ success: true, lifecycleEpoch: 2 });
await resume;
assert.equal(isTransferPauseLatched("resume-then-pause"), true);
assert.equal(getTasks()[0].status, "paused", "latest user intent must win over an older response");
assert.equal(getTasks()[0].lifecycleEpoch, 3);
});
test("softPauseTransfer latches and paints paused for a live directory walk without a panel", async () => {
resetTransferPauseLatchesForTests();
resetTransferWalkRegistryForTests();
@@ -245,3 +285,238 @@ test("directory softResume stamps bridge epoch only on successIds; queued siblin
resetTransferPauseLatchesForTests();
resetTransferWalkRegistryForTests();
});
for (const isDirectory of [false, true]) {
for (const newerLocalPause of [false, true]) {
test(`cross-window resume releases ${isDirectory ? "folder" : "file"} latches unless local pause is newer: ${newerLocalPause}`, async (t) => {
t.after(resetTransferPauseLatchesForTests);
let finish!: (result: { success: boolean; superseded: true; supersededBy: "resume" }) => void;
let pauses = 0;
const id = `remote-resume-${isDirectory}-${newerLocalPause}`;
const initial: TransferTask[] = [{ ...makeTask(id), isDirectory }];
if (isDirectory) initial.push({ ...makeTask(`${id}-child`), parentTaskId: id });
const { host, getTasks } = createHost(initial, () => ({
pauseTransfer: () => ++pauses === 1
? new Promise((resolve) => { finish = resolve; })
: Promise.resolve({ success: true, lifecycleEpoch: 9 }),
}));
const pending = softPauseTransfer(host, id);
host.setTasks(getTasks().map(task => isDirectory && task.id === id ? task : ({ ...task, status: "transferring", lifecycleEpoch: 8 })));
if (newerLocalPause) await softPauseTransfer(host, id);
finish({ success: false, superseded: true, supersededBy: "resume" });
await pending;
await new Promise((resolve) => setImmediate(resolve));
assert.equal(isTransferPauseLatched(id), newerLocalPause, "authoritative resume must release the old root pause barrier");
assert.equal(getTasks().find(task => task.id === id)?.status, newerLocalPause ? "paused" : "transferring");
if (isDirectory) assert.equal(isTransferPauseLatched(`${id}-child`), newerLocalPause);
});
}
}
for (const action of ["pause", "resume"] as const) {
test(`superseded cross-window ${action} preserves authoritative paused state`, async (t) => {
t.after(resetTransferPauseLatchesForTests);
let finish!: (result: { success: boolean; superseded: boolean }) => void;
const deferred = () => new Promise<{ success: boolean; superseded: boolean }>((resolve) => { finish = resolve; });
const { host, getTasks } = createHost([makeTask(`cross-window-${action}`, action === "pause" ? "transferring" : "paused")], () => ({ pauseTransfer: deferred, resumeTransfer: deferred }));
const id = getTasks()[0].id;
const operation = action === "pause" ? softPauseTransfer(host, id) : softResumeTransfer(host, id);
// A global event from another window changes lifecycle, not this window's local control epoch.
host.setTasks(getTasks().map(task => ({ ...task, status: "paused", lifecycleEpoch: 8 })));
finish({ success: false, superseded: true });
const result = await operation;
assert.equal(getTasks()[0].status, "paused");
assert.equal(getTasks()[0].lifecycleEpoch, 8);
if (action === "pause") assert.equal(isTransferPauseLatched(id), true);
else assert.deepEqual(result, { handled: true }, "obsolete response must not trigger dedicated recovery");
});
}
for (const isDirectory of [false, true]) {
test(`remote pause restores released ${isDirectory ? "folder" : "file"} barriers after stale resume`, async (t) => {
t.after(resetTransferPauseLatchesForTests);
const id = `remote-pause-${isDirectory}`;
const tasks: TransferTask[] = [{ ...makeTask(id, "paused"), isDirectory }];
if (isDirectory) tasks.push({ ...makeTask(`${id}-child`, "paused"), parentTaskId: id });
const { host, getTasks } = createHost(tasks, () => ({
resumeTransfer: async () => ({ success: false, superseded: true, supersededBy: "pause" }),
}));
assert.deepEqual(await softResumeTransfer(host, id), { handled: true });
assert.equal(isTransferPauseLatched(id), true);
if (isDirectory) assert.equal(isTransferPauseLatched(`${id}-child`), true);
assert.equal(getTasks()[0].status, "paused");
});
}
test("a child-only remote resume does not release the folder pause", async (t) => {
t.after(resetTransferPauseLatchesForTests);
const { host } = createHost([
{ ...makeTask("mixed-root"), isDirectory: true },
{ ...makeTask("mixed-one"), parentTaskId: "mixed-root" },
{ ...makeTask("mixed-two"), parentTaskId: "mixed-root" },
], () => ({ pauseTransfer: async id => id === "mixed-one"
? { success: false, superseded: true, supersededBy: "resume" }
: { success: true } }));
await softPauseTransfer(host, "mixed-root");
await new Promise(resolve => setImmediate(resolve));
assert.equal(isTransferPauseLatched("mixed-root"), true);
assert.equal(isTransferPauseLatched("mixed-two"), true);
});
test("directory resume joins successful and remotely resumed children", async (t) => {
t.after(resetTransferPauseLatchesForTests);
const id = "mixed-success-resume";
const { host, getTasks } = createHost([
{ ...makeTask(id, "paused"), isDirectory: true },
{ ...makeTask(`${id}-one`, "paused"), parentTaskId: id },
{ ...makeTask(`${id}-two`, "paused"), parentTaskId: id },
], () => ({ resumeTransfer: async childId => childId === `${id}-one`
? { success: true, lifecycleEpoch: 3 }
: { success: false, superseded: true, supersededBy: "resume" } }));
assert.deepEqual(await softResumeTransfer(host, id), { handled: true });
assert.equal(getTasks().find(task => task.id === id)?.status, "transferring");
assert.equal(isTransferPauseLatched(id), false);
});
for (const newerPause of [false, true]) {
test(`directory live resume rejection is ignored only when superseded: ${newerPause}`, async (t) => {
t.after(resetTransferPauseLatchesForTests);
t.after(resetTransferWalkRegistryForTests);
const id = `rejected-folder-resume-${newerPause}`;
registerTransferWalk(id);
let rejectResume!: (error: Error) => void;
let backendPaused = true;
const { host, getTasks } = createHost([
{ ...makeTask(id, "paused"), isDirectory: true },
{ ...makeTask(`${id}-child`, "paused"), parentTaskId: id },
], () => ({
resumeTransfer: () => new Promise((_, reject) => { rejectResume = reject; }),
pauseTransfer: async () => { backendPaused = true; return { success: true, lifecycleEpoch: 4 }; },
}));
const running = softResumeTransfer(host, id);
if (newerPause) await softPauseTransfer(host, id);
rejectResume(new Error("resume transport disconnected"));
const result = await running;
assert.equal(result.handled, newerPause);
if (!newerPause) assert.match(result.reason || "", /resume transport disconnected/);
assert.equal(backendPaused, true);
assert.equal(getTasks().find(task => task.id === id)?.status, "paused");
if (newerPause) assert.equal(isTransferPauseLatched(id), true);
});
}
for (const newerResume of [false, true]) {
test(`partial folder resume rejection reports running and paused children unless superseded: ${newerResume}`, async (t) => {
t.after(resetTransferPauseLatchesForTests);
const id = `partial-reject-${newerResume}`;
const successfulId = `${id}-one`;
let rejectResume!: (error: Error) => void;
let successfulBackendPaused = true;
let round = 0;
let rollbackCalls = 0;
const { host, getTasks } = createHost([
{ ...makeTask(id, "paused"), isDirectory: true },
{ ...makeTask(successfulId, "paused"), parentTaskId: id },
{ ...makeTask(`${id}-two`, "paused"), parentTaskId: id },
], () => ({
resumeTransfer: async childId => {
if (childId === successfulId) { successfulBackendPaused = false; return { success: true }; }
if (round > 0) return { success: true };
return new Promise((_, reject) => { rejectResume = reject; });
},
pauseTransfer: async childId => {
rollbackCalls++;
if (childId === successfulId) successfulBackendPaused = true;
return { success: true };
},
}));
const running = softResumeTransfer(host, id);
if (newerResume) { round++; await softResumeTransfer(host, id); }
rejectResume(new Error("second child IPC rejected"));
const result = await running;
assert.equal(result.handled, true);
assert.equal(successfulBackendPaused, false, "successful child keeps running after partial resume");
assert.equal(rollbackCalls, 0, "partial reporting must not introduce compensating controls");
assert.equal(getTasks()[0].status, "transferring", "root must report the successful child still running");
assert.equal(isTransferPauseLatched(id), false);
const rejected = getTasks().find(task => task.id === `${id}-two`);
assert.equal(rejected?.status, newerResume ? "transferring" : "paused");
assert.equal(isTransferPauseLatched(`${id}-two`), !newerResume);
if (!newerResume) assert.match(rejected?.error || "", /second child IPC rejected/);
});
}
for (const resolvedFailure of [false, true]) {
test(`remote-resumed child remains visibly running when sibling resume fails: resolved=${resolvedFailure}`, async (t) => {
t.after(resetTransferPauseLatchesForTests);
const id = "remote-partial-reject";
const runningId = `${id}-one`;
const rejectedId = `${id}-two`;
const { host, getTasks } = createHost([
{ ...makeTask(id, "paused"), isDirectory: true },
{ ...makeTask(runningId, "paused"), parentTaskId: id },
{ ...makeTask(rejectedId, "paused"), parentTaskId: id },
], () => ({ resumeTransfer: async childId => {
if (childId === rejectedId) {
if (resolvedFailure) return { success: false, reason: "sibling resume rejected" };
throw new Error("sibling resume rejected");
}
host.setTasks(getTasks().map(task => task.id === runningId ? { ...task, status: "transferring", lifecycleEpoch: 8 } : task));
return { success: false, superseded: true, supersededBy: "resume" };
} }));
assert.equal((await softResumeTransfer(host, id)).handled, true);
assert.equal(getTasks()[0].status, "transferring");
assert.equal(isTransferPauseLatched(id), false);
assert.equal(getTasks().find(task => task.id === runningId)?.status, "transferring");
assert.equal(getTasks().find(task => task.id === runningId)?.lifecycleEpoch, 8);
assert.equal(getTasks().find(task => task.id === rejectedId)?.status, "paused");
assert.equal(isTransferPauseLatched(rejectedId), true);
assert.match(getTasks().find(task => task.id === rejectedId)?.error || "", /sibling resume rejected/);
});
}
for (const newerResume of [false, true]) {
test(`resolved verification failure holds live folder unless newer resume won: ${newerResume}`, async (t) => {
t.after(resetTransferPauseLatchesForTests);
t.after(resetTransferWalkRegistryForTests);
const id = `resolved-verification-${newerResume}`;
registerTransferWalk(id);
let finish!: (value: { success: boolean; reason: string }) => void;
let calls = 0;
const { host, getTasks } = createHost([
{ ...makeTask(id, "paused"), isDirectory: true },
{ ...makeTask(`${id}-child`, "paused"), parentTaskId: id },
], () => ({ resumeTransfer: () => ++calls === 1
? new Promise(resolve => { finish = resolve; }) : Promise.resolve({ success: true }) }));
const running = softResumeTransfer(host, id);
if (newerResume) await softResumeTransfer(host, id);
finish({ success: false, reason: "Could not verify the source file for resume" });
const result = await running;
assert.equal(result.handled, newerResume);
if (!newerResume) assert.match(result.reason || "", /verify the source file/);
assert.equal(getTasks()[0].status, newerResume ? "transferring" : "paused");
assert.equal(isTransferPauseLatched(id), !newerResume);
assert.equal(isTransferPauseLatched(`${id}-child`), !newerResume);
});
}
test("folder resume releases a completed child compacted while pause was draining", async (t) => {
t.after(resetTransferPauseLatchesForTests);
t.after(resetTransferWalkRegistryForTests);
const root = { ...makeTask("compacted-pause-root"), isDirectory: true };
const child = { ...makeTask("compacted-pause-child"), parentTaskId: root.id };
registerTransferWalk(root.id);
const { host, getTasks } = createHost([root, child], () => ({
pauseTransfer: async () => ({ success: true }),
resumeTransfer: async () => ({ success: false, reason: "Transfer is no longer active" }),
}));
await softPauseTransfer(host, root.id);
await new Promise<void>((resolve) => setImmediate(resolve));
assert.equal(isTransferPauseLatched(child.id), true);
// Completion during soft-drain compacts this row before the user resumes.
host.setTasks(getTasks().filter((task) => task.id !== child.id));
const resumed = await softResumeTransfer(host, root.id);
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");
});
@@ -20,6 +20,7 @@ import {
isTransferControlEpochCurrent,
} from "./transferControlEpoch";
import {
isTransferOrRootPauseLatched,
latchTransferPauseTree,
releaseTransferPauseTree,
} from "./transferPauseLatch";
@@ -28,6 +29,9 @@ import { isTransferWalkInFlight } from "./transferWalkRegistry";
export type TransferControlBridge = {
pauseTransfer?: (id: string) => Promise<{
success: boolean;
/** A newer control in another window owns the authoritative state. */
superseded?: boolean;
supersededBy?: "pause" | "resume" | "cancel";
reason?: string;
checkpointBytes?: number;
resumeStage?: TransferTask["resumeStage"];
@@ -39,12 +43,67 @@ export type TransferControlBridge = {
}>;
resumeTransfer?: (id: string) => Promise<{
success: boolean;
/** A newer control in another window owns the authoritative state. */
superseded?: boolean;
supersededBy?: "pause" | "resume" | "cancel";
reason?: string;
lifecycleEpoch?: number;
}>;
cancelTransfer?: (id: string) => Promise<unknown>;
};
function wasSuperseded(result: { success?: boolean; superseded?: boolean } | undefined | null): boolean {
return result?.superseded === true;
}
type SupersededControlResult = {
success?: boolean;
superseded?: boolean;
supersededBy?: "pause" | "resume" | "cancel";
};
export function reconcileSupersededControls(
host: TransferControlHost,
taskId: string,
childIds: string[],
backendIds: string[],
results: ReadonlyArray<SupersededControlResult | undefined>,
epoch: number,
requestedAction: "pause" | "resume",
controlTaskId = taskId,
): void {
if (!isTransferControlEpochCurrent(controlTaskId, epoch)) 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") => {
if (action === "resume") releaseTransferPauseTree(id, descendants);
else latchTransferPauseTree(id, descendants);
for (const affectedId of [id, ...descendants]) {
try {
if (action === "resume") globalSftpTransferScheduler.resume(affectedId);
else globalSftpTransferScheduler.pause(affectedId);
} catch { /* best-effort */ }
}
};
const decisions = results.map((result) => result?.superseded
? result.supersededBy
: result?.success ? requestedAction : undefined);
// A child-only resume does not establish a folder-wide decision. Require all
// relevant children to agree, or an already-authoritative resumed root row.
if (decisions.length > 0 && decisions.every((action) => action === "resume")) {
apply(taskId, childIds, "resume");
host.setTasks(host.getTasks().map((row) => row.id === taskId ? { ...row, status: "transferring" } : row));
} else if (decisions.length > 0 && decisions.every((action) => action === "pause")) {
apply(taskId, childIds, "pause");
host.setTasks(host.getTasks().map((row) => row.id === taskId ? { ...row, status: "paused" } : row));
} else if (task.status === "transferring" && decisions.includes("resume")) {
apply(taskId, [], "resume");
}
decisions.forEach((action, index) => {
if (action) apply(backendIds[index], [], action);
});
}
/** Prefer the highest bridge lifecycleEpoch from successful pause/resume results. */
function maxBridgeLifecycleEpoch(
results: ReadonlyArray<{ success?: boolean; lifecycleEpoch?: number } | undefined | null>,
@@ -170,14 +229,23 @@ export async function softPauseTransfer(
}
const backendIds = treeIds.length > 0 ? treeIds : [taskId];
// An obsolete pause can be superseded by another pause or cancellation,
// not only by resume. Compensate only while the tree still wants to run.
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;
try { await bridge!.resumeTransfer?.(id); } catch { /* best-effort */ }
};
const pauseOne = async (id: string) => {
let result = await bridge!.pauseTransfer?.(id)
?? { success: false, reason: "Pause unavailable" };
const maxAttempts = task.isDirectory ? 4 : 16;
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
if (wasSuperseded(result)) return result;
if (!isTransferControlEpochCurrent(taskId, pauseEpoch)) {
if (result.success) {
try { await bridge!.resumeTransfer?.(id); } catch { /* best-effort */ }
await undoObsoletePause(id);
}
return { success: false, reason: "Pause superseded by resume" };
}
@@ -186,6 +254,9 @@ export async function softPauseTransfer(
return result;
}
await new Promise((resolve) => setTimeout(resolve, 40));
if (!isTransferControlEpochCurrent(taskId, pauseEpoch)) {
return { success: false, reason: "Pause superseded by newer control" };
}
result = await bridge!.pauseTransfer?.(id)
?? { success: false, reason: "Pause unavailable" };
}
@@ -200,17 +271,22 @@ export async function softPauseTransfer(
return { id, result: { success: false, reason: "Pause superseded by resume" } };
}
const result = await pauseOne(id);
if (wasSuperseded(result)) return { id, result };
const live = host.getTasks().find((candidate) => candidate.id === taskId);
const userResumed = !pauseStillCurrent()
|| !live
|| (live.status !== "paused" && live.status !== "pausing");
if (userResumed) {
try { await bridge!.resumeTransfer?.(id); } catch { /* best-effort */ }
await undoObsoletePause(id);
return { id, result: { success: false, reason: "Pause superseded by resume" } };
}
return { id, result };
}));
if (!pauseStillCurrent()) return;
if (pauseResults.some(({ result }) => wasSuperseded(result))) {
reconcileSupersededControls(host, taskId, childIds, backendIds, pauseResults.map(({ result }) => result), pauseEpoch, "pause");
return;
}
const after = host.getTasks().find((candidate) => candidate.id === taskId);
if (!after || after.status === "cancelled") return;
if (after.status !== "paused" && after.status !== "pausing") return;
@@ -259,6 +335,10 @@ export async function softPauseTransfer(
id,
result: await pauseOne(id),
})));
if (pauseResults.some(({ result }) => wasSuperseded(result))) {
reconcileSupersededControls(host, taskId, childIds, backendIds, pauseResults.map(({ result }) => result), pauseEpoch, "pause");
return "noop";
}
const afterLivePause = host.getTasks().find((candidate) => candidate.id === taskId);
if (afterLivePause?.status === "cancelled") {
releaseTransferPauseTree(taskId, childIds);
@@ -271,7 +351,7 @@ export async function softPauseTransfer(
if (userAlreadyResumed) {
for (const { id, result } of pauseResults) {
if (result?.success) {
try { await bridge.resumeTransfer?.(id); } catch { /* best-effort */ }
await undoObsoletePause(id);
}
}
return "noop";
@@ -284,7 +364,7 @@ export async function softPauseTransfer(
if (!isTransferControlEpochCurrent(taskId, pauseEpoch)) {
for (const { id, result } of pauseResults) {
if (result?.success) {
try { await bridge.resumeTransfer?.(id); } catch { /* best-effort */ }
await undoObsoletePause(id);
}
}
return "noop";
@@ -367,8 +447,9 @@ export async function softPauseTransfer(
bridgeResults: pauseResults.map((row) => row.result),
});
for (const id of rollback.bridgeIdsToResume) {
try { await bridge.resumeTransfer?.(id); } catch { /* best-effort */ }
await undoObsoletePause(id);
}
if (!isTransferControlEpochCurrent(taskId, pauseEpoch)) return "noop";
const hard = pauseResults.find(({ result }) =>
result && !result.success && !isBenignPauseMiss(result.reason),
)?.result;
@@ -428,7 +509,7 @@ export async function softResumeTransfer(
: [taskId, ...childIds.filter((id) => id !== taskId)];
// Supersede in-flight soft-drain / pauseWatch only — not a bridge lifecycle stamp.
bumpTransferControlEpoch(taskId);
const resumeEpoch = bumpTransferControlEpoch(taskId);
releaseTransferPauseTree(taskId, releaseIds);
for (const id of [taskId, ...releaseIds]) {
try { globalSftpTransferScheduler.resume(id); } catch { /* best-effort */ }
@@ -438,17 +519,50 @@ export async function softResumeTransfer(
try { bridge = host.getBridge(); } catch { bridge = undefined; }
const resumeIds = treeIds.length > 0 ? treeIds : [taskId];
const results = await Promise.all(resumeIds.map(async (id) =>
bridge?.resumeTransfer?.(id) ?? { success: false, reason: "Resume unavailable" },
));
const failedIds = new Set<string>();
const results = await Promise.all(resumeIds.map(async (id) => {
try {
return await bridge?.resumeTransfer?.(id) ?? { success: false, reason: "Resume unavailable" };
} catch (error) {
// Rejections must pass the same stale-control check as ordinary failures.
failedIds.add(id);
return { success: false, reason: error instanceof Error && error.message ? error.message : "Resume request failed" };
}
}));
const after = host.getTasks().find((candidate) => candidate.id === taskId);
if (after?.status === "cancelled") return { handled: true };
if (!isTransferControlEpochCurrent(taskId, resumeEpoch)) return { handled: true };
if (!after || ["completed", "cancelled", "failed"].includes(after.status)) return { handled: true };
results.forEach((result, index) => {
// A missing stream can belong to queued directory work. Verification and
// other explicit failures still own a paused stream and must remain held.
const benignMiss = /^(?:Transfer is no longer active|not active|Resume unavailable)$/i.test(result.reason || "");
if (!result.success && !result.superseded && !benignMiss) failedIds.add(resumeIds[index]);
});
if (results.some(wasSuperseded)) {
reconcileSupersededControls(host, taskId, releaseIds, resumeIds, results, resumeEpoch, "resume");
}
const successIds = resumeIds.filter((_, index) => results[index]?.success);
const effectiveRunning = results.some((result) => result?.success || (result?.superseded && result.supersededBy === "resume"));
const failedIndex = resumeIds.findIndex((id) => failedIds.has(id));
const failureReason = failedIndex >= 0 ? results[failedIndex]?.reason || "Resume request failed" : undefined;
if (failedIds.size > 0) {
// A partial resume stays visibly active; hold only failed children. Do not
// hide successfully running streams behind a paused root barrier.
const heldIds = effectiveRunning ? [...failedIds] : [taskId, ...failedIds];
for (const id of heldIds) {
latchTransferPauseTree(id, []);
try { globalSftpTransferScheduler.pause(id); } catch { /* best-effort */ }
}
if (!effectiveRunning) return { handled: false, reason: failureReason };
}
if (results.some(wasSuperseded) && failedIds.size === 0) {
return { handled: true };
}
const walkAlive = isTransferWalkInFlight(taskId);
// Directory walk can continue after unlatch without bridge resume on every child.
// Single-file must not claim success when every bridge resume fails (stuck bar).
if (successIds.length === 0) {
if (!effectiveRunning) {
if (task.isDirectory && walkAlive) {
host.setTasks(paintTreeStatus(
host.getTasks(),
@@ -491,6 +605,12 @@ export async function softResumeTransfer(
return candidate;
}
if (failedIds.has(candidate.id)) {
return { ...candidate, status: "paused" as const, speed: 0, error: failureReason };
}
const candidateResult = results[resumeIds.indexOf(candidate.id)];
if (candidate.id !== taskId && candidateResult?.superseded) return candidate;
// Parent: transferring. Prefer bridge epoch; never wipe to undefined or a
// late pause fanout re-applies "paused" (acceptsLifecycle treats missing as any).
if (candidate.id === taskId) {
@@ -502,7 +622,7 @@ export async function softResumeTransfer(
return {
...candidate,
status: "transferring" as const,
error: undefined,
error: failureReason,
reconnectRequired: false,
pauseUnavailableReason: undefined,
phase: undefined,
@@ -4,6 +4,11 @@ import test from "node:test";
import {
createGlobalSftpTransferScheduler,
} from "./globalTransferScheduler";
import { resolveProgressiveFolderUploadConcurrency } from "../../../lib/progressiveFolderUpload";
import {
DEFAULT_SFTP_FILE_TRANSFER_CONCURRENCY,
resolveSftpTransferConcurrency,
} from "./transferConcurrency";
test("scheduler limits each remote host independently", async () => {
const scheduler = createGlobalSftpTransferScheduler();
@@ -28,6 +33,40 @@ test("scheduler limits each remote host independently", async () => {
assert.equal(maxActive, 2);
});
test("progressive folder uploads use six slots by default and preserve a saved limit", async () => {
const displayedDefault = resolveSftpTransferConcurrency(() => null);
const effectiveDefault = resolveProgressiveFolderUploadConcurrency(null);
assert.equal(displayedDefault, DEFAULT_SFTP_FILE_TRANSFER_CONCURRENCY);
assert.equal(effectiveDefault, displayedDefault);
assert.equal(effectiveDefault, 6);
assert.equal(resolveProgressiveFolderUploadConcurrency(3), 3);
assert.equal(resolveProgressiveFolderUploadConcurrency(99), 6);
const scheduler = createGlobalSftpTransferScheduler();
const releases: Array<() => void> = [];
let active = 0;
let maxActive = 0;
const jobs = Array.from({ length: 8 }, (_, index) => scheduler.run(
"progressive",
`file-${index}`,
["host:one"],
() => null,
async () => {
active += 1;
maxActive = Math.max(maxActive, active);
await new Promise<void>((resolve) => releases.push(resolve));
active -= 1;
},
));
while (releases.length < 6) await new Promise((resolve) => setImmediate(resolve));
assert.equal(maxActive, 6);
releases.splice(0).forEach((release) => release());
while (releases.length < 2) await new Promise((resolve) => setImmediate(resolve));
releases.splice(0).forEach((release) => release());
await Promise.all(jobs);
});
test("scheduler alternates owners when both have queued work", async () => {
const scheduler = createGlobalSftpTransferScheduler();
const order: string[] = [];
@@ -1,3 +1,5 @@
import { resolveSftpTransferConcurrency } from "../../../domain/sftpTransferConcurrency";
type LimitReader = () => number | null | undefined;
interface ScheduledJob<T> {
@@ -20,9 +22,7 @@ export interface GlobalSftpTransferScheduler {
}
function normalizeLimit(value: number | null | undefined): number {
return Number.isInteger(value) && value !== undefined && value !== null && value >= 1 && value <= 16
? value
: 2;
return resolveSftpTransferConcurrency(() => value);
}
export function getSftpTransferResourceKeys(input: {
+11 -1
View File
@@ -2,7 +2,7 @@ import { useCallback, useMemo, useSyncExternalStore } from "react";
import type { SftpBookmark } from "../../../domain/models";
import { localStorageAdapter } from "../../../infrastructure/persistence/localStorageAdapter";
import { STORAGE_KEY_SFTP_LOCAL_BOOKMARKS } from "../../../infrastructure/config/storageKeys";
import { createSftpBookmark } from "./bookmarkHelpers";
import { createSftpBookmark, moveSftpBookmark, renameSftpBookmark } from "./bookmarkHelpers";
type Listener = () => void;
@@ -74,10 +74,20 @@ export const useLocalSftpBookmarks = ({
setLocalSftpBookmarks((prev) => prev.filter((b) => b.id !== id));
}, []);
const reorderBookmark = useCallback((fromId: string, toId: string) => {
setLocalSftpBookmarks((prev) => moveSftpBookmark(prev, fromId, toId));
}, []);
const renameBookmark = useCallback((id: string, label: string) => {
setLocalSftpBookmarks((prev) => renameSftpBookmark(prev, id, label));
}, []);
return {
bookmarks,
isCurrentPathBookmarked,
toggleBookmark,
deleteBookmark,
reorderBookmark,
renameBookmark,
};
};
@@ -0,0 +1,28 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
consumePendingDualPaneSftpRequest,
requestOpenDualPaneSftp,
resetDualPaneSftpOpenStore,
subscribeDualPaneSftpOpen,
} from "./sftpDualPaneOpenStore.ts";
test("requestOpenDualPaneSftp stores a pending request when nothing is listening", () => {
resetDualPaneSftpOpenStore();
requestOpenDualPaneSftp("host-1");
assert.deepEqual(consumePendingDualPaneSftpRequest(), { hostId: "host-1", seq: 1 });
assert.equal(consumePendingDualPaneSftpRequest(), null);
});
test("requestOpenDualPaneSftp delivers live to subscribers instead of queueing", () => {
resetDualPaneSftpOpenStore();
const received: string[] = [];
const unsubscribe = subscribeDualPaneSftpOpen((request) => {
received.push(request.hostId);
});
requestOpenDualPaneSftp("host-2");
assert.deepEqual(received, ["host-2"]);
assert.equal(consumePendingDualPaneSftpRequest(), null);
unsubscribe();
});
@@ -0,0 +1,45 @@
import { activeTabStore } from "../activeTabStore";
export type DualPaneSftpRequest = {
hostId: string;
seq: number;
};
type Listener = (request: DualPaneSftpRequest) => void;
const listeners = new Set<Listener>();
let pending: DualPaneSftpRequest | null = null;
let seq = 0;
export function requestOpenDualPaneSftp(hostId: string): DualPaneSftpRequest {
const request: DualPaneSftpRequest = { hostId, seq: ++seq };
if (listeners.size > 0) {
pending = null;
for (const listener of listeners) listener(request);
} else {
pending = request;
}
if (typeof globalThis.window !== "undefined") {
activeTabStore.setActiveTabId("sftp");
}
return request;
}
export function consumePendingDualPaneSftpRequest(): DualPaneSftpRequest | null {
const current = pending;
pending = null;
return current;
}
export function subscribeDualPaneSftpOpen(listener: Listener) {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}
export function resetDualPaneSftpOpenStore() {
pending = null;
listeners.clear();
seq = 0;
}
@@ -0,0 +1,67 @@
import { useCallback, useSyncExternalStore } from "react";
import {
DEFAULT_SFTP_LIST_DENSITY,
getNextSftpListDensity,
parseSftpListDensity,
type SftpListDensity,
} from "../../../domain/sftpListDensity";
import { STORAGE_KEY_SFTP_LIST_DENSITY } from "../../../infrastructure/config/storageKeys";
import { localStorageAdapter } from "../../../infrastructure/persistence/localStorageAdapter";
type Listener = () => void;
const listeners = new Set<Listener>();
let snapshot: SftpListDensity = parseSftpListDensity(
localStorageAdapter.readString(STORAGE_KEY_SFTP_LIST_DENSITY),
);
function emit() {
for (const listener of listeners) listener();
}
export function subscribeSftpListDensity(listener: Listener) {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}
export function getSftpListDensitySnapshot(): SftpListDensity {
return snapshot;
}
export function setSftpListDensity(next: SftpListDensity) {
const density = parseSftpListDensity(next);
if (density === snapshot) return;
snapshot = density;
localStorageAdapter.writeString(STORAGE_KEY_SFTP_LIST_DENSITY, density);
emit();
}
export function toggleSftpListDensity() {
setSftpListDensity(getNextSftpListDensity(snapshot));
}
if (typeof window !== "undefined") {
window.addEventListener("storage", (event) => {
if (event.key !== STORAGE_KEY_SFTP_LIST_DENSITY) return;
snapshot = parseSftpListDensity(event.newValue);
emit();
});
}
export function useSftpListDensity() {
const density = useSyncExternalStore(
subscribeSftpListDensity,
getSftpListDensitySnapshot,
() => DEFAULT_SFTP_LIST_DENSITY,
);
const setDensity = useCallback((next: SftpListDensity) => {
setSftpListDensity(next);
}, []);
const toggleDensity = useCallback(() => {
toggleSftpListDensity();
}, []);
return { density, setDensity, toggleDensity };
}
@@ -12,9 +12,9 @@ import {
runSftpTransferWorkers,
} from "./transferConcurrency";
test("defaults folder file transfers to two concurrent files", () => {
test("defaults folder file transfers to six concurrent files", () => {
assert.equal(resolveSftpTransferConcurrency(() => null), DEFAULT_SFTP_FILE_TRANSFER_CONCURRENCY);
assert.equal(DEFAULT_SFTP_FILE_TRANSFER_CONCURRENCY, 2);
assert.equal(DEFAULT_SFTP_FILE_TRANSFER_CONCURRENCY, 6);
});
test("defaults directory listing fanout to four concurrent readdirs", () => {
@@ -34,6 +34,7 @@ test("defaults skip-unchanged to enabled", () => {
test("keeps explicit folder transfer concurrency within the supported range", () => {
assert.equal(resolveSftpTransferConcurrency(() => 1), 1);
assert.equal(resolveSftpTransferConcurrency(() => 16), 16);
assert.equal(resolveSftpTransferConcurrency(() => 1.5), DEFAULT_SFTP_FILE_TRANSFER_CONCURRENCY);
assert.equal(resolveSftpTransferConcurrency(() => 0), DEFAULT_SFTP_FILE_TRANSFER_CONCURRENCY);
assert.equal(resolveSftpTransferConcurrency(() => 17), DEFAULT_SFTP_FILE_TRANSFER_CONCURRENCY);
});
@@ -51,18 +52,18 @@ test("keeps directory listing concurrency within the supported range", () => {
);
});
test("limits default multi-file transfer scheduling to two concurrent workers", async () => {
test("limits default multi-file transfer scheduling to six concurrent workers", async () => {
let active = 0;
let maxActive = 0;
await runSftpTransferWorkers([1, 2, 3, 4], () => null, async () => {
await runSftpTransferWorkers([1, 2, 3, 4, 5, 6, 7], () => null, async () => {
active += 1;
maxActive = Math.max(maxActive, active);
await new Promise((resolve) => setTimeout(resolve, 5));
active -= 1;
});
assert.equal(maxActive, 2);
assert.equal(maxActive, 6);
});
test("runBoundedConcurrency respects an explicit limit", async () => {
+8 -12
View File
@@ -1,6 +1,11 @@
export const DEFAULT_SFTP_FILE_TRANSFER_CONCURRENCY = 2;
export const MIN_SFTP_FILE_TRANSFER_CONCURRENCY = 1;
export const MAX_SFTP_FILE_TRANSFER_CONCURRENCY = 16;
import { resolveSftpTransferConcurrency } from "../../../domain/sftpTransferConcurrency";
export {
DEFAULT_SFTP_FILE_TRANSFER_CONCURRENCY,
MAX_SFTP_FILE_TRANSFER_CONCURRENCY,
MIN_SFTP_FILE_TRANSFER_CONCURRENCY,
resolveSftpTransferConcurrency,
} from "../../../domain/sftpTransferConcurrency";
/**
* Bounded parallel directory listings while walking a folder tree.
@@ -16,15 +21,6 @@ export const MAX_SFTP_DIRECTORY_LISTING_CONCURRENCY = 8;
/** Default on: skip size+mtime matches like rsync's generator. */
export const DEFAULT_SFTP_SKIP_UNCHANGED = true;
export function resolveSftpTransferConcurrency(readStoredValue: () => number | null | undefined): number {
const stored = readStoredValue();
return stored != null &&
stored >= MIN_SFTP_FILE_TRANSFER_CONCURRENCY &&
stored <= MAX_SFTP_FILE_TRANSFER_CONCURRENCY
? stored
: DEFAULT_SFTP_FILE_TRANSFER_CONCURRENCY;
}
export function resolveSftpDirectoryListingConcurrency(
readStoredValue?: () => number | null | undefined,
): number {
@@ -41,6 +41,106 @@ const rootTask = (): TransferTask => ({
progressMode: "files",
});
for (const newestAction of ["pause", "cancel", "resume", "remote-resume", "remote-child-resume"] as const) {
test(`directory pause watcher respects a newer ${newestAction}`, async () => {
const { bumpTransferControlEpoch, resetTransferControlEpochsForTests } = await import("./transferControlEpoch");
const { latchTransferPauseTree, isTransferPauseLatched, resetTransferPauseLatchesForTests } = await import("./transferPauseLatch");
const previousWindow = (globalThis as { window?: unknown }).window;
const previousLocalStorage = (globalThis as { localStorage?: unknown }).localStorage;
const previousActEnvironment = (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT;
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const root = { ...rootTask(), id: `watch-root-${newestAction}` };
let tasks: TransferTask[] = [root];
const transfersRef = { current: tasks };
const cancelledTasksRef = { current: new Set<string>() };
const pausedTasksRef = { current: new Set<string>() };
let pauseCalls = 0;
let finishPause!: (value: { success: boolean; superseded?: boolean; supersededBy?: "resume" }) => void;
let finishTransfer!: (value: { error?: string }) => void;
let pauseStarted!: () => void;
const pauseGate = new Promise<void>((resolve) => { pauseStarted = resolve; });
const transferGate = new Promise<{ error?: string }>((resolve) => { finishTransfer = resolve; });
let resumeCalls = 0;
let childId = "";
(globalThis as { window?: unknown }).window = { netcatty: {
mkdirLocal: async () => undefined,
statLocal: async () => ({ type: "directory" }),
startStreamTransfer: (options: { transferId: string }) => {
childId = options.transferId;
// Reproduce a stream arming during the parent's initial pause round.
bumpTransferControlEpoch(root.id);
latchTransferPauseTree(root.id, [childId]);
if (newestAction === "remote-resume" || newestAction === "remote-child-resume") { pausedTasksRef.current.add(root.id); pausedTasksRef.current.add(childId); }
return transferGate;
},
pauseTransfer: () => {
pauseCalls++;
pauseStarted();
return new Promise<{ success: boolean; superseded?: boolean; supersededBy?: "resume" }>((resolve) => { finishPause = resolve; });
},
resumeTransfer: async () => { resumeCalls++; return { success: true }; },
} };
(globalThis as { localStorage?: unknown }).localStorage = {
getItem: () => null, setItem: () => undefined, removeItem: () => undefined,
};
let operations: ReturnType<typeof useSftpDirectoryTransferOps> | undefined;
let renderer: ReactTestRenderer | null = null;
let running: Promise<unknown> | undefined;
const Probe = () => {
operations = useSftpDirectoryTransferOps({
ownerId: `watch-owner-${newestAction}`, cancelledTasksRef,
pausedTasksRef,
waitUntilTransferResumed: async () => undefined,
activeChildIdsRef: { current: new Map() }, transfersRef,
setTransfers: (update) => {
tasks = typeof update === "function" ? update(tasks) : update;
transfersRef.current = tasks;
},
listLocalFiles: async () => [], listRemoteFiles: async () => [fileEntry("file.txt")],
});
return null;
};
try {
await act(async () => { renderer = create(React.createElement(Probe)); });
assert.ok(operations);
running = operations.transferDirectory(root, "source-sftp", null, false, true, "auto", "auto", root.id);
await pauseGate;
if (newestAction === "remote-resume" || newestAction === "remote-child-resume") {
// Another window's resumed event updates rows without bumping this local epoch.
transfersRef.current = tasks = tasks.map(task => ({ ...task, status: task.id === root.id && newestAction === "remote-child-resume" ? "paused" : "transferring", lifecycleEpoch: 8 }));
finishPause({ success: false, superseded: true, supersededBy: "resume" });
await new Promise((resolve) => setTimeout(resolve, 110));
assert.equal(pauseCalls, 1, "superseded watcher must not re-pause the resumed backend");
assert.equal(isTransferPauseLatched(root.id), newestAction === "remote-child-resume");
assert.equal(isTransferPauseLatched(childId), false);
assert.equal(pausedTasksRef.current.has(root.id), newestAction === "remote-child-resume");
assert.equal(pausedTasksRef.current.has(childId), false);
return;
}
bumpTransferControlEpoch(root.id);
if (newestAction === "pause") latchTransferPauseTree(root.id, [childId]);
else {
if (newestAction === "cancel") cancelledTasksRef.current.add(root.id);
resetTransferPauseLatchesForTests();
}
finishPause({ success: true });
await new Promise((resolve) => setImmediate(resolve));
assert.equal(resumeCalls, newestAction === "resume" ? 1 : 0, "compensation must follow the latest decision");
} finally {
pausedTasksRef.current.clear();
resetTransferPauseLatchesForTests();
finishPause?.({ success: true });
finishTransfer({});
await running?.catch(() => {});
await act(async () => { renderer?.unmount(); });
resetTransferControlEpochsForTests();
(globalThis as { window?: unknown }).window = previousWindow;
(globalThis as { localStorage?: unknown }).localStorage = previousLocalStorage;
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
}
});
}
test("directory transfer discovers each directory once with bounded listing concurrency", async () => {
const previousWindow = (globalThis as { window?: unknown }).window;
const previousLocalStorage = (globalThis as { localStorage?: unknown }).localStorage;
@@ -233,3 +333,74 @@ test("live directory download rejects a Windows backslash traversal entry", asyn
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
}
});
test("live folder settles a superseded child whose completed row was compacted", async () => {
const { sftpTransferCenterStore } = await import("../sftpTransferCenterStore");
const previousWindow = (globalThis as { window?: unknown }).window;
const previousLocalStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
const previousActEnvironment = (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT;
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
Object.defineProperty(globalThis, "localStorage", {
configurable: true,
value: { getItem: () => null, setItem: () => {}, removeItem: () => {} },
});
const root = { ...rootTask(), id: "live-compacted-root", startTime: Date.now() };
let tasks: TransferTask[] = [root];
const transfersRef = { current: tasks };
const cancelledTasksRef = { current: new Set<string>() };
let childId: string | undefined;
(globalThis as { window?: unknown }).window = { netcatty: {
mkdirLocal: async () => undefined,
statLocal: async () => ({ type: "directory" }),
startStreamTransfer: async (options: { transferId: string }) => {
childId = options.transferId;
sftpTransferCenterStore.publishOwner("live-compacted-owner", tasks);
// The winning invocation has already completed; history compaction drops its row.
sftpTransferCenterStore.ingestBackgroundEvent({
type: "completed", transferId: childId, transferred: 1, totalBytes: 1, lifecycleEpoch: 0,
});
return { superseded: true };
},
} };
let operations: ReturnType<typeof useSftpDirectoryTransferOps> | undefined;
let renderer: ReactTestRenderer | null = null;
let running: Promise<number> | undefined;
const Probe = () => {
operations = useSftpDirectoryTransferOps({
ownerId: "live-compacted-owner", cancelledTasksRef,
pausedTasksRef: { current: new Set() }, waitUntilTransferResumed: async () => undefined,
activeChildIdsRef: { current: new Map() }, transfersRef,
setTransfers: (update) => {
tasks = typeof update === "function" ? update(tasks) : update;
transfersRef.current = tasks;
},
listLocalFiles: async () => [], listRemoteFiles: async () => [fileEntry("file.txt")],
});
return null;
};
try {
await act(async () => { renderer = create(React.createElement(Probe)); });
assert.ok(operations);
running = operations.transferDirectory(root, "source-sftp", null, false, true, "auto", "auto", root.id);
const result = await Promise.race([
running,
new Promise<"still-waiting">((resolve) => setTimeout(() => resolve("still-waiting"), 450)),
]);
assert.ok(childId);
assert.equal(sftpTransferCenterStore.getTask(childId), undefined);
assert.equal(sftpTransferCenterStore.getTask(root.id)?.directoryResumeCheckpoint?.completedEntries, 1);
assert.notEqual(result, "still-waiting", "compacted completion must settle the live folder");
assert.equal(result, 0);
} finally {
cancelledTasksRef.current.add(root.id);
await running?.catch(() => {});
await act(async () => { renderer?.unmount(); });
sftpTransferCenterStore.patchTask(root.id, { status: "completed" });
sftpTransferCenterStore.dismiss(root.id);
(globalThis as { window?: unknown }).window = previousWindow;
if (previousLocalStorage) Object.defineProperty(globalThis, "localStorage", previousLocalStorage);
else Reflect.deleteProperty(globalThis, "localStorage");
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
}
});
+39 -31
View File
@@ -1,3 +1,5 @@
import { reconcileSupersededControls } from "./globalSftpTransferControl";
import { runTransferAndWaitForOwner } from "./waitForTransferOwner";
import { useCallback, type Dispatch, type MutableRefObject, type SetStateAction } from "react";
import type { Host, SftpFileEntry, SftpFilenameEncoding, TransferStatus, TransferTask } from "../../../domain/models";
import {
@@ -412,7 +414,11 @@ export function useSftpDirectoryTransferOps({
}
// Await the invoke result — cancel resolves with { error } and may
// not fire onComplete/onError after preload clears listeners.
const transferPromise = netcattyBridge.require().startStreamTransfer!(options);
const transferPromise = runTransferAndWaitForOwner(
task,
() => netcattyBridge.require().startStreamTransfer!(options),
() => isCancelledLocalOrGlobal(cancelledTasksRef, rootTaskId, task.id),
);
// Streams that arm after the parent pause round never receive the
// initial pauseTransfer. Keep pausing while the folder is latched.
// Capture epoch per attempt so Resume (epoch bump) undoes a late pause.
@@ -427,12 +433,33 @@ export function useSftpDirectoryTransferOps({
)
) {
const epochAtAttempt = getTransferControlEpoch(rootTaskId);
const childEpochAtAttempt = getTransferControlEpoch(task.id);
try {
const result = await netcattyBridge.get()?.pauseTransfer?.(task.id);
// Resume won while we were awaiting pause — undo.
if (
result?.superseded && result.supersededBy === "resume"
&& isTransferControlEpochCurrent(rootTaskId, epochAtAttempt)
&& getTransferControlEpoch(task.id) === childEpochAtAttempt
&& !isCancelledLocalOrGlobal(cancelledTasksRef, rootTaskId, task.id)
) {
const rootAlreadyRunning = transfersRef.current.find((row) => row.id === rootTaskId)?.status === "transferring";
reconcileSupersededControls({
getTasks: () => transfersRef.current,
setTasks: (next) => setTransfers(next),
getBridge: () => netcattyBridge.get(),
}, rootAlreadyRunning ? rootTaskId : task.id, rootAlreadyRunning ? [task.id] : [], [task.id], [result],
epochAtAttempt, "pause", rootTaskId);
if (rootAlreadyRunning) pausedTasksRef.current.delete(rootTaskId);
pausedTasksRef.current.delete(task.id);
break;
}
// Compensate only if the newest decision still wants this file running.
// A new pause or cancellation also changes the epoch.
if (
result?.success
&& !isTransferControlEpochCurrent(rootTaskId, epochAtAttempt)
&& !isPauseLatched(rootTaskId, task.id)
&& !isCancelledLocalOrGlobal(cancelledTasksRef, rootTaskId, task.id)
) {
try {
await netcattyBridge.get()?.resumeTransfer?.(task.id);
@@ -450,33 +477,6 @@ export function useSftpDirectoryTransferOps({
watchPaused = false;
await pauseWatch.catch(() => {});
}
// Same-id retry stole ownership while OPEN was pending. The live
// owner still drives progress/completion events; this invoke must
// not mark the child completed or failed (Codex P2 on b17f64e9).
if (result?.superseded === true) {
// Wait for the live same-id owner only — no fixed deadline so
// long transfers are not failed while still running (Codex P2).
for (;;) {
if (
cancelledTasksRef.current.has(task.id)
|| cancelledTasksRef.current.has(rootTaskId)
) {
throw new Error("Transfer cancelled");
}
const latest = sftpTransferCenterStore.getTask(task.id)
?? transfersRef.current.find((candidate) => candidate.id === task.id);
const status = latest?.status;
if (status === "completed") break;
if (status === "failed") {
throw new Error(latest?.error || "Transfer failed");
}
if (status === "cancelled") {
throw new Error("Transfer cancelled");
}
await new Promise((resolve) => setTimeout(resolve, 200));
}
break;
}
if (result?.error || result?.cancelled) {
throw new Error(result.error || "Transfer cancelled");
}
@@ -840,7 +840,9 @@ export function useSftpDirectoryTransferOps({
if (row.status === "paused" || row.status === "pausing" || isPauseLatched(rootTaskId)) {
return { ...row, speed: 0 };
}
return { ...row, transferredBytes: row.transferredBytes + 1 };
const completed = (row.directoryResumeCheckpoint?.completedEntries ?? 0)
+ base.filter((child) => child.parentTaskId === rootTaskId && child.status === "completed").length;
return { ...row, transferredBytes: completed };
});
});
return;
@@ -924,6 +926,12 @@ export function useSftpDirectoryTransferOps({
|| parentRow.status === "pausing"
|| isPauseLatched(rootTaskId)
);
// Background completion may have already compacted this child and
// advanced the checkpoint. Count retained + compacted completions
// instead of incrementing the same file again when invoke settles.
const completed = (parentRow?.directoryResumeCheckpoint?.completedEntries ?? 0)
+ prev.filter((row) => row.parentTaskId === rootTaskId
&& (row.status === "completed" || row.id === fileId)).length;
const updated = prev.map((t) => {
if (t.id === fileId) {
return { ...t, status: "completed" as TransferStatus, endTime: Date.now(), transferredBytes: t.totalBytes };
@@ -934,7 +942,7 @@ export function useSftpDirectoryTransferOps({
}
return {
...t,
transferredBytes: t.transferredBytes + 1,
transferredBytes: completed,
speed: t.speed,
};
}
+12 -1
View File
@@ -8,6 +8,9 @@
const pausedIds = new Set<string>();
const barriers = new Map<string, { promise: Promise<void>; resolve: () => void }>();
// A completed child's history row can disappear while its worker still waits
// for the folder to resume. Keep the paused tree independent of UI history.
const pausedChildren = new Map<string, Set<string>>();
function ensureBarrier(taskId: string): { promise: Promise<void>; resolve: () => void } {
let barrier = barriers.get(taskId);
@@ -45,11 +48,18 @@ export function releaseTransferPause(taskId: string): void {
}
export function releaseTransferPauseTree(rootTaskId: string, childIds: readonly string[] = []): void {
const children = new Set([...childIds, ...(pausedChildren.get(rootTaskId) ?? [])]);
pausedChildren.delete(rootTaskId);
releaseTransferPause(rootTaskId);
for (const id of childIds) releaseTransferPause(id);
for (const id of children) releaseTransferPause(id);
}
export function latchTransferPauseTree(rootTaskId: string, childIds: readonly string[] = []): void {
if (childIds.length > 0) {
const children = pausedChildren.get(rootTaskId) ?? new Set<string>();
for (const id of childIds) children.add(id);
pausedChildren.set(rootTaskId, children);
}
latchTransferPause(rootTaskId);
for (const id of childIds) latchTransferPause(id);
}
@@ -77,6 +87,7 @@ export function resetTransferPauseLatchesForTests(): void {
for (const id of ids) releaseTransferPause(id);
pausedIds.clear();
barriers.clear();
pausedChildren.clear();
}
export function listTransferPauseLatchesForTests(): string[] {
@@ -0,0 +1,171 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { TransferTask } from "../../../domain/models";
import { createSftpTransferCenterStore } from "../sftpTransferCenterStore";
function child(): TransferTask {
return {
id: "child", parentTaskId: "root", directoryEntryIndex: 0, directoryEntryIdentity: "a".repeat(64),
sourcePath: "/source/a", targetPath: "/target/a", fileName: "a",
sourceConnectionId: "local", targetConnectionId: "remote", direction: "upload",
totalBytes: 1, transferredBytes: 0, speed: 0, startTime: 0, isDirectory: false, status: "transferring",
};
}
for (const status of ["completed", "failed", "cancelled"] as const) {
test(`settlement observation retains exact ${status} before history pruning`, () => {
const store = createSftpTransferCenterStore();
const task = child();
store.upsertTasks([{ ...task, id: "root", parentTaskId: undefined, isDirectory: true }, task]);
const observation = store.observeTaskSettlement(task);
store.patchTask(task.id, { status, error: status === "failed" ? "write failed" : undefined });
assert.equal(observation.read()?.status, status);
if (status === "completed") assert.equal(store.getTask(task.id), undefined);
observation.dispose();
assert.equal(observation.read(), undefined);
});
}
test("another file reusing an id and index is not completion evidence", () => {
const store = createSftpTransferCenterStore();
const task = child();
store.upsertTasks([task]);
const observation = store.observeTaskSettlement(task);
store.upsertTasks([{ ...task, directoryEntryIdentity: "b".repeat(64), status: "completed" }]);
assert.equal(observation.read(), undefined);
observation.dispose();
});
test("a disposed observer receives no later completion", () => {
const store = createSftpTransferCenterStore();
const task = child();
store.upsertTasks([task]);
const observation = store.observeTaskSettlement(task);
observation.dispose();
store.patchTask(task.id, { status: "completed" });
assert.equal(observation.read(), undefined);
});
test("explicit dispatch refreshes failed identity without overwriting checkpoints", () => {
const store = createSftpTransferCenterStore();
const task = { ...child(), status: "failed" as const, checkpointBytes: 7 };
store.upsertTasks([task]);
const next = { ...task, directoryEntryIdentity: "b".repeat(64), checkpointBytes: 0 };
assert.equal(store.admitTaskRun(next), "ready");
assert.equal(store.getTask(task.id)?.status, "transferring");
assert.equal(store.getTask(task.id)?.directoryEntryIdentity, next.directoryEntryIdentity);
assert.equal(store.getTask(task.id)?.checkpointBytes, 7);
});
for (const status of ["cancelled", "completed", "paused", "pausing"] as const) {
test(`dispatch cannot revive a retained ${status} row`, () => {
const store = createSftpTransferCenterStore();
const task = child();
store.upsertTasks([
{ ...task, id: "root", parentTaskId: undefined },
{ ...task, status },
]);
assert.equal(store.admitTaskRun(task), status === "pausing" ? "paused" : status);
assert.equal(store.getTask(task.id)?.status, status);
});
}
test("dispatch preserves active lifecycle guards and avoids replacing unchanged rows", () => {
const store = createSftpTransferCenterStore();
const task = { ...child(), lifecycleEpoch: 9 };
store.upsertTasks([task]);
const before = store.getTask(task.id);
assert.equal(store.admitTaskRun(task), "ready");
assert.equal(store.getTask(task.id), before);
assert.equal(store.admitTaskRun({ ...task, directoryEntryIdentity: "b".repeat(64) }), "ready");
assert.equal(store.getTask(task.id)?.lifecycleEpoch, 9);
});
test("dispatch rejects a later pause or cancellation before changing identity", async (t) => {
const { latchTransferPause, resetTransferPauseLatchesForTests } = await import("./transferPauseLatch");
const { markTransferCancelledTree, settleTransferCancelTree } = await import("./transferCancelLatch");
const store = createSftpTransferCenterStore();
const task = { ...child(), status: "failed" as const };
store.upsertTasks([task]);
t.after(() => { resetTransferPauseLatchesForTests(); settleTransferCancelTree("root", [task.id]); });
latchTransferPause("root");
assert.equal(store.admitTaskRun(task), "paused");
resetTransferPauseLatchesForTests();
markTransferCancelledTree("root", [task.id]);
assert.equal(store.admitTaskRun(task), "cancelled");
assert.equal(store.getTask(task.id)?.status, "failed");
});
for (const nextStatus of ["transferring", "completed", "cancelled"] as const) {
test(`dispatch waits through a later pause until ${nextStatus}`, async (t) => {
const { sftpTransferCenterStore: store } = await import("../sftpTransferCenterStore");
const { runTransferAndWaitForOwner } = await import("./waitForTransferOwner");
const task = { ...child(), id: `paused-admission-${nextStatus}`, parentTaskId: undefined };
store.upsertTasks([{ ...task, status: "paused" }]);
let starts = 0;
let abort = false;
const running = runTransferAndWaitForOwner(task, async () => { starts += 1; return {}; }, () => abort);
// Attach rejection handling before the cancellation event is delivered.
const settled = running.then(() => "completed", (error: Error) => error.message);
t.after(async () => { abort = true; await settled; store.dismiss(task.id); });
await new Promise((resolve) => setTimeout(resolve, 30));
assert.equal(starts, 0);
assert.equal(store.getTask(task.id)?.status, "paused");
if (nextStatus !== "cancelled") store.patchTask(task.id, { status: "transferring", lifecycleEpoch: 1 });
if (nextStatus === "cancelled") {
const { markTransferCancelledTree, settleTransferCancelTree } = await import("./transferCancelLatch");
markTransferCancelledTree(task.id, []);
t.after(() => settleTransferCancelTree(task.id, []));
} else store.patchTask(task.id, { status: nextStatus });
const outcome = await Promise.race([settled, new Promise((resolve) => setTimeout(() => resolve("still-waiting"), 1000))]);
assert.equal(outcome, nextStatus === "cancelled" ? "Transfer cancelled" : "completed");
assert.equal(starts, nextStatus === "transferring" ? 1 : 0);
});
}
test("completed dispatch consumes only exact identity and never restarts", async (t) => {
const { sftpTransferCenterStore: store } = await import("../sftpTransferCenterStore");
const { runTransferAndWaitForOwner } = await import("./waitForTransferOwner");
const task = { ...child(), id: "completed-admission", parentTaskId: undefined };
store.upsertTasks([{ ...task, status: "completed" }]);
t.after(() => store.dismiss(task.id));
const start = async () => { assert.fail("completed transfer must not restart"); };
await runTransferAndWaitForOwner(task, start, () => false);
await assert.rejects(runTransferAndWaitForOwner({ ...task, directoryEntryIdentity: "b".repeat(64) }, start, () => false), /identity changed/);
assert.equal(store.getTask(task.id)?.status, "completed");
});
test("a resumed retry does not inherit failed settlement captured while admission was paused", async (t) => {
const { sftpTransferCenterStore: store } = await import("../sftpTransferCenterStore");
const { runTransferAndWaitForOwner } = await import("./waitForTransferOwner");
const { latchTransferPause, resetTransferPauseLatchesForTests } = await import("./transferPauseLatch");
const task = { ...child(), id: "failed-paused-admission", parentTaskId: undefined };
store.upsertTasks([{ ...task, status: "failed", error: "previous attempt failed" }]);
latchTransferPause(task.id);
let abort = false;
const running = runTransferAndWaitForOwner(task, async () => {
store.patchTask(task.id, { status: "completed" });
return { superseded: true };
}, () => abort);
const settled = running.then(() => "completed", (error: Error) => error.message);
t.after(async () => { abort = true; resetTransferPauseLatchesForTests(); await settled; store.dismiss(task.id); });
// An unrelated lifecycle publication captures the previous failed row while waiting.
store.upsertTasks([{ ...child(), id: "unrelated-admission", parentTaskId: undefined }]);
t.after(() => store.dismiss("unrelated-admission"));
resetTransferPauseLatchesForTests();
assert.equal(await Promise.race([settled, new Promise((resolve) => setTimeout(() => resolve("still-waiting"), 1000))]), "completed");
});
test("fresh directory recovery authorizes only an unchanged retained pause under an active parent", () => {
const store = createSftpTransferCenterStore();
const task = child();
store.upsertTasks([{ ...task, id: "root", parentTaskId: undefined, isDirectory: true, status: "pending" }, { ...task, status: "paused", lifecycleEpoch: 4 }]);
const paused = store.getTask(task.id)!;
assert.equal(store.admitTaskRun(task), "paused", "ordinary live dispatch must respect cross-window pause");
assert.equal(store.admitTaskRun(task, paused), "ready");
store.patchTask(task.id, { status: "paused", lifecycleEpoch: 5 });
assert.equal(store.admitTaskRun(task, paused), "paused", "newer child pause invalidates fresh recovery permission");
const currentPause = store.getTask(task.id)!;
store.patchTask("root", { status: "paused", lifecycleEpoch: 6 });
assert.equal(store.admitTaskRun(task, currentPause), "paused", "parent pause still wins");
});
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { FileConflict, FileConflictAction, Host, TransferStatus, SftpFilenameEncoding } from "../../../domain/models";
import { getSftpConflictTypeKey } from "../../../domain/sftpConflict";
import { resolveSftpTransferConcurrency } from "../../../domain/sftpTransferConcurrency";
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
import { logger } from "../../../lib/logger";
import { notify } from "../../notification";
@@ -105,6 +106,11 @@ import {
waitWhileTransferOrRootPaused,
} from "./transferPauseLatch";
const readSftpFileTransferConcurrency = (): number =>
resolveSftpTransferConcurrency(
() => localStorageAdapter.readNumber(STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY),
);
type UploadConflictResolver = {
resolve: (action: FileConflictAction) => void;
setDefault: (action: FileConflictAction) => void;
@@ -833,7 +839,10 @@ export const useSftpExternalOperations = (
// Create upload bridge that wraps netcattyBridge.
// Pass connect-time Host so pooled stream uploads open the pinned endpoint
// (session hostname/port/user overrides), not the vault entry by hostId alone.
const createUploadBridge = useCallback((connectHost?: Host): UploadBridge => {
const createUploadBridge = useCallback((
connectHost?: Host,
readTransferConcurrency = () => localStorageAdapter.readNumber(STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY),
): UploadBridge => {
const bridge = netcattyBridge.get();
return {
managesTransferLifecycle: Boolean(
@@ -896,7 +905,7 @@ export const useSftpExternalOperations = (
ownerId,
options.transferId,
getSftpTransferResourceKeys(options),
() => localStorageAdapter.readNumber(STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY),
readTransferConcurrency,
async () => {
let lease: { sftpId: string; release: () => void; discard: () => void } | null = null;
try {
@@ -1031,7 +1040,10 @@ export const useSftpExternalOperations = (
const uploadPaneId = livePane.id;
const liveTargetPath = targetPath || livePane.connection.currentPath;
const connectHost = resolveUploadConnectHost(uploadPaneId, livePane.connection.isLocal);
const uploadBridge = createUploadBridge(connectHost);
const uploadBridge = createUploadBridge(
connectHost,
readSftpFileTransferConcurrency,
);
const liveCallbacks = livePane.connection.id === pane.connection.id
&& liveTargetPath === uploadTargetPath
? callbacks
@@ -1068,6 +1080,7 @@ export const useSftpExternalOperations = (
targetPath: liveTargetPath,
sftpId,
targetHostId: livePane.connection.isLocal ? undefined : livePane.connection.hostId,
fileTransferConcurrency: readSftpFileTransferConcurrency(),
isLocal: livePane.connection.isLocal,
bridge: uploadBridge,
joinPath,
@@ -1246,6 +1259,7 @@ export const useSftpExternalOperations = (
targetPath: liveTargetPath,
sftpId: uploadSftpId,
targetHostId: livePane.connection!.isLocal ? undefined : livePane.connection!.hostId,
fileTransferConcurrency: readSftpFileTransferConcurrency(),
isLocal: livePane.connection!.isLocal,
bridge: uploadBridge,
joinPath,
@@ -1375,6 +1389,7 @@ export const useSftpExternalOperations = (
targetPath: uploadTargetPath,
sftpId: uploadSftpId,
targetHostId: livePane.connection!.isLocal ? undefined : livePane.connection!.hostId,
fileTransferConcurrency: readSftpFileTransferConcurrency(),
isLocal: livePane.connection!.isLocal,
bridge: uploadBridge,
joinPath,
@@ -1595,6 +1610,7 @@ export const useSftpExternalOperations = (
targetPath: uploadTargetPath,
sftpId: uploadSftpId,
targetHostId: livePane.connection!.isLocal ? undefined : livePane.connection!.hostId,
fileTransferConcurrency: readSftpFileTransferConcurrency(),
isLocal: livePane.connection!.isLocal,
bridge: uploadBridge,
joinPath,
@@ -1780,6 +1796,7 @@ export const useSftpExternalOperations = (
targetPath: uploadTargetPath,
sftpId: uploadSftpId,
targetHostId: livePane.connection!.isLocal ? undefined : livePane.connection!.hostId,
fileTransferConcurrency: readSftpFileTransferConcurrency(),
isLocal: livePane.connection!.isLocal,
bridge: directUploadBridge,
joinPath,
+100 -122
View File
@@ -37,10 +37,7 @@ import {
releaseTransferPauseTree,
waitUntilTransferPauseReleased,
} from "./transferPauseLatch";
import {
bumpTransferControlEpoch,
settleTransferControlEpochTree,
} from "./transferControlEpoch";
import { bumpTransferControlEpoch } from "./transferControlEpoch";
import { transferRuntime } from "./transferRuntime";
import {
clearTransferCancelled,
@@ -48,7 +45,6 @@ import {
isTransferCancelledFlag,
markTransferCancelled,
markTransferCancelledTree,
settleTransferCancelTree,
} from "./transferCancelLatch";
import type { TransferResult, UseSftpTransfersParams, UseSftpTransfersResult } from "./useSftpTransfers.types";
import type { TransferConnectionLease } from "./transferConnectionPool";
@@ -1809,127 +1805,109 @@ export const useSftpTransfers = ({
return "attention";
}
const sourceEncoding = params.sourceEncoding ?? "auto";
// Mutable counter to track child failures outside React state,
// so the final status check doesn't depend on render timing.
let childFailureCount = 0;
const patchDownload = (updates: Partial<TransferTask>) => {
transferRuntime.patchTask(task.id, updates);
const canonical = transferRuntime.getTask(task.id);
if (canonical) setTransfers((prev) => prev.map((row) => row.id === task.id ? canonical : row));
};
const executeDownload = async (): Promise<TransferStatus> => {
const sourceEncoding = params.sourceEncoding ?? "auto";
// Mutable counter to track child failures outside React state,
// so the final status check doesn't depend on render timing.
let childFailureCount = 0;
// Dedicated pool only when host id is known — never fall back to browse
// (tab close would kill the download and freeze the global center).
let sourceWorkLease: TransferConnectionLease | null = null;
let workingSourceSftpId = params.sftpId;
if (acquireTransferSession && params.sourceHostId) {
sourceWorkLease = await acquireTransferSession(
params.sourceHostId,
`${task.id}:work-source`,
);
workingSourceSftpId = sourceWorkLease.sftpId;
}
// Dedicated pool only when host id is known — never fall back to browse
// (tab close would kill the download and freeze the global center).
let sourceWorkLease: TransferConnectionLease | null = null;
let workingSourceSftpId = params.sftpId;
try {
if (acquireTransferSession && params.sourceHostId) {
sourceWorkLease = await acquireTransferSession(
params.sourceHostId,
`${task.id}:work-source`,
);
workingSourceSftpId = sourceWorkLease.sftpId;
}
await waitUntilTransferResumed(task.id);
if (cancelledTasksRef.current.has(task.id)) throw new Error("Transfer cancelled");
if (params.isDirectory) patchDownload({ status: "transferring", error: undefined });
try {
if (params.isDirectory) {
childFailureCount = await transferDirectory(
task,
workingSourceSftpId,
null, // targetSftpId = null (local)
false, // sourceIsLocal = false
true, // targetIsLocal = true
sourceEncoding,
"auto", // targetEncoding
task.id,
false, // sameHost
0, // symlinkDepth
true, // followSymlinks — download should expand symlink dirs
);
} else {
await transferFile(
task,
workingSourceSftpId,
null,
false,
true,
sourceEncoding,
"auto",
task.id,
);
}
if (params.isDirectory) {
childFailureCount = await transferDirectory(
task,
workingSourceSftpId,
null, // targetSftpId = null (local)
false, // sourceIsLocal = false
true, // targetIsLocal = true
sourceEncoding,
"auto", // targetEncoding
task.id,
false, // sameHost
0, // symlinkDepth
true, // followSymlinks — download should expand symlink dirs
);
} else {
await transferFile(
task,
workingSourceSftpId,
null,
false,
true,
sourceEncoding,
"auto",
task.id,
);
}
// Use childFailureCount (tracked outside React state) to determine
// final status reliably, regardless of render timing.
// Cancel must win: transferDirectory counts cancelled children as errors,
// but cancelTransfer already marked the parent cancelled — do not demote
// it to failed with "Some files failed to transfer".
// Re-read cancel inside the state update so a cancel that lands after
// transferDirectory returns cannot be overwritten by completed/failed.
let appliedStatus: TransferStatus = "completed";
setTransfers((prev) => {
const liveParent = prev.find((candidate) => candidate.id === task.id);
const completedCount = (liveParent?.directoryResumeCheckpoint?.completedEntries ?? 0) + prev.filter(
(t) => t.parentTaskId === task.id && t.status === "completed",
).length;
return prev.map((t) => {
if (t.id !== task.id) return t;
const parentCancelled = t.status === "cancelled"
|| cancelledTasksRef.current.has(task.id);
const resolved = resolveDirectDirectoryDownloadFinalStatus({
parentCancelled,
childFailureCount,
});
appliedStatus = resolved.status;
if (resolved.status === "cancelled") {
cancelledTasksRef.current.delete(task.id);
return {
...t,
status: "cancelled" as TransferStatus,
error: undefined,
endTime: Date.now(),
// Keep partial progress — do not look 100% complete when cancelled.
speed: 0,
};
}
const finalTotal = t.totalBytes > 0 ? t.totalBytes : completedCount;
const hasFailures = resolved.status === "failed";
return {
...t,
status: resolved.status,
error: resolved.error,
endTime: Date.now(),
totalBytes: finalTotal,
transferredBytes: hasFailures ? completedCount : finalTotal,
speed: 0,
};
// Runtime owns lifecycle while the walk is registered. Publish the
// final root there before mirroring it to the panel.
const rows = sftpTransferCenterStore.getSnapshot().tasks;
const liveParent = transferRuntime.getTask(task.id) ?? task;
const completedCount = (liveParent.directoryResumeCheckpoint?.completedEntries ?? 0)
+ rows.filter((row) => row.parentTaskId === task.id && row.status === "completed").length;
const resolved = resolveDirectDirectoryDownloadFinalStatus({
parentCancelled: liveParent.status === "cancelled" || cancelledTasksRef.current.has(task.id),
childFailureCount,
});
});
activeChildIdsRef.current.delete(task.id);
return appliedStatus;
} catch (err) {
activeChildIdsRef.current.delete(task.id);
const isCancelled = cancelledTasksRef.current.has(task.id);
// Clean up cancelled task tracking to prevent memory leak
if (isCancelled) cancelledTasksRef.current.delete(task.id);
const errMsg = err instanceof Error ? err.message : String(err);
setTransfers((prev) =>
prev.map((t) =>
t.id === task.id
? {
...t,
status: isCancelled ? ("cancelled" as TransferStatus) : ("failed" as TransferStatus),
error: isCancelled ? undefined : errMsg,
endTime: Date.now(),
}
: t,
),
);
return isCancelled ? "cancelled" : "failed";
} finally {
sourceWorkLease?.release();
sourceWorkLease = null;
const childIds = sftpTransferCenterStore.getSnapshot().tasks
.filter((candidate) => candidate.parentTaskId === task.id)
.map((candidate) => candidate.id);
const relatedChildIds = settleTransferCancelTree(task.id, childIds);
settleTransferControlEpochTree(task.id, relatedChildIds);
}
const finalTotal = liveParent.totalBytes > 0 ? liveParent.totalBytes : completedCount;
patchDownload({
status: resolved.status,
error: resolved.error,
endTime: Date.now(),
speed: 0,
...(resolved.status === "cancelled" ? {} : {
totalBytes: finalTotal,
transferredBytes: resolved.status === "failed" ? completedCount : finalTotal,
}),
});
const appliedStatus = transferRuntime.getTask(task.id)?.status ?? resolved.status;
activeChildIdsRef.current.delete(task.id);
return appliedStatus;
} catch (err) {
activeChildIdsRef.current.delete(task.id);
const isCancelled = cancelledTasksRef.current.has(task.id);
// Clean up cancelled task tracking to prevent memory leak
if (isCancelled) cancelledTasksRef.current.delete(task.id);
const errMsg = err instanceof Error ? err.message : String(err);
patchDownload({
status: isCancelled ? "cancelled" : "failed",
error: isCancelled ? undefined : errMsg,
endTime: Date.now(),
speed: 0,
});
return isCancelled ? "cancelled" : "failed";
} finally {
sourceWorkLease?.release();
sourceWorkLease = null;
}
};
let result: TransferStatus = "failed";
await runTrackedTransferAttempt(inFlightTransferIdsRef.current, task.id, () =>
transferRuntime.runWalk(task.id, async () => {
result = await executeDownload();
}),
);
return result;
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[sftpSessionsRef, acquireTransferSession],
@@ -0,0 +1,41 @@
import type { TransferTask } from "../../../domain/models";
import { sftpTransferCenterStore } from "../sftpTransferCenterStore";
type StreamResult = { error?: string; cancelled?: boolean; superseded?: boolean } | undefined;
/** A live waiter owns bounded settlement evidence; persisted history stays compact. */
export async function runTransferAndWaitForOwner(
task: TransferTask,
start: () => Promise<StreamResult>,
shouldAbort: () => boolean,
pausedAtResume?: TransferTask,
): Promise<StreamResult> {
// Register before admission/start: an owner may finish while dispatch waits for resume.
let observation = sftpTransferCenterStore.observeTaskSettlement(task);
try {
for (;;) {
if (shouldAbort()) throw new Error("Transfer cancelled");
const admission = sftpTransferCenterStore.admitTaskRun(task, pausedAtResume);
if (admission === "cancelled") throw new Error("Transfer cancelled");
if (admission === "completed" || observation.read()?.status === "completed") return {};
if (admission === "conflict") throw new Error("Transfer identity changed before dispatch");
if (admission === "ready") break;
await new Promise((resolve) => setTimeout(resolve, 200));
}
// Admission begins a new attempt; discard any previous failed settlement.
observation.dispose();
observation = sftpTransferCenterStore.observeTaskSettlement(task);
const result = await start();
if (!result?.superseded) return result;
for (;;) {
if (shouldAbort()) throw new Error("Transfer cancelled");
const latest = observation.read();
if (latest?.status === "completed") return { ...result, superseded: false };
if (latest?.status === "failed") throw new Error(latest.error || "Transfer failed");
if (latest?.status === "cancelled") throw new Error("Transfer cancelled");
await new Promise((resolve) => setTimeout(resolve, 200));
}
} finally {
observation.dispose();
}
}
@@ -3762,3 +3762,108 @@ test("hard directory resume failure falls back to bounded fresh retry history",
assert.equal(retainedParent?.checkpointBytes, 0);
assert.ok(snapshot.length <= 200, `failed directory history must stay bounded, got ${snapshot.length}`);
});
test("held dedicated file resume cannot overwrite a subsequent pause", async (t) => {
const store = createSftpTransferCenterStore();
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
let releaseRun!: () => void;
let started!: () => void;
let releaseResume!: (result: { success: boolean; lifecycleEpoch: number }) => void;
const runGate = new Promise<void>((resolve) => { releaseRun = resolve; });
const startGate = new Promise<void>((resolve) => { started = resolve; });
const resumeGate = new Promise<{ success: boolean; lifecycleEpoch: number }>((resolve) => { releaseResume = resolve; });
t.after(async () => {
releaseRun();
if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow);
else Reflect.deleteProperty(globalThis, "window");
const { resetTransferPauseLatchesForTests } = await import("./sftp/transferPauseLatch");
resetTransferPauseLatchesForTests();
});
Object.defineProperty(globalThis, "window", { configurable: true, value: { netcatty: {
pauseTransfer: async () => ({ success: true, checkpointBytes: 2, lifecycleEpoch: 3 }),
resumeTransfer: () => resumeGate,
} } });
store.publishOwner("dedicated-resume", [{
...makeTask("held-file", "interrupted"),
ownerId: "dedicated-resume", targetHostId: "host-a", reconnectRequired: true,
}]);
store.setDedicatedResumeHandler(async () => {
store.patchTask("held-file", { status: "transferring", reconnectRequired: false });
started();
await runGate;
return { success: true };
});
const first = store.resume("held-file");
await startGate;
await store.pause("held-file");
const second = store.resume("held-file");
await store.pause("held-file");
releaseResume({ success: true, lifecycleEpoch: 2 });
await new Promise((resolve) => setImmediate(resolve));
const row = store.getSnapshot().tasks.find((task) => task.id === "held-file");
releaseRun();
await Promise.all([first, second]);
assert.equal(row?.status, "paused", "held transfer must respect the latest pause");
assert.equal(row?.lifecycleEpoch, 3);
});
for (const outcome of ["rejected", "stream-gone"] as const) {
test(`held file ${outcome} resume cannot restart after a newer pause during wind-down`, async (t) => {
const store = createSftpTransferCenterStore();
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
let releaseRun!: () => void;
let started!: () => void;
let resolveResume!: (result: { success: boolean; reason: string }) => void;
let rejectResume!: (error: Error) => void;
let resumeCalls = 0;
const runGate = new Promise<void>((resolve) => { releaseRun = resolve; });
const startGate = new Promise<void>((resolve) => { started = resolve; });
const resumeGate = new Promise<{ success: boolean; reason: string }>((resolve, reject) => {
resolveResume = resolve;
rejectResume = reject;
});
t.after(async () => {
releaseRun();
if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow);
else Reflect.deleteProperty(globalThis, "window");
const { resetTransferPauseLatchesForTests } = await import("./sftp/transferPauseLatch");
resetTransferPauseLatchesForTests();
});
Object.defineProperty(globalThis, "window", { configurable: true, value: { netcatty: {
pauseTransfer: async () => ({ success: true, checkpointBytes: 2, lifecycleEpoch: 3 }),
resumeTransfer: () => ++resumeCalls === 1
? resumeGate
: Promise.resolve({ success: true, lifecycleEpoch: 4 }),
} } });
const id = `held-file-${outcome}`;
store.publishOwner("dedicated-resume", [{
...makeTask(id, "interrupted"),
ownerId: "dedicated-resume", targetHostId: "host-a", reconnectRequired: true,
}]);
store.setDedicatedResumeHandler(async () => {
store.patchTask(id, { status: "transferring", reconnectRequired: false });
started();
await runGate;
return { success: false, error: "Transfer cancelled" };
});
const first = store.resume(id);
await startGate;
await store.pause(id);
const second = store.resume(id);
await new Promise((resolve) => setImmediate(resolve));
if (outcome === "stream-gone") {
resolveResume({ success: false, reason: "Transfer is no longer active" });
await new Promise((resolve) => setImmediate(resolve));
}
await store.pause(id);
if (outcome === "rejected") {
rejectResume(new Error("worker channel unavailable"));
await new Promise((resolve) => setImmediate(resolve));
}
releaseRun();
await Promise.all([first, second]);
assert.equal(store.getSnapshot().tasks.find((task) => task.id === id)?.status, "paused");
assert.equal(resumeCalls, 1, "obsolete resume must not issue another resume after the held run ends");
});
}
+227 -104
View File
@@ -28,6 +28,7 @@ import {
} from "./sftp/transferControlEpoch";
import { isTransferWalkInFlight } from "./sftp/transferWalkRegistry";
import {
isTransferOrRootCancelled,
markTransferCancelledTree,
settleTransferCancelTree,
} from "./sftp/transferCancelLatch";
@@ -96,6 +97,10 @@ export interface SftpTransferCenterStore {
getBadgeSnapshot(): { count: number; hasAttention: boolean };
/** Single task row — same object identity until that row is patched. */
getTask(taskId: string): TransferTask | undefined;
/** Observe exact task settlement before completed child rows are compacted. */
observeTaskSettlement(task: TransferTask): { read(): TransferTask | undefined; dispose(): void };
/** Publish an explicit dispatch identity without flushing large child-history batches. */
admitTaskRun(task: TransferTask, pausedAtResume?: TransferTask): "ready" | "paused" | "completed" | "cancelled" | "conflict";
getOwnerTasks(ownerId: string): TransferTask[];
publishOwner(ownerId: string, tasks: readonly TransferTask[]): void;
registerOwner(ownerId: string, controls: SftpTransferOwnerControls): () => void;
@@ -104,6 +109,8 @@ export interface SftpTransferCenterStore {
/** Insert or merge tasks by id (used by dedicated directory resume for children). */
upsertTasks(incoming: readonly TransferTask[]): void;
canControl(taskId: string): boolean;
subscribeResume(listener: Listener): () => void;
isResuming(taskId: string): boolean;
pause(taskId: string): Promise<void>;
resume(taskId: string): Promise<void>;
cancel(taskId: string): Promise<void>;
@@ -398,6 +405,15 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
let snapshotDirty = false;
const listeners = new Set<Listener>();
const progressListeners = new Set<Listener>();
const settlementObservers = new Map<string, Set<{ expected: TransferTask; settled?: TransferTask }>>();
const matchesObservedTask = (expected: TransferTask, candidate: TransferTask) => (
expected.id === candidate.id
&& expected.sourcePath === candidate.sourcePath
&& expected.targetPath === candidate.targetPath
&& expected.parentTaskId === candidate.parentTaskId
&& expected.directoryEntryIndex === candidate.directoryEntryIndex
&& expected.directoryEntryIdentity === candidate.directoryEntryIdentity
);
const refreshBadgeSnapshot = () => {
const next = buildBadgeSnapshot(tasks);
if (
@@ -417,6 +433,13 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
let persistenceDirty = false;
let persistenceTimer: ReturnType<typeof setTimeout> | null = null;
const resumeInvocations = 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>();
const notifyResume = () => resumeListeners.forEach((listener) => listener());
const clearResumeRequest = (taskId: string) => {
if (resumeRequests.delete(taskId)) notifyResume();
};
const resumePreparationFailures = new Map<string, string>();
let dedicatedResumeHandler: DedicatedTransferResumeHandler | null = null;
const dedicatedResumeWaiters = new Set<{
@@ -609,6 +632,14 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
// live renderer walk keeps its controls until TransferRuntime.runWalk
// settles; everything else can release its whole tree here.
settleFinishedTransferControlState(tasks, tasks);
if (settlementObservers.size > 0) {
for (const task of tasks) {
if (!TERMINAL_OWNER_STATUSES.has(task.status)) continue;
for (const observer of settlementObservers.get(task.id) ?? []) {
if (!observer.settled && matchesObservedTask(observer.expected, task)) observer.settled = task;
}
}
}
const beforePrune = tasks;
tasks = pruneSftpTransferHistory(tasks);
const retainedIds = new Set(tasks.map((task) => task.id));
@@ -1307,6 +1338,70 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
},
getSnapshot: () => ensureSnapshot(),
getBadgeSnapshot: () => badgeSnapshot,
admitTaskRun(incoming, pausedAtResume) {
const rootId = incoming.parentTaskId ?? incoming.id;
if (isTransferOrRootCancelled(rootId, incoming.id)) return "cancelled";
const parent = incoming.parentTaskId ? tasks.find((task) => task.id === rootId) : undefined;
if (parent?.status === "cancelled") return "cancelled";
const index = tasks.findIndex((task) => task.id === incoming.id);
const existing = index < 0 ? undefined : tasks[index];
if (existing && (existing.sourcePath !== incoming.sourcePath || existing.targetPath !== incoming.targetPath
|| existing.parentTaskId !== incoming.parentTaskId)) return "conflict";
if (existing?.status === "cancelled") return "cancelled";
if (existing?.status === "completed") {
return matchesObservedTask(incoming, existing) ? "completed" : "conflict";
}
const resumesUnchangedPause = existing === pausedAtResume
&& existing?.status === "paused"
&& (parent?.status === "pending" || parent?.status === "transferring");
if (isTransferOrRootPauseLatched(rootId, incoming.id)
|| parent?.status === "paused" || parent?.status === "pausing"
|| (existing?.status === "paused" && !resumesUnchangedPause) || existing?.status === "pausing") return "paused";
if (parent?.status === "completed") return "conflict";
if (!existing) return "ready";
if (existing.status === "transferring"
&& existing.directoryEntryIndex === incoming.directoryEntryIndex
&& existing.directoryEntryIdentity === incoming.directoryEntryIdentity) return "ready";
// Explicit retry may leave failed/interrupted and replace its manifest identity.
// Do not overwrite byte checkpoints with a delayed renderer snapshot.
const next = tasks.slice();
next[index] = {
...existing,
directoryEntryIndex: incoming.directoryEntryIndex,
directoryEntryIdentity: incoming.directoryEntryIdentity,
...(existing.status === "transferring" ? {} : {
status: "transferring" as const,
lifecycleEpoch: undefined,
error: undefined,
endTime: undefined,
reconnectRequired: false,
}),
};
tasks = next;
emitProgress(false);
return "ready";
},
observeTaskSettlement(expected) {
const observer: { expected: TransferTask; settled?: TransferTask } = { expected: { ...expected } };
const observers = settlementObservers.get(expected.id) ?? new Set();
observers.add(observer);
settlementObservers.set(expected.id, observers);
let disposed = false;
return {
read() {
if (disposed) return undefined;
if (observer.settled) return observer.settled;
const current = tasks.find((task) => task.id === expected.id);
return current && matchesObservedTask(observer.expected, current) ? current : undefined;
},
dispose() {
disposed = true;
observer.settled = undefined;
observers.delete(observer);
if (observers.size === 0) settlementObservers.delete(expected.id);
},
};
},
getTask(taskId) {
return tasks.find((task) => task.id === taskId);
},
@@ -1639,125 +1734,140 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
controls.adopt && controls.canAdopt?.(task)
)));
},
pause: (taskId) => invoke(taskId, "pause"),
subscribeResume(listener) {
resumeListeners.add(listener);
return () => { resumeListeners.delete(listener); };
},
isResuming(taskId) {
if (!resumeRequests.has(taskId)) return false;
const task = tasks.find((candidate) => candidate.id === taskId);
return !!task && !task.error
&& ["paused", "interrupted", "attention", "pending", "queued"].includes(task.status);
},
pause(taskId) {
clearResumeRequest(taskId);
return invoke(taskId, "pause");
},
async resume(taskId) {
const startFresh = () => {
const running = invoke(taskId, "resume").finally(() => {
if (resumeInvocations.get(taskId) === running) resumeInvocations.delete(taskId);
});
resumeInvocations.set(taskId, running);
return running;
};
const request = Symbol("resume");
resumeRequests.set(taskId, request);
notifyResume();
const runResume = async () => {
const startFresh = () => {
const running = invoke(taskId, "resume").finally(() => {
if (resumeInvocations.get(taskId) === running) resumeInvocations.delete(taskId);
});
resumeInvocations.set(taskId, running);
return running;
};
const existing = resumeInvocations.get(taskId);
if (existing) {
// Dedicated resume holds the invocation for the full stream lifetime.
const task = tasks.find((candidate) => candidate.id === taskId);
if (task?.status === "cancelled") return existing;
const existing = resumeInvocations.get(taskId);
if (existing) {
// Dedicated resume holds the invocation for the full stream lifetime.
const task = tasks.find((candidate) => candidate.id === taskId);
if (task?.status === "cancelled") return existing;
// Soft-unpause live backend streams when still paused under a held run.
// Dedicated *directory* walks treat status===paused as shouldAbort between
// files, so soft-rejoin is unsafe (dying promise + false transferring).
// Wind down soft-paused children then startFresh from checkpoints.
// Single-file dedicated (and non-directory live streams) soft-unpause.
if (task && (task.status === "paused" || task.status === "pausing")) {
const childIds = tasks
.filter((candidate) => candidate.parentTaskId === taskId
&& (candidate.status === "paused"
|| candidate.status === "pausing"
|| candidate.status === "transferring"))
.map((candidate) => candidate.id);
// Soft-unpause live backend streams when still paused under a held run.
// Dedicated *directory* walks treat status===paused as shouldAbort between
// files, so soft-rejoin is unsafe (dying promise + false transferring).
// Wind down soft-paused children then startFresh from checkpoints.
// Single-file dedicated (and non-directory live streams) soft-unpause.
if (task && (task.status === "paused" || task.status === "pausing")) {
const childIds = tasks
.filter((candidate) => candidate.parentTaskId === taskId
&& (candidate.status === "paused"
|| candidate.status === "pausing"
|| candidate.status === "transferring"))
.map((candidate) => candidate.id);
// Always clear process-global latches + control epoch so walks wake and
// late soft-drain / pauseWatch cannot re-pause streams. Do not stamp
// control epoch as task.lifecycleEpoch (bridge-aligned only).
bumpTransferControlEpoch(taskId);
releaseTransferPauseTree(taskId, childIds);
// Always clear process-global latches + control epoch so walks wake and
// late soft-drain / pauseWatch cannot re-pause streams. Do not stamp
// control epoch as task.lifecycleEpoch (bridge-aligned only).
let resumeEpoch = bumpTransferControlEpoch(taskId);
releaseTransferPauseTree(taskId, childIds);
if (task.ownerId === "dedicated-resume" && task.isDirectory) {
const bridge = netcattyBridge.get();
// Cancel soft-paused child streams so the held walk settles. When a
// child is not in activeTransfers, cancelTransfer leaves a sticky
// pendingCancel latch — clear it before startFresh reuses the same
// child transferIds (otherwise startStreamTransfer aborts immediately).
for (const id of childIds) {
try { await bridge?.cancelTransfer?.(id); } catch { /* best-effort wind-down */ }
try { await bridge?.clearPendingTransferCancel?.(id); } catch { /* best-effort */ }
if (task.ownerId === "dedicated-resume" && task.isDirectory) {
const bridge = netcattyBridge.get();
// Cancel soft-paused child streams so the held walk settles. When a
// child is not in activeTransfers, cancelTransfer leaves a sticky
// pendingCancel latch — clear it before startFresh reuses the same
// child transferIds (otherwise startStreamTransfer aborts immediately).
for (const id of childIds) {
try { await bridge?.cancelTransfer?.(id); } catch { /* best-effort wind-down */ }
try { await bridge?.clearPendingTransferCancel?.(id); } catch { /* best-effort */ }
}
try { await bridge?.clearPendingTransferCancel?.(taskId); } catch { /* best-effort */ }
try {
await existing;
} catch { /* previous aborted / cancelled */ }
// Clear again after wind-down in case cancel raced during await.
for (const id of childIds) {
try { await bridge?.clearPendingTransferCancel?.(id); } catch { /* best-effort */ }
}
try { await bridge?.clearPendingTransferCancel?.(taskId); } catch { /* best-effort */ }
if (!isTransferControlEpochCurrent(taskId, resumeEpoch)) return existing;
return resumeInvocations.get(taskId) ?? startFresh();
}
try { await bridge?.clearPendingTransferCancel?.(taskId); } catch { /* best-effort */ }
try {
// Reuse the live control path: held dedicated transfers must obey
// the same ordering and per-child lifecycle rules as panel jobs.
const softOperation = softResumeTransfer({
getTasks: () => tasks,
setTasks: (next) => { tasks = next; emit(); },
getBridge: defaultTransferControlBridge,
}, taskId);
resumeEpoch = getTransferControlEpoch(taskId);
const soft = await softOperation;
if (soft.handled) return existing;
const streamGone = /no longer active|not active|not found|session is no longer|Resume unavailable|Transfer not found/i
.test(soft.reason ?? "");
if (!streamGone) {
tasks = tasks.map((candidate) => candidate.id === taskId ? {
...candidate,
status: "paused" as const,
speed: 0,
phase: undefined,
error: soft.reason,
} : candidate);
emit();
return existing;
}
} catch {
// Fall through to await + restart.
}
if (!isTransferControlEpochCurrent(taskId, resumeEpoch)) return existing;
try {
await existing;
} catch { /* previous aborted / cancelled */ }
// Clear again after wind-down in case cancel raced during await.
for (const id of childIds) {
try { await bridge?.clearPendingTransferCancel?.(id); } catch { /* best-effort */ }
}
try { await bridge?.clearPendingTransferCancel?.(taskId); } catch { /* best-effort */ }
} catch { /* previous aborted */ }
if (!isTransferControlEpochCurrent(taskId, resumeEpoch)) return existing;
return resumeInvocations.get(taskId) ?? startFresh();
}
try {
const resumeIds = [taskId, ...childIds.filter((id) => id !== taskId)];
const results = await Promise.all(resumeIds.map(async (id) =>
netcattyBridge.get()?.resumeTransfer?.(id) ?? { success: false },
));
const after = tasks.find((candidate) => candidate.id === taskId);
if (after?.status === "cancelled") return existing;
// Only rejoin when at least one backend stream actually resumed.
// Empty/all-fail must not paint transferring over a dead held run.
const successIds = resumeIds.filter((_, index) => results[index]?.success);
if (successIds.length > 0) {
const resumed = new Set(successIds);
// Align with softResumeTransfer: prefer bridge lifecycleEpoch; clear
// if omitted so main-process progress is not stale-dropped.
let bridgeEpoch: number | undefined;
for (let index = 0; index < results.length; index += 1) {
if (!results[index]?.success) continue;
const epoch = (results[index] as { lifecycleEpoch?: number } | undefined)?.lifecycleEpoch;
if (!Number.isFinite(epoch)) continue;
bridgeEpoch = bridgeEpoch === undefined
? (epoch as number)
: Math.max(bridgeEpoch, epoch as number);
}
tasks = tasks.map((candidate) => {
if (candidate.id === taskId || resumed.has(candidate.id)) {
return {
...candidate,
status: "transferring" as const,
error: undefined,
reconnectRequired: false,
pauseUnavailableReason: undefined,
phase: undefined,
lifecycleEpoch: bridgeEpoch,
};
}
return candidate;
});
emit();
return existing;
}
} catch {
// Fall through to await + restart.
// After demotion to interrupted/attention/failed while work unwinds:
// wait then re-invoke (do not rejoin a dying canceling promise).
if (task && (task.status === "interrupted" || task.status === "attention" || task.status === "failed")) {
const resumeEpoch = getTransferControlEpoch(taskId);
try {
await existing;
} catch { /* previous aborted */ }
if (getTransferControlEpoch(taskId) !== resumeEpoch) return existing;
return resumeInvocations.get(taskId) ?? startFresh();
}
try {
await existing;
} catch { /* previous aborted */ }
return resumeInvocations.get(taskId) ?? startFresh();
return existing;
}
// After demotion to interrupted/attention/failed while work unwinds:
// wait then re-invoke (do not rejoin a dying canceling promise).
if (task && (task.status === "interrupted" || task.status === "attention" || task.status === "failed")) {
try {
await existing;
} catch { /* previous aborted */ }
return resumeInvocations.get(taskId) ?? startFresh();
}
return existing;
return startFresh();
};
try {
return await runResume();
} finally {
if (resumeRequests.get(taskId) === request) clearResumeRequest(taskId);
}
return startFresh();
},
cancel: (taskId) => invoke(taskId, "cancel"),
cancel(taskId) {
clearResumeRequest(taskId);
return invoke(taskId, "cancel");
},
async retry(taskId) {
const ownerId = findOwner(taskId);
const task = tasks.find((candidate) => candidate.id === taskId);
@@ -2355,3 +2465,16 @@ export function useSftpTransferTask(taskId: string, fallback: TransferTask): Tra
() => fallbackRef.current,
);
}
/** Shared control feedback for every transfer surface, including newly mounted rows. */
export function useSftpTransferResuming(taskId: string): boolean {
return useSyncExternalStore(
(listener) => {
const unsubscribeTask = sftpTransferCenterStore.subscribe(listener);
const unsubscribeResume = sftpTransferCenterStore.subscribeResume(listener);
return () => { unsubscribeTask(); unsubscribeResume(); };
},
() => sftpTransferCenterStore.isResuming(taskId),
() => false,
);
}
@@ -0,0 +1,46 @@
import assert from "node:assert/strict";
import test from "node:test";
import { createSftpTransferCenterStore } from "./sftpTransferCenterStore";
for (const outcome of ["success", "failure", "pause", "cancel"] as const) {
test(`resume feedback clears after ${outcome} without changing the paused checkpoint`, async (t) => {
const previous = Object.getOwnPropertyDescriptor(globalThis, "window");
t.after(() => {
if (previous) Object.defineProperty(globalThis, "window", previous);
else Reflect.deleteProperty(globalThis, "window");
});
let settle!: (value: { success: boolean; reason?: string }) => void;
let started!: () => void;
const entered = new Promise<void>((resolve) => { started = resolve; });
const gate = new Promise<{ success: boolean; reason?: string }>((resolve) => { settle = resolve; });
Object.defineProperty(globalThis, "window", { configurable: true, value: { netcatty: {
resumeTransfer: () => { started(); return gate; },
pauseTransfer: async () => ({ success: true, checkpointBytes: 100 }),
cancelTransfer: async () => ({ success: true }),
} } });
const store = createSftpTransferCenterStore();
store.publishOwner("feedback", [{
id: "feedback", fileName: "file.bin", sourcePath: "/source.bin", targetPath: "/target.bin",
sourceConnectionId: "remote", targetConnectionId: "local", direction: "download",
status: "paused", totalBytes: 1000, transferredBytes: 100, checkpointBytes: 100,
speed: 0, startTime: 1, isDirectory: false, resumable: true,
}]);
let notifications = 0;
const unsubscribe = store.subscribeResume(() => { notifications += 1; });
t.after(unsubscribe);
const running = store.resume("feedback");
assert.equal(store.isResuming("feedback"), true);
assert.equal(store.getTask("feedback")?.status, "paused");
assert.equal(store.getTask("feedback")?.checkpointBytes, 100);
await entered;
const followup = outcome === "pause" ? store.pause("feedback")
: outcome === "cancel" ? store.cancel("feedback") : Promise.resolve();
if (outcome === "pause" || outcome === "cancel") assert.equal(store.isResuming("feedback"), false);
settle(outcome === "failure" ? { success: false, reason: "Resume safety check failed" } : { success: true });
await Promise.all([running, followup]);
assert.equal(store.isResuming("feedback"), false);
assert.ok(notifications >= 2);
if (outcome === "pause") assert.equal(store.getTask("feedback")?.status, "paused");
if (outcome === "cancel") assert.equal(store.getTask("feedback")?.status, "cancelled");
});
}
@@ -0,0 +1,79 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
import vm from 'node:vm';
import React, { useCallback, useRef, useSyncExternalStore } from 'react';
import { act, create } from 'react-test-renderer';
import ts from 'typescript';
import { getSidePanelLiveSnapshot, subscribeSidePanelLiveSnapshot, sidePanelLiveStore, SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT, type SidePanelLiveSnapshot } from './sidePanelLiveStore';
// Exercise the production hook without importing the unrelated panel UI modules.
const source = readFileSync(new URL('../../components/terminalLayer/terminalLayerSidePanelSlots.tsx', import.meta.url), 'utf8');
const ast = ts.createSourceFile('slots.tsx', source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
const hook = ast.statements.find((node) => ts.isFunctionDeclaration(node) && node.name?.text === 'useSidePanelLiveSnapshotForTab');
assert.ok(hook);
const code = ts.transpile(hook.getText(ast), { target: ts.ScriptTarget.ES2022 });
const useSnapshot = vm.runInNewContext(`${code}; useSidePanelLiveSnapshotForTab`, {
useCallback, useRef, useSyncExternalStore, getSidePanelLiveSnapshot,
subscribeSidePanelLiveSnapshot, SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT,
}) as (tabId: string, enabled: boolean) => SidePanelLiveSnapshot;
const env = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
test('returning to a tab never exposes the previous tab snapshot before its publisher catches up', async () => {
const previous = env.IS_REACT_ACT_ENVIRONMENT;
env.IS_REACT_ACT_ENVIRONMENT = true;
const own = { ...SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT, focusedSessionId: 'ssh-a', activeTerminalSessionIdForSftp: 'ssh-a', activeTerminalCwd: '/root', activeTerminalCwdTrusted: true };
const other = { ...SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT, focusedSessionId: 'local-b' };
let visible = true;
let observed: SidePanelLiveSnapshot = SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT;
function Probe() { observed = useSnapshot('ssh-a', visible); return null; }
sidePanelLiveStore.update(own);
let renderer: ReturnType<typeof create> | undefined;
try {
await act(async () => { renderer = create(React.createElement(Probe)); });
assert.equal(observed.focusedSessionId, 'ssh-a');
visible = false;
await act(async () => { renderer?.update(React.createElement(Probe)); });
await act(async () => { sidePanelLiveStore.update(other); });
visible = true;
await act(async () => { renderer?.update(React.createElement(Probe)); });
assert.equal(observed.focusedSessionId, 'ssh-a', 'stale local session must not enter the SSH panel');
assert.equal(observed.activeTerminalCwd, '/root', 'unchanged cwd must survive the reveal gap');
await act(async () => { sidePanelLiveStore.update({ ...own, activeTerminalCwd: '/srv/changed' }); });
assert.equal(observed.activeTerminalCwd, '/srv/changed', 'real cwd changes must still arrive');
} finally {
await act(async () => renderer?.unmount());
sidePanelLiveStore.update(SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT);
env.IS_REACT_ACT_ENVIRONMENT = previous;
}
});
test('new panels wait for their own snapshot and workspace panes accept focus changes within the workspace', async () => {
const previous = env.IS_REACT_ACT_ENVIRONMENT;
env.IS_REACT_ACT_ENVIRONMENT = true;
let observed = SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT;
const workspace = { id: 'workspace-a' } as NonNullable<SidePanelLiveSnapshot['activeWorkspace']>;
const own = { ...SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT, activeWorkspace: workspace, focusedSessionId: 'pane-a' };
let panelTabId = 'workspace-a';
function Probe() { observed = useSnapshot(panelTabId, true); return null; }
sidePanelLiveStore.update({ ...SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT, focusedSessionId: 'unrelated' });
let renderer: ReturnType<typeof create> | undefined;
try {
await act(async () => { renderer = create(React.createElement(Probe)); });
assert.equal(observed, SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT);
await act(async () => { sidePanelLiveStore.update(own); });
assert.equal(observed.focusedSessionId, 'pane-a');
await act(async () => { sidePanelLiveStore.update({ ...own, focusedSessionId: 'pane-b' }); });
assert.equal(observed.focusedSessionId, 'pane-b');
await act(async () => { sidePanelLiveStore.update({ ...SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT, focusedSessionId: 'unrelated' }); });
assert.equal(observed.focusedSessionId, 'pane-b');
panelTabId = 'new-owner';
await act(async () => { renderer?.update(React.createElement(Probe)); });
assert.equal(observed, SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT, 'a reused hook must not retain another owner');
} finally {
await act(async () => renderer?.unmount());
sidePanelLiveStore.update(SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT);
env.IS_REACT_ACT_ENVIRONMENT = previous;
}
});
@@ -7,6 +7,7 @@ import {
createTerminalSelectionAttachment,
decodeTerminalSelectionAttachment,
} from "./terminalSelectionAttachment.ts";
import { createVaultNoteAttachment, formatVaultNoteReferences } from "./vaultNoteAttachment.ts";
test("createTerminalSelectionAttachment returns null for blank selections", () => {
assert.equal(createTerminalSelectionAttachment(" \n\t"), null);
@@ -50,3 +51,13 @@ test("buildPromptWithTerminalSelectionAttachments supports terminal-only prompts
`[Terminal selection: ${attachment.filename}]\nsystemctl status nginx`,
);
});
test("buildPromptWithTerminalSelectionAttachments keeps empty Vault note blocks", () => {
const attachment = createVaultNoteAttachment({ id: "note-1", title: "Runbook" });
assert.ok(attachment);
assert.equal(
buildPromptWithTerminalSelectionAttachments("add deployment steps to this note", [attachment]),
`add deployment steps to this note\n\n${formatVaultNoteReferences([attachment])}`,
);
});
@@ -1,4 +1,8 @@
import type { ChatMessageAttachment, UploadedFile } from "../../infrastructure/ai/types";
import {
formatVaultNoteReferences,
isVaultNoteAttachment,
} from "./vaultNoteAttachment";
export const TERMINAL_SELECTION_ATTACHMENT_MEDIA_TYPE = "text/plain";
@@ -81,6 +85,16 @@ export function isTerminalSelectionAttachment(
return attachment.terminalSelection === true;
}
/**
* Attachments whose text is inlined into the prompt instead of being sent as
* file parts: terminal selections and Vault note mentions.
*/
export function isInlineTextAttachment(
attachment: Pick<ChatMessageAttachment | UploadedFile, "terminalSelection" | "vaultNoteId">,
): boolean {
return isTerminalSelectionAttachment(attachment) || isVaultNoteAttachment(attachment);
}
export function buildPromptWithTerminalSelectionAttachments(
prompt: string,
attachments: Array<ChatMessageAttachment | UploadedFile>,
@@ -95,7 +109,11 @@ export function buildPromptWithTerminalSelectionAttachments(
})
.filter((block): block is string => block !== null);
if (terminalBlocks.length === 0) return prompt;
if (!prompt.trim()) return terminalBlocks.join("").trimStart();
return `${prompt}${terminalBlocks.join("")}`;
const notes = attachments.filter(isVaultNoteAttachment);
const noteBlocks = notes.length ? [`\n\n${formatVaultNoteReferences(notes)}`] : [];
const blocks = [...terminalBlocks, ...noteBlocks];
if (blocks.length === 0) return prompt;
if (!prompt.trim()) return blocks.join("").trimStart();
return `${prompt}${blocks.join("")}`;
}
+45
View File
@@ -8,6 +8,7 @@ import {
uploadFromDataTransfer,
uploadFromFileList,
} from "../../lib/uploadService.ts";
import { DEFAULT_SFTP_FILE_TRANSFER_CONCURRENCY } from "../../domain/sftpTransferConcurrency.ts";
function createDataTransfer(files: File[]): DataTransfer {
return {
@@ -197,6 +198,50 @@ test("uploads picked folder files with their relative directory structure", asyn
]);
});
test("ordinary multi-file uploads use the shared transfer concurrency", async () => {
const countInitiallyStarted = async (fileTransferConcurrency?: number) => {
const started: string[] = [];
let releaseAll!: () => void;
const gate = new Promise<void>((resolve) => { releaseAll = resolve; });
const files = Array.from({ length: DEFAULT_SFTP_FILE_TRANSFER_CONCURRENCY + 2 }, (_, index) => {
const file = new File([`file-${index}`], `file-${index}.txt`);
Object.defineProperty(file, "path", { value: `/local/file-${index}.txt` });
return file;
});
const uploading = uploadFromFileList(
files,
{
targetPath: "/target",
sftpId: "sftp-1",
fileTransferConcurrency,
isLocal: false,
bridge: {
mkdirSftp: async () => {},
startStreamTransfer: async ({ targetPath }) => {
started.push(targetPath);
await gate;
return { transferId: targetPath };
},
},
joinPath: (base, name) => `${base}/${name}`,
},
);
await new Promise((resolve) => setImmediate(resolve));
const initiallyStarted = started.length;
releaseAll();
await uploading;
return initiallyStarted;
};
assert.equal(
await countInitiallyStarted(),
DEFAULT_SFTP_FILE_TRANSFER_CONCURRENCY,
);
assert.equal(await countInitiallyStarted(3), 3);
});
test("compression remains enabled for DataTransfer folder uploads that have a conflict resolver", async (t) => {
const starts: string[] = [];
installCompressedUploadBridge(t, {
@@ -0,0 +1,57 @@
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 { STORAGE_KEY_AI_COMMAND_BLOCKLIST } from '../../infrastructure/config/storageKeys.ts';
import { useAISettingsState } from './useAISettingsState.ts';
test('command blocklist storage events refresh the mounted AI settings state', async () => {
const store = new Map<string, string>();
const fakeStorage = {
getItem: (key: string) => store.get(key) ?? null,
setItem: (key: string, value: string) => store.set(key, String(value)),
removeItem: (key: string) => store.delete(key),
};
const fakeWindow = new EventTarget() as EventTarget & { netcatty?: unknown };
const globals = globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean;
localStorage?: typeof fakeStorage;
window?: typeof fakeWindow;
};
const previousActEnvironment = globals.IS_REACT_ACT_ENVIRONMENT;
const previousLocalStorage = Object.getOwnPropertyDescriptor(globalThis, 'localStorage');
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
globals.IS_REACT_ACT_ENVIRONMENT = true;
Object.defineProperty(globalThis, 'localStorage', { value: fakeStorage, configurable: true });
Object.defineProperty(globalThis, 'window', { value: fakeWindow, configurable: true });
let currentBlocklist: string[] = [];
let renderer: ReactTestRenderer | null = null;
const Probe = () => {
currentBlocklist = useAISettingsState().commandBlocklist;
return null;
};
try {
await act(async () => {
renderer = create(React.createElement(Probe));
});
fakeStorage.setItem(STORAGE_KEY_AI_COMMAND_BLOCKLIST, JSON.stringify(['company-blocked-command']));
const event = new Event('storage');
Object.defineProperty(event, 'key', { value: STORAGE_KEY_AI_COMMAND_BLOCKLIST });
await act(async () => {
fakeWindow.dispatchEvent(event);
});
assert.deepEqual(currentBlocklist, ['company-blocked-command']);
} finally {
await act(async () => {
renderer?.unmount();
});
globals.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
if (previousLocalStorage) Object.defineProperty(globalThis, 'localStorage', previousLocalStorage);
else delete globals.localStorage;
if (previousWindow) Object.defineProperty(globalThis, 'window', previousWindow);
else delete globals.window;
}
});
+7 -8
View File
@@ -28,7 +28,6 @@ import type {
WebSearchConfig,
} from '../../infrastructure/ai/types';
import {
DEFAULT_COMMAND_BLOCKLIST,
DEFAULT_COMMAND_TIMEOUT_SECONDS,
DEFAULT_RESPONSE_IDLE_TIMEOUT_SECONDS,
normalizeCommandTimeoutSeconds,
@@ -38,6 +37,10 @@ import { removeProviderReferences } from './aiProviderCleanup';
import { AI_STATE_CHANGED_EVENT, emitAIStateChanged } from './aiStateEvents';
import { getAIBridge } from './aiStateSnapshots';
import { useStoredBoolean } from './useStoredBoolean';
import {
persistCommandBlocklistSetting,
readCommandBlocklistSetting,
} from './commandBlocklistSettings';
function readPermissionMode(): AIPermissionMode {
const stored = localStorageAdapter.readString(STORAGE_KEY_AI_PERMISSION_MODE);
@@ -69,9 +72,7 @@ export function useAISettingsState() {
const [defaultAgentId, setDefaultAgentIdRaw] = useState<string>(() =>
localStorageAdapter.readString(STORAGE_KEY_AI_DEFAULT_AGENT) ?? 'catty'
);
const [commandBlocklist, setCommandBlocklistRaw] = useState<string[]>(() =>
localStorageAdapter.read<string[]>(STORAGE_KEY_AI_COMMAND_BLOCKLIST) ?? [...DEFAULT_COMMAND_BLOCKLIST]
);
const [commandBlocklist, setCommandBlocklistRaw] = useState<string[]>(readCommandBlocklistSetting);
const [commandTimeout, setCommandTimeoutRaw] = useState<number>(() =>
normalizeCommandTimeoutSeconds(
localStorageAdapter.readNumber(STORAGE_KEY_AI_COMMAND_TIMEOUT) ?? DEFAULT_COMMAND_TIMEOUT_SECONDS,
@@ -173,7 +174,7 @@ export function useAISettingsState() {
const setCommandBlocklist = useCallback((value: string[]) => {
setCommandBlocklistRaw(value);
localStorageAdapter.write(STORAGE_KEY_AI_COMMAND_BLOCKLIST, value);
persistCommandBlocklistSetting(value);
getAIBridge()?.aiMcpSetCommandBlocklist?.(value);
}, []);
@@ -249,9 +250,7 @@ export function useAISettingsState() {
setDefaultAgentIdRaw(localStorageAdapter.readString(STORAGE_KEY_AI_DEFAULT_AGENT) ?? 'catty');
break;
case STORAGE_KEY_AI_COMMAND_BLOCKLIST: {
const list = localStorageAdapter.read<string[]>(STORAGE_KEY_AI_COMMAND_BLOCKLIST);
if (list != null && !Array.isArray(list)) break;
const blocklist = list ?? [...DEFAULT_COMMAND_BLOCKLIST];
const blocklist = readCommandBlocklistSetting();
setCommandBlocklistRaw(blocklist);
getAIBridge()?.aiMcpSetCommandBlocklist?.(blocklist);
break;
+11 -15
View File
@@ -37,7 +37,6 @@ import type {
WebSearchConfig,
} from '../../infrastructure/ai/types';
import {
DEFAULT_COMMAND_BLOCKLIST,
DEFAULT_COMMAND_TIMEOUT_SECONDS,
DEFAULT_RESPONSE_IDLE_TIMEOUT_SECONDS,
normalizeCommandTimeoutSeconds,
@@ -69,7 +68,7 @@ import {
latestAIDraftsByScopeSnapshot,
latestAIPanelViewByScopeSnapshot,
latestAISessionsSnapshot,
pruneSessionsForStorage,
writeSessionsForStorage,
setLatestAIActiveSessionMapSnapshot,
setLatestAIDraftsByScopeSnapshot,
setLatestAIPanelViewByScopeSnapshot,
@@ -78,6 +77,10 @@ import {
type PanelViewByScope,
} from './aiStateSnapshots';
import { AI_STATE_CHANGED_EVENT, emitAIStateChanged } from './aiStateEvents';
import {
persistCommandBlocklistSetting,
readCommandBlocklistSetting,
} from './commandBlocklistSettings';
import {
handoffDissolvedWorkspaceAIScope,
retargetWorkspaceActiveChatAfterMemberLoss,
@@ -140,9 +143,7 @@ export function useAIState() {
);
// ── Safety Settings ──
const [commandBlocklist, setCommandBlocklistRaw] = useState<string[]>(() =>
localStorageAdapter.read<string[]>(STORAGE_KEY_AI_COMMAND_BLOCKLIST) ?? [...DEFAULT_COMMAND_BLOCKLIST]
);
const [commandBlocklist, setCommandBlocklistRaw] = useState<string[]>(readCommandBlocklistSetting);
const [commandTimeout, setCommandTimeoutRaw] = useState<number>(() =>
normalizeCommandTimeoutSeconds(
localStorageAdapter.readNumber(STORAGE_KEY_AI_COMMAND_TIMEOUT) ?? DEFAULT_COMMAND_TIMEOUT_SECONDS,
@@ -423,7 +424,7 @@ export function useAIState() {
const setCommandBlocklist = useCallback((value: string[]) => {
setCommandBlocklistRaw(value);
localStorageAdapter.write(STORAGE_KEY_AI_COMMAND_BLOCKLIST, value);
persistCommandBlocklistSetting(value);
// Sync to MCP Server bridge so SDK agents also respect the blocklist
const bridge = getAIBridge();
bridge?.aiMcpSetCommandBlocklist?.(value);
@@ -503,12 +504,7 @@ export function useAIState() {
setDefaultAgentIdRaw(localStorageAdapter.readString(STORAGE_KEY_AI_DEFAULT_AGENT) ?? 'catty');
break;
case STORAGE_KEY_AI_COMMAND_BLOCKLIST: {
const list = localStorageAdapter.read<string[]>(STORAGE_KEY_AI_COMMAND_BLOCKLIST);
if (list != null && !Array.isArray(list)) {
console.warn('[useAIState] Cross-window sync: AI_COMMAND_BLOCKLIST is not an array, skipping');
break;
}
const blocklist = list ?? [...DEFAULT_COMMAND_BLOCKLIST];
const blocklist = readCommandBlocklistSetting();
setCommandBlocklistRaw(blocklist);
getAIBridge()?.aiMcpSetCommandBlocklist?.(blocklist);
break;
@@ -627,7 +623,7 @@ export function useAIState() {
// ── Sync initial safety settings to MCP Server on mount ──
useEffect(() => {
const bridge = getAIBridge();
const initialBlocklist = localStorageAdapter.read<string[]>(STORAGE_KEY_AI_COMMAND_BLOCKLIST) ?? [...DEFAULT_COMMAND_BLOCKLIST];
const initialBlocklist = readCommandBlocklistSetting();
bridge?.aiMcpSetCommandBlocklist?.(initialBlocklist);
const initialTimeout = normalizeCommandTimeoutSeconds(
localStorageAdapter.readNumber(STORAGE_KEY_AI_COMMAND_TIMEOUT) ?? DEFAULT_COMMAND_TIMEOUT_SECONDS,
@@ -650,7 +646,7 @@ export function useAIState() {
// ── Session CRUD ──
const persistSessions = useCallback((next: AISession[]) => {
localStorageAdapter.write(STORAGE_KEY_AI_SESSIONS, pruneSessionsForStorage(next));
writeSessionsForStorage(next);
}, []);
// Debounced version of persistSessions for high-frequency updates (e.g. streaming)
@@ -661,7 +657,7 @@ export function useAIState() {
if (persistTimerRef.current) clearTimeout(persistTimerRef.current);
persistTimerRef.current = setTimeout(() => {
if (!mountedRef.current) return; // Skip writes after unmount
localStorageAdapter.write(STORAGE_KEY_AI_SESSIONS, pruneSessionsForStorage(sessionsRef.current));
writeSessionsForStorage(sessionsRef.current);
persistTimerRef.current = null;
}, 500);
}, []);
@@ -0,0 +1,65 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import React from 'react';
import { createDomRenderer, flushEffects, installDomEnvironment } from '../../components/test-support/renderReactDom';
import { EncryptionService } from '../../infrastructure/services/EncryptionService';
import type { SyncManagerState } from '../../infrastructure/services/CloudSyncManager';
import type { SyncPayload } from '../../domain/sync';
test('manual sync does not clear a newly shared password when an older unlock is superseded', async (t) => {
const env = installDomEnvironment();
const previousStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
Object.defineProperty(globalThis, "localStorage", { configurable: true, value: env.window.localStorage });
t.after(() => {
if (previousStorage) Object.defineProperty(globalThis, "localStorage", previousStorage);
else Reflect.deleteProperty(globalThis, "localStorage");
});
let sharedPassword: string | undefined;
let clears = 0;
Object.defineProperty(env.window, 'netcatty', { configurable: true, value: {
cloudSyncGetSessionPassword: async () => sharedPassword,
cloudSyncClearSessionPassword: async () => { clears += 1; sharedPassword = undefined; },
} });
const { getCloudSyncManager } = await import('../../infrastructure/services/CloudSyncManager');
const manager = getCloudSyncManager();
await manager.setupMasterKey('old-fixture-password');
manager.lock();
const newConfig = await EncryptionService.createMasterKeyConfig('new-fixture-password');
const { useCloudSync } = await import('./useCloudSync');
let current!: ReturnType<typeof useCloudSync>;
const Probe = () => { current = useCloudSync(); return null; };
const renderer = await createDomRenderer(env.document);
const originalUnlock = EncryptionService.unlockMasterKey;
let release!: () => void;
let started!: () => void;
const gate = new Promise<void>((resolve) => { release = resolve; });
const entered = new Promise<void>((resolve) => { started = resolve; });
try {
await renderer.render(<Probe />);
await flushEffects();
EncryptionService.unlockMasterKey = async (...args) => {
started();
await gate;
return originalUnlock(...args);
};
sharedPassword = 'old-fixture-password';
const result = current.syncNow({} as SyncPayload).then(() => null, (error: unknown) => error);
await entered;
const state = Reflect.get(manager, 'state') as SyncManagerState;
state.masterKeyConfig = newConfig;
Reflect.get(manager, 'bumpSyncSecurityGeneration').call(manager);
sharedPassword = 'new-fixture-password';
release();
const error = await result;
assert.equal(clears, 0, 'stale work must not erase the new shared password');
assert.equal(sharedPassword, 'new-fixture-password');
assert.ok(error instanceof Error);
assert.match(error.message, /changed while unlocking/i);
} finally {
release();
EncryptionService.unlockMasterKey = originalUnlock;
await renderer.unmount();
manager.destroy();
env.cleanup();
}
});
+9 -36
View File
@@ -6,6 +6,7 @@
* Uses useSyncExternalStore for real-time state synchronization across all components.
*/
import { useCloudSyncAutoUnlock } from './useCloudSyncAutoUnlock';
import { useCallback, useEffect, useMemo, useRef, useSyncExternalStore } from 'react';
import {
type CloudProvider,
@@ -266,42 +267,14 @@ export const useCloudSync = (): CloudSyncHook => {
getConvergentSyncLocalConfigSnapshot,
);
// Auto-unlock: if a master key exists, retrieve the persisted password (Electron safeStorage)
// and unlock silently so users don't have to manage a LOCKED state in the UI.
// Track the master key config hash to detect when a new master key is set up in another window.
const lastMasterKeyHashRef = useRef<string | null>(null);
const attemptedAutoUnlockRef = useRef(false);
useEffect(() => {
// Compute a simple hash of the master key config to detect changes
const currentHash = state.masterKeyConfig
? JSON.stringify({ salt: state.masterKeyConfig.salt, kdf: state.masterKeyConfig.kdf })
: null;
// If master key config changed (e.g., set up in settings window), reset the attempt flag
if (currentHash !== lastMasterKeyHashRef.current) {
lastMasterKeyHashRef.current = currentHash;
attemptedAutoUnlockRef.current = false;
}
if (attemptedAutoUnlockRef.current) return;
if (state.securityState !== 'LOCKED') return;
attemptedAutoUnlockRef.current = true;
void (async () => {
try {
const bridge = netcattyBridge.get();
const password = await bridge?.cloudSyncGetSessionPassword?.();
if (!password) return;
const ok = await manager.unlock(password);
if (!ok) {
void bridge?.cloudSyncClearSessionPassword?.();
}
} catch {
// Ignore auto-unlock errors; manual actions will surface them.
}
})();
}, [state.securityState, state.masterKeyConfig]);
useCloudSyncAutoUnlock({
securityState: state.securityState,
masterKeyIdentity: state.masterKeyConfig
? JSON.stringify(state.masterKeyConfig)
: null,
manager,
bridge: netcattyBridge.get(),
});
useEffect(() => {
if (state.securityState !== 'UNLOCKED') return;
@@ -0,0 +1,253 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import React from 'react';
import { createDomRenderer, flushEffects, installDomEnvironment, runWithAct } from '../../components/test-support/renderReactDom.tsx';
import { useCloudSyncAutoUnlock } from './useCloudSyncAutoUnlock.ts';
for (const strict of [false, true]) {
test(`pending password read is handed off when its owner unmounts (StrictMode=${strict})`, async () => {
const dom = installDomEnvironment();
const renderer = await createDomRenderer(dom.document);
const pending: Array<(value: string | null) => void> = [];
let unlocks = 0;
const manager = { unlock: async () => { unlocks++; return true; } };
const bridge = { cloudSyncGetSessionPassword: () => new Promise<string | null>(resolve => { pending.push(resolve); }) };
function Consumer() {
useCloudSyncAutoUnlock({ securityState: 'LOCKED', masterKeyIdentity: 'handoff-key', manager, bridge });
return null;
}
function tree(owner: boolean) {
return React.createElement(strict ? React.StrictMode : React.Fragment, null,
owner ? React.createElement(Consumer, { key: 'owner' }) : null,
React.createElement(Consumer, { key: 'peer' }));
}
try {
await renderer.render(tree(true));
await flushEffects();
assert.ok(pending.length > 0);
await renderer.render(tree(false));
await flushEffects();
await runWithAct(async () => { for (const resolve of pending) resolve('fixture-only'); });
await flushEffects();
assert.equal(unlocks, 1, 'cancelled owners must not unlock when their old responses arrive');
} finally { await renderer.unmount(); dom.cleanup(); }
});
}
for (const strict of [false, true]) {
test(`peer auto-unlocks when password arrives after the config (StrictMode=${strict})`, async () => {
const dom = installDomEnvironment();
const renderer = await createDomRenderer(dom.document);
let password: string | null = null;
const listeners = new Set<() => void>();
const unlocked: string[] = [];
const manager = { unlock: async (value: string) => { unlocked.push(value); return true; } };
const bridge = {
cloudSyncGetSessionPassword: async () => password,
onCloudSyncSessionPasswordAvailable: (cb: () => void) => { listeners.add(cb); return () => { listeners.delete(cb); }; },
};
function Harness() {
useCloudSyncAutoUnlock({ securityState: 'LOCKED', masterKeyIdentity: 'first-key', manager, bridge });
return null;
}
try {
await renderer.render(strict ? React.createElement(React.StrictMode, null, React.createElement(Harness)) : React.createElement(Harness));
await flushEffects();
assert.deepEqual(unlocked, [], 'the initial read really happened before the password existed');
await runWithAct(async () => { password = 'fixture-only'; for (const cb of listeners) cb(); });
await flushEffects();
assert.deepEqual(unlocked, ['fixture-only']);
} finally { await renderer.unmount(); dom.cleanup(); }
assert.equal(listeners.size, 0);
});
}
for (const strict of [false, true]) {
test(`a failed attempt during key rotation keeps the password for the arriving config (StrictMode=${strict})`, async () => {
const dom = installDomEnvironment();
const renderer = await createDomRenderer(dom.document);
let password: string | null = null;
let cleared = 0;
let currentIdentity = 'old-key';
const listeners = new Set<() => void>();
const unlocked: Array<[string, string]> = [];
const manager = {
unlock: async (value: string) => {
unlocked.push([currentIdentity, value]);
return currentIdentity === 'new-key';
},
};
const bridge = {
cloudSyncGetSessionPassword: async () => password,
cloudSyncClearSessionPassword: async () => { cleared++; return true; },
onCloudSyncSessionPasswordAvailable: (cb: () => void) => { listeners.add(cb); return () => { listeners.delete(cb); }; },
};
function Harness() {
useCloudSyncAutoUnlock({ securityState: 'LOCKED', masterKeyIdentity: currentIdentity, manager, bridge });
return null;
}
try {
await renderer.render(strict ? React.createElement(React.StrictMode, null, React.createElement(Harness)) : React.createElement(Harness));
await flushEffects();
// The password notification overtakes the config event: the peer is
// still locked on the old config when the new password arrives.
await runWithAct(async () => { password = 'rotation-password'; for (const cb of listeners) cb(); });
await flushEffects();
assert.deepEqual(unlocked, [['old-key', 'rotation-password']], 'the new password was tried against the old config');
assert.equal(cleared, 0, 'the failed attempt must not clear the shared password');
// The config event arrives afterwards; the retained password must
// still be available to auto-unlock the rotated key.
currentIdentity = 'new-key';
await renderer.render(strict ? React.createElement(React.StrictMode, null, React.createElement(Harness)) : React.createElement(Harness));
await flushEffects();
assert.deepEqual(unlocked, [
['old-key', 'rotation-password'],
['new-key', 'rotation-password'],
]);
assert.equal(cleared, 0);
} finally { await renderer.unmount(); dom.cleanup(); }
assert.equal(listeners.size, 0);
});
}
test('completed auto-unlock does not undo a later deliberate lock', async () => {
const dom = installDomEnvironment();
const renderer = await createDomRenderer(dom.document);
let calls = 0;
const manager = { unlock: async () => { calls++; return true; } };
let onAvailable: (() => void) | undefined;
const bridge = {
cloudSyncGetSessionPassword: async () => 'fixture-only',
onCloudSyncSessionPasswordAvailable: (cb: () => void) => { onAvailable = cb; return () => { onAvailable = undefined; }; },
};
function Harness({ state, identity = 'key' }: { state: string; identity?: string }) {
useCloudSyncAutoUnlock({ securityState: state, masterKeyIdentity: identity, manager, bridge });
return null;
}
try {
await renderer.render(React.createElement(Harness, { state: 'LOCKED' }));
await flushEffects();
await renderer.render(React.createElement(Harness, { state: 'UNLOCKED' }));
await runWithAct(async () => { onAvailable?.(); });
await renderer.render(React.createElement(Harness, { state: 'LOCKED' }));
await flushEffects();
assert.equal(calls, 1);
await runWithAct(async () => { onAvailable?.(); });
await flushEffects();
assert.equal(calls, 1, 'a peer password event after deliberate lock must not reopen this vault');
await renderer.render(null);
await renderer.render(React.createElement(Harness, { state: 'LOCKED' }));
await runWithAct(async () => { onAvailable?.(); });
await flushEffects();
assert.equal(calls, 1, 'a remounted consumer must retain the manager deliberate lock');
await renderer.render(React.createElement(Harness, { state: 'LOCKED', identity: 'replacement-key' }));
await flushEffects();
assert.equal(calls, 2, 'a genuinely replaced key can still initialize automatically');
} finally { await renderer.unmount(); dom.cleanup(); }
});
for (const strict of [false, true]) {
test(`remount reads a password shared while no consumer was mounted (StrictMode=${strict})`, async () => {
const dom = installDomEnvironment();
const renderer = await createDomRenderer(dom.document);
let password: string | null = null;
let reads = 0;
const unlocked: string[] = [];
const manager = { unlock: async (value: string) => { unlocked.push(value); return true; } };
const bridge = { cloudSyncGetSessionPassword: async () => { reads++; return password; } };
function Consumer() {
useCloudSyncAutoUnlock({ securityState: 'LOCKED', masterKeyIdentity: 'remount-key', manager, bridge });
return null;
}
const tree = () => React.createElement(strict ? React.StrictMode : React.Fragment, null, React.createElement(Consumer));
try {
await renderer.render(tree());
await flushEffects();
assert.ok(reads > 0);
assert.deepEqual(unlocked, []);
await renderer.render(null);
await flushEffects();
password = 'fixture-shared-while-absent';
await renderer.render(tree());
await flushEffects();
assert.deepEqual(unlocked, ['fixture-shared-while-absent']);
} finally { await renderer.unmount(); dom.cleanup(); }
});
}
test('remount during an active derivation does not start a duplicate unlock', async () => {
const dom = installDomEnvironment();
const renderer = await createDomRenderer(dom.document);
let finish!: (value: boolean) => void;
let unlocks = 0;
const manager = { unlock: () => { unlocks++; return new Promise<boolean>(resolve => { finish = resolve; }); } };
const bridge = { cloudSyncGetSessionPassword: async () => 'fixture-only' };
function Consumer() {
useCloudSyncAutoUnlock({ securityState: 'LOCKED', masterKeyIdentity: 'active-key', manager, bridge });
return null;
}
try {
await renderer.render(React.createElement(Consumer));
await flushEffects();
assert.equal(unlocks, 1);
await renderer.render(null);
await renderer.render(React.createElement(Consumer));
await flushEffects();
assert.equal(unlocks, 1);
await runWithAct(async () => finish(true));
await flushEffects();
assert.equal(unlocks, 1);
} finally { await renderer.unmount(); dom.cleanup(); }
});
test('two StrictMode consumers share one derivation after initial effect replay', async () => {
const dom = installDomEnvironment();
const renderer = await createDomRenderer(dom.document);
const pending: Array<(value: string | null) => void> = [];
let unlocks = 0;
const manager = { unlock: async () => { unlocks++; return true; } };
const bridge = { cloudSyncGetSessionPassword: () => new Promise<string | null>(resolve => pending.push(resolve)) };
function Consumer() {
useCloudSyncAutoUnlock({ securityState: 'LOCKED', masterKeyIdentity: 'strict-shared', manager, bridge });
return null;
}
try {
await renderer.render(React.createElement(React.StrictMode, null,
React.createElement(Consumer, { key: 'one' }), React.createElement(Consumer, { key: 'two' })));
await flushEffects();
await runWithAct(async () => { for (const resolve of pending) resolve('fixture-only'); });
await flushEffects();
assert.equal(unlocks, 1);
} finally { await renderer.unmount(); dom.cleanup(); }
});
test('password notification and a later peer mount share the same attempt', async () => {
const dom = installDomEnvironment();
const renderer = await createDomRenderer(dom.document);
let password: string | null = null;
const listeners = new Set<() => void>();
let unlocks = 0;
const manager = { unlock: async () => { unlocks++; return false; } };
const bridge = {
cloudSyncGetSessionPassword: async () => password,
onCloudSyncSessionPasswordAvailable: (cb: () => void) => { listeners.add(cb); return () => { listeners.delete(cb); }; },
};
function Consumer() {
useCloudSyncAutoUnlock({ securityState: 'LOCKED', masterKeyIdentity: 'notification-key', manager, bridge });
return null;
}
const tree = (late: boolean) => React.createElement(React.Fragment, null,
React.createElement(Consumer, { key: 'one' }), React.createElement(Consumer, { key: 'two' }),
late ? React.createElement(Consumer, { key: 'late' }) : null);
try {
await renderer.render(tree(false));
await flushEffects();
assert.equal(unlocks, 0);
await runWithAct(async () => { password = 'fixture-only'; for (const cb of listeners) cb(); });
await flushEffects();
assert.equal(unlocks, 1);
await renderer.render(tree(true));
await flushEffects();
assert.equal(unlocks, 1, 'new consumers share the notification generation');
} finally { await renderer.unmount(); dom.cleanup(); }
});
+115
View File
@@ -0,0 +1,115 @@
import { useEffect, useState } from 'react';
interface AutoUnlockManager {
unlock(password: string): Promise<boolean>;
}
interface AutoUnlockBridge {
cloudSyncGetSessionPassword?(): Promise<string | null>;
onCloudSyncSessionPasswordAvailable?(callback: () => void): () => void;
}
// A renderer can mount several consumers of the same manager. Remember the
// unlocked key across consumer unmounts so reopening settings cannot undo a lock.
const unlockedIdentityByManager = new WeakMap<AutoUnlockManager, string>();
// Dedupe attempts per manager (not per hook instance): every consumer of the
// same manager observes the same events, and without sharing they would each
// run the expensive PBKDF2 derivation inside manager.unlock.
const attemptedAttemptByManager = new WeakMap<AutoUnlockManager, string | null>();
const passwordRevisionByManager = new WeakMap<AutoUnlockManager, number>();
const completedAttemptByManager = new WeakMap<AutoUnlockManager, string>();
// Mounted consumers register a retry trigger here so an attempt can be handed
// off when its owner unmounts (see the cleanup below).
const retryListenersByManager = new WeakMap<AutoUnlockManager, Set<() => void>>();
/** Retry a locked peer window when the setting window finishes sharing its key. */
export function useCloudSyncAutoUnlock(input: {
securityState: string;
masterKeyIdentity: string | null;
manager: AutoUnlockManager;
bridge: AutoUnlockBridge | null | undefined;
}) {
const { securityState, masterKeyIdentity, manager, bridge } = input;
const [retryRevision, setRetryRevision] = useState(0);
useEffect(() => bridge?.onCloudSyncSessionPasswordAvailable?.(() => {
passwordRevisionByManager.set(manager, (passwordRevisionByManager.get(manager) ?? 0) + 1);
setRetryRevision(value => value + 1);
}), [bridge, manager]);
useEffect(() => {
if (!masterKeyIdentity) return;
const attempt = JSON.stringify([masterKeyIdentity, passwordRevisionByManager.get(manager) ?? 0]);
if (securityState === 'UNLOCKED') {
unlockedIdentityByManager.set(manager, masterKeyIdentity);
attemptedAttemptByManager.set(manager, attempt);
return;
}
if (securityState !== 'LOCKED') return;
// Once this key has been unlocked, a later lock is intentional. A peer
// sharing its password must not undo it, regardless of notification order.
if (unlockedIdentityByManager.get(manager) === masterKeyIdentity) return;
// Register before the dedup check: a consumer that only observes an
// in-flight attempt must still be reachable for an ownership handoff.
let listeners = retryListenersByManager.get(manager);
if (!listeners) {
listeners = new Set();
retryListenersByManager.set(manager, listeners);
}
const notifyRetry = () => setRetryRevision(value => value + 1);
listeners.add(notifyRetry);
const removeListener = () => {
listeners.delete(notifyRetry);
if (listeners.size === 0 && completedAttemptByManager.get(manager) === attemptedAttemptByManager.get(manager)) {
attemptedAttemptByManager.delete(manager);
completedAttemptByManager.delete(manager);
}
};
if (attemptedAttemptByManager.get(manager) === attempt) {
return removeListener;
}
attemptedAttemptByManager.set(manager, attempt);
completedAttemptByManager.delete(manager);
let handedOff = false;
let cancelled = false;
let awaitingPassword = true;
void (async () => {
try {
const password = await bridge?.cloudSyncGetSessionPassword?.();
awaitingPassword = false;
if (cancelled || !password) return;
// A failed attempt must not clear the shared password: the config and
// the password travel over unordered channels (localStorage vs IPC),
// so this attempt may have raced a master-key rotation and the
// password may match the config that is still in flight. The attempt
// is deduped per [identity, revision], so keeping the password lets
// the arriving config trigger a retry instead of stranding the vault.
await manager.unlock(password);
} catch {
// Explicit sync actions surface errors; keep auto-unlock silent.
} finally {
if (!handedOff && attemptedAttemptByManager.get(manager) === attempt) {
completedAttemptByManager.set(manager, attempt);
// Availability events can arrive while no consumer is mounted.
// A future mount must read again after this attempt has settled.
if (listeners.size === 0) {
attemptedAttemptByManager.delete(manager);
completedAttemptByManager.delete(manager);
}
}
}
})();
return () => {
cancelled = true;
removeListener();
// React StrictMode can immediately remount the same effect.
if (awaitingPassword && attemptedAttemptByManager.get(manager) === attempt) {
handedOff = true;
attemptedAttemptByManager.set(manager, null);
// Hand the attempt off: this consumer's in-flight password request is
// discarded as cancelled, but other mounted consumers already returned
// at the dedup check above, so they must be nudged to retry.
for (const notify of listeners) notify();
}
};
}, [securityState, masterKeyIdentity, manager, bridge, retryRevision]);
}
+148 -1
View File
@@ -1,8 +1,13 @@
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 { Host } from "../../domain/models.ts";
import { haveSameManagedSshAgentFields } from "./useManagedSourceSync.ts";
import { STORAGE_KEY_MANAGED_SOURCES } from "../../infrastructure/config/storageKeys.ts";
import { isVaultImportLockHeld } from "./vaultManagedImportLock.ts";
import { haveSameManagedSshAgentFields, useManagedSourceSync } from "./useManagedSourceSync.ts";
const host: Host = {
id: "host-1",
@@ -54,3 +59,145 @@ test("managed SSH source comparison tracks identity file paths", () => {
identityFilePaths: [...withPaths.identityFilePaths].reverse(),
}), false);
});
type TestHost = Host & { managedSourceId?: string };
const managedHost = (label: string): TestHost => ({
...host,
label,
managedSourceId: "source-1",
});
const managedSource = {
id: "source-1",
type: "ssh_config" as const,
filePath: "/home/tester/.ssh/config",
groupName: "Managed",
lastSyncedAt: 0,
};
function createFakeLockManager() {
const tails = new Map<string, Promise<unknown>>();
return {
request<T>(name: string, callback: () => Promise<T>): Promise<T> {
const previous = (tails.get(name) ?? Promise.resolve()) as Promise<unknown>;
const run = previous.then(callback, callback) as Promise<T>;
tails.set(name, run.then(() => undefined, () => undefined));
return run;
},
};
}
test("managed sync writes the latest persisted hosts, not the pre-edit snapshot", async () => {
const dom = new JSDOM("<!doctype html><html><body></body></html>", {
url: "http://localhost",
});
const globalAny = globalThis as Record<string, unknown>;
const saved = {
window: globalAny.window,
document: globalAny.document,
localStorage: globalAny.localStorage,
navigator: Object.getOwnPropertyDescriptor(globalThis, "navigator"),
actEnv: (globalAny as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT,
};
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, "navigator", {
configurable: true,
value: { locks: createFakeLockManager() },
});
Object.defineProperty(globalAny, "IS_REACT_ACT_ENVIRONMENT", { configurable: true, value: true });
const container = dom.window.document.createElement("div");
dom.window.document.body.appendChild(container);
try {
dom.window.localStorage.setItem(
STORAGE_KEY_MANAGED_SOURCES,
JSON.stringify([managedSource]),
);
// Simulate updateHosts: the React state already holds the edited alias,
// but the encrypted disk write is still queued and only lands later.
const hostBeforeEdit = managedHost("aaaaaaaa");
const hostAfterEdit = managedHost("bbbbbbbb");
let persistedHosts: TestHost[] = [hostBeforeEdit];
let resolveCommit!: () => void;
const commit = new Promise<void>((resolve) => { resolveCommit = resolve; });
setTimeout(() => {
persistedHosts = [hostAfterEdit];
resolveCommit();
}, 20);
const onReadPersistedHosts = async (): Promise<Host[]> => {
// Mirrors the real readPersistedHosts semantics: while the vault lock is
// held, only the encrypt phase is drained (the disk write queued behind
// that lock is not visible yet); outside the lock it also waits for the
// queued disk write before reading.
if (isVaultImportLockHeld("vault")) return persistedHosts;
await commit;
return persistedHosts;
};
let written = "";
(dom.window as unknown as Record<string, unknown>).netcatty = {
readLocalFile: async () => new TextEncoder().encode(
"# BEGIN NETCATTY MANAGED - DO NOT EDIT THIS BLOCK\n"
+ "Host aaaaaaaa\n HostName managed.example.com\n"
+ "# END NETCATTY MANAGED\n",
).buffer,
writeLocalFile: async (_filePath: string, buffer: ArrayBuffer) => {
written = new TextDecoder().decode(buffer);
},
};
function Harness() {
useManagedSourceSync({
hosts: [hostAfterEdit],
managedSources: [managedSource],
onUpdateManagedSources: () => {},
onReadPersistedHosts,
});
return null;
}
const root = createRoot(container);
await act(async () => {
root.render(React.createElement(Harness));
});
for (let i = 0; i < 250 && !written; i += 1) {
await new Promise((resolve) => setTimeout(resolve, 10));
}
await act(async () => root.unmount());
assert.ok(written, "managed sync never wrote the ssh_config file");
assert.ok(written.includes("Host bbbbbbbb"), "sync wrote a stale alias");
assert.ok(!written.includes("Host aaaaaaaa"), "sync wrote the pre-edit alias");
} finally {
container.remove();
dom.window.close();
for (const key of ["window", "document", "localStorage"] as const) {
if (key in globalAny) {
Object.defineProperty(globalThis, key, {
configurable: true,
enumerable: true,
writable: true,
value: saved[key],
});
} else {
delete globalAny[key];
}
}
if (saved.navigator) {
Object.defineProperty(globalThis, "navigator", saved.navigator);
} else {
delete (globalThis as { navigator?: unknown }).navigator;
}
Object.defineProperty(globalAny, "IS_REACT_ACT_ENVIRONMENT", {
configurable: true,
value: saved.actEnv,
});
}
});
@@ -156,6 +156,14 @@ export const useManagedSourceSync = ({
const syncManagedSource = useCallback(
async (source: ManagedSource): Promise<{ sourceId: string; success: boolean }> => {
// Drain any in-flight Vault host write BEFORE taking the vault lock.
// updateHosts commits its encrypted write under the vault lock, so if this
// sync acquires the lock first, readPersistedHosts runs while the lock is
// held and only waits for the encrypt phase — returning the previous host
// snapshot and writing the stale alias back to the file (lagging one edit
// behind, see issue #3259). Waiting outside the lock also waits for the
// queued disk write, so the read below sees the just-edited hosts.
await onReadPersistedHosts();
return withVaultImportLock("vault", async () => {
const persistedSources = localStorageAdapter.read<ManagedSource[]>(
STORAGE_KEY_MANAGED_SOURCES,
@@ -385,7 +385,10 @@ export const usePortForwardingAutoStart = ({
return startPortForward(rule, host, resolveEffectiveHosts(hostsRef.current), keysRef.current, identitiesRef.current, onStatusChange, true, terminalSettingsRef.current, knownHostsRef.current);
};
setReconnectCallback(handleReconnect);
setReconnectCallback(handleReconnect, (ruleId) => isPortForwardingAutoStartEnabled(
localStorageAdapter.read<PortForwardingRule[]>(STORAGE_KEY_PORT_FORWARDING) ?? [],
ruleId,
));
return () => {
setReconnectCallback(null);
};
@@ -0,0 +1,99 @@
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 { useSessionState } from "./useSessionState.ts";
type SessionState = ReturnType<typeof useSessionState>;
test("global broadcast availability follows visible orphan session count", async () => {
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
const eventTarget = new EventTarget() as EventTarget & Record<string, unknown>;
Object.assign(eventTarget, {
setTimeout,
clearTimeout,
});
Object.defineProperty(globalThis, "window", {
configurable: true,
value: eventTarget,
});
const actEnvironment = globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean;
};
const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
let state: SessionState | null = null;
let renderer: ReactTestRenderer | null = null;
const current = (): SessionState => {
assert.ok(state);
return state;
};
function Probe() {
state = useSessionState({ persistSessionRestore: false });
return null;
}
try {
await act(async () => {
renderer = create(React.createElement(Probe));
});
assert.equal(current().orphanSessions.length, 0);
assert.equal(current().canUseGlobalBroadcast, false);
assert.equal(current().isGlobalBroadcastEnabled, false);
await act(async () => {
current().createLocalTerminal({ shellName: "01" });
});
assert.equal(current().orphanSessions.length, 1);
assert.equal(current().canUseGlobalBroadcast, false);
await act(async () => {
current().createLocalTerminal({ shellName: "02" });
});
assert.equal(current().orphanSessions.length, 2);
assert.equal(current().canUseGlobalBroadcast, true);
await act(async () => {
current().toggleGlobalBroadcast();
});
assert.equal(current().isGlobalBroadcastEnabled, true);
await act(async () => {
current().toggleGlobalBroadcast();
});
assert.equal(current().isGlobalBroadcastEnabled, false);
await act(async () => { current().toggleGlobalBroadcast(); });
const closedId = current().orphanSessions[1].id;
await act(async () => { current().closeSession(closedId); });
assert.equal(current().canUseGlobalBroadcast, false);
assert.equal(current().isGlobalBroadcastEnabled, false);
await act(async () => { current().createLocalTerminal({ shellName: "03" }); });
assert.equal(current().canUseGlobalBroadcast, true);
assert.equal(current().isGlobalBroadcastEnabled, false);
await act(async () => { current().toggleGlobalBroadcast(); });
const [first, second] = current().orphanSessions;
await act(async () => {
current().createWorkspaceFromSessions(first.id, second.id, "right");
});
assert.equal(current().canUseGlobalBroadcast, false);
assert.equal(current().isGlobalBroadcastEnabled, false);
await act(async () => {
current().removeSessionFromWorkspace(second.id);
});
assert.equal(current().canUseGlobalBroadcast, true);
assert.equal(current().isGlobalBroadcastEnabled, false);
} finally {
await act(async () => {
renderer?.unmount();
});
actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow);
else Reflect.deleteProperty(globalThis, "window");
}
});
+90 -4
View File
@@ -59,7 +59,12 @@ import type { SessionRestorePayload } from '../../domain/sessionRestore';
import { resolveRestorePreviousSessionSetting } from './sessionRestoreSettings';
import type { CodingCliProviderId } from '../../domain/codingCliProviders';
import { normalizeCodingCliDynamicTitleForStorage } from '../../domain/codingCliTitleParse';
import { DEFAULT_WORKSPACE_TITLE } from '../../domain/sessionTabTitle';
import {
DEFAULT_WORKSPACE_TITLE,
buildMergedWorkspaceTitle,
buildMergedWorkspaceTitleFromSessions,
getSessionConnectionLabel,
} from '../../domain/sessionTabTitle';
import { cleanupClosedTerminalSessions } from './aiStateSnapshots';
export function addWorkspaceIfMissing(
@@ -228,6 +233,8 @@ export const useSessionState = ({
const [tabOrder, setTabOrder] = useState<string[]>(initialRestoreState.tabOrder);
// Broadcast mode: stores workspace IDs that have broadcast enabled
const [broadcastWorkspaceIds, setBroadcastWorkspaceIds] = useState<Set<string>>(new Set());
// Global broadcast mode: enables broadcast for all orphan sessions (not in workspaces)
const [globalBroadcastEnabled, setGlobalBroadcastEnabled] = useState<boolean>(false);
// Log views: stores open log replay tabs
const [logViews, setLogViews] = useState<LogView[]>([]);
const [restorePreviousSessionRevision, setRestorePreviousSessionRevision] = useState(0);
@@ -260,6 +267,41 @@ export const useSessionState = ({
}
}, [initialRestoreState.activeTabId]);
// Keep generated merged-workspace titles (e.g. "01/02" after dragging tab
// "02" onto "01") synchronized with the workspace's current membership and
// each pane's connection label. `autoTitle: false` makes
// resolveWorkspaceTabLabel treat the composed title as a user-chosen name,
// so without this pass a pane rename or a pane being added/removed would
// leave the label stale. Runs off session changes and only touches
// workspaces still flagged `generatedTitle` (a user rename clears it).
useEffect(() => {
setWorkspaces(prev => {
let changed = false;
const next = prev.map(ws => {
if (!ws.generatedTitle) return ws;
// Order members by the workspace's pane tree (base/join or split
// layout), not by global session creation order — merging a newer tab
// onto an older one must keep the merged title as created (e.g.
// "02/01"), while `sessions.filter` would rewrite it to "01/02".
const rootIds = collectSessionIds(ws.root);
const members = [
...rootIds
.map(id => sessions.find(s => s.id === id))
.filter((s): s is TerminalSession => Boolean(s)),
// Safety net for transient membership: any session assigned to this
// workspace whose pane has not landed in `root` yet keeps its
// previous (creation-order) position after the pane-tree members.
...sessions.filter(s => s.workspaceId === ws.id && !rootIds.includes(s.id)),
];
const recomputed = buildMergedWorkspaceTitleFromSessions(members) ?? DEFAULT_WORKSPACE_TITLE;
if (recomputed === ws.title) return ws;
changed = true;
return { ...ws, title: recomputed };
});
return changed ? next : prev;
});
}, [sessions]);
useEffect(() => {
const handleRestorePreviousSessionChanged = (key?: string) => {
if (key !== STORAGE_KEY_RESTORE_PREVIOUS_SESSION) return;
@@ -622,11 +664,16 @@ export const useSessionState = ({
}, []);
const submitWorkspaceRename = useCallback((workspaceId?: string, name?: string) => {
// A user rename takes ownership of the title: clear `generatedTitle` so a
// previously synthesized merged title ("01/02") stops being auto-updated.
const applyRename = (w: Workspace, trimmed: string): Workspace => (
{ ...w, title: trimmed, autoTitle: false, generatedTitle: false }
);
if (workspaceId !== undefined && name !== undefined) {
const trimmed = name.trim();
if (!trimmed) return;
setWorkspaces(prev => prev.map(w => (
w.id === workspaceId ? { ...w, title: trimmed, autoTitle: false } : w
w.id === workspaceId ? applyRename(w, trimmed) : w
)));
return;
}
@@ -637,7 +684,7 @@ export const useSessionState = ({
setWorkspaceRenameTarget(prevTarget => {
if (!prevTarget) return prevTarget;
setWorkspaces(prev => prev.map(w => w.id === prevTarget.id ? { ...w, title: trimmed, autoTitle: false } : w));
setWorkspaces(prev => prev.map(w => w.id === prevTarget.id ? applyRename(w, trimmed) : w));
return null;
});
@@ -729,7 +776,29 @@ export const useSessionState = ({
hint: SplitHint
) => {
if (!hint || baseSessionId === joiningSessionId) return;
const newWorkspace = createWorkspaceEntity(baseSessionId, joiningSessionId, hint);
// Name the merged workspace after both tabs' connection labels (e.g.
// "01/02") so the window title and the rename dialog show the merged
// identity instead of the generic "Workspace". Falls back to the default
// auto title when neither tab has a label. The explicit `autoTitle: false`
// makes tab labels use this composed name too; `generatedTitle: true`
// marks it as synthesized so it keeps tracking membership/renames (see the
// generated-title sync effect below) instead of going stale.
// Compose the title from the created pane-tree order (not base/join
// order): for left/top merges `createWorkspaceEntity` places the joining
// pane before the base pane in `root`, and the generated-title sync
// effect above recomputes from `root` order — building the initial title
// in tree order keeps it stable instead of renaming itself right after
// creation.
const created = createWorkspaceEntity(baseSessionId, joiningSessionId, hint);
const treeLabels = collectSessionIds(created.root).map((id) =>
getSessionConnectionLabel(
sessionsRef.current.find(s => s.id === id) ?? { customName: '', hostLabel: '' },
),
);
const mergedTitle = buildMergedWorkspaceTitle(treeLabels[0] ?? '', treeLabels[1] ?? '');
const newWorkspace = mergedTitle
? { ...created, title: mergedTitle, autoTitle: false, generatedTitle: true }
: created;
setSessions((prevSessions) => {
const base = prevSessions.find((s) => s.id === baseSessionId);
@@ -1054,6 +1123,12 @@ export const useSessionState = ({
}, [setActiveTabId]);
const orphanSessions = useMemo(() => sessions.filter(s => !s.workspaceId && !s.hiddenFromTabs), [sessions]);
const canUseGlobalBroadcast = orphanSessions.length >= 2;
// Losing the group must disarm broadcast before another tab can join it.
if (!canUseGlobalBroadcast && globalBroadcastEnabled) {
setGlobalBroadcastEnabled(false);
}
const openLogView = useCallback((log: ConnectionLog) => {
const tabId = getLogViewTabId(log);
@@ -1205,6 +1280,14 @@ export const useSessionState = ({
return broadcastWorkspaceIds.has(workspaceId);
}, [broadcastWorkspaceIds]);
// Toggle global broadcast mode for orphan sessions
const toggleGlobalBroadcast = useCallback(() => {
setGlobalBroadcastEnabled(prev => !prev);
}, []);
// Global broadcast enabled state - direct boolean value
const isGlobalBroadcastEnabled = globalBroadcastEnabled;
const baseWorkTabIds = useMemo(() => [
...orphanSessions.map(s => s.id),
...workspaces.map(w => w.id),
@@ -1328,9 +1411,12 @@ export const useSessionState = ({
moveFocusInWorkspace,
runSnippet,
orphanSessions,
canUseGlobalBroadcast,
// Broadcast mode
toggleBroadcast,
isBroadcastEnabled,
toggleGlobalBroadcast,
isGlobalBroadcastEnabled,
orderedTabs,
getOrderedWorkTabs,
reorderTabs,
@@ -0,0 +1,144 @@
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 { useSessionState } from "./useSessionState.ts";
type SessionState = ReturnType<typeof useSessionState>;
test("merged tab titles follow the workspace lifecycle until the user renames it", async (t) => {
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
const eventTarget = new EventTarget() as EventTarget & Record<string, unknown>;
Object.assign(eventTarget, {
setTimeout,
clearTimeout,
});
Object.defineProperty(globalThis, "window", {
configurable: true,
value: eventTarget,
});
t.after(() => {
if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow);
else Reflect.deleteProperty(globalThis, "window");
});
const actEnvironment = globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean;
};
const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
t.after(() => {
actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
});
let state: SessionState | null = null;
let renderer: ReactTestRenderer | null = null;
const current = (): SessionState => {
assert.ok(state);
return state;
};
const workspace = (workspaceId: string) => {
const found = current().workspaces.find((candidate) => candidate.id === workspaceId);
assert.ok(found);
return found;
};
function Probe() {
state = useSessionState({ persistSessionRestore: false });
return null;
}
await act(async () => {
renderer = create(React.createElement(Probe));
});
let firstSessionId = "";
let secondSessionId = "";
await act(async () => {
firstSessionId = current().createLocalTerminal({ shellName: "01" });
secondSessionId = current().createLocalTerminal({ shellName: "02" });
});
await act(async () => {
current().createWorkspaceFromSessions(firstSessionId, secondSessionId, {
direction: "vertical",
position: "right",
targetSessionId: firstSessionId,
});
});
const workspaceId = current().workspaces[0]?.id;
assert.ok(workspaceId);
assert.equal(workspace(workspaceId).title, "01/02");
assert.equal(workspace(workspaceId).autoTitle, false);
assert.equal(workspace(workspaceId).generatedTitle, true);
await act(async () => {
current().startWorkspaceRename(workspaceId);
});
assert.equal(current().workspaceRenameValue, "01/02");
await act(async () => {
current().resetWorkspaceRename();
current().renameSessionInline(secondSessionId, "prod");
});
assert.equal(workspace(workspaceId).title, "01/prod");
let thirdSessionId = "";
await act(async () => {
thirdSessionId = current().createLocalTerminal({ shellName: "03" });
});
await act(async () => {
current().addSessionToWorkspace(workspaceId, thirdSessionId, {
direction: "vertical",
position: "right",
targetSessionId: secondSessionId,
});
});
assert.equal(workspace(workspaceId).title, "01/prod/03");
await act(async () => {
current().removeSessionFromWorkspace(thirdSessionId);
});
assert.equal(workspace(workspaceId).title, "01/prod");
const workspaceIdsBeforeCopy = new Set(current().workspaces.map((candidate) => candidate.id));
await act(async () => {
current().copyWorkspace(workspaceId);
});
const copiedWorkspace = current().workspaces.find(
(candidate) => !workspaceIdsBeforeCopy.has(candidate.id),
);
assert.ok(copiedWorkspace);
assert.equal(copiedWorkspace.title, "01/prod");
assert.equal(copiedWorkspace.autoTitle, false);
assert.equal(copiedWorkspace.generatedTitle, true);
await act(async () => {
current().submitWorkspaceRename(workspaceId, "Pinned");
current().renameSessionInline(firstSessionId, "ops");
});
assert.equal(workspace(workspaceId).title, "Pinned");
assert.equal(workspace(workspaceId).generatedTitle, false);
let leftBaseSessionId = "";
let leftJoiningSessionId = "";
await act(async () => {
leftBaseSessionId = current().createLocalTerminal({ shellName: "04" });
leftJoiningSessionId = current().createLocalTerminal({ shellName: "05" });
});
await act(async () => {
current().createWorkspaceFromSessions(leftBaseSessionId, leftJoiningSessionId, {
direction: "vertical",
position: "left",
targetSessionId: leftBaseSessionId,
});
});
const leftWorkspace = current().workspaces.find((candidate) => candidate.title === "05/04");
assert.ok(leftWorkspace);
assert.equal(leftWorkspace.generatedTitle, true);
await act(async () => {
renderer?.unmount();
});
});
+103
View File
@@ -0,0 +1,103 @@
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 { useStoredBoolean } from "./useStoredBoolean.ts";
const STORAGE_KEY = "netcatty:test:stored-boolean";
test("functional updates stay pure when another consumer shares the storage key", async () => {
const values = new Map<string, string>([[STORAGE_KEY, "false"]]);
const fakeStorage = {
getItem: (key: string) => values.get(key) ?? null,
setItem: (key: string, value: string) => values.set(key, value),
removeItem: (key: string) => values.delete(key),
};
const fakeWindow = new EventTarget();
const globals = globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean;
localStorage?: typeof fakeStorage;
window?: typeof fakeWindow;
};
const previousActEnvironment = globals.IS_REACT_ACT_ENVIRONMENT;
const previousLocalStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
const previousDispatchEvent = Object.getOwnPropertyDescriptor(globalThis, "dispatchEvent");
const previousCustomEvent = Object.getOwnPropertyDescriptor(globalThis, "CustomEvent");
let renderer: ReactTestRenderer | null = null;
let toggle: (() => void) | null = null;
let parentValue = false;
let childValue = false;
const errors: string[] = [];
const originalConsoleError = console.error;
Object.defineProperty(globalThis, "localStorage", { value: fakeStorage, configurable: true });
Object.defineProperty(globalThis, "window", { value: fakeWindow, configurable: true });
Object.defineProperty(globalThis, "dispatchEvent", {
value: fakeWindow.dispatchEvent.bind(fakeWindow),
configurable: true,
});
Object.defineProperty(globalThis, "CustomEvent", {
value: class TestCustomEvent<T> extends Event implements CustomEvent<T> {
readonly detail: T;
constructor(type: string, init?: CustomEventInit<T>) {
super(type);
this.detail = init?.detail as T;
}
initCustomEvent(): void {
// Deprecated browser API required by the CustomEvent interface.
}
},
configurable: true,
});
globals.IS_REACT_ACT_ENVIRONMENT = true;
console.error = (...args: unknown[]) => {
errors.push(args.map(String).join(" "));
};
const Parent = () => {
[parentValue] = useStoredBoolean(STORAGE_KEY);
return React.createElement(Child);
};
const Child = () => {
const [value, setValue] = useStoredBoolean(STORAGE_KEY);
childValue = value;
toggle = () => setValue((previous) => !previous);
return null;
};
try {
await act(async () => {
renderer = create(React.createElement(React.StrictMode, null, React.createElement(Parent)));
});
await act(async () => {
toggle?.();
});
assert.equal(parentValue, true);
assert.equal(childValue, true);
assert.equal(values.get(STORAGE_KEY), "true");
assert.equal(
errors.some((message) => message.includes("Cannot update a component")
&& message.includes("while rendering a different component")),
false,
);
} finally {
console.error = originalConsoleError;
await act(async () => {
renderer?.unmount();
});
globals.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
if (previousLocalStorage) Object.defineProperty(globalThis, "localStorage", previousLocalStorage);
else delete globals.localStorage;
if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow);
else delete globals.window;
if (previousDispatchEvent) Object.defineProperty(globalThis, "dispatchEvent", previousDispatchEvent);
else delete (globalThis as typeof globalThis & { dispatchEvent?: unknown }).dispatchEvent;
if (previousCustomEvent) Object.defineProperty(globalThis, "CustomEvent", previousCustomEvent);
else delete (globalThis as typeof globalThis & { CustomEvent?: unknown }).CustomEvent;
}
});
+19 -12
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter";
/**
@@ -17,30 +17,37 @@ export const useStoredBoolean = (
const stored = localStorageAdapter.readBoolean(storageKey);
return stored ?? fallback;
});
const valueRef = useRef(value);
const setAndPersist = useCallback((next: boolean | ((prev: boolean) => boolean)) => {
setValue((prev) => {
const resolved = typeof next === "function" ? next(prev) : next;
localStorageAdapter.writeBoolean(storageKey, resolved);
// Notify other same-window consumers
window.dispatchEvent(
new CustomEvent("stored-boolean-change", { detail: { key: storageKey, value: resolved } }),
);
return resolved;
});
// Resolve functional updates before scheduling React state. The updater
// must stay pure because React may evaluate it while rendering.
const resolved = typeof next === "function" ? next(valueRef.current) : next;
valueRef.current = resolved;
setValue(resolved);
localStorageAdapter.writeBoolean(storageKey, resolved);
// Notify other same-window consumers outside the state updater.
window.dispatchEvent(
new CustomEvent("stored-boolean-change", { detail: { key: storageKey, value: resolved } }),
);
}, [storageKey]);
useEffect(() => {
// Sync from other components in the same window
const handleCustom = (e: Event) => {
const { key, value: newValue } = (e as CustomEvent).detail;
if (key === storageKey) setValue(newValue);
if (key === storageKey) {
valueRef.current = newValue;
setValue(newValue);
}
};
// Sync from other windows
const handleStorage = (e: StorageEvent) => {
if (e.key === storageKey) {
const stored = localStorageAdapter.readBoolean(storageKey);
setValue(stored ?? fallback);
const nextValue = stored ?? fallback;
valueRef.current = nextValue;
setValue(nextValue);
}
};
window.addEventListener("stored-boolean-change", handleCustom);
@@ -0,0 +1,54 @@
import assert from "node:assert/strict";
import test from "node:test";
import { createVaultNoteAttachment, formatVaultNoteReferences, vaultNoteReferencesFit } from "./vaultNoteAttachment.ts";
import { buildPromptWithTerminalSelectionAttachments, createTerminalSelectionAttachment, isInlineTextAttachment } from "./terminalSelectionAttachment.ts";
test("mentions carry identity without copying even large note bodies", () => {
const note = { id: "note-1", title: "Runbook", content: "secret body".repeat(200_000) };
const attachment = createVaultNoteAttachment(note)!;
assert.equal(attachment.vaultNoteId, note.id);
assert.equal(attachment.base64Data, "");
assert.equal(attachment.dataUrl, "");
assert.ok(JSON.stringify(attachment).length < 400);
assert.ok(isInlineTextAttachment(attachment));
const prompt = buildPromptWithTerminalSelectionAttachments("summarize", [attachment]);
assert.match(prompt, /vault_notes_get/);
assert.match(prompt, /latest content/);
assert.doesNotMatch(prompt, /secret body/);
});
test("same titled notes keep distinct exact IDs including imported special characters", () => {
for (const id of ["note-a", "note-b", ' quoted"\\id\r\n ']) {
const attachment = createVaultNoteAttachment({ id, title: "Same title" })!;
const reference = formatVaultNoteReferences([attachment]).split("\n")[0];
const [metadata] = JSON.parse(reference.slice("[Vault note references: ".length, -1));
assert.equal(metadata.noteId, id);
assert.equal(metadata.title, "Same title");
}
});
test("invalid IDs are rejected and displayed titles remain bounded without broken emoji", () => {
assert.equal(createVaultNoteAttachment({ id: " ", title: "x" }), null);
assert.equal(createVaultNoteAttachment({ id: "a".repeat(201), title: "x" }), null);
const attachment = createVaultNoteAttachment({ id: "ok", title: "😀".repeat(200) })!;
assert.equal(Array.from(attachment.vaultNoteTitle!).length, 120);
});
test("note-only sends and mixed terminal selections retain both kinds of context", () => {
const note = createVaultNoteAttachment({ id: "empty-note", title: "" })!;
const terminal = createTerminalSelectionAttachment("systemctl status nginx")!;
assert.match(buildPromptWithTerminalSelectionAttachments("", [note]), /empty-note/);
const prompt = buildPromptWithTerminalSelectionAttachments("compare", [note, terminal]);
assert.match(prompt, /systemctl status nginx/);
assert.match(prompt, /empty-note/);
assert.match(prompt, /^compare/);
});
test("reference limit reserves replay space and does not count ordinary files", () => {
const notes = Array.from({length: 10}, (_, i) => createVaultNoteAttachment({id: String(i).padEnd(200, "x"), title: "t".repeat(120)})!);
assert.equal(vaultNoteReferencesFit(notes), false);
const accepted = notes.filter((_, index) => vaultNoteReferencesFit(notes.slice(0, index + 1)));
assert.ok(accepted.length > 0);
assert.ok(formatVaultNoteReferences(accepted).length <= 1000);
assert.equal(vaultNoteReferencesFit([...accepted, {id: "file", filename: "file", mediaType: "text/plain", dataUrl: "", base64Data: "x".repeat(2_000_000)}]), true);
});
+38
View File
@@ -0,0 +1,38 @@
import type { VaultNote } from "../../domain/models";
import type { ChatMessageAttachment, UploadedFile } from "../../infrastructure/ai/types";
/** A mention stores only identity; the agent reads current content through the Vault tool. */
export function createVaultNoteAttachment(note: Pick<VaultNote, "id" | "title">): UploadedFile | null {
// Preserve imported IDs exactly. Reject oversized IDs instead of changing their identity.
if (!note.id.trim() || note.id.length > 200) return null;
const title = Array.from(note.title.trim() || "Untitled note").slice(0, 120).join("");
return {
id: crypto.randomUUID(),
filename: title,
mediaType: "text/markdown",
base64Data: "",
dataUrl: "",
vaultNoteId: note.id,
vaultNoteTitle: title,
};
}
export function isVaultNoteAttachment(
attachment: Pick<ChatMessageAttachment | UploadedFile, "vaultNoteId">,
): boolean {
return typeof attachment.vaultNoteId === "string" && attachment.vaultNoteId.length > 0;
}
/** JSON preserves special characters and whitespace in exact note IDs. */
export function formatVaultNoteReferences(
attachments: Array<Pick<ChatMessageAttachment | UploadedFile, "vaultNoteId" | "vaultNoteTitle">>,
): string {
const notes = attachments.map((attachment) => ({ noteId: attachment.vaultNoteId, title: attachment.vaultNoteTitle }));
return `[Vault note references: ${JSON.stringify(notes)}]\nUse vault_notes_get with each exact noteId to read the latest content before summarizing or editing. If unavailable, report that instead of choosing another note.`;
}
/** Reserve at least half of external replay's 2,000 characters for the user's request. */
export function vaultNoteReferencesFit(attachments: UploadedFile[]): boolean {
const notes = attachments.filter(isVaultNoteAttachment);
return notes.length === 0 || formatVaultNoteReferences(notes).length <= 1000;
}
+83
View File
@@ -5,6 +5,7 @@ import type { SyncPayload } from "../domain/sync.ts";
import type { KnownHost } from "../domain/models.ts";
import type { SyncableVaultData } from "./syncPayload.ts";
import { parseTerminalFontSizeRecord } from "./state/terminalFontSizeSync.ts";
import commandBlocklistTable from "../lib/commandBlocklist.json";
type LocalStorageMock = {
clear(): void;
@@ -56,6 +57,7 @@ const {
const storageKeys = await import("../infrastructure/config/storageKeys.ts");
const { SYNC_STORAGE_KEYS } = await import("../domain/sync.ts");
const { localStorageAdapter } = await import("../infrastructure/persistence/localStorageAdapter.ts");
const { readCommandBlocklistSetting } = await import("./state/commandBlocklistSettings.ts");
const knownHost = (id = "kh-1"): KnownHost => ({
id,
@@ -642,6 +644,87 @@ test("applySyncPayload restores AI configuration settings", async () => {
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_AI_SHOW_TERMINAL_SELECTION_ACTION), "false");
});
test("applySyncPayload migrates an untouched legacy command blocklist", async () => {
const legacyDefaults = [
...commandBlocklistTable.common,
...commandBlocklistTable.posixNative,
...commandBlocklistTable.posix,
];
await applySyncPayload({
hosts: [],
keys: [],
identities: [],
snippets: [],
customGroups: [],
settings: {
ai: {
commandBlocklist: legacyDefaults,
},
},
syncedAt: 1,
} as SyncPayload, { importVaultData: () => {} });
assert.deepEqual(
JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_COMMAND_BLOCKLIST)!),
[...legacyDefaults, ...commandBlocklistTable.powershell],
);
assert.deepEqual(
readCommandBlocklistSetting(),
[...legacyDefaults, ...commandBlocklistTable.powershell],
);
});
test("command blocklist read migrates an untouched local legacy list", () => {
const legacyDefaults = [
...commandBlocklistTable.common,
...commandBlocklistTable.posixNative,
...commandBlocklistTable.posix,
];
const currentDefaults = [...legacyDefaults, ...commandBlocklistTable.powershell];
assert.deepEqual(readCommandBlocklistSetting(), currentDefaults);
assert.deepEqual(
JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_COMMAND_BLOCKLIST)!),
currentDefaults,
);
localStorage.setItem(
storageKeys.STORAGE_KEY_AI_COMMAND_BLOCKLIST,
JSON.stringify([...legacyDefaults, "old-client-user-rule"]),
);
assert.deepEqual(readCommandBlocklistSetting(), [
...legacyDefaults,
"old-client-user-rule",
...commandBlocklistTable.powershell,
]);
});
test("applySyncPayload preserves a customized PowerShell blocklist", async () => {
const currentCustomized = [
...commandBlocklistTable.common,
...commandBlocklistTable.posixNative,
...commandBlocklistTable.posix,
...commandBlocklistTable.powershell.slice(1),
];
await applySyncPayload({
hosts: [],
keys: [],
identities: [],
snippets: [],
customGroups: [],
settings: {
ai: {
commandBlocklist: currentCustomized,
},
},
syncedAt: 1,
} as SyncPayload, { importVaultData: () => {} });
assert.deepEqual(readCommandBlocklistSetting(), currentCustomized);
});
test("applySyncPayload encrypts synced plaintext AI API keys before saving locally", async () => {
Object.defineProperty(globalThis, "window", {
value: {
+12 -3
View File
@@ -28,6 +28,7 @@ import {
sanitizeHostsForSync,
type SyncPayload,
} from '../domain/sync';
import { migrateLegacyCommandBlocklist } from '../domain/commandBlocklist';
import { migrateHostsFromLegacyLineTimestamps } from '../domain/host';
import { toPersistedPortForwardingRules } from '../domain/portForwardingPersistence';
import {
@@ -43,6 +44,10 @@ import { localStorageAdapter } from '../infrastructure/persistence/localStorageA
import { decryptField, encryptField } from '../infrastructure/persistence/secureFieldAdapter';
import { sanitizeQuickMessages } from '../infrastructure/ai/quickMessages';
import { emitAIStateChanged } from './state/aiStateEvents';
import {
persistCommandBlocklistSetting,
readCommandBlocklistSetting,
} from './state/commandBlocklistSettings';
import { rehydrateGlobalSftpBookmarks } from './state/sftp/globalSftpBookmarks';
import {
nextTerminalFontSizeSyncVersion,
@@ -572,8 +577,10 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
// externalAgents intentionally not collected: command/args/env are device-local.
const defaultAgentId = localStorageAdapter.readString(STORAGE_KEY_AI_DEFAULT_AGENT);
if (defaultAgentId != null) ai.defaultAgentId = defaultAgentId;
const commandBlocklist = localStorageAdapter.read<string[]>(STORAGE_KEY_AI_COMMAND_BLOCKLIST);
if (Array.isArray(commandBlocklist)) ai.commandBlocklist = commandBlocklist;
const storedCommandBlocklist = localStorageAdapter.read<string[]>(STORAGE_KEY_AI_COMMAND_BLOCKLIST);
if (Array.isArray(storedCommandBlocklist)) {
ai.commandBlocklist = readCommandBlocklistSetting();
}
const commandTimeout = localStorageAdapter.readNumber(STORAGE_KEY_AI_COMMAND_TIMEOUT);
if (commandTimeout != null && Number.isFinite(commandTimeout)) ai.commandTimeout = commandTimeout;
const responseIdleTimeout = localStorageAdapter.readNumber(STORAGE_KEY_AI_RESPONSE_IDLE_TIMEOUT);
@@ -824,7 +831,9 @@ async function applySyncableSettings(settings: NonNullable<SyncPayload['settings
// externalAgents intentionally not applied: device-local. Legacy snapshots
// that still carry an `externalAgents` field are silently ignored.
if (ai.defaultAgentId != null) localStorageAdapter.writeString(STORAGE_KEY_AI_DEFAULT_AGENT, ai.defaultAgentId);
if (ai.commandBlocklist != null) localStorageAdapter.write(STORAGE_KEY_AI_COMMAND_BLOCKLIST, ai.commandBlocklist);
if (ai.commandBlocklist != null) {
persistCommandBlocklistSetting(migrateLegacyCommandBlocklist(ai.commandBlocklist));
}
if (ai.commandTimeout != null) localStorageAdapter.writeNumber(STORAGE_KEY_AI_COMMAND_TIMEOUT, ai.commandTimeout);
if (ai.responseIdleTimeout != null) {
localStorageAdapter.writeNumber(STORAGE_KEY_AI_RESPONSE_IDLE_TIMEOUT, ai.responseIdleTimeout);
+5
View File
@@ -88,6 +88,8 @@ interface AIChatPanelContentProps {
setGlobalPermissionMode?: (mode: AIPermissionMode) => void;
notes?: VaultNote[];
hosts?: Host[];
/** Mention Note: attach a Vault → Notes entry as inline context. */
onMentionNote?: (note: VaultNote) => void;
onOpenVaultNote?: (noteId: string) => void;
onOpenVaultHost?: (hostId: string) => void;
onOpenVaultSection?: (section: 'notes' | 'hosts') => void;
@@ -157,6 +159,7 @@ export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
setGlobalPermissionMode,
notes = [],
hosts = [],
onMentionNote,
onOpenVaultNote,
onOpenVaultHost,
onOpenVaultSection,
@@ -361,6 +364,8 @@ export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
onAddFiles={addFiles}
onRemoveFile={removeFile}
hosts={mentionHosts}
notes={notes}
onMentionNote={onMentionNote}
selectedUserSkills={selectedUserSkills}
userSkills={userSkillOptions}
quickMessages={quickMessages}
+66 -10
View File
@@ -9,9 +9,11 @@ import type {
AIPanelView,
AgentModelPreset,
AISessionScope,
UploadedFile,
DiscoveredAgent,
ExternalAgentConfig,
} from '../infrastructure/ai/types';
import type { VaultNote } from '../domain/models';
import type { ExecutorContext } from '../infrastructure/ai/cattyAgent/executor';
import {
filterAgentModelPresetsForCliVersion,
@@ -20,6 +22,7 @@ import {
resolveAgentModelSelection,
} from '../infrastructure/ai/types';
import { getExternalAgentSdkBackend, getManualAgentCommand, matchesManagedAgentConfig } from '../infrastructure/ai/managedAgents';
import { toast } from './ui/toast';
import { useAgentDiscovery } from '../application/state/useAgentDiscovery';
import {
getReadyUserSkillOptions,
@@ -42,8 +45,9 @@ import { draftsByScopeEqualIgnoringComposerText, selectDraftForAgentSwitch } fro
import { sanitizeContextWindow } from '../infrastructure/ai/contextCompaction';
import {
buildPromptWithTerminalSelectionAttachments,
isTerminalSelectionAttachment,
isInlineTextAttachment,
} from '../application/state/terminalSelectionAttachment';
import { createVaultNoteAttachment, isVaultNoteAttachment, vaultNoteReferencesFit } from '../application/state/vaultNoteAttachment';
import type { CodexIntegrationStatus } from './settings/tabs/ai/types';
import {
useAIChatStreaming,
@@ -597,6 +601,42 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
removeDraftFile(scopeKey, currentAgentId, fileId);
}, [removeDraftFile, scopeKey, currentAgentId]);
// External turns keep the tools from launch; changed settings cannot enable note reads mid-turn.
const canMentionNotes = currentAgentId === 'catty' || (toolIntegrationMode === 'mcp' && !isStreaming);
const validateNoteMentions = useCallback((attachments: UploadedFile[]) => {
if (!canMentionNotes && attachments.some(isVaultNoteAttachment)) {
toast.warning(t('ai.chat.mentionNoteUnavailable'));
return false;
}
if (!vaultNoteReferencesFit(attachments)) {
toast.warning(t('ai.chat.mentionNoteTooMany'));
return false;
}
return true;
}, [canMentionNotes, t]);
/** Mention Note: attach a Vault → Notes entry as inline context for the next send. */
const mentionNote = useCallback((note: VaultNote) => {
if (!canMentionNotes) return;
const upload = createVaultNoteAttachment({ id: note.id, title: note.title.trim() || t('ai.chat.untitledNote') });
if (!upload) {
toast.error(t('ai.chat.mentionNoteInvalid', {
title: String(note.title || '').trim() || t('ai.chat.untitledNote'),
}));
return;
}
const existing = currentDraftRef.current?.attachments ?? [];
if (!validateNoteMentions([...existing.filter((file) => file.vaultNoteId !== upload.vaultNoteId), upload])) return;
enterScopeDraftMode(currentAgentId, panelViewRef.current.mode === 'session');
updateDraft(scopeKey, currentAgentId, (current) => ({
...current,
attachments: [
...current.attachments.filter((file) => file.vaultNoteId !== upload.vaultNoteId),
upload,
],
}));
}, [canMentionNotes, validateNoteMentions, updateDraft, currentAgentId, enterScopeDraftMode, scopeKey, t]);
useEffect(() => {
if (isVisible) return undefined;
flushDraftText();
@@ -1141,6 +1181,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
const draft = currentDraftRef.current;
const currentPanelView = panelViewRef.current;
const currentSessionView = activeSessionRef.current;
if (!validateNoteMentions(draft?.attachments ?? [])) return;
const trimmed = draft?.text.trim() ?? '';
const sendScopeKey = scopeKey;
const attachments = (draft?.attachments ?? []).map((file) => ({
@@ -1149,18 +1190,27 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
filename: file.filename,
filePath: file.filePath,
terminalSelection: file.terminalSelection,
vaultNoteId: file.vaultNoteId,
vaultNoteTitle: file.vaultNoteTitle,
previewText: file.previewText,
lineCount: file.lineCount,
}));
const hasTerminalSelectionAttachments = attachments.some(isTerminalSelectionAttachment);
if ((!trimmed && !hasTerminalSelectionAttachments) || isStreaming) return;
const hasInlineTextAttachments = attachments.some(isInlineTextAttachment);
if ((!trimmed && !hasInlineTextAttachments) || isStreaming) 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.
const noteOnlyTitle = attachments.find(
(attachment) => isVaultNoteAttachment(attachment) && attachment.vaultNoteTitle,
)?.vaultNoteTitle ?? '';
const titleText = trimmed || noteOnlyTitle.trim();
const sendAgentId = currentSessionView?.agentId ?? draft?.agentId ?? currentAgentId;
const agentConfig = sendAgentId !== 'catty' ? findEnabledExternalAgent(externalAgents, sendAgentId) : undefined;
if (sendAgentId !== 'catty' && !agentConfig) return;
const selectedSkillSlugs = draft?.selectedUserSkillSlugs ?? [];
const modelPrompt = buildPromptWithTerminalSelectionAttachments(trimmed, attachments);
const modelAttachments = attachments.filter((attachment) => !isTerminalSelectionAttachment(attachment));
const modelAttachments = attachments.filter((attachment) => !isInlineTextAttachment(attachment));
const isDraftMode = currentPanelView.mode === 'draft';
flushDraftText();
@@ -1337,7 +1387,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
updateLastMessage(sessionId, msg => msg.statusText ? { ...msg, statusText: '' } : msg);
setStreamingForScope(sessionId, false);
abortControllersRef.current.delete(sessionId);
autoTitleSession(sessionId, trimmed);
autoTitleSession(sessionId, titleText);
} else {
const toolScope = {
type: scopeType,
@@ -1360,7 +1410,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
getExecutorContext: () => buildExecutorContextForScope(toolScope),
autoTitleSession,
selectedUserSkillSlugs: selectedSkillSlugs,
titleText: trimmed,
titleText,
}, modelAttachments.length > 0 ? modelAttachments : undefined);
}
} finally {
@@ -1371,6 +1421,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
}
}
}, [
validateNoteMentions,
isStreaming, activeProvider, effectiveActiveProvider, effectiveActiveModelId, selectedCattyThinking, scopeKey, currentAgentId,
activeModelId, externalAgents,
createSession, addMessageToSession, updateMessageById, updateLastMessage,
@@ -1467,6 +1518,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
const draft = currentDraftRef.current;
if (!sessionId || !draft || steeringSessionId || !canSteerCurrentTurn) return;
if (!validateNoteMentions(draft.attachments)) return;
const trimmed = draft.text.trim();
const attachments = draft.attachments.map((file) => ({
base64Data: file.base64Data,
@@ -1474,15 +1526,17 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
filename: file.filename,
filePath: file.filePath,
terminalSelection: file.terminalSelection,
vaultNoteId: file.vaultNoteId,
vaultNoteTitle: file.vaultNoteTitle,
previewText: file.previewText,
lineCount: file.lineCount,
}));
const hasTerminalSelectionAttachments = attachments.some(isTerminalSelectionAttachment);
if (!trimmed && !hasTerminalSelectionAttachments) return;
const hasInlineTextAttachments = attachments.some(isInlineTextAttachment);
if (!trimmed && !hasInlineTextAttachments) return;
const userMessageId = generateId();
const modelPrompt = buildPromptWithTerminalSelectionAttachments(trimmed, attachments);
const modelAttachments = attachments.filter((attachment) => !isTerminalSelectionAttachment(attachment));
const modelAttachments = attachments.filter((attachment) => !isInlineTextAttachment(attachment));
setSteerWarnings(current => {
const next = { ...current };
delete next[sessionId];
@@ -1511,7 +1565,8 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
} finally {
setSteeringSessionId(current => current === sessionId ? null : current);
}
}, [canSteerCurrentTurn, clearScopeDraft, steerExternalAgent, steeringSessionId]);
}, [
validateNoteMentions, canSteerCurrentTurn, clearScopeDraft, steerExternalAgent, steeringSessionId]);
const stopStreamingForSession = useCallback(async (sessionId: string) => {
const controller = abortControllersRef.current.get(sessionId);
@@ -1707,6 +1762,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
files={files}
addFiles={addFiles}
removeFile={removeFile}
onMentionNote={canMentionNotes ? mentionNote : undefined}
terminalSessions={terminalSessions}
selectedUserSkills={selectedUserSkills}
userSkillOptions={userSkillOptions}
@@ -0,0 +1,69 @@
import assert from "node:assert/strict";
import test from "node:test";
import React, { act } from "react";
import { createDomRenderer, dispatchDomEvent, flushEffects, installDomEnvironment } from "./test-support/renderReactDom.tsx";
test("resume from another panel is visible while backend verification is pending", async (t) => {
const env = installDomEnvironment();
const previous = new Map<string, PropertyDescriptor | undefined>();
const globals = {
MutationObserver: env.window.MutationObserver,
NodeFilter: env.window.NodeFilter,
HTMLInputElement: env.window.HTMLInputElement,
Element: env.window.Element,
requestAnimationFrame: (callback: FrameRequestCallback) => setTimeout(() => callback(Date.now()), 0),
cancelAnimationFrame: clearTimeout,
};
for (const [key, value] of Object.entries(globals)) {
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
}
Object.defineProperties(env.window.HTMLElement.prototype, {
offsetWidth: { configurable: true, get: () => 460 },
offsetHeight: { configurable: true, get() { return this.dataset.section === "global-sftp-transfer-list" ? 460 : 112; } },
});
const { I18nProvider } = await import("../application/i18n/I18nProvider.tsx");
const { sftpTransferCenterStore: store } = await import("../application/state/sftpTransferCenterStore.ts");
const { GlobalSftpTransferCenter } = await import("./GlobalSftpTransferCenter.tsx");
const { TooltipProvider } = await import("./ui/tooltip.tsx");
const renderer = await createDomRenderer(env.document);
t.after(async () => {
await renderer.unmount();
for (const [key, descriptor] of previous) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else Reflect.deleteProperty(globalThis, key);
}
env.cleanup();
});
let settleResume!: (result: { success: boolean }) => void;
let resumeCalls = 0;
const gate = new Promise<{ success: boolean }>((resolve) => { settleResume = resolve; });
Object.defineProperty(env.window, "netcatty", { configurable: true, value: {
resumeTransfer: async () => { resumeCalls += 1; return gate; },
} });
store.publishOwner("resume-display-test", [{
id: "resume-display", fileName: "resume-display.bin",
sourcePath: "/source.bin", targetPath: "/target.bin",
sourceConnectionId: "remote", targetConnectionId: "local", direction: "download",
status: "paused", totalBytes: 1000, transferredBytes: 100, speed: 0,
startTime: 1, isDirectory: false, resumable: true,
}]);
await renderer.render(<I18nProvider locale="en"><TooltipProvider><GlobalSftpTransferCenter /></TooltipProvider></I18nProvider>);
const toggle = env.document.querySelector("[data-section=global-sftp-transfer-toggle]");
assert.ok(toggle);
await dispatchDomEvent(toggle, new env.window.MouseEvent("click", { bubbles: true }));
let resumed!: Promise<void>;
await act(async () => { resumed = store.resume("resume-display"); });
try {
await flushEffects();
assert.equal(resumeCalls, 1);
const row = env.document.querySelector('[role=progressbar][aria-label="resume-display.bin"]')?.closest('[data-transfer-status]');
assert.ok(row);
assert.match(row.textContent ?? "", /Reconnecting and resuming/);
assert.equal(row.querySelector('button[aria-label="Resume"]'), null);
} finally {
await act(async () => { settleResume({ success: true }); await resumed; });
await dispatchDomEvent(toggle, new env.window.MouseEvent("click", { bubbles: true }));
await flushEffects();
}
});
+3 -18
View File
@@ -23,6 +23,7 @@ import { useI18n } from "../application/i18n/I18nProvider";
import {
sftpTransferCenterStore,
useSftpTransferCenterBadge,
useSftpTransferResuming,
type SftpTransferCenterSnapshot,
} from "../application/state/sftpTransferCenterStore";
import { transferRuntime } from "../application/state/sftp/transferRuntime";
@@ -355,7 +356,6 @@ function TransferRow({
const { t } = useI18n();
const folderReplaceWarningId = React.useId();
// Optimistic spinner from click until store status moves off paused/interrupted.
const [resumeClicked, setResumeClicked] = useState(false);
const isDirParent = isDirectoryParentTask(task);
const showCollapsedChildren = isDirParent && shouldShowCollapsedActiveChildren(task.status);
const activeChildren = useMemo(
@@ -397,22 +397,8 @@ function TransferRow({
const storeResuming = task.reconnectRequired === true
&& ["pending", "queued", "transferring"].includes(task.status)
&& !task.error;
const isResuming = storeResuming
|| (resumeClicked && ["paused", "interrupted", "attention", "pending", "queued"].includes(task.status) && !task.error);
// Clear optimistic click once the store has left the idle-resume surface or failed.
useEffect(() => {
if (!resumeClicked) return;
if (
storeResuming
|| task.status === "transferring"
|| task.status === "completed"
|| task.status === "failed"
|| task.status === "cancelled"
|| !!task.error
) {
setResumeClicked(false);
}
}, [resumeClicked, storeResuming, task.status, task.error]);
const sharedResuming = useSftpTransferResuming(task.id);
const isResuming = sharedResuming || storeResuming;
const canPause = task.resumable !== false && task.status === "transferring" && canControl && !isResuming;
// Orphaned tasks after app restart (interrupted / attention / paused without a
// live panel owner) must still expose resume/cancel from the global center.
@@ -467,7 +453,6 @@ function TransferRow({
}));
};
const resumeTask = () => {
setResumeClicked(true);
// Dedicated resume opens vault sessions for local↔remote and SFTP↔SFTP.
// Only force-open the panel when the row still needs a live owner/adoption
// (e.g. conflict) — not on every remote-to-remote resume click.
@@ -64,4 +64,6 @@ test("large transfer histories mount only visible rows and can scroll to the las
const lastRow = env.document.querySelector('[role=progressbar][aria-label="file-0.bin"]')?.closest('[data-transfer-status]');
assert.ok(lastRow?.classList.contains("border-b-0"), "only the actual final row omits its divider");
assert.equal(store.getSnapshot().tasks.length, 1_000, "offscreen files must remain queued");
await dispatchDomEvent(toggle, new env.window.MouseEvent("click", { bubbles: true }));
await flushEffects();
});
+49 -15
View File
@@ -1,3 +1,4 @@
import { getHostOsSelection, HOST_OS_SELECTIONS, resolveHostOs } from '../domain/host';
import React from "react";
import { AlertTriangle, ChevronDown, ChevronUp, Forward, Globe, HeartPulse, KeyRound, Link2, Palette, Plus, Router, ShieldAlert, TerminalSquare, Timer, Wifi, X, Variable } from "lucide-react";
import { customThemeStore } from "../application/state/customThemeStore";
@@ -350,26 +351,59 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
</HostDetailsSection>
)}
{/* Network Device Mode — only for SSH hosts without Mosh / ET (serial already uses raw mode) */}
{(!form.protocol || form.protocol === 'ssh') && !form.moshEnabled && !form.etEnabled && (
{(!form.protocol || ['ssh', 'telnet', 'mosh', 'et'].includes(form.protocol)) && (
<HostDetailsSection
icon={<Router size={14} className="text-muted-foreground" />}
title={t("hostDetails.section.deviceType")}
>
<ToggleRow
label={t("hostDetails.deviceType")}
hint={t("hostDetails.deviceType.desc")}
enabled={effectiveDeviceType === 'network'}
onToggle={() => update("deviceType", effectiveDeviceType === 'network' ? 'general' : 'network')}
/>
{effectiveDeviceType === 'network' && (
<div className="flex items-start gap-2 p-2 rounded-md bg-yellow-500/10 border border-yellow-500/20">
<AlertTriangle size={14} className="text-yellow-500 mt-0.5 flex-shrink-0" />
<p className="text-xs text-yellow-600 dark:text-yellow-400 break-words">
{t("hostDetails.deviceType.warning")}
{(!form.protocol || form.protocol === 'ssh') && !form.moshEnabled && !form.etEnabled && (<>
<ToggleRow
label={t("hostDetails.deviceType")}
hint={t("hostDetails.deviceType.desc")}
enabled={effectiveDeviceType === 'network'}
onToggle={() => update("deviceType", effectiveDeviceType === 'network' ? 'general' : 'network')}
/>
{effectiveDeviceType === 'network' && (
<div className="flex items-start gap-2 p-2 rounded-md bg-yellow-500/10 border border-yellow-500/20">
<AlertTriangle size={14} className="text-yellow-500 mt-0.5 flex-shrink-0" />
<p className="text-xs text-yellow-600 dark:text-yellow-400 break-words">
{t("hostDetails.deviceType.warning")}
</p>
</div>
)}
</>)}
<Collapsible>
<CollapsibleTrigger asChild>
<Button
variant="ghost"
className="group w-full justify-between h-8 px-2 hover:bg-accent/50"
aria-label={t("hostDetails.os.title")}
>
<span className="text-xs font-medium text-muted-foreground">{t("hostDetails.os.title")}</span>
<span className="flex items-center gap-1.5 text-xs text-muted-foreground">
{t(`hostDetails.os.${getHostOsSelection(form)}`)}
<ChevronDown size={14} className="group-data-[state=open]:rotate-180" />
</span>
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="mt-2 space-y-2">
<HostDetailsSettingRow label={t("hostDetails.os.selection")} hint={t("hostDetails.os.desc")}>
<Select value={getHostOsSelection(form)} onValueChange={(value) => update("osOverride", value)}>
<SelectTrigger className="h-8 w-36 text-xs" aria-label={t("hostDetails.os.selection")}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{HOST_OS_SELECTIONS.map((value) => (
<SelectItem key={value} value={value}>{t(`hostDetails.os.${value}`)}</SelectItem>
))}
</SelectContent>
</Select>
</HostDetailsSettingRow>
<p className="text-xs text-muted-foreground">
{t("hostDetails.os.current")}: {t(`hostDetails.os.${resolveHostOs({ ...form, deviceType: effectiveDeviceType })}`)}
</p>
</div>
)}
</CollapsibleContent>
</Collapsible>
</HostDetailsSection>
)}
@@ -111,6 +111,11 @@ test("color and icon settings render for non-Linux hosts", () => {
assert.match(markup, /hostDetails\.icon\.manualLabel/);
});
test("basic connection settings do not require operating system selection", () => {
const markup = renderConnectionSections({ os: "windows" });
assert.doesNotMatch(markup, /hostDetails\.os\./);
});
test("host credentials expose automatic and password-only choices", () => {
const markup = renderConnectionSections({
authMethod: "auto",
+7
View File
@@ -10,6 +10,13 @@ test("main SftpView keeps browse sessions across top-tab switches", () => {
assert.doesNotMatch(source, /interactive:\s*isActive/);
});
test("Open SFTP always restores dual-pane layout through the planner", () => {
const source = readFileSync(new URL("./SftpView.tsx", import.meta.url), "utf8");
assert.match(source, /setMagnifiedSide\(null\);\s*applyDualPaneSftpOpen/);
assert.match(source, /applyDualPaneSftpOpen/);
assert.doesNotMatch(source, /firstVisitBothEmpty/);
});
test("SFTP magnification overlays one side while preserving the original two-pane geometry", async () => {
const { resolveTwoPaneMagnificationStyle } = await import("../domain/paneMagnification.ts");
+54
View File
@@ -20,6 +20,7 @@ import { activeTabStore as globalActiveTabStore, useIsSftpActive } from "../appl
import { useSftpState } from "../application/state/useSftpState";
import { useSftpBackend } from "../application/state/useSftpBackend";
import { getParentPath, isConcreteTransferTargetPath } from "../application/state/sftp/utils";
import { buildCacheKey } from "../application/state/sftp/sharedRemoteHostCache";
import { HotkeyScheme, KeyBinding, TerminalSession } from "../domain/models";
import {
getPaneMagnificationShortcutLabel,
@@ -27,6 +28,11 @@ import {
type PaneMagnificationController,
} from "../domain/paneMagnification";
import { listSftpConnectedHosts, resolveSftpTransferSourceSessionId, sftpPickerSessionsEqual } from "../domain/sftpConnectedHosts";
import { applyDualPaneSftpOpen, dualPaneTabFromPane } from "../domain/sftpDualPaneOpen";
import {
consumePendingDualPaneSftpRequest,
subscribeDualPaneSftpOpen,
} from "../application/state/sftp/sftpDualPaneOpenStore";
import { logger } from "../lib/logger";
import { useRenderTracker } from "../lib/useRenderTracker";
import { cn } from "../lib/utils";
@@ -193,6 +199,54 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
// without needing to re-create when sftp changes
const sftpRef = useRef(sftp);
sftpRef.current = sftp;
const effectiveHostsRef = useRef(effectiveHosts);
effectiveHostsRef.current = effectiveHosts;
useEffect(() => {
const toTabs = (panes: Array<{
id: string;
connection: { id: string; isLocal?: boolean; hostId?: string | null; status?: string | null } | null;
}>, getEndpointKey: (connectionId: string) => string | null) => panes.map(
(pane) => dualPaneTabFromPane(
pane,
pane.connection ? getEndpointKey(pane.connection.id) : null,
),
);
const applyRequest = (request: { hostId: string } | null) => {
if (!request) return;
const host = effectiveHostsRef.current.find((candidate) => candidate.id === request.hostId);
if (!host) return;
const current = sftpRef.current;
const hostEndpointKey = buildCacheKey(
host.id,
host.hostname,
host.port,
host.protocol,
host.sftpSudo,
host.username,
host.sftpFileProtocol,
);
// External "Open SFTP" promises a visible local-left / host-right pair.
// Clear a stale single-pane magnification before selecting or connecting.
setMagnifiedSide(null);
applyDualPaneSftpOpen(
{
leftTabs: toTabs(current.leftTabs.tabs, current.getConnectionCacheKey),
rightTabs: toTabs(current.rightTabs.tabs, current.getConnectionCacheKey),
selectTab: current.selectTab,
connect: (side, nextHost, options) => {
void current.connect(side, nextHost === "local" ? "local" : host, options);
},
},
host,
hostEndpointKey,
);
};
applyRequest(consumePendingDualPaneSftpRequest());
return subscribeDualPaneSftpOpen(applyRequest);
}, []);
// Register this useSftpState's writeTextFileByConnection with the bridge so
// the editor tab's save path can reach the active SFTP session. The bridge
+52 -2
View File
@@ -1,3 +1,4 @@
import { resolveHostOs } from '../domain/host';
import { Terminal as XTerm } from "@xterm/xterm";
import { FitAddon } from "@xterm/addon-fit";
import { SerializeAddon } from "@xterm/addon-serialize";
@@ -212,6 +213,7 @@ import {
import {
releaseTerminalFlowBeforeHibernate,
} from "./terminal/runtime/terminalSessionAttachment";
import { resetTerminalLineTimestamps } from "./terminal/runtime/terminalLineTimestamps";
import {
flushPendingTerminalWritesBeforeHibernate,
hasPendingTerminalWrites,
@@ -251,6 +253,8 @@ import {
shouldStartTerminalBackend,
} from "./terminal/restoredSessionGate";
import {
alignTerminalViewportScroll,
createSynchronizedOutputFitScheduler,
AUTO_RUN_SNIPPET_LINE_DELAY_MS,
forceSyncRenderAfterResize,
MAX_CONNECTION_LOG_DATA_CHARS,
@@ -563,6 +567,11 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const reconnectWakeInvalidateModeRef = useRef<"dispose" | "keep">("dispose");
const reconnectWakeTokenRef = useRef<symbol | null>(null);
const manualReconnectRequestRef = useRef<() => void>(() => {});
// Once a pane reconnects, every later SSH attempt in it must dial a fresh
// connection: reusing a live/idle pooled transport multiplexes onto the old
// login, so remote supplementary-group changes (e.g. `usermod -aG`) are not
// picked up until the app fully quits (#3293). See createTerminalSessionStarters.
const requireFreshConnectionOnReconnectRef = useRef(false);
const terminalDataCapturedRef = useRef(false);
const connectionLogBufferRef = useRef(createConnectionLogBuffer(MAX_CONNECTION_LOG_DATA_CHARS));
const terminalOutputHistoryRef = useRef(createTerminalOutputHistoryPreview());
@@ -637,6 +646,17 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const fontWeightFixupDoneRef = useRef(false);
const captureTerminalLogData = useCallback((data: string) => {
// Keep the history tracker's viewport size current so cursor-row and
// cursor-column moves are clamped exactly like the terminal clamps them.
const captureTerm = termRef.current;
if (captureTerm) {
terminalOutputHistoryRef.current.setViewportRows(captureTerm.rows);
terminalOutputHistoryRef.current.setViewportCols(captureTerm.cols);
// The tracker's wrap decisions must measure cell widths the way this
// terminal's Unicode provider does, or the preview diverges from what
// xterm actually rendered (e.g. `15-graphemes` emoji widths).
terminalOutputHistoryRef.current.setWidthTerminal(captureTerm);
}
const readableCommandData = commandLogRewriterRef.current.append(data);
// The alternate-screen preview reads the raw display stream (before the
// replay sanitizer, which drops alternate-screen output on purpose) but
@@ -1080,12 +1100,15 @@ const TerminalComponent: React.FC<TerminalProps> = ({
// hosts (TerminalLayer) and saved-host defaults (HostDetailsPanel) both
// stamp os: "linux", which mis-routes the autocomplete clear sequence to
// Ctrl-U on Windows where cmd/PowerShell render it literally (#1112).
const autocompleteHostOs: "linux" | "windows" | "macos" = host.protocol === "local"
const resolvedAutocompleteOs = host.protocol === "local"
? detectLocalOs(navigator.userAgent || navigator.platform)
: (host.os || "linux");
: resolveHostOs(host);
const autocompleteHostOs = resolvedAutocompleteOs === "windows" || resolvedAutocompleteOs === "macos"
? resolvedAutocompleteOs : "linux";
const autocompleteSettings = resolveTerminalAutocompleteSettings({
protocol: effectiveTerminalProtocol,
terminalSettings,
systemUnknown: resolvedAutocompleteOs === 'unknown',
isNetworkDevice: host.deviceType === 'network'
|| classifyDistroId(host.distro) === 'network-device',
});
@@ -1631,6 +1654,9 @@ const TerminalComponent: React.FC<TerminalProps> = ({
}
if (term) {
term.reset();
// The buffer was just wiped; drop ledger stamps anchored to the old
// rows so the reconnect gutter cannot paint stale timestamps.
resetTerminalLineTimestamps(term);
if (payload.snapshot) {
await new Promise<void>((resolve) => term.write(payload.snapshot, resolve));
}
@@ -2270,6 +2296,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
clearHibernateRetry();
clearAutoReconnect();
void cleanupSession();
synchronizedFitSchedulerRef.current?.dispose();
disposeRuntimeOnly();
};
@@ -2371,6 +2398,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
sessionId,
reuseConnectionFromSessionIdRef: reuseConnectionSourceRef,
requireFreshConnection,
requireFreshConnectionOnReconnectRef,
reuseConnectionSourceAttemptedRef,
setConnectionReuseAttemptSourceId,
shouldUseFreshSshConnection: () => {
@@ -2863,6 +2891,12 @@ const TerminalComponent: React.FC<TerminalProps> = ({
});
}, [reuseConnectionFromSessionId, sessionId, terminalBackend]);
const synchronizedFitSchedulerRef = useRef<ReturnType<typeof createSynchronizedOutputFitScheduler> | null>(null);
if (!synchronizedFitSchedulerRef.current) {
synchronizedFitSchedulerRef.current = createSynchronizedOutputFitScheduler();
}
useEffect(() => () => synchronizedFitSchedulerRef.current?.dispose(), []);
type SafeFitOptions = { force?: boolean; requireVisible?: boolean; immediate?: boolean; allowHidden?: boolean };
const pendingWriteSafeFitRef = useRef<{
term: XTerm;
@@ -2960,6 +2994,14 @@ const TerminalComponent: React.FC<TerminalProps> = ({
return;
}
// Keep the frozen buffer intact until synchronized output finishes.
// Re-enter safeFit to use the latest size, buffer and reading position.
if (synchronizedFitSchedulerRef.current?.defer(term, () => {
if (termRef.current === term) {
safeFitRef.current({ ...options, force: true, immediate: true });
}
})) return;
const buffer = term.buffer.active;
const wasPinnedToBottom = buffer.viewportY >= buffer.baseY;
const savedViewportY = buffer.viewportY;
@@ -2984,6 +3026,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
forceSyncRenderAfterResize(term);
}
// Reflow changes the buffer row before xterm updates its scroll range.
// Align first so the relative restore below does not apply that delta twice.
alignTerminalViewportScroll(term);
// Preserve scroll position across resize (superset/Tabby pattern).
if (wasPinnedToBottom) {
term.scrollToBottom();
@@ -3623,6 +3669,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const retryStillActive = () => retryTokenStillCurrent() && termRef.current === term;
bootEpochRef.current += 1;
// This start is a reconnect: force the bridge to dial a fresh connection
// instead of borrowing a parked/live transport, so the server performs a
// new login and picks up remote supplementary-group changes (#3293).
requireFreshConnectionOnReconnectRef.current = true;
publishBootEpoch();
isBootActiveRef.current = true;
auth.resetForRetry();
+20
View File
@@ -46,6 +46,9 @@ const baseProps = {
onSetWorkspaceFocusedSession: () => {},
isBroadcastEnabled: () => false,
onToggleBroadcast: () => {},
isGlobalBroadcastEnabled: false,
canUseGlobalBroadcast: false,
onToggleGlobalBroadcast: () => {},
updateSnippets: () => {},
updateSnippetPackages: () => {},
onSplitSession: () => {},
@@ -121,6 +124,23 @@ test("TerminalLayer re-renders when broadcast state changes", () => {
);
});
test("TerminalLayer re-renders when global broadcast state changes", () => {
assert.equal(
terminalLayerAreEqual(
baseProps as never,
{ ...baseProps, isGlobalBroadcastEnabled: true } as never,
),
false,
);
assert.equal(
terminalLayerAreEqual(
baseProps as never,
{ ...baseProps, canUseGlobalBroadcast: true } as never,
),
false,
);
});
test("TerminalLayer re-renders when terminal side panel auto-open settings change", () => {
assert.equal(
terminalLayerAreEqual(
+22 -14
View File
@@ -67,6 +67,7 @@ import { ThemeSidePanel } from './terminal/ThemeSidePanel';
import { focusTerminalSessionInput } from './terminal/focusTerminalSession';
import { TerminalComposeBar } from './terminal/TerminalComposeBar';
import { resolveTerminalFontSizeUpdateTarget } from './terminalLayer/terminalFontSizeUpdate';
import { resolveTerminalBroadcastTargetIds } from '../domain/terminalBroadcast';
import {
AUTO_RUN_SNIPPET_LINE_DELAY_MS,
shouldDelayAutoRunSnippetInput,
@@ -246,6 +247,9 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
onCreateLocalTerminal,
isBroadcastEnabled,
onToggleBroadcast,
isGlobalBroadcastEnabled,
onToggleGlobalBroadcast,
canUseGlobalBroadcast,
updateHosts,
updateSnippets,
updateSnippetPackages,
@@ -1049,27 +1053,25 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
const sessionHostsMapRef = useRef(sessionHostsMap);
sessionHostsMapRef.current = sessionHostsMap;
// Handle broadcast input - write to all other sessions in the source workspace.
// Resolve the active broadcast mode, then write to its target sessions.
const handleBroadcastInput = useCallback((
data: string,
sourceSessionId: string,
options?: TerminalBroadcastInputOptions,
) => {
const directTargetIds = options?.kittyKeyboardTargetSessionIds;
const sourceSession = directTargetIds
? undefined
: sessionsRef.current.find((session) => session.id === sourceSessionId);
const workspaceId = sourceSession?.workspaceId;
if (!directTargetIds && !workspaceId) return [];
const directTargetSet = directTargetIds ? new Set(directTargetIds) : null;
const targetSessionIds = resolveTerminalBroadcastTargetIds({
sessions: sessionsRef.current,
sourceSessionId,
globalBroadcastEnabled: isGlobalBroadcastEnabled,
directTargetSessionIds: options?.kittyKeyboardTargetSessionIds,
});
if (targetSessionIds.length === 0) return [];
const targetSessionIdSet = new Set(targetSessionIds);
const deliveredSessionIds: string[] = [];
for (const session of sessionsRef.current) {
if (directTargetSet) {
if (!directTargetSet.has(session.id)) continue;
} else if (session.workspaceId !== workspaceId || session.id === sourceSessionId) {
continue;
}
if (!targetSessionIdSet.has(session.id)) continue;
if (!canUseDirectSessionWriteFallback(session)) continue;
if (options?.kittyKeyboardInput) {
@@ -1107,7 +1109,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
deliveredSessionIds.push(session.id);
}
return deliveredSessionIds;
}, [terminalBackend]);
}, [terminalBackend, isGlobalBroadcastEnabled]);
const handleCommandSubmitted = useCallback((command: string, _hostId: string, _hostLabel: string, sessionId: string) => {
codingCliSignalController.handleCommandSubmitted(sessionId, command);
@@ -1297,6 +1299,9 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
onToggleBroadcastRef.current = onToggleBroadcast;
const workspaceBroadcastHandlersRef = useRef<Map<string, () => void>>(new Map());
const onToggleGlobalBroadcastRef = useRef(onToggleGlobalBroadcast);
onToggleGlobalBroadcastRef.current = onToggleGlobalBroadcast;
const mountedSftpTabIds = useMemo(
() => Array.from(sftpHostForTab.keys()),
[sftpHostForTab],
@@ -2298,6 +2303,8 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
restoreTerminalCwd,
identities,
isBroadcastEnabled,
isGlobalBroadcastEnabled,
canUseGlobalBroadcast,
isComposeBarOpen,
keyBindings,
keys,
@@ -2334,6 +2341,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
onSplitSession,
onSplitSessionRef,
onToggleBroadcastRef,
onToggleGlobalBroadcastRef,
onToggleWorkspaceViewMode,
onToggleWorkspaceViewModeRef,
onUpdateHost,
+18
View File
@@ -332,3 +332,21 @@ test('ChatInput wires /compact through getSystemSlashCommand and canCompact', ()
assert.match(source, /onCompact\?\.\(\)/);
assert.match(source, /command\.slug !== 'compact' \|\| canCompact/);
});
test('renders a Mention Note picker entry with a searchable note list', () => {
const source = readFileSync(new URL('./ChatInput.tsx', import.meta.url), 'utf8');
assert.match(source, /openInputPanelMenu\('noteMention'\)/);
assert.match(source, /ai\.chat\.menuMentionNote/);
assert.match(source, /aria-activedescendant=\{noteMentionItems\[activeMenuIndex\] \? `\$\{noteListId\}/);
assert.match(source, /createVaultNoteSearchIndex\(notes\)/);
assert.match(source, /onClick=\{\(\) => handleSelectNoteMention\(note\)\}/);
assert.match(source, /onKeyDown=\{handleNoteMentionKeyDown\}/);
});
test('renders vault note attachment chips with the note title', () => {
const source = readFileSync(new URL('./ChatInput.tsx', import.meta.url), 'utf8');
assert.match(source, /file\.vaultNoteId \? \(/);
assert.match(source, /\{file\.vaultNoteTitle \|\| file\.filename\}/);
});
+166 -4
View File
@@ -6,7 +6,7 @@
* and a bottom toolbar with muted controls + subtle send button.
*/
import { ArrowUp, AtSign, Check, ChevronDown, ChevronRight, Cpu, Eye, FileText, ImageIcon, Loader2, MessageSquare, Package, Plus, ShieldCheck, SquareTerminal, X, Zap } from 'lucide-react';
import { ArrowUp, AtSign, BookOpen, Check, ChevronDown, ChevronRight, Cpu, Eye, FileText, ImageIcon, Loader2, MessageSquare, Package, Plus, ShieldCheck, SquareTerminal, X, Zap } from 'lucide-react';
import {
resolveModelSelectionWithThinking,
resolveThinkingSelection,
@@ -54,6 +54,8 @@ import {
} from '../ai-elements/prompt-input';
import type { PromptInputStatus } from '../ai-elements/prompt-input';
import type { AgentModelPreset, AIPermissionMode, ProviderConfig, UploadedFile } from '../../infrastructure/ai/types';
import type { VaultNote } from '../../domain/models';
import { createVaultNoteSearchIndex } from '../../domain/notes';
import { ProviderIconBadge } from '../settings/tabs/ai/ProviderIconBadge';
import { VariableSizeVirtualList, type VariableSizeVirtualListHandle } from '../ui/VariableSizeVirtualList';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
@@ -216,6 +218,10 @@ interface ChatInputProps {
onRemoveFile?: (id: string) => void;
/** Available hosts for @ mention */
hosts?: Array<{ sessionId: string; hostname: string; label: string; connected: boolean }>;
/** Available Vault → Notes entries for the Mention Note picker */
notes?: VaultNote[];
/** Callback when the user picks a note to attach as context */
onMentionNote?: (note: VaultNote) => void;
/** User skills currently selected for the next send */
selectedUserSkills?: Array<{ id: string; slug: string; name: string; description: string }>;
/** Available user skills for /skill-slug insertion */
@@ -271,6 +277,8 @@ const ChatInput: React.FC<ChatInputProps> = ({
onAddFiles,
onRemoveFile,
hosts = [],
notes = [],
onMentionNote,
selectedUserSkills = [],
userSkills = [],
quickMessages = [],
@@ -285,7 +293,7 @@ const ChatInput: React.FC<ChatInputProps> = ({
parked = false,
}) => {
const { t } = useI18n();
const hasTerminalSelectionAttachment = files.some((file) => file.terminalSelection);
const hasTerminalSelectionAttachment = files.some((file) => file.terminalSelection || file.vaultNoteId);
const composerDisabled = disabled || isSteering;
const composerTextRef = useRef(value);
const hasTextStoreRef = useRef<ComposerHasTextStore | null>(null);
@@ -298,13 +306,15 @@ const ChatInput: React.FC<ChatInputProps> = ({
const [composerMaxHeight, setComposerMaxHeight] = useState(CHAT_INPUT_MAX_HEIGHT);
const composerDesiredHeightRef = useRef<number | null>(null);
// Consolidate menu state into a single discriminated union to prevent multiple menus open simultaneously
type ActiveMenu = 'model' | 'thinking' | 'attach' | 'atMention' | 'slashCommand' | 'perm' | null;
type ActiveMenu = 'model' | 'thinking' | 'attach' | 'atMention' | 'noteMention' | 'slashCommand' | 'perm' | null;
const [activeMenu, setActiveMenu] = useState<ActiveMenu>(null);
const [menuPos, setMenuPos] = useState<{ left: number; bottom: number } | null>(null);
const [inputPanelPos, setInputPanelPos] = useState<{ left: number; bottom: number; width: number } | null>(null);
const [modelPrefs, setModelPrefs] = useState<ComposerModelPrefs>(() => readComposerModelPrefs(pickerScope));
const [slashQuery, setSlashQuery] = useState('');
const [slashRange, setSlashRange] = useState<{ start: number; end: number } | null>(null);
// Search query for the Mention Note picker
const [noteQuery, setNoteQuery] = useState('');
// Active highlight index for @ mention / slash skill keyboard navigation
const [activeMenuIndex, setActiveMenuIndex] = useState(0);
@@ -313,6 +323,7 @@ const ChatInput: React.FC<ChatInputProps> = ({
const showThinkingPicker = activeMenu === 'thinking';
const showAttachMenu = activeMenu === 'attach';
const showAtMention = activeMenu === 'atMention';
const showNoteMention = activeMenu === 'noteMention';
const showSlashCommandPicker = activeMenu === 'slashCommand';
const showPermPicker = activeMenu === 'perm';
@@ -322,6 +333,7 @@ const ChatInput: React.FC<ChatInputProps> = ({
setInputPanelPos(null);
setSlashQuery('');
setSlashRange(null);
setNoteQuery('');
}, []);
useEffect(() => {
@@ -582,7 +594,12 @@ const ChatInput: React.FC<ChatInputProps> = ({
closeAllMenus();
}, [readComposerText, commitComposerText, closeAllMenus]);
const openInputPanelMenu = useCallback((menu: 'atMention' | 'slashCommand') => {
const handleSelectNoteMention = useCallback((note: VaultNote) => {
onMentionNote?.(note);
closeAllMenus();
}, [onMentionNote, closeAllMenus]);
const openInputPanelMenu = useCallback((menu: 'atMention' | 'noteMention' | 'slashCommand') => {
const pos = getInputPanelMenuPos();
if (!pos) return;
setMenuPos(null);
@@ -746,6 +763,58 @@ const ChatInput: React.FC<ChatInputProps> = ({
if (showSlashCommandPicker) setActiveMenuIndex(0);
}, [showSlashCommandPicker, slashCommandKey]);
// Mention Note picker: filtered notes, highlight reset, search-input focus
const searchMentionNotes = useMemo(
() => showNoteMention ? createVaultNoteSearchIndex(notes) : null,
[notes, showNoteMention],
);
const noteMentionItems = useMemo(
() => searchMentionNotes?.(noteQuery) ?? [],
[searchMentionNotes, noteQuery],
);
const noteMentionKey = useMemo(
() => JSON.stringify(noteMentionItems.map((note) => note.id)),
[noteMentionItems],
);
const noteSearchInputRef = useRef<HTMLInputElement>(null);
const noteListId = React.useId();
useEffect(() => {
if (showNoteMention) setActiveMenuIndex(0);
}, [showNoteMention, noteMentionKey]);
useEffect(() => {
if (!showNoteMention || noteMentionItems.length === 0) return;
atMentionListRef.current?.scrollToIndex(activeMenuIndex);
}, [activeMenuIndex, noteMentionKey, noteMentionItems.length, showNoteMention]);
useEffect(() => {
if (!showNoteMention) return;
noteSearchInputRef.current?.focus();
}, [showNoteMention]);
const handleNoteMentionKeyDown = useCallback((e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.nativeEvent.isComposing) return;
if (e.key === 'Escape') {
e.preventDefault();
closeAllMenus();
return;
}
if (e.key === 'Enter') {
e.preventDefault();
const note = noteMentionItems[Math.min(activeMenuIndex, noteMentionItems.length - 1)];
if (note) handleSelectNoteMention(note);
return;
}
if (noteMentionItems.length === 0) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
setActiveMenuIndex((i) => (i + 1) % noteMentionItems.length);
return;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
setActiveMenuIndex((i) => (i - 1 + noteMentionItems.length) % noteMentionItems.length);
}
}, [activeMenuIndex, closeAllMenus, handleSelectNoteMention, noteMentionItems]);
useEffect(() => {
if (!showSlashCommandPicker || !menuPos || slashCommandItems.length === 0) return;
slashPickerListRef.current?.focus();
@@ -1041,6 +1110,8 @@ const ChatInput: React.FC<ChatInputProps> = ({
>
{file.terminalSelection ? (
<SquareTerminal size={12} className="text-muted-foreground/70 shrink-0" />
) : file.vaultNoteId ? (
<BookOpen size={11} className="text-muted-foreground/60 shrink-0" />
) : file.mediaType.startsWith('image/') ? (
<ImageIcon size={11} className="text-muted-foreground/60 shrink-0" />
) : (
@@ -1053,6 +1124,12 @@ const ChatInput: React.FC<ChatInputProps> = ({
{file.lineCount ? ` · ${t('ai.chat.terminalSelectionLines').replace('{count}', String(file.lineCount))}` : ''}
</span>
</span>
) : file.vaultNoteId ? (
<span className="min-w-0">
<span className="block truncate max-w-[210px] text-foreground/82">
{file.vaultNoteTitle || file.filename}
</span>
</span>
) : (
<span className="truncate max-w-[80px]">{file.filename}</span>
)}
@@ -1209,6 +1286,76 @@ const ChatInput: React.FC<ChatInputProps> = ({
document.body,
)}
{/* Mention Note popover */}
{showNoteMention && inputPanelPos && createPortal(
<>
<div className="fixed inset-0 z-[999]" onClick={closeAllMenus} />
<div
className="fixed z-[1000] overflow-hidden rounded-lg border border-border/50 bg-popover shadow-lg"
style={{ left: inputPanelPos.left, bottom: inputPanelPos.bottom, width: 'auto', minWidth: Math.min(240, inputPanelPos.width), maxWidth: inputPanelPos.width }}
>
<div className="p-1.5 border-b border-border/40">
<input
ref={noteSearchInputRef}
role="combobox"
aria-label={t('ai.chat.menuMentionNote')}
aria-expanded={true}
aria-controls={noteListId}
aria-autocomplete="list"
aria-activedescendant={noteMentionItems[activeMenuIndex] ? `${noteListId}-${activeMenuIndex}` : undefined}
type="text"
value={noteQuery}
onChange={(e) => setNoteQuery(e.target.value)}
onKeyDown={handleNoteMentionKeyDown}
placeholder={t('ai.chat.mentionNoteSearch')}
className="w-full h-6 rounded-md bg-muted/40 px-2 text-[12px] text-foreground outline-none placeholder:text-muted-foreground/50"
/>
</div>
<div id={noteListId} role="listbox" aria-label={t('ai.chat.menuMentionNote')}>
{noteMentionItems.length === 0 ? (
<div className="px-3 py-2 text-[11px] text-muted-foreground/60">{t('ai.chat.mentionNoteEmpty')}</div>
) : (
<div className="max-h-[280px]" style={{ height: Math.min(280, 8 + noteMentionItems.reduce((total, note) => total + (note.group ? 52 : 36), 0)) }}>
<VariableSizeVirtualList
ref={atMentionListRef}
items={noteMentionItems}
getItemHeight={(note) => note.group ? 52 : 36}
getItemKey={(note) => note.id}
className="h-full"
contentClassName="p-1"
renderItem={(note, idx) => {
const isActive = idx === activeMenuIndex;
return (
<button
id={`${noteListId}-${idx}`}
type="button"
role="option"
aria-selected={isActive}
onMouseEnter={() => setActiveMenuIndex(idx)}
onClick={() => handleSelectNoteMention(note)}
className={`h-full w-full rounded-md px-2 py-1 text-left transition-colors cursor-pointer ${isActive ? 'bg-muted/40' : 'hover:bg-muted/30'}`}
>
<div className="flex items-center gap-2 text-[12px] text-foreground/90">
<BookOpen size={11} className="text-muted-foreground/50 shrink-0" />
<span className="truncate">{note.title || t('ai.chat.untitledNote')}</span>
</div>
{note.group ? (
<div className="pl-5 text-[10px] text-muted-foreground/60 truncate">
{note.group}
</div>
) : null}
</button>
);
}}
/>
</div>
)}
</div>
</div>
</>,
document.body,
)}
{/* / command popover */}
{showSlashPickerUI && createPortal(
<>
@@ -1311,6 +1458,19 @@ const ChatInput: React.FC<ChatInputProps> = ({
<ImageIcon size={13} className="text-muted-foreground/60" />
<span className="text-foreground/85">{t('ai.chat.menuImage')}</span>
</button>
<button
type="button"
role="menuitem"
aria-label={t('ai.chat.menuMentionNote')}
disabled={!onMentionNote}
title={!onMentionNote ? t('ai.chat.mentionNoteUnavailable') : undefined}
onClick={() => openInputPanelMenu('noteMention')}
className="w-full flex items-center gap-2.5 px-3 py-1.5 text-left text-[12px] hover:bg-muted/30 transition-colors cursor-pointer whitespace-nowrap disabled:opacity-40 disabled:cursor-not-allowed"
>
<BookOpen size={13} className="text-muted-foreground/60" />
<span className="flex-1 text-foreground/85">{t('ai.chat.menuMentionNote')}</span>
{notes.length > 0 && <ChevronRight size={10} className="text-muted-foreground/50" />}
</button>
<button
type="button"
role="menuitem"
@@ -1640,6 +1800,8 @@ function chatInputPropsAreEqual(prev: ChatInputProps, next: ChatInputProps): boo
&& prev.onAddFiles === next.onAddFiles
&& prev.onRemoveFile === next.onRemoveFile
&& prev.hosts === next.hosts
&& prev.notes === next.notes
&& prev.onMentionNote === next.onMentionNote
&& prev.selectedUserSkills === next.selectedUserSkills
&& prev.userSkills === next.userSkills
&& prev.quickMessages === next.quickMessages
+9 -1
View File
@@ -6,7 +6,7 @@
* No avatars. Thinking blocks are collapsible.
*/
import { AlertCircle, FileText, RotateCcw, SquareTerminal, X, ZoomIn, ZoomOut } from 'lucide-react';
import { AlertCircle, BookOpen, FileText, RotateCcw, SquareTerminal, X, ZoomIn, ZoomOut } from 'lucide-react';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import type { ChatMessage, ToolCall as AgentToolCall } from '../../infrastructure/ai/types';
@@ -701,6 +701,14 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({
<SquareTerminal size={12} className="text-muted-foreground/60 shrink-0" />
<span className="truncate max-w-[150px]">{att.filename || 'terminal selection'}</span>
</div>
) : att.vaultNoteId ? (
<div
key={att.filename ? `${att.filename}-${i}` : `att-${message.id}-${i}`}
className="inline-flex items-center gap-1.5 h-7 px-2 rounded-md bg-muted/20 border border-border/20 text-[11px] text-foreground/70"
>
<BookOpen size={12} className="text-muted-foreground/60 shrink-0" />
<span className="truncate max-w-[150px]">{att.vaultNoteTitle || att.filename || 'note'}</span>
</div>
) : att.mediaType.startsWith('image/') ? (
<img
key={att.filename ? `${att.filename}-${i}` : `att-${message.id}-${i}`}
+66
View File
@@ -1,5 +1,6 @@
import assert from "node:assert/strict";
import test from "node:test";
import { buildExternalBridgeContextMessages } from "../../infrastructure/ai/harness/externalBridgeContext.ts";
import type { ChatMessageAttachment, ToolCall, ToolResult } from "../../infrastructure/ai/types.ts";
import {
@@ -210,3 +211,68 @@ test("buildHistoricalToolReplayMaps pairs reused tool ids with the nearest prece
assert.equal(maps.resolvedToolCallsByAssistant.get(messages[1]), undefined);
assert.equal(maps.resolvedToolCallsByAssistant.get(messages[2])?.has(messages[2].toolCalls![0]), true);
});
test("buildHistoricalUserReplayContent replaces historical vault note mentions with id metadata", () => {
const attachment: ChatMessageAttachment = {
base64Data: "A".repeat(5_000),
mediaType: "text/markdown",
filename: "runbook.md",
vaultNoteId: "note-123",
vaultNoteTitle: "Runbook",
previewText: "restart nginx",
};
const result = buildHistoricalUserReplayContent("check this note", [attachment]);
assert.match(result, /check this note/);
assert.match(result, /Vault note reference/);
assert.match(result, /"noteId":"note-123"/);
assert.match(result, /"title":"Runbook"/);
assert.doesNotMatch(result, /AAAAAA/);
});
test("external recovery retains note identity before truncating a long user request", () => {
const history = buildExternalBridgeContextMessages([{
id: "long-note-request", role: "user", timestamp: 1, content: "x".repeat(2500),
attachments: [{mediaType: "text/markdown", base64Data: "", vaultNoteId: "note-123", vaultNoteTitle: "Runbook"}],
}]);
const replay = history.find((message) => message.role === "user")!;
assert.match(replay.content, /"noteId":"note-123"/);
assert.match(replay.content, /vault_notes_get/);
assert.match(replay.content, /truncated/);
assert.ok(replay.content.length <= 2000);
});
test("external recovery keeps multiple note IDs and a short user constraint", () => {
const attachments = Array.from({length: 10}, (_, index) => ({
mediaType: "text/markdown", base64Data: "", vaultNoteId: `note-${index}`, vaultNoteTitle: `Runbook ${index}`,
}));
const history = buildExternalBridgeContextMessages([{
id: "many-notes", role: "user", timestamp: 1, content: "Only compare; do not edit.", attachments,
}]);
const replay = history.find((message) => message.role === "user")!;
for (const attachment of attachments) assert.ok(replay.content.includes(JSON.stringify(attachment.vaultNoteId)));
assert.match(replay.content, /Only compare; do not edit/);
assert.equal(replay.content.match(/Use vault_notes_get/g)?.length, 1);
});
for (const terminalSelection of [false, true]) {
test(`compact external recovery retains the request and attachment (terminal=${terminalSelection}) alongside note references`, () => {
const noteId = '550e8400-e29b-41d4-a716-446655440000';
const request = "Only summarize yesterday's deployments; do not edit.";
const messages: ChatMessage[] = [{
id: 'old-note', role: 'user', timestamp: 1, content: request,
attachments: [
{ mediaType: 'text/markdown', base64Data: '', vaultNoteId: noteId, vaultNoteTitle: 'Deployment Runbook' },
{ mediaType: 'text/plain', base64Data: 'YWJj', filename: 'other.txt', terminalSelection },
],
}, ...Array.from({ length: 8 }, (_, i): ChatMessage => ({
id: `later-${i}`, role: i % 2 ? 'assistant' : 'user', timestamp: i + 2, content: i % 2 ? 'Done.' : 'ok',
}))];
const compact = buildExternalBridgeContextMessages(messages)[0].content;
assert.ok(compact.includes(noteId));
assert.ok(compact.includes(request));
assert.ok(compact.includes('other.txt'));
assert.ok(compact.includes(terminalSelection ? 'terminal selection omitted' : 'attachment omitted'));
});
}
+12 -7
View File
@@ -1,5 +1,6 @@
import type { ChatMessage, ChatMessageAttachment, ToolCall, ToolResult } from "../../infrastructure/ai/types";
import { isTerminalSelectionAttachment } from "../../application/state/terminalSelectionAttachment";
import { formatVaultNoteReferences, isVaultNoteAttachment } from "../../application/state/vaultNoteAttachment";
import { redactSecretsForModel } from "../../infrastructure/ai/harness/modelSecretRedaction";
const MAX_ATTACHMENT_PLACEHOLDER_DETAIL_CHARS = 120;
@@ -50,15 +51,19 @@ export function buildHistoricalUserReplayContent(
content: string,
attachments: ChatMessageAttachment[] = [],
): string {
const placeholders = attachments.map((attachment, index) => (
isTerminalSelectionAttachment(attachment)
? formatTerminalSelectionPlaceholder(attachment, index)
: formatAttachmentPlaceholder(attachment, index)
));
if (!attachments.length) return content;
// Keep note identities ahead of prose so bounded external history retains them.
const notes = attachments.filter(isVaultNoteAttachment);
const placeholders = attachments.filter((attachment) => !isVaultNoteAttachment(attachment))
.map((attachment, index) => (
isTerminalSelectionAttachment(attachment)
? formatTerminalSelectionPlaceholder(attachment, index)
: formatAttachmentPlaceholder(attachment, index)
));
if (!placeholders.length) return content;
const attachmentBlock = placeholders.map((line) => `\n\n${line}`).join("");
return content.trim() ? `${content}${attachmentBlock}` : placeholders.join("\n\n");
const body = content.trim() ? `${content}${attachmentBlock}` : placeholders.join("\n\n");
return notes.length ? [formatVaultNoteReferences(notes), body].filter(Boolean).join("\n\n") : body;
}
function getToolCommand(toolCall?: ToolCall): string | undefined {
@@ -0,0 +1,18 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const source = fs.readFileSync(
path.join(path.dirname(fileURLToPath(import.meta.url)), "HostTreeContextMenus.tsx"),
"utf8",
);
test("host context menu offers dual-pane SFTP without extra prop drilling", () => {
assert.match(source, /OpenDualPaneSftpMenuItem/);
assert.match(source, /requestOpenDualPaneSftp/);
assert.match(source, /vault\.hosts\.openSftp/);
assert.match(source, /useSettingsChromeStore/);
assert.match(source, /!showSftpTab\s*\|\|\s*!canOpenDualPaneSftp/);
});
+16 -1
View File
@@ -1,9 +1,12 @@
import { Copy, FileSymlink, Folder, FolderOpen, Monitor, Pencil, Plus, Server, Settings2 } from 'lucide-react';
import { Copy, FileSymlink, Files, Folder, FolderOpen, Monitor, Pencil, Plus, Server, Settings2 } from 'lucide-react';
import React from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { requestOpenDualPaneSftp } from '../../application/state/sftp/sftpDualPaneOpenStore';
import { useSettingsChromeStore } from '../../application/state/settingsChromeStore';
import { sanitizeHost } from '../../domain/host';
import { isPluginHostProtocol } from '../../domain/pluginConnection';
import { canOpenDualPaneSftp } from '../../domain/sftpDualPaneOpen';
import type { Host } from '../../types';
import { ContextMenuContent, ContextMenuItem, ContextMenuShortcut } from '../ui/context-menu';
import { collectOwnedPluginMenus, comparePluginMenus, usePluginContributions } from '../../application/state/usePluginContributions';
@@ -19,6 +22,17 @@ export interface HostTreeHostContextMenuHandlers {
onDeleteHost: (host: Host) => void;
}
export const OpenDualPaneSftpMenuItem: React.FC<{ host: Host }> = ({ host }) => {
const { t } = useI18n();
const { showSftpTab } = useSettingsChromeStore();
if (!showSftpTab || !canOpenDualPaneSftp(host)) return null;
return (
<ContextMenuItem onClick={() => requestOpenDualPaneSftp(host.id)}>
<Files className="mr-2 h-4 w-4" /> {t('vault.hosts.openSftp')}
</ContextMenuItem>
);
};
export const HostTreeHostContextMenuContent: React.FC<
HostTreeHostContextMenuHandlers & { host: Host }
> = ({
@@ -50,6 +64,7 @@ export const HostTreeHostContextMenuContent: React.FC<
<ContextMenuItem onClick={() => onConnect(safeHost)}>
<Monitor className="mr-2 h-4 w-4" /> {t('vault.hosts.connect')}
</ContextMenuItem>
<OpenDualPaneSftpMenuItem host={safeHost} />
{onEditHost && (
<ContextMenuItem onClick={() => onEditHost(host)}>
<Settings2 className="mr-2 h-4 w-4" /> {t('terminal.layer.hostTree.editHost')}
@@ -367,8 +367,15 @@ test("note editor registers a code block editor for pasted fenced code", () => {
source,
/codeBlockPlugin\([^)]*\),\s*codeMirrorPlugin\(\{\s*codeBlockLanguages:/s,
);
assert.match(source, /codeMirrorExtensions:\s*NOTE_CODE_MIRROR_EXTENSIONS/);
assert.match(source, /codeMirrorExtensions:\s*\[\s*\.\.\.NOTE_CODE_MIRROR_EXTENSIONS,/);
assert.match(source, /syntaxHighlighting\(noteCodeHighlightStyle\)/);
// Tooltips mount on body to escape clipped code blocks, so their placement
// must be bounded to this editor instead of the whole window.
assert.match(
source,
/createNoteCodeTooltipExtensions\(\s*typeof document === "undefined" \? undefined : document\.body,/,
);
assert.match(source, /\(\) => containerRef\.current,/);
});
test("note editor enables image plugin for remote markdown images", () => {
+11 -1
View File
@@ -63,6 +63,7 @@ import {
} from "./noteClipboardPaste";
import { annotateNoteImageSizes } from "./noteImageLayout";
import { renderNoteMathFormula } from "./noteMathRenderer";
import { createNoteCodeTooltipExtensions } from "./noteCodeTooltips";
import {
EMPTY_ACTIVE_FORMATS,
type ActiveTextFormats,
@@ -1203,7 +1204,16 @@ export const InlineMarkdownEditor = React.memo(
codeBlockPlugin({ defaultCodeBlockLanguage: "" }),
codeMirrorPlugin({
codeBlockLanguages: NOTE_CODE_BLOCK_LANGUAGES,
codeMirrorExtensions: NOTE_CODE_MIRROR_EXTENSIONS,
codeMirrorExtensions: [
...NOTE_CODE_MIRROR_EXTENSIONS,
...createNoteCodeTooltipExtensions(
typeof document === "undefined" ? undefined : document.body,
// Tooltips escape clipped code blocks via the body parent; bound
// their placement to this notes editor so a completion near a pane
// edge flips or shrinks instead of rendering over an adjacent pane.
() => containerRef.current,
),
],
}),
markdownShortcutPlugin(),
noteEditorDialogBridgePlugin({
+153
View File
@@ -0,0 +1,153 @@
import assert from "node:assert/strict";
import test from "node:test";
import { JSDOM } from "jsdom";
import { EditorState } from "@codemirror/state";
import { EditorView, showTooltip } from "@codemirror/view";
import { syncNoteCodeTooltipStyle, createNoteCodeTooltipExtensions, getNoteTooltipSpace } from "./noteCodeTooltips";
const stubRect = (element: Element, top: number, left: number, right: number, bottom: number) => {
element.getBoundingClientRect = () => ({
top,
left,
right,
bottom,
width: right - left,
height: bottom - top,
x: left,
y: top,
toJSON: () => ({}),
} as DOMRect);
};
test("note tooltips escape clipped code blocks and disappear with their editor", () => {
const dom = new JSDOM('<div id="note" style="overflow:hidden;height:20px"></div>', {
pretendToBeVisual: true,
});
const keys = ["window", "document", "MutationObserver", "requestAnimationFrame", "cancelAnimationFrame"] as const;
const previous = keys.map((key) => Object.getOwnPropertyDescriptor(globalThis, key));
for (const key of keys) {
const value = dom.window[key];
Object.defineProperty(globalThis, key, {
configurable: true,
value: typeof value === "function" && key.includes("AnimationFrame") ? value.bind(dom.window) : value,
});
}
let view: EditorView | undefined;
try {
const parent = dom.window.document.querySelector("#note") as HTMLElement;
view = new EditorView({
parent,
state: EditorState.create({
doc: "con",
extensions: [
...createNoteCodeTooltipExtensions(dom.window.document.body),
showTooltip.of({
pos: 0,
create() {
const tooltip = dom.window.document.createElement("div");
tooltip.textContent = "const";
return { dom: tooltip };
},
}),
],
}),
});
const tooltip = dom.window.document.querySelector(".cm-tooltip")!;
assert.ok(tooltip);
assert.equal(parent.contains(tooltip), false);
assert.equal(tooltip.parentElement?.parentElement, dom.window.document.body);
for (const themeClass of view.themeClasses.split(" ")) {
assert.ok(tooltip.parentElement?.classList.contains(themeClass));
}
view.dom.style.setProperty("--popover", "120 50% 20%");
view.dom.style.setProperty("--accent", "120 70% 40%");
syncNoteCodeTooltipStyle(view, { top: 0, left: 100, right: 300, bottom: 400 });
assert.equal((tooltip as HTMLElement).style.getPropertyValue("--popover"), "120 50% 20%");
assert.equal((tooltip as HTMLElement).style.getPropertyValue("--accent"), "120 70% 40%");
assert.equal((tooltip as HTMLElement).style.getPropertyValue("--note-tooltip-width"), "200px");
view.dom.style.setProperty("--popover", "240 50% 20%");
view.dom.style.removeProperty("--accent");
syncNoteCodeTooltipStyle(view, { top: 0, left: 100, right: 250, bottom: 400 });
assert.equal((tooltip as HTMLElement).style.getPropertyValue("--popover"), "240 50% 20%");
assert.equal((tooltip as HTMLElement).style.getPropertyValue("--accent"), "");
assert.equal((tooltip as HTMLElement).style.getPropertyValue("--note-tooltip-width"), "150px");
view.destroy();
view = undefined;
assert.equal(dom.window.document.querySelector(".cm-tooltip"), null);
} finally {
view?.destroy();
keys.forEach((key, index) => {
const descriptor = previous[index];
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else Reflect.deleteProperty(globalThis, key);
});
dom.window.close();
}
});
test("note tooltip space is bounded by clipping pane ancestors", () => {
const dom = new JSDOM('<div id="pane" style="overflow:hidden"><div id="note"></div></div>');
const pane = dom.window.document.querySelector("#pane") as HTMLElement;
const note = dom.window.document.querySelector("#note") as HTMLElement;
stubRect(pane, 100, 200, 600, 500);
// The notes editor extends below its pane; a tooltip must not follow it out.
stubRect(note, 150, 250, 550, 700);
const realGetComputedStyle = dom.window.getComputedStyle.bind(dom.window);
dom.window.getComputedStyle = ((element: Element) => ({
...realGetComputedStyle(element),
overflowX: element === pane ? "hidden" : "visible",
overflowY: element === pane ? "hidden" : "visible",
})) as typeof dom.window.getComputedStyle;
try {
assert.deepEqual(getNoteTooltipSpace(note, dom.window.document), {
top: 150,
left: 250,
right: 550,
bottom: 500,
});
} finally {
dom.window.close();
}
});
test("note tooltip space honors paint containment without overflow clipping", () => {
const dom = new JSDOM(
'<style>#pane{contain:strict}</style><div id="pane"><div id="note"></div></div>',
);
const pane = dom.window.document.querySelector("#pane") as HTMLElement;
const note = dom.window.document.querySelector("#note") as HTMLElement;
stubRect(pane, 0, 0, 800, 600);
stubRect(note, 50, -50, 850, 900);
const realGetComputedStyle = dom.window.getComputedStyle.bind(dom.window);
dom.window.getComputedStyle = ((element: Element) => ({
...realGetComputedStyle(element),
overflowX: "visible",
overflowY: "visible",
contain: element === pane ? "strict" : "none",
})) as typeof dom.window.getComputedStyle;
try {
assert.deepEqual(getNoteTooltipSpace(note, dom.window.document), {
top: 50,
left: 0,
right: 800,
bottom: 600,
});
} finally {
dom.window.close();
}
});
test("note tooltip space falls back to window bounds when the editor is unmounted", () => {
const dom = new JSDOM("<div></div>");
const detached = dom.window.document.createElement("div");
try {
assert.deepEqual(getNoteTooltipSpace(detached, dom.window.document), {
top: 0,
left: 0,
right: dom.window.document.documentElement.clientWidth,
bottom: dom.window.document.documentElement.clientHeight,
});
} finally {
dom.window.close();
}
});
+128
View File
@@ -0,0 +1,128 @@
import { Prec } from "@codemirror/state";
import { EditorView, getTooltip, showTooltip, tooltips } from "@codemirror/view";
export interface NoteTooltipSpace {
top: number;
left: number;
right: number;
bottom: number;
}
/** True when the element clips horizontally overflowing descendants. */
const clipsHorizontally = (style: CSSStyleDeclaration): boolean =>
style.overflowX !== "visible" ||
/(^|\s)(strict|content|paint)(\s|$)/.test(style.contain || "");
/** True when the element clips vertically overflowing descendants. Paint
* containment (strict/content/paint) also clips and captures fixed
* descendants even when overflow is visible. */
const clipsVertically = (style: CSSStyleDeclaration): boolean =>
style.overflowY !== "visible" ||
/(^|\s)(strict|content|paint)(\s|$)/.test(style.contain || "");
/**
* Viewport-space bounds CodeMirror may place note tooltips in: the owning
* notes region (`boundsRoot`) shrunk by every clipping ancestor (terminal
* side-panel pane hosts, ScrollArea viewports). Tooltips are mounted on
* `document.body` so they escape clipped code blocks; bounding their space
* to the notes pane keeps a completion from rendering over an adjacent
* split pane. Without a mounted `boundsRoot`, falls back to the window
* bounds CodeMirror would use by default.
*/
export const getNoteTooltipSpace = (
boundsRoot: HTMLElement | null | undefined,
doc: Document,
): NoteTooltipSpace => {
if (!boundsRoot || !boundsRoot.isConnected) {
const docElt = doc.documentElement;
return { top: 0, left: 0, right: docElt.clientWidth, bottom: docElt.clientHeight };
}
const rootRect = boundsRoot.getBoundingClientRect();
let space: NoteTooltipSpace = {
top: rootRect.top,
left: rootRect.left,
right: rootRect.right,
bottom: rootRect.bottom,
};
const defaultView = boundsRoot.ownerDocument.defaultView;
if (!defaultView) return space;
for (let node: Element | null = boundsRoot; node; node = node.parentElement) {
const style = defaultView.getComputedStyle(node);
const rect = node.getBoundingClientRect();
if (clipsHorizontally(style)) {
space = {
...space,
left: Math.max(space.left, rect.left),
right: Math.min(space.right, rect.right),
};
}
if (clipsVertically(style)) {
space = {
...space,
top: Math.max(space.top, rect.top),
bottom: Math.min(space.bottom, rect.bottom),
};
}
}
return space;
};
export const syncNoteCodeTooltipStyle = (view: EditorView, space: NoteTooltipSpace): void => {
const width = `${Math.max(0, space.right - space.left)}px`;
const paneStyle = view.dom.ownerDocument.defaultView?.getComputedStyle(view.dom);
const tokens = ["--popover", "--popover-foreground", "--border", "--accent", "--accent-foreground"];
for (const tooltip of view.state.facet(showTooltip)) {
if (!tooltip) continue;
const dom = getTooltip(view, tooltip)?.dom;
if (!dom) continue;
dom.style.setProperty("--note-tooltip-width", width);
// Detached tooltips must retain terminal-side-panel theme overrides.
for (const token of tokens) {
const value = paneStyle?.getPropertyValue(token).trim();
if (value) dom.style.setProperty(token, value);
else dom.style.removeProperty(token);
}
}
};
export const createNoteCodeTooltipExtensions = (
parent: HTMLElement | undefined,
getBoundsRoot?: () => HTMLElement | null,
) => [
// Code blocks and their enclosing note panes can clip editor descendants.
Prec.highest(
tooltips({
parent,
// Tooltips escape those clips via the global parent, so constrain their
// placement to the owning notes pane instead of the whole window.
tooltipSpace: (view: EditorView) => {
const space = getNoteTooltipSpace(getBoundsRoot?.(), view.dom.ownerDocument);
// CodeMirror constrains coordinates and height, but not width. Set
// the available width; its ResizeObserver remeasures after resizing.
syncNoteCodeTooltipStyle(view, space);
return space;
},
}),
),
// CodeMirror carries this theme's scope onto its detached tooltip container.
// Keep these rules local to Notes and use the application's existing colors.
Prec.highest(EditorView.theme({
".cm-tooltip": {
backgroundColor: "hsl(var(--popover))",
color: "hsl(var(--popover-foreground))",
border: "1px solid hsl(var(--border))",
},
".cm-tooltip-autocomplete": {
maxWidth: "var(--note-tooltip-width, 95vw)",
boxSizing: "border-box",
},
".cm-tooltip.cm-tooltip-autocomplete > ul": {
minWidth: "min(250px, max(0px, calc(var(--note-tooltip-width, 95vw) - 2px)))",
maxWidth: "min(700px, max(0px, calc(var(--note-tooltip-width, 95vw) - 2px)))",
},
".cm-tooltip-autocomplete > ul > li[aria-selected]": {
backgroundColor: "hsl(var(--accent))",
color: "hsl(var(--accent-foreground))",
},
})),
];
Loaded 100 of 281 files, more files were not shown because too many files have changed in this diff. Show more