feat!: remove push, triage, import, clone, and dead deps
Web Push: the VAPID keypair, subscription store, `/api/push/*`, the service-worker push handlers, the presence heartbeat that only existed to suppress pushes, the Notifications settings tab, and the `[web]` config section. Drops p256, hkdf, aes-gcm, jsonwebtoken and getrandom. Session triage: per-session color, web pin, and archive are gone from the Instance, the REST API, the CLI (`session color` / `archive` / `unarchive`), the TUI (the `z` and `U` keybinds, the Archived sidebar section, group archive-all) and the web sidebar (row badges, context menu, bulk actions, sort tiers, optimistic overlay). Unread stays: the marker, the auto-mark on a finished turn, dwell-to-read and the mark-read-on-open endpoint are all unchanged; only the manual toggle is removed. Claude Code session import: the on-disk scanner, `aoe session import`, `/api/claude-sessions`, and the wizard's import tab. Repo clone: `/api/git/clone`, `clone_repo` / `clone_bare_repo`, the wizard's Clone URL tab and the dashboard action. Markdown: ToolCards now renders through react-markdown like MarkdownFileView instead of carrying a second stack, dropping marked and dompurify. Dead dependencies with no call sites: portable-pty, serde_yaml, glob, clsx. Web multi-select is left in place but no longer drives anything; it existed only for bulk pin/archive. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Generated
+7
-536
@@ -8,41 +8,6 @@ version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||
|
||||
[[package]]
|
||||
name = "aead"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1973cfbc1a2daf9cf550e74e1f088c28e7f7d8c1e1418fb6c9dc5184b7e84c99"
|
||||
dependencies = [
|
||||
"crypto-common 0.2.2",
|
||||
"inout",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aes"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1fc76eaeac4c9164506c466d4ffdd8ec9d0c5bf57ee97177c4d8eceb3a0e138"
|
||||
dependencies = [
|
||||
"cipher",
|
||||
"cpubits",
|
||||
"cpufeatures 0.3.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aes-gcm"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fdf011db2e21ce0d575593d749db5554b47fed37aff429e4dc50bc91ac93a028"
|
||||
dependencies = [
|
||||
"aead",
|
||||
"aes",
|
||||
"cipher",
|
||||
"ctr",
|
||||
"ghash",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "agent-client-protocol"
|
||||
version = "1.3.0"
|
||||
@@ -97,7 +62,6 @@ dependencies = [
|
||||
name = "agent-of-empires"
|
||||
version = "1.15.0"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"agent-client-protocol",
|
||||
"agent-of-empires",
|
||||
"ansi-to-tui",
|
||||
@@ -115,20 +79,14 @@ dependencies = [
|
||||
"flate2",
|
||||
"fs2",
|
||||
"futures-util",
|
||||
"getrandom 0.4.3",
|
||||
"git2",
|
||||
"glob",
|
||||
"hkdf 0.13.0",
|
||||
"indexmap 2.14.0",
|
||||
"jsonwebtoken",
|
||||
"libc",
|
||||
"mime_guess",
|
||||
"nix 0.31.3",
|
||||
"notify",
|
||||
"nucleo-matcher",
|
||||
"p256",
|
||||
"percent-encoding",
|
||||
"portable-pty",
|
||||
"pulldown-cmark",
|
||||
"rand 0.10.2",
|
||||
"ratatui",
|
||||
@@ -142,7 +100,6 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_ignored",
|
||||
"serde_json",
|
||||
"serde_yaml",
|
||||
"serial_test",
|
||||
"sha2 0.11.0",
|
||||
"shell-words",
|
||||
@@ -403,7 +360,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ec6fb3fe69024a75fa7e1bfb48aa6cf59706a101658ea01bfd33b2b248a038f"
|
||||
dependencies = [
|
||||
"aws-lc-sys",
|
||||
"untrusted 0.7.1",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
@@ -474,12 +430,6 @@ dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "base16ct"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf"
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.22.1"
|
||||
@@ -492,12 +442,6 @@ version = "0.23.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
|
||||
|
||||
[[package]]
|
||||
name = "base64ct"
|
||||
version = "1.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
|
||||
|
||||
[[package]]
|
||||
name = "bit-set"
|
||||
version = "0.5.3"
|
||||
@@ -671,12 +615,6 @@ version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "cfg_aliases"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e"
|
||||
|
||||
[[package]]
|
||||
name = "cfg_aliases"
|
||||
version = "0.2.1"
|
||||
@@ -708,17 +646,6 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cipher"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c"
|
||||
dependencies = [
|
||||
"block-buffer 0.12.0",
|
||||
"crypto-common 0.2.2",
|
||||
"inout",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap"
|
||||
version = "4.6.6"
|
||||
@@ -777,12 +704,6 @@ dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cmov"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f88a43d011fc4a6876cb7344703e297c71dda42494fee094d5f7c76bf13f746"
|
||||
|
||||
[[package]]
|
||||
name = "colorchoice"
|
||||
version = "1.0.5"
|
||||
@@ -822,12 +743,6 @@ dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "const-oid"
|
||||
version = "0.9.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
|
||||
|
||||
[[package]]
|
||||
name = "const-oid"
|
||||
version = "0.10.2"
|
||||
@@ -859,12 +774,6 @@ version = "0.8.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
||||
|
||||
[[package]]
|
||||
name = "cpubits"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae"
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.2.17"
|
||||
@@ -932,18 +841,6 @@ dependencies = [
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crypto-bigint"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
"rand_core 0.6.4",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.1.6"
|
||||
@@ -960,9 +857,7 @@ version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
|
||||
dependencies = [
|
||||
"getrandom 0.4.3",
|
||||
"hybrid-array",
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -975,15 +870,6 @@ dependencies = [
|
||||
"phf",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ctr"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "baaca1c4b237092596f64d571e9db6ce4109c4ef9742e27590f1709594461f21"
|
||||
dependencies = [
|
||||
"cipher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ctrlc"
|
||||
version = "3.5.2"
|
||||
@@ -995,15 +881,6 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ctutils"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e"
|
||||
dependencies = [
|
||||
"cmov",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling"
|
||||
version = "0.23.0"
|
||||
@@ -1050,17 +927,6 @@ version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4"
|
||||
|
||||
[[package]]
|
||||
name = "der"
|
||||
version = "0.7.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
|
||||
dependencies = [
|
||||
"const-oid 0.9.6",
|
||||
"pem-rfc7468",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deranged"
|
||||
version = "0.5.8"
|
||||
@@ -1101,9 +967,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||
dependencies = [
|
||||
"block-buffer 0.10.4",
|
||||
"const-oid 0.9.6",
|
||||
"crypto-common 0.1.6",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1113,9 +977,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
|
||||
dependencies = [
|
||||
"block-buffer 0.12.0",
|
||||
"const-oid 0.10.2",
|
||||
"const-oid",
|
||||
"crypto-common 0.2.2",
|
||||
"ctutils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1171,12 +1034,6 @@ dependencies = [
|
||||
"litrs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "downcast-rs"
|
||||
version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
|
||||
|
||||
[[package]]
|
||||
name = "dunce"
|
||||
version = "1.0.5"
|
||||
@@ -1189,47 +1046,12 @@ version = "1.0.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
|
||||
|
||||
[[package]]
|
||||
name = "ecdsa"
|
||||
version = "0.16.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca"
|
||||
dependencies = [
|
||||
"der",
|
||||
"digest 0.10.7",
|
||||
"elliptic-curve",
|
||||
"rfc6979",
|
||||
"signature",
|
||||
"spki",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
|
||||
|
||||
[[package]]
|
||||
name = "elliptic-curve"
|
||||
version = "0.13.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47"
|
||||
dependencies = [
|
||||
"base16ct",
|
||||
"crypto-bigint",
|
||||
"digest 0.10.7",
|
||||
"ff",
|
||||
"generic-array",
|
||||
"group",
|
||||
"hkdf 0.12.4",
|
||||
"pem-rfc7468",
|
||||
"pkcs8",
|
||||
"rand_core 0.6.4",
|
||||
"sec1",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.2"
|
||||
@@ -1310,16 +1132,6 @@ version = "2.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
|
||||
|
||||
[[package]]
|
||||
name = "ff"
|
||||
version = "0.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393"
|
||||
dependencies = [
|
||||
"rand_core 0.6.4",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filedescriptor"
|
||||
version = "0.8.3"
|
||||
@@ -1544,7 +1356,6 @@ checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2"
|
||||
dependencies = [
|
||||
"typenum",
|
||||
"version_check",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1586,15 +1397,6 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ghash"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2eecf2d5dc9b66b732b97707a0210906b1d30523eb773193ab777c0c84b3e8d5"
|
||||
dependencies = [
|
||||
"polyval",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "git2"
|
||||
version = "0.21.0"
|
||||
@@ -1607,23 +1409,6 @@ dependencies = [
|
||||
"log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "glob"
|
||||
version = "0.3.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b"
|
||||
|
||||
[[package]]
|
||||
name = "group"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63"
|
||||
dependencies = [
|
||||
"ff",
|
||||
"rand_core 0.6.4",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.12.3"
|
||||
@@ -1679,42 +1464,6 @@ version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
||||
|
||||
[[package]]
|
||||
name = "hkdf"
|
||||
version = "0.12.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7"
|
||||
dependencies = [
|
||||
"hmac 0.12.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hkdf"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018"
|
||||
dependencies = [
|
||||
"hmac 0.13.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hmac"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
|
||||
dependencies = [
|
||||
"digest 0.10.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hmac"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f"
|
||||
dependencies = [
|
||||
"digest 0.11.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "http"
|
||||
version = "1.4.0"
|
||||
@@ -2019,15 +1768,6 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "inout"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7"
|
||||
dependencies = [
|
||||
"hybrid-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "instability"
|
||||
version = "0.3.12"
|
||||
@@ -2161,24 +1901,6 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jsonwebtoken"
|
||||
version = "10.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eba32bfb4ffdeaca3e34431072faf01745c9b26d25504aa7a6cf5684334fc4fc"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"base64 0.22.1",
|
||||
"getrandom 0.2.17",
|
||||
"js-sys",
|
||||
"pem",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"signature",
|
||||
"simple_asn1",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kasuari"
|
||||
version = "0.4.12"
|
||||
@@ -2437,18 +2159,6 @@ version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b"
|
||||
|
||||
[[package]]
|
||||
name = "nix"
|
||||
version = "0.28.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"cfg-if",
|
||||
"cfg_aliases 0.1.1",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nix"
|
||||
version = "0.29.0"
|
||||
@@ -2457,7 +2167,7 @@ checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"cfg-if",
|
||||
"cfg_aliases 0.2.1",
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
"memoffset",
|
||||
]
|
||||
@@ -2470,7 +2180,7 @@ checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"cfg-if",
|
||||
"cfg_aliases 0.2.1",
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
"memoffset",
|
||||
]
|
||||
@@ -2540,16 +2250,6 @@ dependencies = [
|
||||
"unicode-segmentation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-bigint"
|
||||
version = "0.4.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9"
|
||||
dependencies = [
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-conv"
|
||||
version = "0.2.1"
|
||||
@@ -2567,15 +2267,6 @@ dependencies = [
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-integer"
|
||||
version = "0.1.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.19"
|
||||
@@ -2675,18 +2366,6 @@ dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "p256"
|
||||
version = "0.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b"
|
||||
dependencies = [
|
||||
"ecdsa",
|
||||
"elliptic-curve",
|
||||
"primeorder",
|
||||
"sha2 0.10.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "palette"
|
||||
version = "0.7.6"
|
||||
@@ -2740,25 +2419,6 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pem"
|
||||
version = "3.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pem-rfc7468"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "percent-encoding"
|
||||
version = "2.3.2"
|
||||
@@ -2897,16 +2557,6 @@ dependencies = [
|
||||
"futures-io",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pkcs8"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
|
||||
dependencies = [
|
||||
"der",
|
||||
"spki",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pkg-config"
|
||||
version = "0.3.33"
|
||||
@@ -2927,44 +2577,12 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "polyval"
|
||||
version = "0.7.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b20f20e954175de5f463f67781b35583397d916b1d148738923711b2ad16bee8"
|
||||
dependencies = [
|
||||
"cpubits",
|
||||
"cpufeatures 0.3.0",
|
||||
"universal-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "portable-atomic"
|
||||
version = "1.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
|
||||
|
||||
[[package]]
|
||||
name = "portable-pty"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4a596a2b3d2752d94f51fac2d4a96737b8705dddd311a32b9af47211f08671e"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags 1.3.2",
|
||||
"downcast-rs",
|
||||
"filedescriptor",
|
||||
"lazy_static",
|
||||
"libc",
|
||||
"log",
|
||||
"nix 0.28.0",
|
||||
"serial2",
|
||||
"shared_library",
|
||||
"shell-words",
|
||||
"winapi",
|
||||
"winreg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "potential_utf"
|
||||
version = "0.1.5"
|
||||
@@ -2989,15 +2607,6 @@ dependencies = [
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "primeorder"
|
||||
version = "0.13.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6"
|
||||
dependencies = [
|
||||
"elliptic-curve",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.107"
|
||||
@@ -3025,7 +2634,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"cfg_aliases 0.2.1",
|
||||
"cfg_aliases",
|
||||
"pin-project-lite",
|
||||
"quinn-proto",
|
||||
"quinn-udp",
|
||||
@@ -3067,7 +2676,7 @@ version = "0.5.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
|
||||
dependencies = [
|
||||
"cfg_aliases 0.2.1",
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"socket2",
|
||||
@@ -3141,9 +2750,6 @@ name = "rand_core"
|
||||
version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
|
||||
dependencies = [
|
||||
"getrandom 0.2.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
@@ -3398,16 +3004,6 @@ dependencies = [
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rfc6979"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2"
|
||||
dependencies = [
|
||||
"hmac 0.12.1",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ring"
|
||||
version = "0.17.14"
|
||||
@@ -3418,7 +3014,7 @@ dependencies = [
|
||||
"cfg-if",
|
||||
"getrandom 0.2.17",
|
||||
"libc",
|
||||
"untrusted 0.9.0",
|
||||
"untrusted",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
@@ -3592,7 +3188,7 @@ dependencies = [
|
||||
"aws-lc-rs",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"untrusted 0.9.0",
|
||||
"untrusted",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3668,20 +3264,6 @@ version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "sec1"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc"
|
||||
dependencies = [
|
||||
"base16ct",
|
||||
"der",
|
||||
"generic-array",
|
||||
"pkcs8",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework"
|
||||
version = "3.7.0"
|
||||
@@ -3840,30 +3422,6 @@ dependencies = [
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_yaml"
|
||||
version = "0.9.34+deprecated"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
|
||||
dependencies = [
|
||||
"indexmap 2.14.0",
|
||||
"itoa",
|
||||
"ryu",
|
||||
"serde",
|
||||
"unsafe-libyaml",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serial2"
|
||||
version = "0.2.36"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fcdbc46aa3882ec3d48ec2b5abcb4f0d863a13d7599265f3faa6d851f23c12f3"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serial_test"
|
||||
version = "3.5.0"
|
||||
@@ -3942,16 +3500,6 @@ dependencies = [
|
||||
"lazy_static",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shared_library"
|
||||
version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a9e7e0f2bfae24d8a5b5a66c5b257a83c7412304311512a0c054cd5e619da11"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shell-words"
|
||||
version = "1.1.1"
|
||||
@@ -3995,16 +3543,6 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "signature"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
|
||||
dependencies = [
|
||||
"digest 0.10.7",
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "simd-adler32"
|
||||
version = "0.3.9"
|
||||
@@ -4036,18 +3574,6 @@ dependencies = [
|
||||
"bstr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "simple_asn1"
|
||||
version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d"
|
||||
dependencies = [
|
||||
"num-bigint",
|
||||
"num-traits",
|
||||
"thiserror 2.0.20",
|
||||
"time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "siphasher"
|
||||
version = "1.0.2"
|
||||
@@ -4076,16 +3602,6 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "spki"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"der",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sqlite-wasm-rs"
|
||||
version = "0.5.3"
|
||||
@@ -4792,28 +4308,6 @@ version = "0.2.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
||||
|
||||
[[package]]
|
||||
name = "universal-hash"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f4987bdc12753382e0bec4a65c50738ffaabc998b9cdd1f952fb5f39b0048a96"
|
||||
dependencies = [
|
||||
"crypto-common 0.2.2",
|
||||
"ctutils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unsafe-libyaml"
|
||||
version = "0.2.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
version = "0.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a"
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
version = "0.9.0"
|
||||
@@ -5405,15 +4899,6 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winreg"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d"
|
||||
dependencies = [
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winx"
|
||||
version = "0.36.4"
|
||||
@@ -5515,20 +5000,6 @@ name = "zeroize"
|
||||
version = "1.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
|
||||
dependencies = [
|
||||
"zeroize_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zeroize_derive"
|
||||
version = "1.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerotrie"
|
||||
|
||||
+2
-22
@@ -44,16 +44,12 @@ aoe-settings-derive = { path = "aoe-settings-derive" }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_ignored = "0.1"
|
||||
serde_json = "1.0"
|
||||
serde_yaml = "0.9"
|
||||
toml = "1.1"
|
||||
toml_edit = "0.25"
|
||||
|
||||
# File watching
|
||||
notify = "8.2"
|
||||
|
||||
# PTY handling
|
||||
portable-pty = "0.9"
|
||||
|
||||
# Fuzzy matching
|
||||
nucleo-matcher = "0.3"
|
||||
|
||||
@@ -79,9 +75,6 @@ rand = "0.10"
|
||||
# Directory utilities
|
||||
dirs = "6.0"
|
||||
|
||||
# Glob expansion for sandbox volume_ignores patterns (e.g. `**/bin`, `**/obj`)
|
||||
glob = "0.3"
|
||||
|
||||
# Logging
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
@@ -148,18 +141,10 @@ tower-http = { version = "0.7", features = ["fs"], optional = true }
|
||||
rust-embed = { version = "8", features = ["debug-embed"], optional = true }
|
||||
mime_guess = { version = "2.0", optional = true }
|
||||
|
||||
# Always-on: used by the TUI for OSC 52 clipboard writes (preview
|
||||
# drag-select in live mode) and re-exported via the `serve` feature
|
||||
# for Web Push payload encoding.
|
||||
# Used by the TUI for OSC 52 clipboard writes (preview drag-select in
|
||||
# live mode).
|
||||
base64 = "0.23"
|
||||
|
||||
# Web Push (VAPID + payload encryption), optional behind "serve".
|
||||
p256 = { version = "0.13", features = ["pem", "pkcs8", "ecdsa", "ecdh"], optional = true }
|
||||
hkdf = { version = "0.13", optional = true }
|
||||
aes-gcm = { version = "0.11", optional = true }
|
||||
jsonwebtoken = { version = "10.4", features = ["aws_lc_rs"], optional = true }
|
||||
getrandom = { version = "0.4", optional = true }
|
||||
|
||||
# Agent Client Protocol — drives the structured view surface bundled with `serve`.
|
||||
agent-client-protocol = { version = "1.0", optional = true, features = ["unstable_end_turn_token_usage", "unstable_elicitation", "unstable_session_fork"] }
|
||||
|
||||
@@ -207,11 +192,6 @@ serve = [
|
||||
"tower-http",
|
||||
"rust-embed",
|
||||
"mime_guess",
|
||||
"p256",
|
||||
"hkdf",
|
||||
"aes-gcm",
|
||||
"jsonwebtoken",
|
||||
"getrandom",
|
||||
"webbrowser",
|
||||
# Structured view (ACP-based structured rendering of agent state) ships
|
||||
# alongside the web dashboard — the two share state, REST routes,
|
||||
|
||||
@@ -2514,10 +2514,10 @@ impl<S: BroadcastSink> Supervisor<S> {
|
||||
/// transcript so the next respawn can resume it via `session/load`.
|
||||
///
|
||||
/// This is the temporary-teardown path: structured view stop, snooze,
|
||||
/// archive, idle auto-stop, and supersede all funnel here. They are
|
||||
/// idle auto-stop and supersede all funnel here. They are
|
||||
/// reversible, so we must NOT fire `session/delete` (which deletes
|
||||
/// the agent's on-disk transcript); doing so left every snooze /
|
||||
/// archive / idle-stop unable to resume, resetting context on the
|
||||
/// idle-stop unable to resume, resetting context on the
|
||||
/// next prompt (#1710). For permanent removal use
|
||||
/// [`Self::shutdown_and_delete`].
|
||||
pub async fn shutdown(&self, session_id: &str) -> Result<(), SupervisorError> {
|
||||
@@ -2573,7 +2573,7 @@ impl<S: BroadcastSink> Supervisor<S> {
|
||||
//
|
||||
// Gated on `delete_adapter_state`: only permanent removal
|
||||
// (session delete / disabling structured view) deletes the
|
||||
// transcript. Reversible teardown (snooze, archive, idle
|
||||
// transcript. Reversible teardown (snooze, idle
|
||||
// auto-stop, stop, supersede) must keep it so the next
|
||||
// respawn resumes via `session/load` instead of resetting
|
||||
// the conversation. See #1710.
|
||||
@@ -4527,10 +4527,10 @@ cursor-acp-bridge = "agent acp"
|
||||
}
|
||||
|
||||
/// Reversible teardown must keep the agent transcript resumable, so
|
||||
/// `shutdown` (snooze, archive, idle auto-stop, stop, supersede)
|
||||
/// `shutdown` (snooze, idle auto-stop, stop, supersede)
|
||||
/// must NOT fire `session/delete`; only permanent removal via
|
||||
/// `shutdown_and_delete` may. Regression test for the snooze /
|
||||
/// archive / idle-stop context loss in #1710. Isolates HOME so the
|
||||
/// idle-stop context loss in #1710. Isolates HOME so the
|
||||
/// registry record (which carries the stored ACP id that
|
||||
/// `try_session_delete` needs) stays out of real state, and uses a
|
||||
/// reaped, never-leader pid so the teardown's process-group SIGTERM
|
||||
|
||||
+15
-48
@@ -21,7 +21,7 @@ const TABLE_COL_STATE: usize = 9;
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
|
||||
#[value(rename_all = "lowercase")]
|
||||
enum StateFilter {
|
||||
/// Only sessions that are neither archived nor trashed.
|
||||
/// Only sessions that are not trashed.
|
||||
Live,
|
||||
/// Only sessions currently in the trash.
|
||||
Trashed,
|
||||
@@ -51,27 +51,22 @@ pub struct ListArgs {
|
||||
|
||||
/// Filter by session state. Defaults to `all`, every persisted session,
|
||||
/// which is what `aoe list` has always shown. Pass `--state=live` to skip
|
||||
/// trashed and archived rows; the vocabulary matches the REST API's
|
||||
/// trashed rows; the vocabulary matches the REST API's
|
||||
/// `GET /api/sessions?state=`.
|
||||
#[arg(long, value_enum, default_value_t = StateFilter::All)]
|
||||
state: StateFilter,
|
||||
}
|
||||
|
||||
/// Simple string tag describing whether a session is live/archived/trashed,
|
||||
/// exposed alongside `trashed_at`/`archived_at` in `--json` so a consumer
|
||||
/// keying on state does not have to reason about the timestamps itself.
|
||||
/// `live` and `trashed` are the [`SessionScope`] filter vocabulary; `archived`
|
||||
/// is an output-only third value, since `--state=archived` is not a filter the
|
||||
/// API offers either (an archived row is excluded by `live` and by `trashed`,
|
||||
/// and only shows under `all`).
|
||||
/// Simple string tag describing whether a session is live or trashed,
|
||||
/// exposed alongside `trashed_at` in `--json` so a consumer keying on state
|
||||
/// does not have to reason about the timestamp itself. The vocabulary matches
|
||||
/// the [`SessionScope`] filter.
|
||||
///
|
||||
/// Derived from [`Instance::effective_bucket`] so the `Trashed > Archived >
|
||||
/// Active` precedence has exactly one definition: an archived row that is then
|
||||
/// trashed keeps its `archived_at` but reports `trashed`.
|
||||
/// Derived from [`Instance::effective_bucket`] so the tag has exactly one
|
||||
/// definition.
|
||||
pub(super) fn state_tag(inst: &Instance) -> &'static str {
|
||||
match inst.effective_bucket() {
|
||||
SessionBucket::Trashed => "trashed",
|
||||
SessionBucket::Archived => "archived",
|
||||
SessionBucket::Active => "live",
|
||||
}
|
||||
}
|
||||
@@ -86,21 +81,15 @@ struct SessionJson {
|
||||
#[serde(skip_serializing_if = "String::is_empty")]
|
||||
command: String,
|
||||
profile: String,
|
||||
/// One of `live`, `archived`, `trashed`; the natural way to distinguish a
|
||||
/// One of `live` or `trashed`; the natural way to distinguish a
|
||||
/// trashed row from a failed one that #3350 was filed for.
|
||||
state: &'static str,
|
||||
created_at: chrono::DateTime<chrono::Utc>,
|
||||
/// Set iff the session is currently in the trash. Together with
|
||||
/// `archived_at`, lets a scripted consumer read the state without a
|
||||
/// second `aoe session list-trash` shellout.
|
||||
/// Set iff the session is currently in the trash, so a scripted
|
||||
/// consumer can read the state without a second
|
||||
/// `aoe session list-trash` shellout.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
trashed_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
/// Set iff the session is currently archived. Orthogonal to `trashed_at`
|
||||
/// on the store: `trash()` deliberately leaves `archived_at` alone so a
|
||||
/// restore is faithful, so both keys can be present at once and `state`
|
||||
/// reports `trashed` in that case.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
archived_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
/// Empty for single-repo sessions; populated with one entry per repo
|
||||
/// (including the primary) for sessions created with `--repo`/`--project`.
|
||||
workspace_repos: Vec<WorkspaceRepoJson>,
|
||||
@@ -145,7 +134,6 @@ fn session_json(inst: &Instance, profile: &str) -> SessionJson {
|
||||
state: state_tag(inst),
|
||||
created_at: inst.created_at,
|
||||
trashed_at: inst.trashed_at,
|
||||
archived_at: inst.archived_at,
|
||||
workspace_repos: workspace_repos_for(inst),
|
||||
worktree: worktree_for(inst),
|
||||
}
|
||||
@@ -244,7 +232,7 @@ fn print_table_row(inst: &Instance, show_state: bool) {
|
||||
/// information) and off when filtering to only `trashed` (same). Only
|
||||
/// meaningful under `all`, where rows are mixed and a scripted consumer
|
||||
/// or human reader benefits from distinguishing a live row from a trashed
|
||||
/// or archived one.
|
||||
/// one.
|
||||
fn table_shows_state(scope: SessionScope) -> bool {
|
||||
matches!(scope, SessionScope::All)
|
||||
}
|
||||
@@ -358,14 +346,10 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn state_tag_covers_the_three_states() {
|
||||
fn state_tag_covers_both_states() {
|
||||
let live = Instance::new("live", "/repo");
|
||||
assert_eq!(state_tag(&live), "live");
|
||||
|
||||
let mut archived = Instance::new("archived", "/repo");
|
||||
archived.archive();
|
||||
assert_eq!(state_tag(&archived), "archived");
|
||||
|
||||
let mut trashed = Instance::new("trashed", "/repo");
|
||||
trashed.trash();
|
||||
assert_eq!(state_tag(&trashed), "trashed");
|
||||
@@ -382,20 +366,6 @@ mod tests {
|
||||
let json = session_json(&inst, "p");
|
||||
assert_eq!(json.state, "trashed");
|
||||
assert!(json.trashed_at.is_some());
|
||||
assert!(json.archived_at.is_none());
|
||||
}
|
||||
|
||||
/// Companion for the follow-up comment on #3350: archived sessions
|
||||
/// need the same treatment. The two states are semantically distinct
|
||||
/// and both must be observable from a single `aoe list --json` call.
|
||||
#[test]
|
||||
fn session_json_exposes_state_and_archived_at_for_an_archived_row() {
|
||||
let mut inst = Instance::new("z", "/repo");
|
||||
inst.archive();
|
||||
let json = session_json(&inst, "p");
|
||||
assert_eq!(json.state, "archived");
|
||||
assert!(json.archived_at.is_some());
|
||||
assert!(json.trashed_at.is_none());
|
||||
}
|
||||
|
||||
/// The default `state = "live"` and both timestamp fields being
|
||||
@@ -411,7 +381,6 @@ mod tests {
|
||||
assert_eq!(json.state, "live");
|
||||
let serialized = serde_json::to_string(&json).unwrap();
|
||||
assert!(!serialized.contains("trashed_at"));
|
||||
assert!(!serialized.contains("archived_at"));
|
||||
assert!(serialized.contains("\"state\":\"live\""));
|
||||
}
|
||||
|
||||
@@ -427,9 +396,7 @@ mod tests {
|
||||
let live_inst = Instance::new("l", "/r");
|
||||
let mut trashed = Instance::new("t", "/r");
|
||||
trashed.trash();
|
||||
let mut archived = Instance::new("a", "/r");
|
||||
archived.archive();
|
||||
for inst in [&live_inst, &trashed, &archived] {
|
||||
for inst in [&live_inst, &trashed] {
|
||||
assert!(
|
||||
SessionScope::matches(Some(default), inst),
|
||||
"default state=all must list every session"
|
||||
|
||||
+1
-1
@@ -45,7 +45,7 @@ pub fn resolve_session<'a>(identifier: &str, instances: &'a [Instance]) -> Resul
|
||||
|
||||
// Try ID prefix match. If more than one session has an ID starting with
|
||||
// `identifier`, fail loudly instead of silently mutating the first one.
|
||||
// Mutating commands (archive, kill) could otherwise act on the
|
||||
// Mutating commands (kill) could otherwise act on the
|
||||
// wrong session when the user provides a too-short prefix.
|
||||
let prefix_matches: Vec<&Instance> = instances
|
||||
.iter()
|
||||
|
||||
+1
-1
@@ -87,7 +87,7 @@ pub async fn run(profile: &str, args: RemoveArgs) -> Result<()> {
|
||||
|
||||
// Trash-first: unless --purge is given (or delete_to_trash is disabled),
|
||||
// stop the live session and mark it trashed, keeping every durable
|
||||
// artifact so it can be restored. Mirrors the archive CLI's tmux
|
||||
// artifact so it can be restored. Mirrors the stop path's tmux
|
||||
// teardown. See #2489.
|
||||
if config.session.delete_to_trash && !args.purge {
|
||||
let _lifecycle_lock = storage
|
||||
|
||||
+2
-2
@@ -92,7 +92,7 @@ pub async fn run(profile: &str, args: SendArgs) -> Result<()> {
|
||||
// given fresh input; the next status poll will reconcile the real state,
|
||||
// but flipping to Running immediately keeps the row from sticking on a
|
||||
// stale Idle/Waiting label during the gap between send and poll.
|
||||
// `touch_last_accessed` also auto-clears `archived_at`
|
||||
// `touch_last_accessed` also clears idle dormancy
|
||||
// (see Instance::touch_last_accessed), so a user can wake any sunk row by
|
||||
// sending to it.
|
||||
let id_for_save = session_id.clone();
|
||||
@@ -104,7 +104,7 @@ pub async fn run(profile: &str, args: SendArgs) -> Result<()> {
|
||||
Ok(())
|
||||
}) {
|
||||
// The tmux send succeeded; the storage write is best-effort
|
||||
// bookkeeping (status remap + auto-unarchive). Surfacing this as a
|
||||
// bookkeeping (status remap + dormancy wake). Surfacing this as a
|
||||
// hard error would tell the user "send failed" when the message
|
||||
// actually reached the agent, so log a warning and keep the success
|
||||
// line. The next status poll will reconcile the row anyway.
|
||||
|
||||
+13
-597
@@ -7,7 +7,7 @@ use std::collections::HashSet;
|
||||
|
||||
use crate::session::{
|
||||
acquire_session_identity_lock, duplicate_session_error, is_duplicate_session, GroupTree,
|
||||
Instance, LifecycleOperation, ResumeIntent, StartOutcome, Storage,
|
||||
Instance, LifecycleOperation, StartOutcome, Storage,
|
||||
};
|
||||
|
||||
#[derive(Subcommand)]
|
||||
@@ -56,33 +56,10 @@ pub enum SessionCommands {
|
||||
/// Clear the favorite flag on a session.
|
||||
Unfavorite(SessionIdArgs),
|
||||
|
||||
/// Set (or clear) a per-session color label, rendered as a colored dot in
|
||||
/// the web sidebar for at-a-glance status signaling. Intended for a
|
||||
/// running agent to flag its own state, e.g.
|
||||
/// `aoe session color $(aoe session current -q) red`. Colors: `red`
|
||||
/// (needs attention), `amber` (working), `green` (done); `none` clears it.
|
||||
Color(SetColorArgs),
|
||||
|
||||
/// Archive a session: sink it in the Attention sort and tear down its
|
||||
/// tmux sessions. Worktree, branch, container preserved. `--no-kill`
|
||||
/// skips tmux teardown. See #1868.
|
||||
Archive(ArchiveArgs),
|
||||
|
||||
/// Unarchive a session (restores it to its tier in the Attention sort)
|
||||
Unarchive(SessionIdArgs),
|
||||
|
||||
/// Restore a trashed session, returning it to its prior bucket with its
|
||||
/// transcript and metadata intact. See #2489.
|
||||
Restore(SessionIdArgs),
|
||||
|
||||
/// Import existing Claude Code sessions from disk. Scans the given
|
||||
/// path(s) (default: current directory) for Claude Code conversations
|
||||
/// whose working directory is at or under a path, and creates an AoE
|
||||
/// session for each: a terminal/tmux session that resumes the
|
||||
/// conversation with `claude --resume <id>` (default), or a
|
||||
/// structured-view session with `--structured`.
|
||||
Import(ImportArgs),
|
||||
|
||||
/// List the sessions currently in the trash.
|
||||
ListTrash,
|
||||
|
||||
@@ -90,52 +67,6 @@ pub enum SessionCommands {
|
||||
EmptyTrash,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct ImportArgs {
|
||||
/// Directories to scan. Only Claude sessions whose recorded working
|
||||
/// directory is at or under one of these are imported. Defaults to the
|
||||
/// current directory. Cannot be combined with `--all`.
|
||||
pub paths: Vec<String>,
|
||||
|
||||
/// Import every discoverable Claude session, ignoring the path filter.
|
||||
#[arg(long, conflicts_with = "paths")]
|
||||
pub all: bool,
|
||||
|
||||
/// Import as structured-view sessions (rendered in the web dashboard and
|
||||
/// the structured TUI view) instead of terminal/tmux sessions. Structured
|
||||
/// sessions replay their transcript under `aoe serve`.
|
||||
#[cfg(feature = "serve")]
|
||||
#[arg(long)]
|
||||
pub structured: bool,
|
||||
|
||||
/// Place imported sessions under this session group.
|
||||
#[arg(long)]
|
||||
pub group: Option<String>,
|
||||
|
||||
/// Start terminal sessions immediately after importing (spawns the tmux
|
||||
/// pane running `claude --resume <id>`). Ignored for structured imports.
|
||||
#[arg(long)]
|
||||
pub launch: bool,
|
||||
|
||||
/// List what would be imported without creating anything.
|
||||
#[arg(long)]
|
||||
pub dry_run: bool,
|
||||
|
||||
/// Skip the confirmation prompt when importing more than one session.
|
||||
#[arg(long, short = 'y')]
|
||||
pub yes: bool,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct ArchiveArgs {
|
||||
/// Session ID or title
|
||||
pub identifier: String,
|
||||
|
||||
/// Skip tmux teardown on archive.
|
||||
#[arg(long = "no-kill")]
|
||||
pub no_kill: bool,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct SessionIdArgs {
|
||||
/// Session ID or title
|
||||
@@ -254,15 +185,6 @@ pub struct SetSessionIdArgs {
|
||||
session_id: String,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct SetColorArgs {
|
||||
/// Session ID or title
|
||||
pub identifier: String,
|
||||
/// Color label: `red` (needs attention), `amber` (working), `green`
|
||||
/// (done), or `none`/`clear` to remove the label.
|
||||
pub color: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct SessionDetails {
|
||||
id: String,
|
||||
@@ -272,17 +194,15 @@ struct SessionDetails {
|
||||
tool: String,
|
||||
command: String,
|
||||
status: String,
|
||||
/// The same `live`/`archived`/`trashed` tag `aoe list --json` carries
|
||||
/// The same `live`/`trashed` tag `aoe list --json` carries
|
||||
/// (#3350/#3361), so a consumer does not have to fall back to `list` to
|
||||
/// learn whether the session it just looked up is still around. `status`
|
||||
/// cannot carry it: an archived session can be running, so the two are
|
||||
/// independent and collapsing them loses one.
|
||||
/// cannot carry it: a trashed session can still be running, so the two
|
||||
/// are independent and collapsing them loses one.
|
||||
state: &'static str,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
trashed_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
archived_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
agent_session_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
parent_session_id: Option<String>,
|
||||
@@ -300,7 +220,6 @@ fn session_details(inst: &Instance, profile: &str) -> SessionDetails {
|
||||
status: format!("{:?}", inst.status).to_lowercase(),
|
||||
state: super::list::state_tag(inst),
|
||||
trashed_at: inst.trashed_at,
|
||||
archived_at: inst.archived_at,
|
||||
agent_session_id: inst.agent_session_id.clone(),
|
||||
parent_session_id: inst.parent_session_id.clone(),
|
||||
profile: profile.to_string(),
|
||||
@@ -322,11 +241,7 @@ pub async fn run(profile: &str, command: SessionCommands) -> Result<()> {
|
||||
SessionCommands::SetSessionId(args) => set_session_id(profile, args).await,
|
||||
SessionCommands::Favorite(args) => favorite_session(profile, args).await,
|
||||
SessionCommands::Unfavorite(args) => unfavorite_session(profile, args).await,
|
||||
SessionCommands::Color(args) => set_color_session(profile, args).await,
|
||||
SessionCommands::Archive(args) => archive_session(profile, args).await,
|
||||
SessionCommands::Unarchive(args) => unarchive_session(profile, args).await,
|
||||
SessionCommands::Restore(args) => restore_session(profile, args).await,
|
||||
SessionCommands::Import(args) => import_sessions(profile, args).await,
|
||||
SessionCommands::ListTrash => list_trash(profile).await,
|
||||
SessionCommands::EmptyTrash => empty_trash(profile).await,
|
||||
}
|
||||
@@ -356,91 +271,11 @@ async fn unfavorite_session(profile: &str, args: SessionIdArgs) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn set_color_session(profile: &str, args: SetColorArgs) -> Result<()> {
|
||||
// `none`/`clear`/empty clears the label; anything else must be a palette
|
||||
// member (validated inside `Instance::set_color`).
|
||||
let normalized = args.color.trim().to_lowercase();
|
||||
let new_color = match normalized.as_str() {
|
||||
"none" | "clear" | "" => None,
|
||||
other => Some(other.to_string()),
|
||||
};
|
||||
|
||||
let storage = Storage::new_unwatched(profile)?;
|
||||
let (title, color) = storage.update(|instances, _groups| {
|
||||
super::patch_instance(instances, &args.identifier, |inst| {
|
||||
inst.set_color(new_color.clone())
|
||||
.map_err(|e| anyhow::anyhow!(e))?;
|
||||
Ok((inst.title.clone(), inst.color.clone()))
|
||||
})
|
||||
})?;
|
||||
|
||||
match color {
|
||||
Some(c) => println!("✓ Set color for '{}': {}", title, c),
|
||||
None => println!("✓ Cleared color for '{}'", title),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn archive_session(profile: &str, args: ArchiveArgs) -> Result<()> {
|
||||
let storage = Storage::open_unwatched(profile)?;
|
||||
|
||||
// Phase 1 (unlocked): resolve identifier.
|
||||
let (instances, _groups) = storage.load_with_groups()?;
|
||||
let inst = super::resolve_session(&args.identifier, &instances)?;
|
||||
let id = inst.id.clone();
|
||||
let title = inst.title.clone();
|
||||
let inst = inst.clone();
|
||||
|
||||
// Serialize teardown and the archive commit as one lifecycle transition.
|
||||
let _lifecycle_lock = storage
|
||||
.acquire_instance_lifecycle_lock(&id)
|
||||
.context("failed to acquire instance archive lock")?;
|
||||
if !args.no_kill {
|
||||
if let Err(e) = inst.kill_locked() {
|
||||
eprintln!("Warning: failed to kill agent tmux session: {}", e);
|
||||
}
|
||||
inst.kill_ancillary_tmux_sessions_locked();
|
||||
}
|
||||
|
||||
// Archive under the lifecycle lock so the state and its generation bump are
|
||||
// durable before the lock releases and a concurrent restart can observe them.
|
||||
let landed = storage.update(|instances, _groups| {
|
||||
if let Some(stored) = instances.iter_mut().find(|i| i.id == id) {
|
||||
stored.archive();
|
||||
stored.lifecycle_generation = stored.lifecycle_generation.saturating_add(1);
|
||||
Ok(true)
|
||||
} else {
|
||||
Ok(false)
|
||||
}
|
||||
})?;
|
||||
if landed {
|
||||
println!("Archived: {}", title);
|
||||
Ok(())
|
||||
} else {
|
||||
bail!(
|
||||
"Session {} was removed by another process before archive could land",
|
||||
title
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async fn unarchive_session(profile: &str, args: SessionIdArgs) -> Result<()> {
|
||||
let storage = Storage::open_unwatched(profile)?;
|
||||
let title = storage.update(|instances, _groups| {
|
||||
super::patch_instance(instances, &args.identifier, |inst| {
|
||||
inst.unarchive();
|
||||
Ok(inst.title.clone())
|
||||
})
|
||||
})?;
|
||||
println!("Unarchived: {}", title);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn restore_session(profile: &str, args: SessionIdArgs) -> Result<()> {
|
||||
let storage = Storage::open_unwatched(profile)?;
|
||||
|
||||
// Resolve within the trashed subset only. The CLI advertises the argument
|
||||
// as an id OR title, and a live or archived session can share a title/path
|
||||
// as an id OR title, and a live session can share a title/path
|
||||
// with a trashed one; resolving against the full list would let that row
|
||||
// win and make `untrash()` a silent no-op on an already-live session.
|
||||
// See #2489.
|
||||
@@ -782,259 +617,6 @@ fn bail_if_acp(_inst: &crate::session::Instance, _verb: &str) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resolve the scan roots for `aoe session import`. Empty input means the
|
||||
/// current directory. Each root is canonicalized (falling back to the path as
|
||||
/// given if it does not resolve) so the component-aware filter compares against
|
||||
/// absolute paths.
|
||||
fn resolve_import_roots(paths: &[String]) -> Result<Vec<std::path::PathBuf>> {
|
||||
let raw: Vec<std::path::PathBuf> = if paths.is_empty() {
|
||||
vec![std::env::current_dir()?]
|
||||
} else {
|
||||
paths.iter().map(std::path::PathBuf::from).collect()
|
||||
};
|
||||
Ok(raw
|
||||
.into_iter()
|
||||
.map(|p| p.canonicalize().unwrap_or(p))
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// True when `id` is already imported by some instance, so a re-run does not
|
||||
/// create duplicates. Checks the terminal resume target, the poller-observed
|
||||
/// id, and (serve builds) the structured-view id.
|
||||
fn already_imported(instances: &[Instance], id: &str) -> bool {
|
||||
instances.iter().any(|inst| {
|
||||
if inst.agent_session_id.as_deref() == Some(id) {
|
||||
return true;
|
||||
}
|
||||
if matches!(&inst.resume_intent, ResumeIntent::Use(s) if s == id) {
|
||||
return true;
|
||||
}
|
||||
#[cfg(feature = "serve")]
|
||||
if inst.acp_session_id.as_deref() == Some(id) {
|
||||
return true;
|
||||
}
|
||||
false
|
||||
})
|
||||
}
|
||||
|
||||
/// Build the AoE `Instance` for one discovered Claude session. Terminal imports
|
||||
/// pin `resume_intent` so the first launch emits `claude --resume <id>`;
|
||||
/// structured imports (serve only) seed the fields the reconciler reads to
|
||||
/// replay the transcript.
|
||||
fn build_import_instance(
|
||||
s: &crate::session::claude_import::ClaudeSessionSummary,
|
||||
structured: bool,
|
||||
group: &str,
|
||||
) -> Instance {
|
||||
let title = s.title.clone().unwrap_or_else(|| {
|
||||
let short = s.session_id.get(..8).unwrap_or(s.session_id.as_str());
|
||||
format!("Claude import {short}")
|
||||
});
|
||||
let mut inst = Instance::new(&title, &s.cwd);
|
||||
inst.tool = "claude".to_string();
|
||||
if !group.is_empty() {
|
||||
inst.group_path = group.to_string();
|
||||
}
|
||||
apply_import_mode(&mut inst, s, structured);
|
||||
inst
|
||||
}
|
||||
|
||||
#[cfg(feature = "serve")]
|
||||
fn apply_import_mode(
|
||||
inst: &mut Instance,
|
||||
s: &crate::session::claude_import::ClaudeSessionSummary,
|
||||
structured: bool,
|
||||
) {
|
||||
if structured {
|
||||
inst.view = crate::session::View::Structured;
|
||||
inst.acp_session_id = Some(s.session_id.clone());
|
||||
inst.import_pending = Some(true);
|
||||
} else {
|
||||
inst.resume_intent = ResumeIntent::Use(s.session_id.clone());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "serve"))]
|
||||
fn apply_import_mode(
|
||||
inst: &mut Instance,
|
||||
s: &crate::session::claude_import::ClaudeSessionSummary,
|
||||
_structured: bool,
|
||||
) {
|
||||
inst.resume_intent = ResumeIntent::Use(s.session_id.clone());
|
||||
}
|
||||
|
||||
async fn import_sessions(profile: &str, args: ImportArgs) -> Result<()> {
|
||||
use crate::session::claude_import::{scan_sessions, sessions_under_paths, MAX_SESSIONS};
|
||||
|
||||
#[cfg(feature = "serve")]
|
||||
let structured = args.structured;
|
||||
#[cfg(not(feature = "serve"))]
|
||||
let structured = false;
|
||||
|
||||
// Discover, then narrow to the requested paths unless --all.
|
||||
let mut discovered = scan_sessions();
|
||||
if !args.all {
|
||||
let roots = resolve_import_roots(&args.paths)?;
|
||||
discovered = sessions_under_paths(discovered, &roots);
|
||||
}
|
||||
|
||||
// A session whose recorded cwd no longer exists cannot be resumed:
|
||||
// `claude --resume` resolves the transcript by cwd, so a dead cwd would
|
||||
// silently start a fresh conversation. Skip and report those.
|
||||
let (candidates, missing_cwd): (Vec<_>, Vec<_>) =
|
||||
discovered.into_iter().partition(|s| s.cwd_exists);
|
||||
|
||||
// Dedupe against sessions already imported into this profile.
|
||||
let (existing, _groups) = Storage::open_unwatched(profile)?.load_with_groups()?;
|
||||
let candidate_count = candidates.len();
|
||||
let mut to_import: Vec<_> = candidates
|
||||
.into_iter()
|
||||
.filter(|s| !already_imported(&existing, &s.session_id))
|
||||
.collect();
|
||||
let already = candidate_count - to_import.len();
|
||||
|
||||
// Bulk safety backstop; the picker cap also applies to the CLI.
|
||||
let capped = to_import.len() > MAX_SESSIONS;
|
||||
if capped {
|
||||
to_import.truncate(MAX_SESSIONS);
|
||||
}
|
||||
|
||||
let report_skipped = || {
|
||||
if already > 0 {
|
||||
println!(" ({already} already imported, skipped)");
|
||||
}
|
||||
if !missing_cwd.is_empty() {
|
||||
println!(
|
||||
" ({} skipped: working directory no longer exists)",
|
||||
missing_cwd.len()
|
||||
);
|
||||
}
|
||||
if capped {
|
||||
println!(" (capped at {MAX_SESSIONS}; narrow the path(s) to import the rest)");
|
||||
}
|
||||
};
|
||||
|
||||
if to_import.is_empty() {
|
||||
println!("No new Claude Code sessions to import.");
|
||||
report_skipped();
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let kind = if structured { "structured" } else { "terminal" };
|
||||
println!(
|
||||
"Found {} Claude Code session(s) to import as {kind} sessions:",
|
||||
to_import.len()
|
||||
);
|
||||
for s in &to_import {
|
||||
let short = s.session_id.get(..8).unwrap_or(s.session_id.as_str());
|
||||
let title = s.title.as_deref().unwrap_or("(no title)");
|
||||
println!(" {short} {title} [{}]", s.cwd);
|
||||
}
|
||||
report_skipped();
|
||||
|
||||
if args.dry_run {
|
||||
println!("Dry run: nothing created.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if to_import.len() > 1 && !args.yes {
|
||||
use std::io::Write;
|
||||
print!("Import {} session(s)? [y/N] ", to_import.len());
|
||||
std::io::stdout().flush().ok();
|
||||
let mut input = String::new();
|
||||
std::io::stdin().read_line(&mut input)?;
|
||||
if !matches!(input.trim().to_lowercase().as_str(), "y" | "yes") {
|
||||
println!("Aborted.");
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
let group = args.group.clone().unwrap_or_default();
|
||||
let storage = Storage::open_unwatched(profile)?;
|
||||
let created_ids = storage.update(|all_instances, groups| {
|
||||
let mut ids = Vec::new();
|
||||
for s in &to_import {
|
||||
// Re-check under the lock so a concurrent import does not duplicate.
|
||||
if already_imported(all_instances, &s.session_id) {
|
||||
continue;
|
||||
}
|
||||
let inst = build_import_instance(s, structured, &group);
|
||||
ids.push(inst.id.clone());
|
||||
all_instances.push(inst.clone());
|
||||
if !inst.group_path.is_empty() {
|
||||
let mut tree = GroupTree::new_with_groups(all_instances, groups);
|
||||
tree.create_group(&inst.group_path);
|
||||
*groups = tree.get_all_groups();
|
||||
}
|
||||
}
|
||||
Ok(ids)
|
||||
})?;
|
||||
|
||||
println!("✓ Imported {} session(s).", created_ids.len());
|
||||
|
||||
if structured {
|
||||
if args.launch {
|
||||
println!("Note: --launch is ignored for structured imports.");
|
||||
}
|
||||
println!(
|
||||
"Structured sessions replay their transcript on the next `aoe serve` \
|
||||
(auto-spawned within ~2s while serve is running)."
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if args.launch {
|
||||
launch_imported(profile, &created_ids)?;
|
||||
} else if !created_ids.is_empty() {
|
||||
println!("Start them with `aoe session start <id>` (or launch on import with --launch).");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Start freshly imported terminal sessions, spawning each tmux pane. Mirrors
|
||||
/// `start_session`'s three-phase pattern; failures are reported per session and
|
||||
/// do not abort the rest.
|
||||
fn launch_imported(profile: &str, ids: &[String]) -> Result<()> {
|
||||
let storage = Storage::open_unwatched(profile)?;
|
||||
let file_watch = crate::file_watch::FileWatchService::noop();
|
||||
for id in ids {
|
||||
let (instances, _groups) = storage.load_with_groups()?;
|
||||
let Some(inst) = instances.iter().find(|i| &i.id == id) else {
|
||||
continue;
|
||||
};
|
||||
let mut working = inst.clone();
|
||||
working.source_profile = profile.to_string();
|
||||
// See `start_session`: a cleared sid whose rollout is still newest on
|
||||
// disk would be re-adopted by the drain below.
|
||||
let prior_sid = working.agent_session_id.clone();
|
||||
if let Err(e) = working.start_with_size(crate::terminal::get_size()) {
|
||||
eprintln!("Warning: failed to start {}: {e}", working.title);
|
||||
continue;
|
||||
}
|
||||
if working.agent_session_id.is_none() {
|
||||
if let Some(sid) = prior_sid {
|
||||
working.retroactive_capture_excludes.insert(sid);
|
||||
}
|
||||
}
|
||||
// Persist the poller-observed id before exit (see start_session).
|
||||
crate::session::sync::capture_launched_session_id_blocking(
|
||||
&mut working,
|
||||
&file_watch,
|
||||
crate::session::sync::CLI_SESSION_ID_CAPTURE_TIMEOUT,
|
||||
true,
|
||||
);
|
||||
let wid = working.id.clone();
|
||||
storage.update(|instances, _groups| {
|
||||
if let Some(stored) = instances.iter_mut().find(|i| i.id == wid) {
|
||||
stored.merge_post_start(&working);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
println!("✓ Started {}", working.title);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// CLI handler for `aoe session stop`.
|
||||
async fn stop_session(profile: &str, args: SessionIdArgs) -> Result<()> {
|
||||
let storage = Storage::open_unwatched(profile)?;
|
||||
@@ -1468,10 +1050,10 @@ async fn show_session(profile: &str, args: ShowArgs) -> Result<()> {
|
||||
println!(" Tool: {}", inst.tool);
|
||||
println!(" Command: {}", inst.command);
|
||||
println!(" Status: {:?}", inst.status);
|
||||
// Only for a session that is not live: an archived or trashed row is
|
||||
// otherwise indistinguishable here from a stopped one, and `status`
|
||||
// cannot carry it (a session can be archived and running at once).
|
||||
if let Some(at) = inst.trashed_at.or(inst.archived_at) {
|
||||
// Only for a session that is not live: a trashed row is otherwise
|
||||
// indistinguishable here from a stopped one, and `status` cannot
|
||||
// carry it (a session can be trashed and still running).
|
||||
if let Some(at) = inst.trashed_at {
|
||||
println!(
|
||||
" State: {} ({})",
|
||||
super::list::state_tag(&inst),
|
||||
@@ -2116,93 +1698,6 @@ mod set_session_id_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod set_color_tests {
|
||||
use super::{set_color_session, SetColorArgs};
|
||||
use crate::session::{Instance, Storage};
|
||||
use serial_test::serial;
|
||||
use tempfile::tempdir;
|
||||
|
||||
async fn seed(profile: &str) -> (Storage, String) {
|
||||
let storage = Storage::new_unwatched(profile).unwrap();
|
||||
let inst = Instance::new("color_session", "/tmp/x");
|
||||
let id = inst.id.clone();
|
||||
let on_disk = inst.clone();
|
||||
storage
|
||||
.update(|i, g| {
|
||||
*i = vec![on_disk.clone()];
|
||||
*g =
|
||||
crate::session::GroupTree::new_with_groups(std::slice::from_ref(&on_disk), &[])
|
||||
.get_all_groups();
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
(storage, id)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn set_color_persists_palette_value_and_clears() {
|
||||
let temp = tempdir().unwrap();
|
||||
std::env::set_var("HOME", temp.path());
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
std::env::set_var("XDG_CONFIG_HOME", temp.path().join(".config"));
|
||||
|
||||
let (storage, id) = seed("set-color-ok").await;
|
||||
|
||||
set_color_session(
|
||||
"set-color-ok",
|
||||
SetColorArgs {
|
||||
identifier: id.clone(),
|
||||
color: "Red".to_string(), // case-insensitive
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let loaded = storage.load().unwrap();
|
||||
assert_eq!(
|
||||
loaded.iter().find(|i| i.id == id).unwrap().color.as_deref(),
|
||||
Some("red")
|
||||
);
|
||||
|
||||
set_color_session(
|
||||
"set-color-ok",
|
||||
SetColorArgs {
|
||||
identifier: id.clone(),
|
||||
color: "none".to_string(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let loaded = storage.load().unwrap();
|
||||
assert_eq!(loaded.iter().find(|i| i.id == id).unwrap().color, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn set_color_rejects_unknown_color() {
|
||||
let temp = tempdir().unwrap();
|
||||
std::env::set_var("HOME", temp.path());
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
std::env::set_var("XDG_CONFIG_HOME", temp.path().join(".config"));
|
||||
|
||||
let (storage, id) = seed("set-color-bad").await;
|
||||
|
||||
let result = set_color_session(
|
||||
"set-color-bad",
|
||||
SetColorArgs {
|
||||
identifier: id.clone(),
|
||||
color: "chartreuse".to_string(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
assert!(result.is_err(), "unknown color must error");
|
||||
// The rejected write must not have touched disk.
|
||||
let loaded = storage.load().unwrap();
|
||||
assert_eq!(loaded.iter().find(|i| i.id == id).unwrap().color, None);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod rename_tests {
|
||||
use super::{rename_session, RenameArgs};
|
||||
@@ -2411,100 +1906,21 @@ mod acp_reject_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod import_tests {
|
||||
use super::*;
|
||||
use crate::session::claude_import::ClaudeSessionSummary;
|
||||
|
||||
fn summary(id: &str, cwd: &str, title: Option<&str>) -> ClaudeSessionSummary {
|
||||
ClaudeSessionSummary {
|
||||
session_id: id.to_string(),
|
||||
cwd: cwd.to_string(),
|
||||
title: title.map(str::to_string),
|
||||
last_modified_ms: 0,
|
||||
cwd_exists: true,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_import_pins_resume_target() {
|
||||
let s = summary("abc123-def456", "/home/me/proj", Some("Fix bug"));
|
||||
let inst = build_import_instance(&s, false, "");
|
||||
assert_eq!(inst.tool, "claude");
|
||||
assert_eq!(inst.project_path, "/home/me/proj");
|
||||
assert_eq!(inst.title, "Fix bug");
|
||||
assert_eq!(
|
||||
inst.resume_intent,
|
||||
ResumeIntent::Use("abc123-def456".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn title_falls_back_to_short_id() {
|
||||
let s = summary("abcdef12-3456-7890", "/home/me/proj", None);
|
||||
let inst = build_import_instance(&s, false, "team/imports");
|
||||
assert_eq!(inst.title, "Claude import abcdef12");
|
||||
assert_eq!(inst.group_path, "team/imports");
|
||||
}
|
||||
|
||||
#[cfg(feature = "serve")]
|
||||
#[test]
|
||||
fn structured_import_seeds_replay_fields() {
|
||||
let s = summary("sid-1", "/home/me/proj", Some("x"));
|
||||
let inst = build_import_instance(&s, true, "");
|
||||
assert!(inst.is_structured());
|
||||
assert_eq!(inst.acp_session_id.as_deref(), Some("sid-1"));
|
||||
assert_eq!(inst.import_pending, Some(true));
|
||||
// Structured imports do not pin a terminal resume target.
|
||||
assert_eq!(inst.resume_intent, ResumeIntent::Default);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn already_imported_matches_resume_and_observed_ids() {
|
||||
let mut by_resume = Instance::new("a", "/p");
|
||||
by_resume.resume_intent = ResumeIntent::Use("id-1".to_string());
|
||||
let mut by_observed = Instance::new("b", "/p");
|
||||
by_observed.agent_session_id = Some("id-2".to_string());
|
||||
let fresh = Instance::new("c", "/p");
|
||||
let instances = vec![by_resume, by_observed, fresh];
|
||||
|
||||
assert!(already_imported(&instances, "id-1"));
|
||||
assert!(already_imported(&instances, "id-2"));
|
||||
assert!(!already_imported(&instances, "id-3"));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod show_json_tests {
|
||||
use super::*;
|
||||
|
||||
/// #3350 gave `aoe list --json` a `state` tag and both timestamps;
|
||||
/// #3350 gave `aoe list --json` a `state` tag and a timestamp;
|
||||
/// `session show --json` was left behind, so a scripted consumer that
|
||||
/// looked a session up by id could not tell an archived one from a live
|
||||
/// looked a session up by id could not tell a trashed one from a live
|
||||
/// one without a second `aoe list` shellout.
|
||||
#[test]
|
||||
fn show_json_exposes_state_and_archived_at_for_an_archived_row() {
|
||||
fn show_json_exposes_state_and_trashed_at_for_a_trashed_row() {
|
||||
let mut inst = Instance::new("z", "/repo");
|
||||
inst.archive();
|
||||
let details = session_details(&inst, "p");
|
||||
assert_eq!(details.state, "archived");
|
||||
assert!(details.archived_at.is_some());
|
||||
assert!(details.trashed_at.is_none());
|
||||
}
|
||||
|
||||
/// `trash()` deliberately leaves `archived_at` alone so a restore is
|
||||
/// faithful, so both keys can be present at once and `state` reports
|
||||
/// `trashed`: the same precedence `list --json` reports, from the same
|
||||
/// `state_tag`.
|
||||
#[test]
|
||||
fn show_json_reports_trashed_for_a_row_that_was_archived_first() {
|
||||
let mut inst = Instance::new("z", "/repo");
|
||||
inst.archive();
|
||||
inst.trash();
|
||||
let details = session_details(&inst, "p");
|
||||
assert_eq!(details.state, "trashed");
|
||||
assert!(details.trashed_at.is_some());
|
||||
assert!(details.archived_at.is_some());
|
||||
}
|
||||
|
||||
/// A live row must serialize exactly as wide as it did before, so a
|
||||
@@ -2515,7 +1931,7 @@ mod show_json_tests {
|
||||
let inst = Instance::new("z", "/repo");
|
||||
let serialized = serde_json::to_string(&session_details(&inst, "p")).unwrap();
|
||||
assert!(!serialized.contains("trashed_at"), "{serialized}");
|
||||
assert!(!serialized.contains("archived_at"), "{serialized}");
|
||||
|
||||
assert!(serialized.contains("\"state\":\"live\""), "{serialized}");
|
||||
}
|
||||
}
|
||||
|
||||
+1
-4
@@ -23,10 +23,7 @@ mod remote;
|
||||
pub mod template;
|
||||
mod worktree;
|
||||
|
||||
pub use remote::{
|
||||
clone_bare_repo, clone_repo, get_remote_owner, get_remote_owner_with_key, get_remote_slug,
|
||||
get_remote_url,
|
||||
};
|
||||
pub use remote::{get_remote_owner, get_remote_owner_with_key, get_remote_slug, get_remote_url};
|
||||
pub use worktree::{GitWorktree, WorktreeEntry};
|
||||
|
||||
/// Open a git repository at the given path without searching parent directories.
|
||||
|
||||
+1
-383
@@ -1,282 +1,9 @@
|
||||
//! Git remote operations: repo cloning and origin-URL parsing.
|
||||
//! Git remote operations: origin-URL parsing.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use super::error::{GitError, Result};
|
||||
use super::open_repo_at;
|
||||
|
||||
/// Clone a git repository as a bare repo with worktree setup, following the
|
||||
/// workflow-guide structure. Returns the path to the created worktree
|
||||
/// (`<destination>/main`). Cleans up `<destination>` on failure.
|
||||
#[tracing::instrument(target = "git.fetch", skip_all, fields(url = %redact_url(url)))]
|
||||
pub fn clone_bare_repo(url: &str, destination: &Path) -> Result<String> {
|
||||
if destination.exists() {
|
||||
return Err(GitError::CloneFailed(format!(
|
||||
"Destination already exists: {}",
|
||||
destination.display()
|
||||
)));
|
||||
}
|
||||
|
||||
let bare_dir = destination.join(".bare");
|
||||
let bare_str = bare_dir
|
||||
.to_str()
|
||||
.ok_or_else(|| GitError::CloneFailed("Invalid bare directory path".to_string()))?;
|
||||
|
||||
let redacted_url = redact_url(url);
|
||||
|
||||
tracing::debug!(
|
||||
target: "git.command",
|
||||
args = ?["clone", "--bare", &redacted_url, bare_str],
|
||||
"spawning git clone --bare"
|
||||
);
|
||||
// Stdin is piped to null so SSH passphrase prompts fail immediately;
|
||||
// output capture and the kill-on-deadline are handled by
|
||||
// run_with_timeout, which writes stdout/stderr to temporary regular
|
||||
// files, so a grandchild that inherits them (credential helper, pager)
|
||||
// cannot block past the timeout.
|
||||
let mut cmd = std::process::Command::new("git");
|
||||
cmd.args(["clone", "--bare", url, bare_str])
|
||||
.stdin(std::process::Stdio::null());
|
||||
let timeout = std::time::Duration::from_secs(300);
|
||||
|
||||
match crate::process::run_with_timeout(&mut cmd, timeout) {
|
||||
Ok(Some(output)) if output.status.success() => {}
|
||||
Ok(Some(output)) => {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
let _ = std::fs::remove_dir_all(destination);
|
||||
return Err(GitError::CloneFailed(stderr));
|
||||
}
|
||||
Ok(None) => {
|
||||
let _ = std::fs::remove_dir_all(destination);
|
||||
return Err(GitError::CloneFailed(
|
||||
"Bare clone timed out after 5 minutes".to_string(),
|
||||
));
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = std::fs::remove_dir_all(destination);
|
||||
return Err(GitError::CloneFailed(format!(
|
||||
"Failed to run git clone --bare: {e}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
let run_in_bare = |args: &[&str]| -> Result<std::process::Output> {
|
||||
let output = std::process::Command::new("git")
|
||||
.args(args)
|
||||
.current_dir(&bare_dir)
|
||||
.stdin(std::process::Stdio::null())
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.output()
|
||||
.map_err(|e| GitError::CloneFailed(format!("Git command failed: {e}")))?;
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
let _ = std::fs::remove_dir_all(destination);
|
||||
return Err(GitError::CloneFailed(stderr));
|
||||
}
|
||||
Ok(output)
|
||||
};
|
||||
|
||||
let gitfile_path = destination.join(".git");
|
||||
if let Err(e) = std::fs::write(&gitfile_path, "gitdir: ./.bare\n") {
|
||||
let _ = std::fs::remove_dir_all(destination);
|
||||
return Err(GitError::CloneFailed(format!(
|
||||
"Failed to create .git file: {e}"
|
||||
)));
|
||||
}
|
||||
|
||||
run_in_bare(&[
|
||||
"config",
|
||||
"remote.origin.fetch",
|
||||
"+refs/heads/*:refs/remotes/origin/*",
|
||||
])?;
|
||||
|
||||
run_in_bare(&["fetch", "origin"])?;
|
||||
|
||||
// Detect the default branch. `git clone --bare` points the bare repo's
|
||||
// own HEAD at the remote's default branch, which works on every git
|
||||
// version. `refs/remotes/origin/HEAD` is only populated by `git fetch`
|
||||
// on git >= 2.45 (followRemoteHEAD), so it can't be relied on; try it
|
||||
// and then main/master as fallbacks. These probes must tolerate a
|
||||
// non-zero exit (the ref simply not existing), so they don't go through
|
||||
// `run_in_bare`, which treats failure as fatal and wipes the clone.
|
||||
let probe = |args: &[&str]| -> Option<String> {
|
||||
let output = std::process::Command::new("git")
|
||||
.args(args)
|
||||
.current_dir(&bare_dir)
|
||||
.stdin(std::process::Stdio::null())
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.output()
|
||||
.ok()?;
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
let out = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
(!out.is_empty()).then_some(out)
|
||||
};
|
||||
let branch_from_ref = |full: &str| full.rsplit_once('/').map(|(_, name)| name.to_string());
|
||||
|
||||
let default_branch = probe(&["symbolic-ref", "--short", "HEAD"])
|
||||
.or_else(|| {
|
||||
probe(&["symbolic-ref", "refs/remotes/origin/HEAD"])
|
||||
.as_deref()
|
||||
.and_then(branch_from_ref)
|
||||
})
|
||||
.or_else(|| {
|
||||
probe(&["show-ref", "--verify", "refs/remotes/origin/main"]).map(|_| "main".into())
|
||||
})
|
||||
.or_else(|| {
|
||||
probe(&["show-ref", "--verify", "refs/remotes/origin/master"]).map(|_| "master".into())
|
||||
});
|
||||
|
||||
let default_branch = match default_branch {
|
||||
Some(b) => b,
|
||||
None => {
|
||||
let _ = std::fs::remove_dir_all(destination);
|
||||
return Err(GitError::CloneFailed(
|
||||
"Could not detect default branch (tried HEAD, origin/HEAD, main, master)"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
let worktree_path = destination.join("main");
|
||||
let worktree_str = worktree_path
|
||||
.to_str()
|
||||
.ok_or_else(|| GitError::CloneFailed("Invalid worktree path".to_string()))?;
|
||||
|
||||
let output = std::process::Command::new("git")
|
||||
.args(["worktree", "add", worktree_str, &default_branch])
|
||||
.current_dir(destination)
|
||||
.stdin(std::process::Stdio::null())
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.output()
|
||||
.map_err(|e| GitError::CloneFailed(format!("Git worktree add failed: {e}")))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
let _ = std::fs::remove_dir_all(destination);
|
||||
return Err(GitError::CloneFailed(format!(
|
||||
"Failed to create worktree: {stderr}"
|
||||
)));
|
||||
}
|
||||
|
||||
// Lock the `main` worktree for the same cross-boundary prune protection
|
||||
// `GitWorktree::create_worktree` applies to every session worktree (#2414):
|
||||
// this is the one aoe-created worktree that does not go through
|
||||
// `create_worktree`, so without this a prune from a context that cannot see
|
||||
// `<destination>/main` would reap its admin entry. Best-effort: a lock
|
||||
// failure only forfeits that protection, so warn and keep the clone.
|
||||
match super::GitWorktree::new(destination.to_path_buf()) {
|
||||
Ok(git_wt) => {
|
||||
if let Err(e) = git_wt.lock_worktree(&worktree_path) {
|
||||
tracing::warn!(
|
||||
target: "git.fetch",
|
||||
path = %worktree_path.display(),
|
||||
error = %e,
|
||||
"Bare clone: could not lock main worktree (cross-boundary prune protection unavailable)"
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
target: "git.fetch",
|
||||
path = %destination.display(),
|
||||
error = %e,
|
||||
"Bare clone: could not open repo to lock main worktree (cross-boundary prune protection unavailable)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
target: "git.fetch",
|
||||
"Bare clone complete: {} -> {}",
|
||||
redacted_url,
|
||||
worktree_path.display()
|
||||
);
|
||||
|
||||
Ok(worktree_path.display().to_string())
|
||||
}
|
||||
|
||||
/// Clone a git repository from a URL into the given destination directory.
|
||||
///
|
||||
/// The destination must not already exist. If `shallow` is true, only the
|
||||
/// latest commit is fetched (`--depth 1`). The clone is killed after 5
|
||||
/// minutes to prevent indefinite hangs (unresponsive remotes, SSH prompts).
|
||||
#[tracing::instrument(target = "git.fetch", skip_all, fields(url = %redact_url(url), shallow))]
|
||||
pub fn clone_repo(url: &str, destination: &Path, shallow: bool) -> Result<()> {
|
||||
if destination.exists() {
|
||||
return Err(GitError::CloneFailed(format!(
|
||||
"Destination already exists: {}",
|
||||
destination.display()
|
||||
)));
|
||||
}
|
||||
|
||||
let dest_str = destination
|
||||
.to_str()
|
||||
.ok_or_else(|| GitError::CloneFailed("Invalid destination path".to_string()))?;
|
||||
|
||||
let mut args = vec!["clone"];
|
||||
if shallow {
|
||||
args.extend(["--depth", "1"]);
|
||||
}
|
||||
args.extend([url, dest_str]);
|
||||
|
||||
// Pipe stdin to /dev/null so SSH passphrase prompts fail immediately
|
||||
// instead of hanging the blocking thread.
|
||||
let redacted_url = redact_url(url);
|
||||
let redacted_args: Vec<&str> = args
|
||||
.iter()
|
||||
.map(|a| if *a == url { redacted_url.as_str() } else { *a })
|
||||
.collect();
|
||||
tracing::debug!(
|
||||
target: "git.command",
|
||||
args = ?redacted_args,
|
||||
"spawning git clone"
|
||||
);
|
||||
let mut cmd = std::process::Command::new("git");
|
||||
cmd.args(&args).stdin(std::process::Stdio::null());
|
||||
|
||||
// 5-minute timeout to avoid blocking the thread pool forever; output is
|
||||
// captured in temporary regular files, so a grandchild that inherits the
|
||||
// handles cannot hang the wait.
|
||||
let timeout = std::time::Duration::from_secs(300);
|
||||
|
||||
match crate::process::run_with_timeout(&mut cmd, timeout) {
|
||||
Ok(Some(output)) if output.status.success() => Ok(()),
|
||||
Ok(Some(output)) => {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
Err(GitError::CloneFailed(stderr))
|
||||
}
|
||||
Ok(None) => {
|
||||
if destination.exists() {
|
||||
let _ = std::fs::remove_dir_all(destination);
|
||||
}
|
||||
Err(GitError::CloneFailed(
|
||||
"Clone timed out after 5 minutes".to_string(),
|
||||
))
|
||||
}
|
||||
Err(e) => Err(GitError::CloneFailed(format!(
|
||||
"Failed to run git clone: {e}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Strip userinfo (`user:token@`) from a URL so credentials don't reach logs.
|
||||
fn redact_url(url: &str) -> String {
|
||||
if let Some(scheme_end) = url.find("://") {
|
||||
let after = &url[scheme_end + 3..];
|
||||
if let Some(at_off) = after.find('@') {
|
||||
let prefix = &url[..scheme_end + 3];
|
||||
let rest = &after[at_off + 1..];
|
||||
return format!("{prefix}***@{rest}");
|
||||
}
|
||||
}
|
||||
url.to_string()
|
||||
}
|
||||
|
||||
/// Split a git remote URL into its host and the path segment after it,
|
||||
/// rejecting any URL that is not a canonical hosted remote: a known scheme
|
||||
/// (`http`/`https`/`ssh`) or SSH shorthand (`user@host:path`). Local schemes
|
||||
@@ -554,113 +281,4 @@ mod tests {
|
||||
parse_owner_with_key_from_remote_url("https://github.com/acme/x.git"),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clone_bare_repo_creates_structure() {
|
||||
use tempfile::TempDir;
|
||||
|
||||
// Create a source repo to clone from
|
||||
let source_dir = TempDir::new().unwrap();
|
||||
let source_repo = git2::Repository::init(source_dir.path()).unwrap();
|
||||
let sig = git2::Signature::now("Test", "test@example.com").unwrap();
|
||||
let tree_id = {
|
||||
let mut index = source_repo.index().unwrap();
|
||||
index.write_tree().unwrap()
|
||||
};
|
||||
let tree = source_repo.find_tree(tree_id).unwrap();
|
||||
source_repo
|
||||
.commit(Some("HEAD"), &sig, &sig, "Initial", &tree, &[])
|
||||
.unwrap();
|
||||
|
||||
// Clone as bare repo
|
||||
let dest_dir = TempDir::new().unwrap();
|
||||
let dest_path = dest_dir.path().join("test-bare-clone");
|
||||
let url = format!("file://{}", source_dir.path().display());
|
||||
|
||||
let result = clone_bare_repo(&url, &dest_path);
|
||||
assert!(result.is_ok(), "clone_bare_repo failed: {:?}", result.err());
|
||||
|
||||
let worktree_path = result.unwrap();
|
||||
assert!(
|
||||
worktree_path.ends_with("/main"),
|
||||
"Expected path ending with /main"
|
||||
);
|
||||
|
||||
// Verify structure
|
||||
assert!(dest_path.join(".bare").exists(), ".bare directory missing");
|
||||
assert!(dest_path.join(".git").exists(), ".git file missing");
|
||||
assert!(dest_path.join("main").exists(), "main worktree missing");
|
||||
|
||||
// Verify .git file content
|
||||
let gitfile = std::fs::read_to_string(dest_path.join(".git")).unwrap();
|
||||
assert_eq!(gitfile.trim(), "gitdir: ./.bare");
|
||||
|
||||
// Verify main is a valid worktree
|
||||
let main_path = dest_path.join("main");
|
||||
assert!(main_path.join(".git").exists(), "worktree .git missing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clone_bare_repo_locks_main_worktree() {
|
||||
use tempfile::TempDir;
|
||||
|
||||
// Regression for #2414: the `main` worktree the bare clone creates is
|
||||
// the one aoe-created worktree that does not go through
|
||||
// `create_worktree`, so it must still be locked or a prune from a
|
||||
// context that cannot see it would reap its admin entry.
|
||||
let source_dir = TempDir::new().unwrap();
|
||||
let source_repo = git2::Repository::init(source_dir.path()).unwrap();
|
||||
let sig = git2::Signature::now("Test", "test@example.com").unwrap();
|
||||
let tree_id = {
|
||||
let mut index = source_repo.index().unwrap();
|
||||
index.write_tree().unwrap()
|
||||
};
|
||||
let tree = source_repo.find_tree(tree_id).unwrap();
|
||||
source_repo
|
||||
.commit(Some("HEAD"), &sig, &sig, "Initial", &tree, &[])
|
||||
.unwrap();
|
||||
|
||||
let dest_dir = TempDir::new().unwrap();
|
||||
let dest_path = dest_dir.path().join("test-bare-clone");
|
||||
let url = format!("file://{}", source_dir.path().display());
|
||||
clone_bare_repo(&url, &dest_path).unwrap();
|
||||
|
||||
let admin_dir = dest_path.join(".bare/worktrees/main");
|
||||
assert!(
|
||||
admin_dir.join("locked").exists(),
|
||||
"clone_bare_repo should lock the main worktree"
|
||||
);
|
||||
|
||||
// Hide the checkout so a prune sees it as missing; the locked admin
|
||||
// entry must survive.
|
||||
let hidden = dest_path.join("main-HIDDEN");
|
||||
std::fs::rename(dest_path.join("main"), &hidden).unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["worktree", "prune"])
|
||||
.current_dir(&dest_path)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
admin_dir.exists(),
|
||||
"locked main worktree admin entry must survive a prune while its checkout is unreachable"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clone_bare_repo_destination_exists() {
|
||||
use tempfile::TempDir;
|
||||
|
||||
let dest_dir = TempDir::new().unwrap();
|
||||
let dest_path = dest_dir.path().join("existing");
|
||||
std::fs::create_dir(&dest_path).unwrap();
|
||||
|
||||
let result = clone_bare_repo("https://example.com/repo.git", &dest_path);
|
||||
assert!(result.is_err());
|
||||
let err = result.unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains("already exists"),
|
||||
"Expected 'already exists' error, got: {}",
|
||||
err
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,7 +133,6 @@ impl MetricsSampler {
|
||||
|
||||
fn eligible_instance(inst: &Instance) -> bool {
|
||||
!inst.is_structured()
|
||||
&& !inst.is_archived()
|
||||
&& !inst.is_trashed()
|
||||
&& matches!(
|
||||
inst.status,
|
||||
|
||||
@@ -286,18 +286,14 @@ pub async fn reconcile_acp_workers(
|
||||
// We then drop the lock so the parallel resume tasks (each ~3s for
|
||||
// a fresh spawn) don't pin it.
|
||||
//
|
||||
// Archived sessions are excluded from the resume targets so the
|
||||
// reconciler does not race the web archive handler's worker teardown.
|
||||
// Without this skip, the 2s tick would respawn an archived structured
|
||||
// view worker immediately after the API handler shuts it down,
|
||||
// defeating the archive semantics. See #1581.
|
||||
// Trashed and idle-dormant sessions are excluded from the resume
|
||||
// targets so the 2s tick never respawns a worker something else
|
||||
// intentionally shut down. See #1581.
|
||||
let raw_targets: Vec<RawTargetTuple> = {
|
||||
let instances = state.instances.read().await;
|
||||
instances
|
||||
.iter()
|
||||
.filter(|i| {
|
||||
i.is_structured() && !i.is_archived() && !i.is_trashed() && !i.is_idle_dormant()
|
||||
})
|
||||
.filter(|i| i.is_structured() && !i.is_trashed() && !i.is_idle_dormant())
|
||||
.map(|i| {
|
||||
(
|
||||
i.id.clone(),
|
||||
@@ -819,7 +815,6 @@ async fn reap_idle_workers(state: &Arc<AppState>) {
|
||||
.iter()
|
||||
.filter(|i| {
|
||||
i.is_structured()
|
||||
&& !i.is_archived()
|
||||
&& !i.is_trashed()
|
||||
&& !i.is_idle_dormant()
|
||||
// A session with queued work waiting to drain is not idle:
|
||||
@@ -1166,7 +1161,6 @@ async fn reap_rate_limit_resumes(state: &Arc<AppState>, attempted: &mut HashSet<
|
||||
.iter()
|
||||
.filter(|i| {
|
||||
i.is_structured()
|
||||
&& !i.is_archived()
|
||||
&& !i.is_trashed()
|
||||
&& !i.is_idle_dormant()
|
||||
&& attempted.contains(&i.id)
|
||||
@@ -1526,12 +1520,7 @@ async fn drain_pending_initial_turns(state: &Arc<AppState>) {
|
||||
let instances = state.instances.read().await;
|
||||
instances
|
||||
.iter()
|
||||
.filter(|i| {
|
||||
i.pending_initial_turn.is_some()
|
||||
&& i.is_structured()
|
||||
&& !i.is_archived()
|
||||
&& !i.is_trashed()
|
||||
})
|
||||
.filter(|i| i.pending_initial_turn.is_some() && i.is_structured() && !i.is_trashed())
|
||||
.map(|i| i.id.clone())
|
||||
.collect()
|
||||
};
|
||||
@@ -1571,7 +1560,6 @@ async fn drain_queued_prompts(state: &Arc<AppState>) {
|
||||
!i.queued_prompts.is_empty()
|
||||
&& i.status == crate::session::Status::Idle
|
||||
&& i.is_structured()
|
||||
&& !i.is_archived()
|
||||
&& !i.is_trashed()
|
||||
})
|
||||
.map(|i| (i.id.clone(), i.is_idle_dormant()))
|
||||
@@ -1721,17 +1709,13 @@ async fn resume_target_for_session(
|
||||
) -> Option<ResumeTarget> {
|
||||
let instances = service.instances.read().await;
|
||||
// Filter the same triage states the reconciler skips everywhere else.
|
||||
// This runs without `instance_lock` held, so an archive can win the
|
||||
// This runs without `instance_lock` held, so a trash can win the
|
||||
// race after dormancy was cleared; resolving to None (then
|
||||
// NotFound) keeps us from respawning a session the reconciler
|
||||
// intentionally leaves sunk. See #1748.
|
||||
let inst = instances.iter().find(|i| {
|
||||
i.id == id
|
||||
&& i.is_structured()
|
||||
&& !i.is_archived()
|
||||
&& !i.is_trashed()
|
||||
&& !i.is_idle_dormant()
|
||||
})?;
|
||||
let inst = instances
|
||||
.iter()
|
||||
.find(|i| i.id == id && i.is_structured() && !i.is_trashed() && !i.is_idle_dormant())?;
|
||||
Some(ResumeTarget {
|
||||
id: inst.id.clone(),
|
||||
tool: inst.tool.clone(),
|
||||
|
||||
+1
-276
@@ -22,7 +22,7 @@ use axum::extract::{
|
||||
Path, Query, State,
|
||||
};
|
||||
use axum::response::IntoResponse;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde::Deserialize;
|
||||
use tokio::select;
|
||||
use tokio::sync::broadcast::error::RecvError;
|
||||
use tokio::time::Instant;
|
||||
@@ -733,221 +733,6 @@ pub fn publish(state: &AppState, frame: AcpBroadcastFrame) {
|
||||
let _ = state.acp_events_tx.send(frame);
|
||||
}
|
||||
|
||||
/// Push-notification trigger for "agent needs your approval." Called
|
||||
/// by the worker supervisor when it observes an `ApprovalRequested`
|
||||
/// structured view event. Re-uses the existing push infrastructure: subscribers
|
||||
/// for `state.push` receive a payload telling the PWA to focus the
|
||||
/// approval card.
|
||||
pub async fn trigger_approval_push(
|
||||
state: &AppState,
|
||||
session_id: &str,
|
||||
approval_title: &str,
|
||||
destructive: bool,
|
||||
seq: u64,
|
||||
) {
|
||||
let badge = if destructive {
|
||||
"DESTRUCTIVE"
|
||||
} else {
|
||||
"approval"
|
||||
};
|
||||
let title = format!("{} needs approval", session_id);
|
||||
let body = if destructive {
|
||||
format!("{badge}: {approval_title}")
|
||||
} else {
|
||||
approval_title.to_string()
|
||||
};
|
||||
let tag = approval_tag(session_id);
|
||||
send_acp_push(state, session_id, |url| AcpNotifyPayload {
|
||||
kind: "notify",
|
||||
title: title.clone(),
|
||||
body: body.clone(),
|
||||
url,
|
||||
tag: tag.clone(),
|
||||
session_id: session_id.to_string(),
|
||||
seq,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Retract a previously shown approval notification on every device once
|
||||
/// the approval is handled. Mirrors `trigger_approval_push`'s tag so the
|
||||
/// service worker can match and close the live notification. See #2491.
|
||||
pub async fn trigger_approval_clear_push(state: &AppState, session_id: &str, seq: u64) {
|
||||
let tag = approval_tag(session_id);
|
||||
send_acp_push(state, session_id, |url| AcpClearPayload {
|
||||
kind: "clear",
|
||||
title: "Resolved",
|
||||
body: "Handled on another device",
|
||||
url,
|
||||
tag: tag.clone(),
|
||||
session_id: session_id.to_string(),
|
||||
seq,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Tag shared by the approval show and clear pushes for a session.
|
||||
/// Single-sourced so the clear path can never drift from the show path.
|
||||
fn approval_tag(session_id: &str) -> String {
|
||||
format!("acp-approval-{session_id}")
|
||||
}
|
||||
|
||||
/// Tag shared by the question show and clear pushes for a session.
|
||||
fn question_tag(session_id: &str) -> String {
|
||||
format!("acp-question-{session_id}")
|
||||
}
|
||||
|
||||
/// Push-notification trigger for "agent asked you a question." Called by
|
||||
/// the worker supervisor when it observes an `ElicitationRequested`
|
||||
/// (`AskUserQuestion`) structured view event. A question blocks the turn
|
||||
/// on the user exactly like an approval, so it gets the same dedicated,
|
||||
/// suppression-bypassing push rather than only the generic Waiting one.
|
||||
/// See #2146.
|
||||
pub async fn trigger_question_push(state: &AppState, session_id: &str, question: &str, seq: u64) {
|
||||
let title = format!("{} has a question", session_id);
|
||||
let body = push_body_snippet(question);
|
||||
let tag = question_tag(session_id);
|
||||
send_acp_push(state, session_id, |url| AcpNotifyPayload {
|
||||
kind: "notify",
|
||||
title: title.clone(),
|
||||
body: body.clone(),
|
||||
url,
|
||||
tag: tag.clone(),
|
||||
session_id: session_id.to_string(),
|
||||
seq,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Retract a previously shown question notification once the question is
|
||||
/// answered. Mirrors `trigger_question_push`'s tag. See #2491.
|
||||
pub async fn trigger_question_clear_push(state: &AppState, session_id: &str, seq: u64) {
|
||||
let tag = question_tag(session_id);
|
||||
send_acp_push(state, session_id, |url| AcpClearPayload {
|
||||
kind: "clear",
|
||||
title: "Resolved",
|
||||
body: "Handled on another device",
|
||||
url,
|
||||
tag: tag.clone(),
|
||||
session_id: session_id.to_string(),
|
||||
seq,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Payload for a dedicated ACP attention push (approval / question).
|
||||
/// `kind: "notify"` lets the service worker tell a show from a clear; an
|
||||
/// old service worker ignores the unknown field and falls back to showing
|
||||
/// `title`/`body`. `seq` is the originating event seq, stored in the
|
||||
/// notification so a later clear can avoid closing a newer notification.
|
||||
#[derive(Serialize)]
|
||||
struct AcpNotifyPayload {
|
||||
kind: &'static str,
|
||||
title: String,
|
||||
body: String,
|
||||
url: String,
|
||||
tag: String,
|
||||
session_id: String,
|
||||
seq: u64,
|
||||
}
|
||||
|
||||
/// Payload telling the service worker to retract a shown ACP attention
|
||||
/// notification once the request is handled. Carries `title`/`body` so a
|
||||
/// not-yet-updated service worker degrades to a benign "Resolved"
|
||||
/// notification (replacing the stale one via `tag`) rather than a blank
|
||||
/// one. `seq` lets the worker skip closing a newer notification. See #2491.
|
||||
#[derive(Serialize)]
|
||||
struct AcpClearPayload {
|
||||
kind: &'static str,
|
||||
title: &'static str,
|
||||
body: &'static str,
|
||||
url: String,
|
||||
tag: String,
|
||||
session_id: String,
|
||||
seq: u64,
|
||||
}
|
||||
|
||||
/// Question text can be long and lands on a lock screen, so collapse
|
||||
/// whitespace and cap it before it goes into a push payload.
|
||||
fn push_body_snippet(s: &str) -> String {
|
||||
const MAX: usize = 120;
|
||||
let compact = s.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
if compact.chars().count() > MAX {
|
||||
format!("{}…", compact.chars().take(MAX).collect::<String>())
|
||||
} else {
|
||||
compact
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared sender for the dedicated ACP "needs your attention" pushes
|
||||
/// (approval and question) and their matching clear pushes. Snapshots
|
||||
/// subscribers and sends one encrypted payload each, deep-linking to the
|
||||
/// session's structured view. `make_payload` builds the per-subscriber
|
||||
/// payload from that subscriber's push URL, so a show path and a clear
|
||||
/// path share the same fan-out. Bypasses the status-push active-session
|
||||
/// suppression on purpose: these are precise, turn-blocking events, not
|
||||
/// the coarse Waiting heuristic.
|
||||
async fn send_acp_push<T, F>(state: &AppState, session_id: &str, make_payload: F)
|
||||
where
|
||||
T: Serialize,
|
||||
F: Fn(String) -> T,
|
||||
{
|
||||
let Some(push) = state.push.as_ref() else {
|
||||
return;
|
||||
};
|
||||
if !state.push_enabled {
|
||||
return;
|
||||
}
|
||||
let path = format!("/sessions/{session_id}/acp");
|
||||
let subs = push.store.snapshot().await;
|
||||
if subs.is_empty() {
|
||||
return;
|
||||
}
|
||||
let client = match super::push_send::build_client() {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
warn!(target: "acp.push", "build_client: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
for sub in subs {
|
||||
let Some(url) = super::push::build_push_url(&sub, &path) else {
|
||||
continue;
|
||||
};
|
||||
let payload = make_payload(url);
|
||||
let body_bytes = match serde_json::to_vec(&payload) {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
warn!(target: "acp.push", "serialise payload: {e}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let auth_header = match super::push_send::vapid_auth_header(push, &sub.endpoint) {
|
||||
Ok(h) => h,
|
||||
Err(e) => {
|
||||
warn!(target: "acp.push", "vapid header: {e}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let cipher = match super::push_send::encrypt_aes128gcm(&sub, &body_bytes) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
warn!(target: "acp.push", "encrypt: {e}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let _ = client
|
||||
.post(&sub.endpoint)
|
||||
.header("Authorization", &auth_header)
|
||||
.header("Content-Encoding", "aes128gcm")
|
||||
.header("Content-Type", "application/octet-stream")
|
||||
.header("TTL", "60")
|
||||
.body(cipher)
|
||||
.send()
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, feature = "serve"))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -1253,66 +1038,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn push_body_snippet_collapses_whitespace_and_caps_length() {
|
||||
// Short text passes through with whitespace collapsed.
|
||||
assert_eq!(
|
||||
push_body_snippet("Which env?\n staging\tor prod"),
|
||||
"Which env? staging or prod"
|
||||
);
|
||||
// Long text is truncated and gets an ellipsis. The cap counts
|
||||
// chars, not bytes, so the result is at most MAX + the ellipsis.
|
||||
let long = "word ".repeat(100);
|
||||
let snippet = push_body_snippet(&long);
|
||||
assert!(snippet.ends_with('…'));
|
||||
assert_eq!(snippet.chars().count(), 120 + 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attention_tags_are_session_scoped_and_kind_distinct() {
|
||||
// The clear path reuses these helpers, so a drift here would
|
||||
// silently fail to close the matching notification (#2491).
|
||||
assert_eq!(approval_tag("s1"), "acp-approval-s1");
|
||||
assert_eq!(question_tag("s1"), "acp-question-s1");
|
||||
assert_ne!(approval_tag("s1"), question_tag("s1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_payload_carries_kind_and_seq() {
|
||||
let json = serde_json::to_value(AcpClearPayload {
|
||||
kind: "clear",
|
||||
title: "Resolved",
|
||||
body: "Handled on another device",
|
||||
url: "/sessions/s1/acp".into(),
|
||||
tag: approval_tag("s1"),
|
||||
session_id: "s1".into(),
|
||||
seq: 7,
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(json["kind"], "clear");
|
||||
assert_eq!(json["tag"], "acp-approval-s1");
|
||||
assert_eq!(json["seq"], 7);
|
||||
// title/body are present so a not-yet-updated service worker
|
||||
// degrades to a benign notification rather than a blank one.
|
||||
assert_eq!(json["title"], "Resolved");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notify_payload_tags_kind_notify() {
|
||||
let json = serde_json::to_value(AcpNotifyPayload {
|
||||
kind: "notify",
|
||||
title: "t".into(),
|
||||
body: "b".into(),
|
||||
url: "/sessions/s1/acp".into(),
|
||||
tag: question_tag("s1"),
|
||||
session_id: "s1".into(),
|
||||
seq: 3,
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(json["kind"], "notify");
|
||||
assert_eq!(json["seq"], 3);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn publish_with_no_receivers_does_not_panic() {
|
||||
// Create a minimal AppState-like fixture: in real code the server
|
||||
|
||||
+9
-74
@@ -1024,23 +1024,17 @@ pub async fn switch_acp_agent(
|
||||
.into_response()
|
||||
}
|
||||
|
||||
/// Auto-wake an archived or idle-dormant session before a prompt is
|
||||
/// forwarded, matching the tmux send path
|
||||
/// (`/api/sessions/{id}/send`). `touch_last_accessed` clears
|
||||
/// `archived_at` and `idle_dormant_since` so the
|
||||
/// structured view reconciler stops skipping the session on its next ~2s tick and
|
||||
/// respawns the worker; the frontend's queue drains as soon as the fresh
|
||||
/// `AcpSessionAssigned` lands. The idle_dormant clear is the wake path for
|
||||
/// auto-stopped idle workers (#1689); a worker reaped for inactivity
|
||||
/// respawns on the next prompt. See #1581.
|
||||
/// Auto-wake an idle-dormant session before a prompt is forwarded, matching
|
||||
/// the tmux send path (`/api/sessions/{id}/send`). `touch_last_accessed`
|
||||
/// clears `idle_dormant_since` so the structured view reconciler stops
|
||||
/// skipping the session on its next ~2s tick and respawns the worker; the
|
||||
/// frontend's queue drains as soon as the fresh `AcpSessionAssigned` lands.
|
||||
/// A worker reaped for inactivity (#1689) respawns on the next prompt.
|
||||
///
|
||||
/// The in-memory mutation and the disk persistence are both held under
|
||||
/// `state.instance_lock(&id)` so they serialize against other
|
||||
/// session-mutating endpoints (archive / pin / rename) on the
|
||||
/// same id. Without this guard, a concurrent archive PATCH could
|
||||
/// interleave with the touch and produce a lost write (archive sets
|
||||
/// archived_at = Some, touch clears it, archive's persist lands first,
|
||||
/// touch's persist lands second and overwrites the archive). The lock is
|
||||
/// session-mutating endpoints (rename, trash) on the same id, which would
|
||||
/// otherwise interleave with the touch and produce a lost write. The lock is
|
||||
/// dropped before the caller reaches the supervisor: publish/send take
|
||||
/// their own locks downstream and holding ours across the agent forward
|
||||
/// would serialize prompts unnecessarily and stall siblings.
|
||||
@@ -1054,7 +1048,7 @@ async fn touch_and_wake_if_sunk(state: &Arc<AppState>, id: &str) -> bool {
|
||||
let mut instances = state.instances.write().await;
|
||||
if let Some(inst) = instances.iter_mut().find(|i| i.id == id) {
|
||||
let was_idle_dormant = inst.is_idle_dormant();
|
||||
let was_sunk = inst.is_archived() || was_idle_dormant;
|
||||
let was_sunk = was_idle_dormant;
|
||||
if was_sunk {
|
||||
inst.touch_last_accessed();
|
||||
if was_idle_dormant {
|
||||
@@ -2452,65 +2446,6 @@ pub async fn acp_replay(
|
||||
.into_response()
|
||||
}
|
||||
|
||||
/// List existing Claude Code sessions on disk, newest first, for the import
|
||||
/// picker. Gated behind `read_only_block`: this exposes external Claude session
|
||||
/// titles and working directories from outside AoE-managed state, and import
|
||||
/// can't run in read-only mode anyway, so read-only dashboard users get no
|
||||
/// historical prompt metadata. See #2276.
|
||||
pub async fn list_claude_sessions(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
||||
if let Some(resp) = read_only_block(&state) {
|
||||
return resp;
|
||||
}
|
||||
let mut sessions = tokio::task::spawn_blocking(crate::session::claude_import::scan_sessions)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
// Drop sessions AoE owns: importing one is a no-op and they are noise in
|
||||
// the picker. Two signals (instances span all profiles, #2276):
|
||||
// - id match: a managed session's on-disk id equals the instance's
|
||||
// acp_session_id (structured) or agent_session_id (terminal resume).
|
||||
// - cwd match: any session whose cwd is inside an AoE-PROVISIONED dir
|
||||
// (scratch, a managed worktree, or a workspace). This catches the
|
||||
// smart-rename one-shot AoE runs in that dir, which has its own id not
|
||||
// stored anywhere. It deliberately does NOT cover a plain project_path:
|
||||
// a user running `claude` directly in the same repo as an AoE session
|
||||
// is a real importable conversation, not AoE's, so it stays in the list
|
||||
// and is only filtered by id match.
|
||||
let (managed_ids, managed_dirs): (std::collections::HashSet<String>, Vec<PathBuf>) = {
|
||||
let instances = state.instances.read().await;
|
||||
let ids = instances
|
||||
.iter()
|
||||
.flat_map(|i| {
|
||||
i.acp_session_id
|
||||
.iter()
|
||||
.chain(i.agent_session_id.iter())
|
||||
.cloned()
|
||||
})
|
||||
.collect();
|
||||
let dirs = instances
|
||||
.iter()
|
||||
.filter(|i| {
|
||||
i.scratch
|
||||
|| i.worktree_info.as_ref().is_some_and(|w| w.managed_by_aoe)
|
||||
|| i.workspace_info.is_some()
|
||||
})
|
||||
.map(|i| PathBuf::from(&i.project_path))
|
||||
.filter(|p| !p.as_os_str().is_empty())
|
||||
.collect();
|
||||
(ids, dirs)
|
||||
};
|
||||
sessions.retain(|s| {
|
||||
if managed_ids.contains(&s.session_id) {
|
||||
return false;
|
||||
}
|
||||
let cwd = std::path::Path::new(&s.cwd);
|
||||
!managed_dirs.iter().any(|d| cwd.starts_with(d))
|
||||
});
|
||||
// Cap AFTER ownership filtering so a burst of AoE-managed sessions can't
|
||||
// push real imports off the (newest-first) list. See #2276.
|
||||
sessions.truncate(crate::session::claude_import::MAX_SESSIONS);
|
||||
Json(sessions).into_response()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
+1
-319
@@ -1,4 +1,4 @@
|
||||
//! Git endpoints: repository cloning and branch listing.
|
||||
//! Git endpoints: branch listing and repository detection.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -7,215 +7,6 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::AppState;
|
||||
|
||||
// --- Clone repository ---
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CloneRepoBody {
|
||||
pub url: String,
|
||||
pub destination: Option<String>,
|
||||
#[serde(default)]
|
||||
pub shallow: bool,
|
||||
#[serde(default)]
|
||||
pub bare: bool,
|
||||
}
|
||||
|
||||
/// Returns true if `url` looks like a git clone URL accepted by this
|
||||
/// endpoint. Recognised forms: https, http, git, ssh, file schemes, and
|
||||
/// scp-style (`user@host:path`). Anything else is rejected with a 400
|
||||
/// before reaching `git clone`.
|
||||
fn looks_like_git_url(url: &str) -> bool {
|
||||
url.starts_with("https://")
|
||||
|| url.starts_with("http://")
|
||||
|| url.starts_with("git://")
|
||||
|| url.starts_with("ssh://")
|
||||
|| url.starts_with("file://")
|
||||
|| (url.contains('@') && url.contains(':') && !url.contains(' '))
|
||||
}
|
||||
|
||||
/// Expand a leading `~` or `~/` to the user's home directory.
|
||||
/// Leaves the path unchanged when no home dir is available.
|
||||
fn expand_tilde(path: &str) -> std::path::PathBuf {
|
||||
if let Some(rest) = path.strip_prefix("~/") {
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
return home.join(rest);
|
||||
}
|
||||
} else if path == "~" {
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
return home;
|
||||
}
|
||||
}
|
||||
std::path::PathBuf::from(path)
|
||||
}
|
||||
|
||||
/// Extract a repository name from a git URL.
|
||||
///
|
||||
/// Handles HTTPS, SSH, and scp-style URLs:
|
||||
/// https://github.com/user/repo.git -> repo
|
||||
/// git@github.com:user/repo.git -> repo
|
||||
/// ssh://git@host/user/repo -> repo
|
||||
/// ssh://git@host:2222/user/repo.git -> repo
|
||||
fn repo_name_from_url(url: &str) -> Option<String> {
|
||||
// For scheme-based URLs (https://, ssh://, git://), take the last path segment.
|
||||
// For scp-style (git@host:path), split on ':' and take the last segment of the path.
|
||||
let last_segment = if url.contains("://") {
|
||||
url.rsplit_once('/')?.1
|
||||
} else if let Some((_host, path)) = url.split_once(':') {
|
||||
// scp-style: git@github.com:user/repo.git
|
||||
path.rsplit('/').next().unwrap_or(path)
|
||||
} else {
|
||||
url.rsplit_once('/')?.1
|
||||
};
|
||||
let name = last_segment.trim_end_matches(".git");
|
||||
if name.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(name.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn clone_repo(
|
||||
State(state): State<Arc<AppState>>,
|
||||
body: Result<Json<CloneRepoBody>, axum::extract::rejection::JsonRejection>,
|
||||
) -> impl IntoResponse {
|
||||
if state.read_only {
|
||||
return (
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(
|
||||
serde_json::json!({"error": "read_only", "message": "Server is in read-only mode"}),
|
||||
),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
let Json(body) = match body {
|
||||
Ok(b) => b,
|
||||
Err(rej) => return rej.into_response(),
|
||||
};
|
||||
|
||||
let url = body.url.trim().to_string();
|
||||
if url.is_empty() {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(
|
||||
serde_json::json!({"error": "validation_failed", "message": "URL cannot be empty"}),
|
||||
),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
if !looks_like_git_url(&url) {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({"error": "validation_failed", "message": "URL does not look like a git repository URL"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
if body.bare && body.shallow {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({"error": "validation_failed", "message": "Cannot use both bare and shallow options"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
// Resolve destination path
|
||||
let destination = if let Some(ref dest) = body.destination {
|
||||
let dest = dest.trim();
|
||||
if dest.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(expand_tilde(dest))
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let destination = match destination {
|
||||
Some(d) => d,
|
||||
None => {
|
||||
let repo_name = repo_name_from_url(&url).unwrap_or_else(|| "cloned-repo".to_string());
|
||||
let home = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from("/tmp"));
|
||||
home.join(&repo_name)
|
||||
}
|
||||
};
|
||||
|
||||
// Security: destination must be within the home directory
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
let canonical_home = home.canonicalize().unwrap_or(home);
|
||||
// For new paths that don't exist yet, check the parent
|
||||
let check_path = if destination.exists() {
|
||||
destination.canonicalize().unwrap_or(destination.clone())
|
||||
} else {
|
||||
destination
|
||||
.parent()
|
||||
.and_then(|p| p.canonicalize().ok())
|
||||
.map(|p| p.join(destination.file_name().unwrap_or_default()))
|
||||
.unwrap_or(destination.clone())
|
||||
};
|
||||
if !check_path.starts_with(&canonical_home) {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({"error": "validation_failed", "message": "Destination must be within the home directory"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
|
||||
let dest_display = destination.display().to_string();
|
||||
|
||||
// Return an actionable error if the destination already exists, before
|
||||
// spawning the blocking task, so the user knows to pick a different name.
|
||||
if destination.exists() {
|
||||
return (
|
||||
StatusCode::CONFLICT,
|
||||
Json(serde_json::json!({
|
||||
"error": "destination_exists",
|
||||
"message": format!(
|
||||
"Directory '{}' already exists. Choose a different destination or delete it first.",
|
||||
destination.file_name().unwrap_or_default().to_string_lossy()
|
||||
),
|
||||
"path": dest_display,
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let shallow = body.shallow;
|
||||
let bare = body.bare;
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
if bare {
|
||||
crate::git::clone_bare_repo(&url, &destination)
|
||||
} else {
|
||||
crate::git::clone_repo(&url, &destination, shallow)?;
|
||||
Ok(destination.display().to_string())
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(Ok(path)) => {
|
||||
(StatusCode::CREATED, Json(serde_json::json!({"path": path}))).into_response()
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
let msg = e.to_string();
|
||||
tracing::warn!(target: "http.api.git", "Clone failed for {dest_display}: {msg}");
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({"error": "clone_failed", "message": msg})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(target: "http.api.git", "Clone task panicked: {e}");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({"error": "internal", "message": "Internal server error"})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct BranchesQuery {
|
||||
pub path: String,
|
||||
@@ -338,112 +129,3 @@ pub async fn is_git_repo(
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
// ── repo_name_from_url tests ─────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn repo_name_from_https_url() {
|
||||
assert_eq!(
|
||||
repo_name_from_url("https://github.com/user/my-repo.git"),
|
||||
Some("my-repo".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repo_name_from_https_url_no_dotgit() {
|
||||
assert_eq!(
|
||||
repo_name_from_url("https://github.com/user/my-repo"),
|
||||
Some("my-repo".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repo_name_from_scp_url() {
|
||||
assert_eq!(
|
||||
repo_name_from_url("git@github.com:user/my-repo.git"),
|
||||
Some("my-repo".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repo_name_from_ssh_url() {
|
||||
assert_eq!(
|
||||
repo_name_from_url("ssh://git@github.com/user/my-repo.git"),
|
||||
Some("my-repo".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repo_name_from_ssh_url_with_port() {
|
||||
assert_eq!(
|
||||
repo_name_from_url("ssh://git@host:2222/user/my-repo.git"),
|
||||
Some("my-repo".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
// ── looks_like_git_url tests ──────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn looks_like_git_url_accepts_https() {
|
||||
assert!(looks_like_git_url("https://github.com/u/r.git"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn looks_like_git_url_accepts_http() {
|
||||
assert!(looks_like_git_url("http://example.com/r.git"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn looks_like_git_url_accepts_git_protocol() {
|
||||
assert!(looks_like_git_url("git://example.com/u/r.git"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn looks_like_git_url_accepts_ssh() {
|
||||
assert!(looks_like_git_url("ssh://git@github.com/u/r.git"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn looks_like_git_url_accepts_scp_style() {
|
||||
assert!(looks_like_git_url("git@github.com:u/r.git"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn looks_like_git_url_accepts_file_scheme() {
|
||||
assert!(looks_like_git_url("file:///tmp/bare.git"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn looks_like_git_url_rejects_bare_word() {
|
||||
assert!(!looks_like_git_url("not-a-url"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn looks_like_git_url_rejects_empty() {
|
||||
assert!(!looks_like_git_url(""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn looks_like_git_url_rejects_scp_style_with_space() {
|
||||
assert!(!looks_like_git_url("git@host: /path"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expand_tilde_expands_home() {
|
||||
let home = dirs::home_dir().expect("home dir");
|
||||
assert_eq!(expand_tilde("~/foo"), home.join("foo"));
|
||||
assert_eq!(expand_tilde("~"), home);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expand_tilde_leaves_absolute_paths_alone() {
|
||||
assert_eq!(
|
||||
expand_tilde("/tmp/foo"),
|
||||
std::path::PathBuf::from("/tmp/foo")
|
||||
);
|
||||
assert_eq!(expand_tilde("foo/bar"), std::path::PathBuf::from("foo/bar"));
|
||||
}
|
||||
}
|
||||
|
||||
+7
-30
@@ -36,8 +36,8 @@ pub(crate) use acp::structured_spawn_error_message;
|
||||
pub use acp::{
|
||||
acp_attachment, acp_cancel, acp_context_primer, acp_disable, acp_enable, acp_files,
|
||||
acp_force_end_turn, acp_prompt, acp_replay, acp_set_config_option, acp_set_mode,
|
||||
acp_worker_log, get_option_catalog, install_agent, list_acp_agents, list_claude_sessions,
|
||||
resolve_approval, resolve_elicitation, shutdown_acp, spawn_acp, switch_acp_agent,
|
||||
acp_worker_log, get_option_catalog, install_agent, list_acp_agents, resolve_approval,
|
||||
resolve_elicitation, shutdown_acp, spawn_acp, switch_acp_agent,
|
||||
};
|
||||
|
||||
#[cfg(feature = "serve")]
|
||||
@@ -45,7 +45,7 @@ pub use queue::{queue_clear, queue_edit, queue_enqueue, queue_list, queue_remove
|
||||
|
||||
#[cfg(feature = "serve")]
|
||||
pub use client_log::post_client_log;
|
||||
pub use git::{clone_repo, is_git_repo, list_branches};
|
||||
pub use git::{is_git_repo, list_branches};
|
||||
pub use log_level::{get_log_level, patch_log_level};
|
||||
pub use projects::{create_project, delete_project, list_projects, update_project};
|
||||
pub use sessions::{
|
||||
@@ -53,9 +53,8 @@ pub use sessions::{
|
||||
force_smart_rename, get_recent_projects, kill_terminal, list_sessions, paste_image,
|
||||
read_output, rename_session, restore_session, search_sessions, send_message,
|
||||
serve_session_artifact, session_file, set_worktree_name, start_session, stop_session,
|
||||
trash_session, update_session_archive, update_session_color, update_session_group,
|
||||
update_session_notifications, update_session_pin, update_session_unread,
|
||||
update_workspace_ordering, CleanupDefaults, OutputQuery, SendMessageRequest, SessionResponse,
|
||||
trash_session, update_session_group, update_session_unread, update_workspace_ordering,
|
||||
CleanupDefaults, OutputQuery, SendMessageRequest, SessionResponse,
|
||||
};
|
||||
// Shared by the status poll loop's auto-unread persistence; not a route handler.
|
||||
pub(crate) use sessions::persist_session_update;
|
||||
@@ -69,8 +68,8 @@ pub use system::{
|
||||
browse_filesystem, create_profile, default_profile, delete_profile, dismiss_update,
|
||||
filesystem_home, get_about, get_current_theme, get_profile_settings, get_resolved_theme,
|
||||
get_settings, get_settings_resolved, get_settings_schema, get_web_ui_state, list_agents,
|
||||
list_groups, list_profiles, list_themes, patch_web_ui_state, post_dashboard_presence,
|
||||
rename_profile, update_profile_settings, update_settings, update_theme,
|
||||
list_groups, list_profiles, list_themes, patch_web_ui_state, rename_profile,
|
||||
update_profile_settings, update_settings, update_theme,
|
||||
};
|
||||
|
||||
/// Canonical 404 for a session id that does not resolve to a live instance.
|
||||
@@ -262,20 +261,14 @@ mod tests {
|
||||
"ensure_session",
|
||||
"ensure_terminal",
|
||||
"update_session_group",
|
||||
"update_session_notifications",
|
||||
"update_session_pin",
|
||||
"update_session_color",
|
||||
"update_session_archive",
|
||||
"trash_session",
|
||||
"restore_session",
|
||||
"update_session_unread",
|
||||
"stop_session",
|
||||
"force_smart_rename",
|
||||
"start_session",
|
||||
"update_workspace_ordering",
|
||||
],
|
||||
),
|
||||
("api/git.rs", include_str!("git.rs"), &["clone_repo"]),
|
||||
(
|
||||
"api/log_level.rs",
|
||||
include_str!("log_level.rs"),
|
||||
@@ -317,11 +310,6 @@ mod tests {
|
||||
"resolve_elicitation",
|
||||
],
|
||||
),
|
||||
(
|
||||
"server/push.rs",
|
||||
include_str!("../push.rs"),
|
||||
&["subscribe", "unsubscribe", "test"],
|
||||
),
|
||||
];
|
||||
|
||||
let guard_patterns: &[&str] = &[
|
||||
@@ -400,18 +388,12 @@ mod tests {
|
||||
"ensure_session",
|
||||
"ensure_terminal",
|
||||
"update_session_group",
|
||||
"update_session_notifications",
|
||||
"update_session_pin",
|
||||
"update_session_color",
|
||||
"update_session_archive",
|
||||
"trash_session",
|
||||
"update_session_unread",
|
||||
"stop_session",
|
||||
"start_session",
|
||||
"update_workspace_ordering",
|
||||
],
|
||||
),
|
||||
("api/git.rs", include_str!("git.rs"), &["clone_repo"]),
|
||||
(
|
||||
"api/log_level.rs",
|
||||
include_str!("log_level.rs"),
|
||||
@@ -453,11 +435,6 @@ mod tests {
|
||||
"resolve_elicitation",
|
||||
],
|
||||
),
|
||||
(
|
||||
"server/push.rs",
|
||||
include_str!("../push.rs"),
|
||||
&["subscribe", "unsubscribe", "test"],
|
||||
),
|
||||
];
|
||||
|
||||
let mut failures: Vec<String> = Vec::new();
|
||||
|
||||
+86
-758
File diff suppressed because it is too large
Load Diff
@@ -4,12 +4,7 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{
|
||||
extract::State,
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::IntoResponse,
|
||||
Json,
|
||||
};
|
||||
use axum::{extract::State, http::StatusCode, response::IntoResponse, Json};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::validate_profile_name;
|
||||
@@ -18,33 +13,6 @@ use crate::session::settings_schema::{
|
||||
clear_path, schema, strip_local_only, validate_patch, PatchRejection, Scope,
|
||||
};
|
||||
|
||||
/// Foreground state reported by one browser dashboard. This is intentionally
|
||||
/// separate from normal API traffic: background polling must not suppress a
|
||||
/// phone's push notification.
|
||||
#[derive(Deserialize)]
|
||||
pub struct DashboardPresenceBody {
|
||||
pub active: bool,
|
||||
}
|
||||
|
||||
/// `POST /api/presence`. Record or clear this browser's foreground presence.
|
||||
/// Hashing the client's `x-aoe-device-binding` header gives each browser an
|
||||
/// ephemeral server-side key without retaining the value itself. A client that
|
||||
/// omits the header shares the all-zero key, so several such browsers count as
|
||||
/// one presence.
|
||||
pub async fn post_dashboard_presence(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Json(body): Json<DashboardPresenceBody>,
|
||||
) -> StatusCode {
|
||||
let client = headers
|
||||
.get("x-aoe-device-binding")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(crate::server::push::sha256_token)
|
||||
.unwrap_or([0u8; 32]);
|
||||
state.set_web_presence(client, body.active);
|
||||
StatusCode::NO_CONTENT
|
||||
}
|
||||
|
||||
// --- Agents ---
|
||||
|
||||
#[derive(Serialize)]
|
||||
|
||||
+9
-11
@@ -3,9 +3,8 @@
|
||||
//! A session created with `callback_url` set receives a fire-and-forget HTTP
|
||||
//! POST when it transitions into Idle, Waiting, or Error, so a headless
|
||||
//! dispatcher can react to completion without polling `GET /api/sessions`.
|
||||
//! Subscribes to the same `state.status_tx` broadcast the web-push consumer
|
||||
//! (`push.rs`) uses, but applies a short debounce instead of push's
|
||||
//! dwell+cooldown: a legitimate second Idle a minute later is real signal a
|
||||
//! Subscribes to the `state.status_tx` broadcast and applies a short
|
||||
//! debounce: a legitimate second Idle a minute later is real signal a
|
||||
//! dispatcher needs, not noise to suppress, so only sub-second tmux-scrape
|
||||
//! flicker gets collapsed. The debounce mirrors `status_hooks.rs`'s
|
||||
//! generation-counter pattern (the TUI's own status-command hooks), ported
|
||||
@@ -19,18 +18,18 @@ use std::time::Duration;
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
use super::push::StatusChange;
|
||||
use super::status_change::StatusChange;
|
||||
use super::AppState;
|
||||
use crate::session::Status;
|
||||
|
||||
/// Debounce window before firing: absorbs sub-second tmux-scrape flicker
|
||||
/// (Waiting -> Running -> Waiting) without push's 60s cooldown, since a real
|
||||
/// second Idle a minute later is signal a dispatcher needs, not noise.
|
||||
/// (Waiting -> Running -> Waiting). A real second Idle a minute later is
|
||||
/// signal a dispatcher needs, not noise, so nothing longer is suppressed.
|
||||
const DEBOUNCE_MS: u64 = 500;
|
||||
|
||||
/// Bounded concurrency for outbound callback POSTs, mirrors `push.rs`'s
|
||||
/// `SEND_CONCURRENCY` so a session with a slow/dead callback endpoint can't
|
||||
/// let outstanding requests grow unbounded.
|
||||
/// Bounded concurrency for outbound callback POSTs, so a session with a
|
||||
/// slow or dead callback endpoint can't let outstanding requests grow
|
||||
/// unbounded.
|
||||
const DISPATCH_CONCURRENCY: usize = 8;
|
||||
|
||||
const CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
@@ -268,8 +267,7 @@ fn is_fire_worthy(status: Status) -> bool {
|
||||
|
||||
/// Spawn the consumer task. Subscribes to `state.status_tx` and dispatches a
|
||||
/// debounced HTTP POST to any instance's `callback_url` on a fire-worthy
|
||||
/// transition. Runs for the lifetime of the server, mirroring
|
||||
/// `push::spawn_consumer`.
|
||||
/// transition. Runs for the lifetime of the server.
|
||||
pub fn spawn_consumer(state: Arc<AppState>) {
|
||||
tokio::spawn(async move {
|
||||
let semaphore = Arc::new(tokio::sync::Semaphore::new(DISPATCH_CONCURRENCY));
|
||||
|
||||
+24
-252
@@ -11,10 +11,9 @@ pub mod api;
|
||||
pub mod callback;
|
||||
pub mod live_ws;
|
||||
mod pane;
|
||||
pub mod push;
|
||||
pub mod push_send;
|
||||
pub(crate) mod session_service;
|
||||
pub(crate) mod session_spawn;
|
||||
pub mod status_change;
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
@@ -27,7 +26,7 @@ use tokio::sync::{broadcast, RwLock};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tracing::{info, Instrument};
|
||||
|
||||
use self::push::{PushState, StatusChange, STATUS_CHANNEL_CAPACITY};
|
||||
use self::status_change::{StatusChange, STATUS_CHANNEL_CAPACITY};
|
||||
|
||||
#[cfg(feature = "serve")]
|
||||
const ACP_CHANNEL_CAPACITY: usize = 256;
|
||||
@@ -188,21 +187,11 @@ pub struct AppState {
|
||||
/// stays the plain owner for display.
|
||||
pub remote_owner_cache: RwLock<std::collections::HashMap<String, Option<(String, String)>>>,
|
||||
/// Broadcasts session status transitions to consumers (currently the
|
||||
/// push-notification module). Emitted from `status_poll_loop` after
|
||||
/// each tmux scrape when `old != new`. Keep the Sender around even
|
||||
/// when no receivers exist so callers can emit without checking.
|
||||
/// per-session HTTP callback dispatcher). Emitted from
|
||||
/// `status_poll_loop` after each tmux scrape when `old != new`. Keep
|
||||
/// the Sender around even when no receivers exist so callers can emit
|
||||
/// without checking.
|
||||
pub status_tx: broadcast::Sender<StatusChange>,
|
||||
/// Web Push state: VAPID keypair, subscription store, VAPID subject.
|
||||
/// None when `web.notifications_enabled` is false at startup (the
|
||||
/// feature is fully off and endpoints return 404).
|
||||
pub push: Option<Arc<PushState>>,
|
||||
/// Cached value of `web.notifications_enabled` at startup. Changes
|
||||
/// to the config flag require a server restart to take effect; this
|
||||
/// is a documented limitation of the toggle for v1.
|
||||
pub push_enabled: bool,
|
||||
/// Snapshot of the resolved WebConfig at startup. Consumed by the
|
||||
/// push consumer task to evaluate per-event-type defaults.
|
||||
pub web_config: crate::session::config::WebConfig,
|
||||
/// Broadcasts acp events to subscribed WebSocket clients. The
|
||||
/// channel carries `(session_id, serialized event JSON)` frames so
|
||||
/// clients can filter by session. Empty when no clients are
|
||||
@@ -226,12 +215,6 @@ pub struct AppState {
|
||||
#[cfg(feature = "serve")]
|
||||
pub acp_supervisor:
|
||||
Arc<crate::acp::supervisor::Supervisor<crate::acp::supervisor::ChannelSink>>,
|
||||
/// Per-browser foreground dashboard presence. Entries are keyed by a hash
|
||||
/// of the device-binding secret and expire when the browser stops sending
|
||||
/// its visibility heartbeat. Push suppression must not treat ordinary
|
||||
/// polling or a backgrounded PWA as evidence that somebody is looking at
|
||||
/// the dashboard.
|
||||
pub web_presence: std::sync::Mutex<std::collections::HashMap<[u8; 32], i64>>,
|
||||
/// Packed sleep-inhibit reconciler snapshot for read-only status reporting:
|
||||
/// bit `SLEEP_INHIBIT_SNAPSHOT_ENABLED` is the
|
||||
/// `prevent_sleep_when_active` toggle as the reconciler last read it, bit
|
||||
@@ -309,27 +292,6 @@ impl AppState {
|
||||
.or_insert_with(|| Arc::new(tokio::sync::Mutex::new(())))
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// Record whether one browser is currently foregrounded. Browser identity
|
||||
/// is derived from its device-binding secret, never retained in plaintext.
|
||||
pub fn set_web_presence(&self, client: [u8; 32], active: bool) {
|
||||
let mut presence = self.web_presence.lock().expect("web_presence poisoned");
|
||||
if active {
|
||||
presence.insert(client, crate::util::now_ms() as i64);
|
||||
} else {
|
||||
presence.remove(&client);
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true if any dashboard recently reported itself visible and
|
||||
/// focused. Stale entries are swept here, on the only read path.
|
||||
pub fn web_active_within(&self, threshold: std::time::Duration) -> bool {
|
||||
let now = crate::util::now_ms() as i64;
|
||||
let max_age = threshold.as_millis() as i64;
|
||||
let mut presence = self.web_presence.lock().expect("web_presence poisoned");
|
||||
presence.retain(|_, last| now.saturating_sub(*last) < max_age);
|
||||
!presence.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// Raise the soft `RLIMIT_NOFILE` so the server can sustain many WS
|
||||
@@ -421,37 +383,12 @@ pub async fn start_server(config: ServerConfig<'_>) -> anyhow::Result<()> {
|
||||
let instances = load_all_instances(&file_watch)?;
|
||||
|
||||
let config = crate::session::profile_config::resolve_config_or_warn(profile);
|
||||
// Feed the unread-feature gate from this daemon's resolved config. Like
|
||||
// `push_enabled`, this is read once at startup; a config change needs a
|
||||
// restart to take effect. The TUI process maintains its own copy.
|
||||
// Feed the unread-feature gate from this daemon's resolved config. This
|
||||
// is read once at startup; a config change needs a restart to take
|
||||
// effect. The TUI process maintains its own copy.
|
||||
crate::session::set_unread_enabled(config.session.unread_indicator);
|
||||
crate::session::set_favorites_first(config.session.favorites_first);
|
||||
|
||||
// Push notifications: initialize only when the operator flag is on at
|
||||
// startup. Flipping it later requires a server restart to take effect.
|
||||
let push_enabled = config.web.notifications_enabled;
|
||||
let push_state = if push_enabled {
|
||||
match crate::session::get_app_dir() {
|
||||
Ok(dir) => match PushState::init(&dir) {
|
||||
Ok(s) => Some(Arc::new(s)),
|
||||
Err(e) => {
|
||||
tracing::warn!(target: "http.middleware",
|
||||
"Push notifications disabled: failed to init VAPID/state: {}",
|
||||
e
|
||||
);
|
||||
None
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
tracing::warn!(target: "http.middleware", "Push notifications disabled: app_dir unavailable: {}", e);
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
info!("Push notifications disabled by web.notifications_enabled=false");
|
||||
None
|
||||
};
|
||||
|
||||
#[cfg(feature = "serve")]
|
||||
let acp_events_tx = broadcast::channel(ACP_CHANNEL_CAPACITY).0;
|
||||
#[cfg(feature = "serve")]
|
||||
@@ -467,10 +404,6 @@ pub async fn start_server(config: ServerConfig<'_>) -> anyhow::Result<()> {
|
||||
let acp_control_cache = Arc::new(crate::acp::control_cache::ControlStateCache::new());
|
||||
#[cfg(feature = "serve")]
|
||||
let acp_supervisor = {
|
||||
// Approval pushes are dispatched from `acp_event_listener`,
|
||||
// which subscribes to the broadcast that ChannelSink::publish
|
||||
// feeds and has `Arc<AppState>` in scope without a closure
|
||||
// dance through the supervisor. See #1038.
|
||||
let sink = std::sync::Arc::new(crate::acp::supervisor::ChannelSink {
|
||||
tx: acp_events_tx.clone(),
|
||||
event_store: acp_event_store.clone(),
|
||||
@@ -620,10 +553,6 @@ pub async fn start_server(config: ServerConfig<'_>) -> anyhow::Result<()> {
|
||||
#[cfg(feature = "serve")]
|
||||
acp_supervisor: acp_supervisor.clone(),
|
||||
#[cfg(feature = "serve")]
|
||||
push: push_state,
|
||||
push_enabled,
|
||||
web_config: config.web.clone(),
|
||||
web_presence: std::sync::Mutex::new(std::collections::HashMap::new()),
|
||||
sleep_inhibit_snapshot: std::sync::atomic::AtomicU8::new(0),
|
||||
shutdown: CancellationToken::new(),
|
||||
file_watch: Arc::clone(&file_watch),
|
||||
@@ -803,7 +732,7 @@ pub async fn start_server(config: ServerConfig<'_>) -> anyhow::Result<()> {
|
||||
|
||||
// Acp broadcast listener: a single subscriber that handles
|
||||
// every in-process consumer of acp events. Status mirroring
|
||||
// (sidebar dot, push-notification source) and ACP-session-id
|
||||
// (sidebar dot) and ACP-session-id
|
||||
// persistence (so `session/load` works across restart) used to be
|
||||
// two separate subscribers, which doubled the broadcast clone
|
||||
// count and locked `state.instances` twice for the events that
|
||||
@@ -819,11 +748,6 @@ pub async fn start_server(config: ServerConfig<'_>) -> anyhow::Result<()> {
|
||||
);
|
||||
}
|
||||
|
||||
// Push-notification consumer: subscribes to status_tx, applies
|
||||
// dwell + cooldown, sends pushes. No-op when push_state is None
|
||||
// (feature disabled via web.notifications_enabled=false).
|
||||
push::spawn_consumer(state.clone());
|
||||
|
||||
// Per-session dispatcher callback consumer: subscribes to the same
|
||||
// status_tx broadcast and fires an HTTP POST to any instance's
|
||||
// callback_url on a fire-worthy transition. See #3156.
|
||||
@@ -912,9 +836,6 @@ fn build_router(state: Arc<AppState>) -> Router {
|
||||
use axum::routing::{delete, get, patch, post, put};
|
||||
|
||||
let app = Router::new()
|
||||
// Explicit browser visibility heartbeat. Ordinary API requests do not
|
||||
// imply the dashboard is foregrounded, so they must not suppress push.
|
||||
.route("/api/presence", post(api::post_dashboard_presence))
|
||||
// Sessions
|
||||
.route(
|
||||
"/api/sessions",
|
||||
@@ -951,26 +872,16 @@ fn build_router(state: Arc<AppState>) -> Router {
|
||||
post(api::paste_image).layer(axum::extract::DefaultBodyLimit::max(8 * 1024 * 1024)),
|
||||
)
|
||||
.route("/api/sessions/{id}/output", get(api::read_output))
|
||||
.route(
|
||||
"/api/sessions/{id}/notifications",
|
||||
patch(api::update_session_notifications),
|
||||
)
|
||||
.route(
|
||||
"/api/sessions/{id}/worktree-name",
|
||||
patch(api::set_worktree_name),
|
||||
)
|
||||
.route("/api/sessions/{id}/pin", patch(api::update_session_pin))
|
||||
.route("/api/sessions/{id}/color", patch(api::update_session_color))
|
||||
.route(
|
||||
"/api/sessions/{id}/archive",
|
||||
patch(api::update_session_archive),
|
||||
)
|
||||
.route("/api/sessions/{id}/trash", post(api::trash_session))
|
||||
.route("/api/sessions/{id}/restore", post(api::restore_session))
|
||||
.route(
|
||||
"/api/sessions/{id}/unread",
|
||||
patch(api::update_session_unread),
|
||||
)
|
||||
.route("/api/sessions/{id}/trash", post(api::trash_session))
|
||||
.route("/api/sessions/{id}/restore", post(api::restore_session))
|
||||
.route("/api/sessions/{id}/stop", post(api::stop_session))
|
||||
.route(
|
||||
"/api/sessions/{id}/smart-rename",
|
||||
@@ -999,7 +910,6 @@ fn build_router(state: Arc<AppState>) -> Router {
|
||||
.route("/api/filesystem/home", get(api::filesystem_home))
|
||||
.route("/api/git/branches", get(api::list_branches))
|
||||
.route("/api/git/is-repo", get(api::is_git_repo))
|
||||
.route("/api/git/clone", post(api::clone_repo))
|
||||
.route("/api/groups", get(api::list_groups))
|
||||
.route(
|
||||
"/api/projects",
|
||||
@@ -1025,15 +935,6 @@ fn build_router(state: Arc<AppState>) -> Router {
|
||||
.route("/api/themes/{name}", get(api::get_resolved_theme))
|
||||
.route("/api/theme/current", get(api::get_current_theme))
|
||||
.route("/api/theme", patch(api::update_theme))
|
||||
// Push notifications
|
||||
.route("/api/push/status", get(push::get_status))
|
||||
.route(
|
||||
"/api/push/vapid-public-key",
|
||||
get(push::get_vapid_public_key),
|
||||
)
|
||||
.route("/api/push/subscribe", post(push::subscribe))
|
||||
.route("/api/push/unsubscribe", post(push::unsubscribe))
|
||||
.route("/api/push/test", post(push::test))
|
||||
// About (version, read-only state)
|
||||
.route("/api/about", get(api::get_about))
|
||||
// Update status (latest release, available flag)
|
||||
@@ -1118,8 +1019,7 @@ fn build_router(state: Arc<AppState>) -> Router {
|
||||
post(api::resolve_elicitation),
|
||||
)
|
||||
.route("/api/acp/agents", get(api::list_acp_agents))
|
||||
.route("/api/acp/option-catalog", get(api::get_option_catalog))
|
||||
.route("/api/claude-sessions", get(api::list_claude_sessions));
|
||||
.route("/api/acp/option-catalog", get(api::get_option_catalog));
|
||||
|
||||
app
|
||||
// Static assets (Vite build output: assets/, manifest.json, sw.js, icons)
|
||||
@@ -1967,7 +1867,7 @@ fn apply_tick_status_decisions(
|
||||
// A trashed row remains in storage until its retention period ends,
|
||||
// but it is no longer a live session. Do not turn its deliberately
|
||||
// stopped pane into a synthetic Error, and do not emit a status event
|
||||
// that the push consumer could notify about.
|
||||
// that consumers could react to.
|
||||
if inst.is_trashed() {
|
||||
if let Some(live) = inst.live_status_baseline {
|
||||
inst.status = live;
|
||||
@@ -2026,8 +1926,8 @@ fn observed_transitions(
|
||||
/// with live, and `status_poll_loop` compares exactly those two: `prev` comes
|
||||
/// from `state.instances` (overlaid, live), `fresh` from disk. Left alone, every
|
||||
/// tick reads that standing mismatch as a brand new transition, which logs a
|
||||
/// `session.status_change` line, broadcasts a `StatusChange`, and resets the
|
||||
/// push dwell timer in `server::push`. Forever, at the 2s tick, surviving daemon
|
||||
/// `session.status_change` line and broadcasts a `StatusChange`. Forever,
|
||||
/// at the 2s tick, surviving daemon
|
||||
/// restarts because `seed_acp_statuses` re-derives the same live status from the
|
||||
/// stored event log on boot. One session whose worker died with
|
||||
/// `AgentStartupError` wrote 81k such lines into a single 43MB log file.
|
||||
@@ -2917,11 +2817,11 @@ async fn flush_passive_transition_writes(
|
||||
/// with the live-session count (#2758).
|
||||
///
|
||||
/// The reconciler also retains these maps, but against its resume-eligible
|
||||
/// subset (structured, not archived / trashed / idle-dormant) and
|
||||
/// subset (structured, not trashed / idle-dormant) and
|
||||
/// only when the tmux scrape succeeds and the reconciler runs. This sweep runs
|
||||
/// at the top of every tick against the full live-instance set, so deletion GC
|
||||
/// is guaranteed even on a tick whose scrape fails, and entries for a session
|
||||
/// that is merely paused (archived / idle-dormant) are not needed to
|
||||
/// that is merely paused (idle-dormant) are not needed to
|
||||
/// be re-derived here.
|
||||
#[cfg(feature = "serve")]
|
||||
fn gc_reconciler_session_maps(
|
||||
@@ -3828,130 +3728,6 @@ async fn acp_event_listener(state: Arc<AppState>) {
|
||||
}
|
||||
};
|
||||
|
||||
// Detect wake-fire: a `UserPromptSent` arriving at-or-after a
|
||||
// `WakeupScheduled`'s `at` timestamp means the agent's pending
|
||||
// wake just fired. Push opt-in to the user's phone so /loop
|
||||
// dynamic runs don't need them to keep checking the dashboard.
|
||||
// See #1091.
|
||||
if matches!(
|
||||
frame.event.as_ref(),
|
||||
crate::acp::state::Event::UserPromptSent { .. }
|
||||
) {
|
||||
match state
|
||||
.acp_event_store
|
||||
.fired_wakeup_for_prompt(&frame.session_id, frame.seq)
|
||||
{
|
||||
Some((at, reason)) => {
|
||||
let session_id = frame.session_id.clone();
|
||||
let session_title = state
|
||||
.instances
|
||||
.read()
|
||||
.await
|
||||
.iter()
|
||||
.find(|i| i.id == session_id)
|
||||
.map(|i| i.title.clone())
|
||||
.unwrap_or_default();
|
||||
tracing::info!(
|
||||
target: "acp.wakeup",
|
||||
session = %session_id,
|
||||
prompt_seq = frame.seq,
|
||||
wake_at = %at,
|
||||
reason = ?reason,
|
||||
"wake-fire detected; dispatching push notification"
|
||||
);
|
||||
let state_for_push = state.clone();
|
||||
tokio::spawn(async move {
|
||||
crate::server::push::fire_wake_fired_push(
|
||||
state_for_push,
|
||||
&session_id,
|
||||
&session_title,
|
||||
reason.as_deref(),
|
||||
)
|
||||
.await;
|
||||
});
|
||||
}
|
||||
None => {
|
||||
tracing::trace!(
|
||||
target: "acp.wakeup",
|
||||
session = %frame.session_id,
|
||||
prompt_seq = frame.seq,
|
||||
"UserPromptSent: no fired-wake match (regular follow-up)"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Approval push: when the worker emits an `ApprovalRequested`
|
||||
// event, trigger a Web Push so the user sees a "needs approval"
|
||||
// alert even when the dashboard is backgrounded. Unlike the
|
||||
// status-change pushes in `push.rs`, approvals do NOT honour
|
||||
// the TUI/web active-session suppression; the service worker
|
||||
// still routes focused clients to an in-app toast via the
|
||||
// existing `aoe-push` postMessage path. See #1038.
|
||||
if let crate::acp::state::Event::ApprovalRequested { approval } = frame.event.as_ref() {
|
||||
let state_for_push = state.clone();
|
||||
let session_id = frame.session_id.clone();
|
||||
let approval_title = approval.tool_call.name.clone();
|
||||
let destructive = approval.destructive;
|
||||
let seq = frame.seq;
|
||||
tokio::spawn(async move {
|
||||
acp_ws::trigger_approval_push(
|
||||
&state_for_push,
|
||||
&session_id,
|
||||
&approval_title,
|
||||
destructive,
|
||||
seq,
|
||||
)
|
||||
.await;
|
||||
});
|
||||
}
|
||||
|
||||
// Clear push: when the approval is handled (on any device), retract
|
||||
// the "needs approval" notification that the request push raised, so
|
||||
// a backgrounded phone or second computer does not keep showing a
|
||||
// stale alert for an already-resolved request. See #2491.
|
||||
if matches!(
|
||||
frame.event.as_ref(),
|
||||
crate::acp::state::Event::ApprovalResolved { .. }
|
||||
) {
|
||||
let state_for_push = state.clone();
|
||||
let session_id = frame.session_id.clone();
|
||||
let seq = frame.seq;
|
||||
tokio::spawn(async move {
|
||||
acp_ws::trigger_approval_clear_push(&state_for_push, &session_id, seq).await;
|
||||
});
|
||||
}
|
||||
|
||||
// Question push: an `AskUserQuestion` (ElicitationRequested) blocks
|
||||
// the turn on the user just like an approval, so it gets the same
|
||||
// dedicated, suppression-bypassing push instead of only the generic
|
||||
// Waiting one. Same live-event-only path as the approval push above.
|
||||
// See #2146.
|
||||
if let crate::acp::state::Event::ElicitationRequested { elicitation } = frame.event.as_ref()
|
||||
{
|
||||
let state_for_push = state.clone();
|
||||
let session_id = frame.session_id.clone();
|
||||
let question = elicitation.message.clone();
|
||||
let seq = frame.seq;
|
||||
tokio::spawn(async move {
|
||||
acp_ws::trigger_question_push(&state_for_push, &session_id, &question, seq).await;
|
||||
});
|
||||
}
|
||||
|
||||
// Clear push for an answered question, mirroring the approval clear
|
||||
// above. See #2491.
|
||||
if matches!(
|
||||
frame.event.as_ref(),
|
||||
crate::acp::state::Event::ElicitationResolved { .. }
|
||||
) {
|
||||
let state_for_push = state.clone();
|
||||
let session_id = frame.session_id.clone();
|
||||
let seq = frame.seq;
|
||||
tokio::spawn(async move {
|
||||
acp_ws::trigger_question_clear_push(&state_for_push, &session_id, seq).await;
|
||||
});
|
||||
}
|
||||
|
||||
// Recall cache: record the agent's advertised config options so the
|
||||
// per-agent defaults settings page can populate its dropdowns without a
|
||||
// live session. `record` debounces unchanged snapshots and writes off
|
||||
@@ -4130,8 +3906,8 @@ async fn acp_event_listener(state: Arc<AppState>) {
|
||||
/// listener start, so a session that was mid-turn when the previous
|
||||
/// daemon died doesn't render Idle until the next live event arrives.
|
||||
/// Acts via the same `apply_status_intent` path as the live listener
|
||||
/// so push subscribers and the broadcast channel see the seeded
|
||||
/// transitions as ordinary StatusChange events. See #1103 (B).
|
||||
/// so the broadcast channel sees the seeded transitions as ordinary
|
||||
/// StatusChange events. See #1103 (B).
|
||||
#[cfg(feature = "serve")]
|
||||
pub(crate) async fn seed_acp_statuses(state: Arc<AppState>) {
|
||||
let acp_ids: Vec<String> = state
|
||||
@@ -4173,8 +3949,8 @@ pub(crate) async fn seed_acp_statuses(state: Arc<AppState>) {
|
||||
|
||||
/// Fold a derived `StatusIntent` into an `Instance`. Pure mutation;
|
||||
/// callers hold the write lock. Sends a `StatusChange` on
|
||||
/// `status_tx` so push notifications and the dashboard see the
|
||||
/// transition like any tmux-driven one.
|
||||
/// `status_tx` so consumers and the dashboard see the transition like
|
||||
/// any tmux-driven one.
|
||||
#[cfg(feature = "serve")]
|
||||
pub(crate) fn apply_status_intent(
|
||||
inst: &mut Instance,
|
||||
@@ -4613,10 +4389,6 @@ pub mod test_support {
|
||||
acp_event_store: event_store,
|
||||
acp_control_cache,
|
||||
acp_supervisor: supervisor,
|
||||
push: None,
|
||||
push_enabled: false,
|
||||
web_config: crate::session::config::WebConfig::default(),
|
||||
web_presence: std::sync::Mutex::new(HashMap::new()),
|
||||
sleep_inhibit_snapshot: std::sync::atomic::AtomicU8::new(0),
|
||||
shutdown: CancellationToken::new(),
|
||||
file_watch,
|
||||
@@ -5849,8 +5621,8 @@ mod tests {
|
||||
// `status_poll_loop` still comparing the live `prev` against the
|
||||
// disk-loaded `fresh`. Those two never converge for a structured row,
|
||||
// so the loop reported one fresh transition per 2s tick forever: a
|
||||
// `session.status_change` line, a `StatusChange` broadcast, and a reset
|
||||
// push dwell timer, plus a re-marked-unread row when the live side was
|
||||
// `session.status_change` line and a `StatusChange` broadcast, plus a
|
||||
// re-marked-unread row when the live side was
|
||||
// `Running`.
|
||||
let mut inst = phantom_structured_row("acp-session");
|
||||
inst.live_status_baseline = Some(Status::Error);
|
||||
|
||||
-1435
File diff suppressed because it is too large
Load Diff
@@ -1,343 +0,0 @@
|
||||
//! Web Push payload encryption and delivery.
|
||||
//!
|
||||
//! Implements RFC 8291 (Web Push payload encryption) over RFC 8188
|
||||
//! (aes128gcm Content-Encoding) plus VAPID (RFC 8292) for the
|
||||
//! Authorization header. The scheme:
|
||||
//!
|
||||
//! 1. Server generates an ephemeral P-256 keypair per push.
|
||||
//! 2. ECDH(server_ephemeral_priv, subscription_p256dh) yields a shared
|
||||
//! secret; HKDF mixes in the subscription's `auth` secret and a
|
||||
//! `WebPush: info\0 || ua_pub || as_pub` info string to produce IKM.
|
||||
//! 3. A random 16-byte salt plus the IKM are fed through HKDF again
|
||||
//! with distinct info strings to derive the content encryption key
|
||||
//! (CEK, 16 bytes) and nonce (12 bytes).
|
||||
//! 4. Plaintext is padded with a trailing 0x02 record-terminator byte
|
||||
//! and encrypted with AES-128-GCM.
|
||||
//! 5. The body is: salt(16) || record_size(4, BE) || idlen(1) ||
|
||||
//! as_pub(65) || ciphertext.
|
||||
//!
|
||||
//! The VAPID JWT is signed with ES256 using the server's long-lived
|
||||
//! VAPID private key and carries `aud` (origin of the push endpoint),
|
||||
//! `exp` (12-hour horizon), and `sub` (contact URL).
|
||||
//!
|
||||
//! Everything here is hand-rolled rather than pulled from the
|
||||
//! `web-push` crate because we already have the p256/hkdf/aes-gcm
|
||||
//! primitives for other features, and the crate's transitive feature
|
||||
//! flags drag in openssl on some configurations which we specifically
|
||||
//! avoid elsewhere.
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use base64::Engine;
|
||||
use serde::Serialize;
|
||||
use sha2::Sha256;
|
||||
use std::time::Duration;
|
||||
|
||||
use super::push::{base64_url_decode, PushState, Subscription};
|
||||
|
||||
/// Per-send HTTPS timeout. A dead Apple/Google endpoint must not tie up
|
||||
/// the worker forever, since a stuck send blocks the Semaphore permit.
|
||||
pub const SEND_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
/// TTL for the push notification, in seconds. If the browser is offline
|
||||
/// for longer than this, the push is discarded by the relay rather than
|
||||
/// queued indefinitely.
|
||||
pub const PUSH_TTL_SECS: u32 = 60 * 60 * 24; // 24h
|
||||
|
||||
/// VAPID JWT lifetime. Spec allows up to 24h but 12h is common.
|
||||
pub const VAPID_EXP_SECS: u64 = 60 * 60 * 12;
|
||||
|
||||
/// Outcome of one attempted push send.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum SendOutcome {
|
||||
/// 2xx from the push endpoint.
|
||||
Delivered,
|
||||
/// 410 Gone or 404 Not Found: subscription is permanently invalid,
|
||||
/// caller should GC (gated on generation counter).
|
||||
Gone,
|
||||
/// Any other failure: timeout, connection error, 5xx, 429, etc.
|
||||
Failed,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct VapidClaims {
|
||||
aud: String,
|
||||
exp: u64,
|
||||
sub: String,
|
||||
}
|
||||
|
||||
/// The body shape the service worker expects from `event.data.json()`.
|
||||
#[derive(Serialize)]
|
||||
pub struct PushPayload {
|
||||
pub title: String,
|
||||
pub body: String,
|
||||
pub url: String,
|
||||
pub tag: String,
|
||||
pub session_id: String,
|
||||
}
|
||||
|
||||
/// Build a pre-configured reqwest client for push delivery:
|
||||
/// - no_proxy: corporate MITM proxies would otherwise see endpoint URLs
|
||||
/// and encrypted payloads
|
||||
/// - SEND_TIMEOUT per request, caps worst-case blocking
|
||||
/// - rustls only, no openssl surface
|
||||
pub fn build_client() -> Result<reqwest::Client> {
|
||||
reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.timeout(SEND_TIMEOUT)
|
||||
.build()
|
||||
.context("build reqwest client for push")
|
||||
}
|
||||
|
||||
/// Construct the VAPID `Authorization: vapid t=<jwt>, k=<pub_b64url>`
|
||||
/// header value for a given endpoint's origin (aud).
|
||||
pub fn vapid_auth_header(state: &PushState, endpoint: &str) -> Result<String> {
|
||||
use jsonwebtoken::{encode, Algorithm, EncodingKey, Header};
|
||||
|
||||
let aud = endpoint_origin(endpoint)?;
|
||||
let exp = chrono::Utc::now().timestamp() as u64 + VAPID_EXP_SECS;
|
||||
let claims = VapidClaims {
|
||||
aud,
|
||||
exp,
|
||||
sub: state.subject.clone(),
|
||||
};
|
||||
let header = Header::new(Algorithm::ES256);
|
||||
let key = EncodingKey::from_ec_pem(state.vapid.private_pem.as_bytes())
|
||||
.context("load VAPID private key for JWT signing")?;
|
||||
let jwt = encode(&header, &claims, &key).context("sign VAPID JWT")?;
|
||||
Ok(format!("vapid t={}, k={}", jwt, state.vapid.public_b64url))
|
||||
}
|
||||
|
||||
fn endpoint_origin(endpoint: &str) -> Result<String> {
|
||||
let url = reqwest::Url::parse(endpoint).context("parse push endpoint URL")?;
|
||||
let scheme = url.scheme();
|
||||
let host = url
|
||||
.host_str()
|
||||
.ok_or_else(|| anyhow!("push endpoint has no host"))?;
|
||||
Ok(match url.port() {
|
||||
Some(p) => format!("{}://{}:{}", scheme, host, p),
|
||||
None => format!("{}://{}", scheme, host),
|
||||
})
|
||||
}
|
||||
|
||||
/// Encrypt `plaintext` into an aes128gcm-encoded body per RFC 8188/8291
|
||||
/// targeting the given subscription. Returns the binary body (salt +
|
||||
/// header + keyid + ciphertext) ready to POST as request body.
|
||||
pub fn encrypt_aes128gcm(subscription: &Subscription, plaintext: &[u8]) -> Result<Vec<u8>> {
|
||||
use aes_gcm::aead::Aead;
|
||||
use aes_gcm::{Aes128Gcm, KeyInit};
|
||||
use hkdf::Hkdf;
|
||||
use p256::ecdh::diffie_hellman;
|
||||
use p256::elliptic_curve::sec1::ToEncodedPoint;
|
||||
use p256::{PublicKey, SecretKey};
|
||||
|
||||
// Subscription-side key material.
|
||||
let p_ua_bytes =
|
||||
base64_url_decode(&subscription.p256dh).context("decode subscription p256dh")?;
|
||||
let auth_secret = base64_url_decode(&subscription.auth).context("decode subscription auth")?;
|
||||
if auth_secret.len() != 16 {
|
||||
return Err(anyhow!(
|
||||
"subscription auth must be 16 bytes, got {}",
|
||||
auth_secret.len()
|
||||
));
|
||||
}
|
||||
let p_ua = PublicKey::from_sec1_bytes(&p_ua_bytes)
|
||||
.context("parse subscription p256dh as SEC1 P-256")?;
|
||||
|
||||
// Ephemeral server keypair (fresh per push).
|
||||
let mut eph_seed = [0u8; 32];
|
||||
getrandom::fill(&mut eph_seed).map_err(|e| anyhow!("getrandom for ephemeral key: {}", e))?;
|
||||
let d_as = SecretKey::from_slice(&eph_seed).context("derive ephemeral P-256 key from seed")?;
|
||||
let p_as = d_as.public_key();
|
||||
let p_as_encoded = p_as.to_encoded_point(false);
|
||||
let p_as_bytes = p_as_encoded.as_bytes();
|
||||
if p_as_bytes.len() != 65 {
|
||||
return Err(anyhow!(
|
||||
"ephemeral public key unexpected length {}",
|
||||
p_as_bytes.len()
|
||||
));
|
||||
}
|
||||
|
||||
// ECDH shared secret.
|
||||
let shared = diffie_hellman(d_as.to_nonzero_scalar(), p_ua.as_affine());
|
||||
let shared_bytes = shared.raw_secret_bytes();
|
||||
|
||||
// First HKDF: derive IKM from (auth_secret, shared, WebPush info).
|
||||
// info = "WebPush: info\0" || ua_pub || as_pub
|
||||
let mut info1 = Vec::with_capacity(14 + 65 + 65);
|
||||
info1.extend_from_slice(b"WebPush: info\0");
|
||||
info1.extend_from_slice(&p_ua_bytes);
|
||||
info1.extend_from_slice(p_as_bytes);
|
||||
|
||||
let hk1 = Hkdf::<Sha256>::new(Some(&auth_secret), shared_bytes.as_ref());
|
||||
let mut ikm = [0u8; 32];
|
||||
hk1.expand(&info1, &mut ikm)
|
||||
.map_err(|e| anyhow!("HKDF expand for IKM: {}", e))?;
|
||||
|
||||
// Random salt.
|
||||
let mut salt = [0u8; 16];
|
||||
getrandom::fill(&mut salt).map_err(|e| anyhow!("getrandom for salt: {}", e))?;
|
||||
|
||||
// Second HKDF: derive CEK and nonce from (salt, IKM).
|
||||
let hk2 = Hkdf::<Sha256>::new(Some(&salt), &ikm);
|
||||
let mut cek = [0u8; 16];
|
||||
hk2.expand(b"Content-Encoding: aes128gcm\0", &mut cek)
|
||||
.map_err(|e| anyhow!("HKDF expand for CEK: {}", e))?;
|
||||
let mut nonce = [0u8; 12];
|
||||
hk2.expand(b"Content-Encoding: nonce\0", &mut nonce)
|
||||
.map_err(|e| anyhow!("HKDF expand for nonce: {}", e))?;
|
||||
|
||||
// AES-128-GCM encryption. Pad with record-terminator 0x02; single
|
||||
// record so no further padding is required.
|
||||
let cipher = Aes128Gcm::new((&cek).into());
|
||||
let mut plaintext_padded = plaintext.to_vec();
|
||||
plaintext_padded.push(0x02);
|
||||
let ciphertext = cipher
|
||||
.encrypt((&nonce).into(), plaintext_padded.as_ref())
|
||||
.map_err(|e| anyhow!("AES-GCM encrypt: {}", e))?;
|
||||
|
||||
// aes128gcm body layout:
|
||||
// salt(16) || record_size u32 BE (4) || idlen u8 (1) || keyid(idlen) || ciphertext
|
||||
let record_size: u32 = (ciphertext.len() + 17)
|
||||
.max(18)
|
||||
.try_into()
|
||||
.unwrap_or(u32::MAX);
|
||||
let mut body = Vec::with_capacity(16 + 4 + 1 + 65 + ciphertext.len());
|
||||
body.extend_from_slice(&salt);
|
||||
body.extend_from_slice(&record_size.to_be_bytes());
|
||||
body.push(65u8);
|
||||
body.extend_from_slice(p_as_bytes);
|
||||
body.extend_from_slice(&ciphertext);
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
/// Send a single push notification. Encrypts the payload, signs VAPID,
|
||||
/// POSTs to the push endpoint under a 10s timeout, and returns whether
|
||||
/// the browser accepted the push, marked the subscription gone, or
|
||||
/// failed for another reason.
|
||||
///
|
||||
/// `observed_generation` is the subscription's generation counter at
|
||||
/// snapshot time; on 410/404 the caller uses it to gate GC so we don't
|
||||
/// wipe an entry that was re-subscribed during the in-flight send.
|
||||
pub async fn send_one(
|
||||
client: &reqwest::Client,
|
||||
state: &PushState,
|
||||
subscription: &Subscription,
|
||||
payload: &PushPayload,
|
||||
) -> SendOutcome {
|
||||
match send_one_inner(client, state, subscription, payload).await {
|
||||
Ok(outcome) => outcome,
|
||||
Err(e) => {
|
||||
tracing::warn!(target: "http.middleware",
|
||||
endpoint = %subscription.endpoint,
|
||||
error = %e,
|
||||
"push: send_one error, marking Failed"
|
||||
);
|
||||
SendOutcome::Failed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn send_one_inner(
|
||||
client: &reqwest::Client,
|
||||
state: &PushState,
|
||||
subscription: &Subscription,
|
||||
payload: &PushPayload,
|
||||
) -> Result<SendOutcome> {
|
||||
let plaintext = serde_json::to_vec(payload).context("serialize push payload")?;
|
||||
let encrypted = encrypt_aes128gcm(subscription, &plaintext)?;
|
||||
let authorization = vapid_auth_header(state, &subscription.endpoint)?;
|
||||
|
||||
let resp = client
|
||||
.post(&subscription.endpoint)
|
||||
.header("Authorization", authorization)
|
||||
.header("Content-Type", "application/octet-stream")
|
||||
.header("Content-Encoding", "aes128gcm")
|
||||
.header("TTL", PUSH_TTL_SECS.to_string())
|
||||
.body(encrypted)
|
||||
.send()
|
||||
.await
|
||||
.context("POST to push endpoint")?;
|
||||
|
||||
let status = resp.status();
|
||||
if status.is_success() {
|
||||
Ok(SendOutcome::Delivered)
|
||||
} else if status == reqwest::StatusCode::GONE || status == reqwest::StatusCode::NOT_FOUND {
|
||||
Ok(SendOutcome::Gone)
|
||||
} else {
|
||||
let text = resp
|
||||
.text()
|
||||
.await
|
||||
.unwrap_or_else(|_| String::from("(body unreadable)"));
|
||||
tracing::warn!(target: "http.middleware",
|
||||
endpoint = %subscription.endpoint,
|
||||
status = %status,
|
||||
body = %text,
|
||||
"push: non-success response"
|
||||
);
|
||||
Ok(SendOutcome::Failed)
|
||||
}
|
||||
}
|
||||
|
||||
/// URL-safe base64 without padding, for things that aren't subscription
|
||||
/// keys. Exposed as an alias for callers so they don't have to import
|
||||
/// `base64` directly.
|
||||
pub fn b64url(bytes: &[u8]) -> String {
|
||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
URL_SAFE_NO_PAD.encode(bytes)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn endpoint_origin_strips_path_and_query() {
|
||||
assert_eq!(
|
||||
endpoint_origin("https://fcm.googleapis.com/fcm/send/abc?x=1").unwrap(),
|
||||
"https://fcm.googleapis.com"
|
||||
);
|
||||
assert_eq!(
|
||||
endpoint_origin("https://updates.push.services.mozilla.com/wpush/v2/XYZ").unwrap(),
|
||||
"https://updates.push.services.mozilla.com"
|
||||
);
|
||||
assert_eq!(
|
||||
endpoint_origin("http://localhost:8080/push/x").unwrap(),
|
||||
"http://localhost:8080"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn endpoint_origin_rejects_garbage() {
|
||||
assert!(endpoint_origin("not-a-url").is_err());
|
||||
assert!(endpoint_origin("data:text/plain,hi").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encrypt_aes128gcm_shape_and_header() {
|
||||
// Subscription key material from a real browser push subscription
|
||||
// is base64url. We'll fabricate reasonable byte shapes (65 bytes
|
||||
// for p256dh, 16 for auth) and verify the body layout.
|
||||
let p_ua_bytes = {
|
||||
// Generate a valid P-256 public key.
|
||||
let seed = [7u8; 32];
|
||||
let sk = p256::SecretKey::from_slice(&seed).unwrap();
|
||||
use p256::elliptic_curve::sec1::ToEncodedPoint;
|
||||
let pt = sk.public_key().to_encoded_point(false);
|
||||
pt.as_bytes().to_vec()
|
||||
};
|
||||
let subscription = super::super::push::Subscription {
|
||||
endpoint: "https://example.com/push/xyz".to_string(),
|
||||
p256dh: b64url(&p_ua_bytes),
|
||||
auth: b64url(&[9u8; 16]),
|
||||
user_agent: String::new(),
|
||||
created_at: chrono::Utc::now(),
|
||||
generation: 0,
|
||||
origin: "http://localhost:8080".to_string(),
|
||||
};
|
||||
|
||||
let body = encrypt_aes128gcm(&subscription, b"hello").expect("encrypt");
|
||||
// Layout: salt(16) + record_size(4) + idlen(1) + keyid(65) + ciphertext(>=5+1+16)
|
||||
assert!(body.len() >= 16 + 4 + 1 + 65 + 5 + 1 + 16);
|
||||
assert_eq!(body[20], 65, "idlen byte must be 65 for uncompressed P-256");
|
||||
}
|
||||
}
|
||||
@@ -771,7 +771,6 @@ impl SessionService {
|
||||
return;
|
||||
};
|
||||
if !inst.is_structured()
|
||||
|| inst.is_archived()
|
||||
|| inst.is_trashed()
|
||||
|| inst.status != crate::session::Status::Idle
|
||||
{
|
||||
|
||||
@@ -57,8 +57,6 @@ pub(crate) struct StructuredSessionSpec {
|
||||
#[cfg(feature = "serve")]
|
||||
pub agent_effort: Option<String>,
|
||||
#[cfg(feature = "serve")]
|
||||
pub import_acp_session_id: Option<String>,
|
||||
#[cfg(feature = "serve")]
|
||||
pub fork_seed: Option<crate::session::ForkSeed>,
|
||||
}
|
||||
|
||||
@@ -137,8 +135,6 @@ pub(crate) async fn spawn_structured_session(
|
||||
#[cfg(feature = "serve")]
|
||||
agent_effort,
|
||||
#[cfg(feature = "serve")]
|
||||
import_acp_session_id,
|
||||
#[cfg(feature = "serve")]
|
||||
fork_seed,
|
||||
} = spec;
|
||||
|
||||
@@ -220,19 +216,6 @@ pub(crate) async fn spawn_structured_session(
|
||||
#[cfg(feature = "serve")]
|
||||
let agent_effort = {
|
||||
instance.view = view;
|
||||
// #2276: importing an existing Claude session forces the
|
||||
// structured view and adopts the on-disk session id, so the
|
||||
// structured spawn resumes it via session/load and seeds the
|
||||
// transcript from the agent's history replay. `path` is the
|
||||
// session's original cwd (the wizard prefills it).
|
||||
if let Some(import_id) = import_acp_session_id
|
||||
.clone()
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
{
|
||||
instance.view = crate::session::View::Structured;
|
||||
instance.acp_session_id = Some(import_id);
|
||||
instance.import_pending = Some(true);
|
||||
}
|
||||
instance.agent_name = agent_name;
|
||||
let agent_key = instance
|
||||
.agent_name
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
//! Session status-transition broadcast.
|
||||
//!
|
||||
//! The serve daemon's poll loop emits a [`StatusChange`] on
|
||||
//! `AppState.status_tx` whenever an instance's status moves. Consumers
|
||||
//! subscribe to react without polling; `callback.rs` (per-session HTTP
|
||||
//! completion callbacks) is the one in-tree consumer.
|
||||
|
||||
use crate::session::Status;
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
/// Emitted when an instance's status changes.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct StatusChange {
|
||||
pub instance_id: String,
|
||||
pub instance_title: String,
|
||||
pub old: Status,
|
||||
pub new: Status,
|
||||
pub at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Capacity of the broadcast channel. Large enough that short bursts of
|
||||
/// concurrent transitions (e.g., `/api/sessions` bulk refresh) don't drop
|
||||
/// events even if a consumer is momentarily behind. If a receiver lags
|
||||
/// past this, broadcast surfaces `RecvError::Lagged` and the consumer
|
||||
/// logs and continues.
|
||||
pub const STATUS_CHANNEL_CAPACITY: usize = 64;
|
||||
@@ -707,7 +707,7 @@ pub(crate) fn compose_exclusion(
|
||||
/// Extend [`compose_exclusion`] with conversations same-project peers parked
|
||||
/// for `current_tool` during an engine swap. When
|
||||
/// `include_inactive_same_tool` is true, also include the sids of stopped,
|
||||
/// archived, or pane-less peers using `current_tool`. Persisted peers are read
|
||||
/// or pane-less peers using `current_tool`. Persisted peers are read
|
||||
/// from `sessions.json` via `Storage` for the caller's effective profile.
|
||||
///
|
||||
/// Used by `Instance::try_retroactive_capture` and snapshotted when its poller
|
||||
@@ -767,9 +767,8 @@ pub(crate) fn compose_exclusion_with_persisted_peers(
|
||||
if !include_inactive_same_tool || inst.tool != current_tool {
|
||||
continue;
|
||||
}
|
||||
let should_exclude = matches!(inst.status, crate::session::Status::Stopped)
|
||||
|| inst.is_archived()
|
||||
|| !inst.has_live_tmux_pane();
|
||||
let should_exclude =
|
||||
matches!(inst.status, crate::session::Status::Stopped) || !inst.has_live_tmux_pane();
|
||||
if !should_exclude {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -1,518 +0,0 @@
|
||||
//! Discovery of existing Claude Code sessions on disk, for importing them
|
||||
//! into a structured-view session via `session/load`.
|
||||
//!
|
||||
//! Claude Code stores one session per file at
|
||||
//! `<config>/projects/<encoded-cwd>/<sessionId>.jsonl`, where `<config>` is
|
||||
//! `$CLAUDE_CONFIG_DIR` or `~/.claude`. The `<sessionId>` is the filename
|
||||
//! stem and equals the id `claude-agent-acp` resumes via the SDK `resume`
|
||||
//! param, so importing is just: create a structured session whose
|
||||
//! `acp_session_id` is this id, with the session's recorded `cwd`.
|
||||
//!
|
||||
//! The encoded directory name is lossy (path separators and real hyphens
|
||||
//! both render as `-`), so we read the real `cwd` from inside the file
|
||||
//! rather than decoding the directory name.
|
||||
|
||||
use std::fs;
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
/// Cap how many lines we read per file when extracting metadata. The `cwd`
|
||||
/// and first user message live at the head of the transcript; a few hundred
|
||||
/// lines is plenty without reading a multi-MB file fully.
|
||||
const MAX_SCAN_LINES: usize = 400;
|
||||
|
||||
/// Cap how many sessions the picker shows. Newest first, so older sessions
|
||||
/// past the cap are the least likely to be resumed. Applied by the endpoint
|
||||
/// AFTER it filters out AoE-managed sessions, so a burst of managed sessions
|
||||
/// can't squeeze real imports off the list.
|
||||
pub const MAX_SESSIONS: usize = 200;
|
||||
|
||||
/// A discovered Claude Code session, summarized for the import picker.
|
||||
#[derive(Debug, Clone, Serialize, PartialEq)]
|
||||
pub struct ClaudeSessionSummary {
|
||||
/// The on-disk session id (filename stem). Fed to `session/load`.
|
||||
pub session_id: String,
|
||||
/// The working directory recorded in the transcript. The structured
|
||||
/// session must run here for `claude --resume` to resolve the file.
|
||||
pub cwd: String,
|
||||
/// First human-authored prompt, truncated, for display. `None` when the
|
||||
/// transcript has no readable user message yet.
|
||||
pub title: Option<String>,
|
||||
/// File modification time as a unix epoch millisecond stamp, for
|
||||
/// recent-first sorting and "last used" display.
|
||||
pub last_modified_ms: u64,
|
||||
/// Whether `cwd` still exists. A resumed session needs its original cwd;
|
||||
/// the picker flags missing ones.
|
||||
pub cwd_exists: bool,
|
||||
}
|
||||
|
||||
/// Base directory Claude Code stores config/sessions under: `$CLAUDE_CONFIG_DIR`
|
||||
/// when set, else `~/.claude`. Returns `None` when neither resolves.
|
||||
fn claude_config_dir() -> Option<PathBuf> {
|
||||
if let Ok(dir) = std::env::var("CLAUDE_CONFIG_DIR") {
|
||||
if !dir.is_empty() {
|
||||
return Some(PathBuf::from(dir));
|
||||
}
|
||||
}
|
||||
dirs::home_dir().map(|h| h.join(".claude"))
|
||||
}
|
||||
|
||||
/// Literal directory tokens derived from the worktree path templates, e.g.
|
||||
/// `"-worktrees"` from `"../{repo-name}-worktrees/{branch}"` and `"-workspace-"`
|
||||
/// from `"../{branch}-workspace-{session-id}"`. A cwd living under a directory
|
||||
/// whose name contains one of these is an AoE worktree or workspace, so it is
|
||||
/// excluded from the import picker. Derived from config so a custom template is
|
||||
/// honored. See #2276.
|
||||
fn worktree_dir_markers() -> Vec<String> {
|
||||
let cfg = crate::session::Config::load_or_warn();
|
||||
let mut markers = Vec::new();
|
||||
for tmpl in [
|
||||
cfg.worktree.path_template.as_str(),
|
||||
cfg.worktree.workspace_path_template.as_str(),
|
||||
] {
|
||||
for seg in tmpl.split('/') {
|
||||
let lit = strip_placeholders(seg);
|
||||
if lit.len() >= 3 && lit != ".." && !markers.contains(&lit) {
|
||||
markers.push(lit);
|
||||
}
|
||||
}
|
||||
}
|
||||
markers
|
||||
}
|
||||
|
||||
/// Remove `{placeholder}` spans from a template path segment, leaving the
|
||||
/// literal text (e.g. `"{repo-name}-worktrees"` -> `"-worktrees"`).
|
||||
fn strip_placeholders(seg: &str) -> String {
|
||||
let mut out = String::new();
|
||||
let mut depth = 0u32;
|
||||
for c in seg.chars() {
|
||||
match c {
|
||||
'{' => depth += 1,
|
||||
'}' => depth = depth.saturating_sub(1),
|
||||
_ if depth == 0 => out.push(c),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// True when `cwd` is an AoE scratch directory (`<app_dir>/scratch/<id>`),
|
||||
/// regardless of namespace. The serving daemon's `get_app_dir()` only resolves
|
||||
/// one namespace (release vs `-dev`), so a scratch session from the other
|
||||
/// namespace slips past a plain `starts_with` check; match the app-dir name +
|
||||
/// `scratch` component pair instead. See #2276.
|
||||
fn cwd_is_aoe_scratch(cwd: &str) -> bool {
|
||||
let comps: Vec<&str> = Path::new(cwd)
|
||||
.components()
|
||||
.filter_map(|c| c.as_os_str().to_str())
|
||||
.collect();
|
||||
comps
|
||||
.windows(2)
|
||||
.any(|w| w[0].contains("agent-of-empires") && w[1] == "scratch")
|
||||
}
|
||||
|
||||
/// True when any directory component of `cwd` contains a worktree marker. Uses
|
||||
/// `contains` (not a suffix match) because workspace dirs carry the marker
|
||||
/// mid-name, e.g. `<branch>-workspace-<id>`.
|
||||
fn cwd_under_worktree(cwd: &str, markers: &[String]) -> bool {
|
||||
if markers.is_empty() {
|
||||
return false;
|
||||
}
|
||||
Path::new(cwd).components().any(|c| {
|
||||
c.as_os_str()
|
||||
.to_str()
|
||||
.is_some_and(|name| markers.iter().any(|m| name.contains(m.as_str())))
|
||||
})
|
||||
}
|
||||
|
||||
/// Scan all discoverable Claude Code sessions, newest first (uncapped; the
|
||||
/// endpoint applies `MAX_SESSIONS` after ownership filtering). Returns an empty
|
||||
/// vec when the projects directory is absent (e.g. Claude Code was never run).
|
||||
/// Unreadable files are skipped, not fatal.
|
||||
///
|
||||
/// AoE's own internal Claude runs are excluded: scratch sessions (cwd under
|
||||
/// `<app_dir>/scratch/`, matched by layout so both release and -dev namespaces
|
||||
/// are covered) and worktree / workspace sessions (cwd under a dir named by the
|
||||
/// worktree path template). Sessions AoE already manages by id or project_path
|
||||
/// are filtered separately by the endpoint, which has the instance list.
|
||||
/// See #2276.
|
||||
pub fn scan_sessions() -> Vec<ClaudeSessionSummary> {
|
||||
let Some(config_dir) = claude_config_dir() else {
|
||||
return Vec::new();
|
||||
};
|
||||
scan_sessions_in(&config_dir)
|
||||
}
|
||||
|
||||
/// Scan the `projects/` tree under `config_dir` (the resolved Claude config
|
||||
/// directory). Split out from [`scan_sessions`] so tests can point at a temp
|
||||
/// config tree without mutating the global `CLAUDE_CONFIG_DIR` env (which would
|
||||
/// race the parallel test runner). See [`scan_sessions`] for filtering rules.
|
||||
pub fn scan_sessions_in(config_dir: &Path) -> Vec<ClaudeSessionSummary> {
|
||||
let projects = config_dir.join("projects");
|
||||
let Ok(project_dirs) = fs::read_dir(&projects) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let worktree_markers = worktree_dir_markers();
|
||||
|
||||
let mut out = Vec::new();
|
||||
for project in project_dirs.flatten() {
|
||||
let path = project.path();
|
||||
if !path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let Ok(files) = fs::read_dir(&path) else {
|
||||
continue;
|
||||
};
|
||||
for file in files.flatten() {
|
||||
let fpath = file.path();
|
||||
if fpath.extension().and_then(|e| e.to_str()) != Some("jsonl") {
|
||||
continue;
|
||||
}
|
||||
if let Some(summary) = summarize_file(&fpath) {
|
||||
// Scratch sessions live under `<app_dir>/scratch/<id>`. Match by
|
||||
// layout so both release and -dev namespaces are excluded the
|
||||
// same way (the feature does not discriminate between them).
|
||||
if cwd_is_aoe_scratch(&summary.cwd) {
|
||||
continue;
|
||||
}
|
||||
// AoE creates session worktrees under a directory named by the
|
||||
// worktree path template (e.g. "<repo>-worktrees"). Any cwd
|
||||
// inside one is an AoE-managed worktree (or a one-shot AoE ran
|
||||
// there, like smart-rename), not a conversation to import.
|
||||
if cwd_under_worktree(&summary.cwd, &worktree_markers) {
|
||||
continue;
|
||||
}
|
||||
out.push(summary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
out.sort_by_key(|s| std::cmp::Reverse(s.last_modified_ms));
|
||||
out
|
||||
}
|
||||
|
||||
/// Retain sessions whose recorded `cwd` is at or under one of `roots`.
|
||||
/// Component-aware via [`Path::starts_with`], so a root of `/a/app` does not
|
||||
/// match a cwd of `/a/app-v2`. Comparison is lexical on the paths as given;
|
||||
/// callers wanting symlink-robust matching should canonicalize `roots` (and the
|
||||
/// cwds) first.
|
||||
pub fn sessions_under_paths(
|
||||
sessions: Vec<ClaudeSessionSummary>,
|
||||
roots: &[PathBuf],
|
||||
) -> Vec<ClaudeSessionSummary> {
|
||||
sessions
|
||||
.into_iter()
|
||||
.filter(|s| {
|
||||
let cwd = Path::new(&s.cwd);
|
||||
roots.iter().any(|r| cwd.starts_with(r))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Build a summary for one `.jsonl` file. Returns `None` when the file has no
|
||||
/// recoverable `cwd` (a session we could not safely resume), or no session id.
|
||||
fn summarize_file(path: &Path) -> Option<ClaudeSessionSummary> {
|
||||
let session_id = path.file_stem()?.to_str()?.to_string();
|
||||
|
||||
let last_modified_ms = fs::metadata(path)
|
||||
.and_then(|m| m.modified())
|
||||
.map(crate::util::system_time_to_ms)
|
||||
.unwrap_or(0);
|
||||
|
||||
let file = fs::File::open(path).ok()?;
|
||||
let reader = BufReader::new(file);
|
||||
|
||||
let mut cwd: Option<String> = None;
|
||||
let mut title: Option<String> = None;
|
||||
|
||||
for line in reader.lines().take(MAX_SCAN_LINES).map_while(Result::ok) {
|
||||
let line = line.trim();
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let Ok(record) = serde_json::from_str::<serde_json::Value>(line) else {
|
||||
continue;
|
||||
};
|
||||
if cwd.is_none() {
|
||||
if let Some(c) = record.get("cwd").and_then(|v| v.as_str()) {
|
||||
if !c.is_empty() {
|
||||
cwd = Some(c.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
if title.is_none() {
|
||||
title = extract_user_title(&record);
|
||||
}
|
||||
if cwd.is_some() && title.is_some() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let cwd = cwd?;
|
||||
let cwd_exists = Path::new(&cwd).is_dir();
|
||||
Some(ClaudeSessionSummary {
|
||||
session_id,
|
||||
cwd,
|
||||
title,
|
||||
last_modified_ms,
|
||||
cwd_exists,
|
||||
})
|
||||
}
|
||||
|
||||
/// Pull a human-readable title from a `user` record. Skips command wrappers
|
||||
/// and caveat blocks (e.g. `<local-command-...>`, `<command-name>`) that
|
||||
/// Claude Code injects, since they are noise, not the user's actual prompt.
|
||||
fn extract_user_title(record: &serde_json::Value) -> Option<String> {
|
||||
if record.get("type").and_then(|v| v.as_str()) != Some("user") {
|
||||
return None;
|
||||
}
|
||||
let content = record.get("message")?.get("content")?;
|
||||
let text = match content {
|
||||
serde_json::Value::String(s) => displayable_user_text(s).map(str::to_owned),
|
||||
serde_json::Value::Array(parts) => parts.iter().find_map(|p| {
|
||||
if p.get("type").and_then(|v| v.as_str()) != Some("text") {
|
||||
return None;
|
||||
}
|
||||
let text = p.get("text").and_then(|v| v.as_str())?;
|
||||
displayable_user_text(text).map(str::to_owned)
|
||||
}),
|
||||
_ => None,
|
||||
}?;
|
||||
Some(truncate(&text, 120))
|
||||
}
|
||||
|
||||
/// A user message's displayable text, or `None` for the command wrappers and
|
||||
/// caveat blocks Claude Code injects (`<local-command-...>`, `<command-...>`).
|
||||
/// Only those specific wrappers are dropped; a real prompt like `<div> is
|
||||
/// rendering wrong` is kept.
|
||||
fn displayable_user_text(text: &str) -> Option<&str> {
|
||||
let text = text.trim();
|
||||
if text.is_empty() || text.starts_with("<local-command-") || text.starts_with("<command-") {
|
||||
None
|
||||
} else {
|
||||
Some(text)
|
||||
}
|
||||
}
|
||||
|
||||
fn truncate(s: &str, max_chars: usize) -> String {
|
||||
let trimmed: String = s.chars().take(max_chars).collect();
|
||||
if trimmed.chars().count() < s.chars().count() {
|
||||
format!("{trimmed}…")
|
||||
} else {
|
||||
trimmed
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::Write;
|
||||
|
||||
fn write_jsonl(dir: &Path, name: &str, lines: &[&str]) -> PathBuf {
|
||||
let path = dir.join(format!("{name}.jsonl"));
|
||||
let mut f = fs::File::create(&path).unwrap();
|
||||
for l in lines {
|
||||
writeln!(f, "{l}").unwrap();
|
||||
}
|
||||
path
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_cwd_and_title_skipping_noise() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let real_cwd = tmp.path().join("work");
|
||||
fs::create_dir(&real_cwd).unwrap();
|
||||
let cwd_str = real_cwd.to_str().unwrap();
|
||||
let path = write_jsonl(
|
||||
tmp.path(),
|
||||
"713b7f46-d0f2-454e-91be-a3305d35660c",
|
||||
&[
|
||||
r#"{"type":"queue-operation","operation":"enqueue"}"#,
|
||||
&format!(
|
||||
r#"{{"type":"user","cwd":"{cwd_str}","message":{{"role":"user","content":"<local-command-caveat>noise</local-command-caveat>"}}}}"#
|
||||
),
|
||||
&format!(
|
||||
r#"{{"type":"user","cwd":"{cwd_str}","message":{{"role":"user","content":[{{"type":"text","text":"Fix the spinner bug please"}}]}}}}"#
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
let s = summarize_file(&path).unwrap();
|
||||
assert_eq!(s.session_id, "713b7f46-d0f2-454e-91be-a3305d35660c");
|
||||
assert_eq!(s.cwd, cwd_str);
|
||||
assert_eq!(s.title.as_deref(), Some("Fix the spinner bug please"));
|
||||
assert!(s.cwd_exists);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn title_keeps_angle_bracket_prompts_and_skips_only_wrappers() {
|
||||
// A real prompt that starts with '<' is kept.
|
||||
assert_eq!(
|
||||
displayable_user_text("<div> is rendering wrong"),
|
||||
Some("<div> is rendering wrong")
|
||||
);
|
||||
// Injected command wrappers are dropped.
|
||||
assert_eq!(
|
||||
displayable_user_text("<local-command-caveat>x</local-command-caveat>"),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
displayable_user_text("<command-name>/foo</command-name>"),
|
||||
None
|
||||
);
|
||||
assert_eq!(displayable_user_text(" "), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn title_picks_first_real_text_part_after_wrapper() {
|
||||
let record = serde_json::json!({
|
||||
"type": "user",
|
||||
"message": { "role": "user", "content": [
|
||||
{ "type": "text", "text": "<command-name>/plan</command-name>" },
|
||||
{ "type": "text", "text": "Actually fix the bug" }
|
||||
]}
|
||||
});
|
||||
assert_eq!(
|
||||
extract_user_title(&record).as_deref(),
|
||||
Some("Actually fix the bug")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_cwd_dir_flagged_not_dropped() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = write_jsonl(
|
||||
tmp.path(),
|
||||
"abc",
|
||||
&[
|
||||
r#"{"type":"user","cwd":"/nonexistent/path/xyz","message":{"role":"user","content":"hi"}}"#,
|
||||
],
|
||||
);
|
||||
let s = summarize_file(&path).unwrap();
|
||||
assert_eq!(s.cwd, "/nonexistent/path/xyz");
|
||||
assert!(!s.cwd_exists);
|
||||
assert_eq!(s.title.as_deref(), Some("hi"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_placeholders_leaves_literal() {
|
||||
assert_eq!(strip_placeholders("{repo-name}-worktrees"), "-worktrees");
|
||||
assert_eq!(strip_placeholders("{branch}"), "");
|
||||
assert_eq!(strip_placeholders(".."), "..");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cwd_under_worktree_matches_worktree_and_workspace_dirs() {
|
||||
let markers = vec!["-worktrees".to_string(), "-workspace-".to_string()];
|
||||
assert!(cwd_under_worktree(
|
||||
"/Users/me/aoe/agent-of-empires-worktrees/Saracens",
|
||||
&markers
|
||||
));
|
||||
assert!(cwd_under_worktree(
|
||||
"/Users/me/aoe/agent-of-empires-worktrees/Saracens/sub",
|
||||
&markers
|
||||
));
|
||||
// Workspace dirs carry the marker mid-name (contains, not suffix).
|
||||
assert!(cwd_under_worktree(
|
||||
"/Users/me/aoe/soft-close-grace-window-workspace-55406399",
|
||||
&markers
|
||||
));
|
||||
assert!(!cwd_under_worktree("/Users/me/projects/alpha", &markers));
|
||||
assert!(!cwd_under_worktree("/Users/me/projects/alpha", &[]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aoe_scratch_detected_in_both_namespaces() {
|
||||
assert!(cwd_is_aoe_scratch(
|
||||
"/Users/me/.agent-of-empires/scratch/5c8d250f60ec4328"
|
||||
));
|
||||
assert!(cwd_is_aoe_scratch(
|
||||
"/Users/me/.agent-of-empires-dev/scratch/abcd"
|
||||
));
|
||||
assert!(cwd_is_aoe_scratch(
|
||||
"/home/me/.config/agent-of-empires/scratch/abcd"
|
||||
));
|
||||
assert!(!cwd_is_aoe_scratch("/Users/me/projects/scratch"));
|
||||
assert!(!cwd_is_aoe_scratch("/Users/me/projects/alpha"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_cwd_means_unimportable_skipped() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = write_jsonl(
|
||||
tmp.path(),
|
||||
"nocwd",
|
||||
&[r#"{"type":"last-prompt","sessionId":"nocwd"}"#],
|
||||
);
|
||||
assert!(summarize_file(&path).is_none());
|
||||
}
|
||||
|
||||
fn summary(id: &str, cwd: &str) -> ClaudeSessionSummary {
|
||||
ClaudeSessionSummary {
|
||||
session_id: id.to_string(),
|
||||
cwd: cwd.to_string(),
|
||||
title: None,
|
||||
last_modified_ms: 0,
|
||||
cwd_exists: true,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sessions_under_paths_is_component_aware() {
|
||||
let sessions = vec![
|
||||
summary("a", "/home/me/app"),
|
||||
summary("b", "/home/me/app/sub/deep"),
|
||||
summary("c", "/home/me/app-v2"),
|
||||
summary("d", "/home/me/other"),
|
||||
];
|
||||
let roots = vec![PathBuf::from("/home/me/app")];
|
||||
let kept: Vec<_> = sessions_under_paths(sessions, &roots)
|
||||
.into_iter()
|
||||
.map(|s| s.session_id)
|
||||
.collect();
|
||||
// Root itself and descendants match; the sibling `app-v2` does NOT
|
||||
// (component-aware, not a string prefix), nor does an unrelated dir.
|
||||
assert_eq!(kept, vec!["a", "b"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sessions_under_paths_matches_any_root() {
|
||||
let sessions = vec![
|
||||
summary("a", "/p/one/x"),
|
||||
summary("b", "/p/two/y"),
|
||||
summary("c", "/p/three/z"),
|
||||
];
|
||||
let roots = vec![PathBuf::from("/p/one"), PathBuf::from("/p/three")];
|
||||
let kept: Vec<_> = sessions_under_paths(sessions, &roots)
|
||||
.into_iter()
|
||||
.map(|s| s.session_id)
|
||||
.collect();
|
||||
assert_eq!(kept, vec!["a", "c"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_sessions_in_reads_temp_config_tree() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let project = tmp.path().join("projects").join("-encoded-cwd");
|
||||
fs::create_dir_all(&project).unwrap();
|
||||
let work = tmp.path().join("work");
|
||||
fs::create_dir(&work).unwrap();
|
||||
let cwd_str = work.to_str().unwrap();
|
||||
write_jsonl(
|
||||
&project,
|
||||
"713b7f46-d0f2-454e-91be-a3305d35660c",
|
||||
&[&format!(
|
||||
r#"{{"type":"user","cwd":"{cwd_str}","message":{{"role":"user","content":"hello"}}}}"#
|
||||
)],
|
||||
);
|
||||
|
||||
let found = scan_sessions_in(tmp.path());
|
||||
assert_eq!(found.len(), 1);
|
||||
assert_eq!(found[0].session_id, "713b7f46-d0f2-454e-91be-a3305d35660c");
|
||||
assert_eq!(found[0].cwd, cwd_str);
|
||||
// Absent projects dir yields an empty scan, never an error.
|
||||
assert!(scan_sessions_in(&tmp.path().join("nope")).is_empty());
|
||||
}
|
||||
}
|
||||
@@ -47,9 +47,6 @@ pub struct Config {
|
||||
#[serde(default)]
|
||||
pub app_state: AppStateConfig,
|
||||
|
||||
#[serde(default)]
|
||||
pub web: WebConfig,
|
||||
|
||||
#[serde(default)]
|
||||
pub acp: AcpConfig,
|
||||
|
||||
@@ -657,15 +654,6 @@ pub struct AppStateConfig {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub last_browse_dir: Option<PathBuf>,
|
||||
|
||||
/// Collapsed state for the synthetic "Archived" sidebar section.
|
||||
/// Defaults to collapsed when absent. Archived sessions are pulled out
|
||||
/// of the natural sort and grouped under this section at the bottom
|
||||
/// across every sort mode, so they stop interleaving with active rows
|
||||
/// without users in non-Attention modes losing the ability to find a
|
||||
/// shelved session.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub archived_section_collapsed: Option<bool>,
|
||||
|
||||
/// Paths of project-mode sidebar folders the user has collapsed. Stored as
|
||||
/// the set of collapsed paths (absent/expanded folders are not listed), so
|
||||
/// the choice survives restarts. Group-mode collapse lives on the per-profile
|
||||
@@ -1490,57 +1478,6 @@ impl SessionConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/// Web dashboard runtime configuration.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, SettingsSection)]
|
||||
#[setting_section(name = "web", category = "Web")]
|
||||
pub struct WebConfig {
|
||||
/// Allow the web dashboard to deliver browser push notifications
|
||||
/// (server-wide kill switch). When false, `/api/push/*` returns 404 and
|
||||
/// the status-change consumer drops events without sending. Existing
|
||||
/// subscriptions persist across flips, so toggling back to true resumes
|
||||
/// delivery without requiring users to re-opt-in.
|
||||
#[serde(default = "default_true")]
|
||||
#[setting(label = "Push notifications", widget = "toggle", global_only)]
|
||||
pub notifications_enabled: bool,
|
||||
|
||||
/// Default: send a push when a session transitions Running to Waiting
|
||||
/// (agent is asking for input). Sessions can override individually.
|
||||
#[serde(default = "default_true")]
|
||||
#[setting(label = "Notify on waiting", widget = "toggle", global_only)]
|
||||
pub notify_on_waiting: bool,
|
||||
|
||||
/// Default: send a push when a session finishes (Running to Idle). Off by
|
||||
/// default because short sessions make this noisy; sessions can opt in
|
||||
/// individually.
|
||||
#[serde(default)]
|
||||
#[setting(label = "Notify on idle", widget = "toggle", global_only)]
|
||||
pub notify_on_idle: bool,
|
||||
|
||||
/// Default: send a push when a session errors (Running to Error).
|
||||
#[serde(default = "default_true")]
|
||||
#[setting(label = "Notify on error", widget = "toggle", global_only)]
|
||||
pub notify_on_error: bool,
|
||||
|
||||
/// Default: send a push when an acp session's ScheduleWakeup timer
|
||||
/// fires (the next /loop turn starts). Suppressed if the TUI or web
|
||||
/// dashboard has been active in the last 30s. See #1091.
|
||||
#[serde(default = "default_true")]
|
||||
#[setting(label = "Notify on scheduled wake", widget = "toggle", global_only)]
|
||||
pub notify_on_wake_fire: bool,
|
||||
}
|
||||
|
||||
impl Default for WebConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
notifications_enabled: true,
|
||||
notify_on_waiting: true,
|
||||
notify_on_idle: false,
|
||||
notify_on_error: true,
|
||||
notify_on_wake_fire: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Serde default for `Config.default_profile`. Empty means "not explicitly
|
||||
/// chosen"; the active profile is then resolved at runtime by
|
||||
/// `resolve_default_profile`, which picks the first existing profile or
|
||||
|
||||
+65
-726
File diff suppressed because it is too large
Load Diff
+287
-909
File diff suppressed because it is too large
Load Diff
+7
-12
@@ -8,7 +8,6 @@ pub(crate) mod claim;
|
||||
// Discovery of on-disk Claude Code sessions. Lives here (not under the
|
||||
// serve-gated `acp` module) because terminal/tmux import via the CLI works in
|
||||
// every build; only the structured-view import path needs `serve`.
|
||||
pub mod claude_import;
|
||||
pub mod config;
|
||||
// Depends on `crate::acp` (Event / event store) and is only driven from the
|
||||
// serve daemon, both of which are serve-gated. See #2808.
|
||||
@@ -51,13 +50,10 @@ pub use fork::{ForkDenied, ForkSeed};
|
||||
/// favorite exactly when it is pinned as one.
|
||||
pub(crate) use groups::is_live_favorite;
|
||||
pub use groups::{
|
||||
append_archived_section, append_archived_section_by_project, append_trash_section,
|
||||
archived_project_sub_path, flatten_sessions_by_attention, flatten_tree,
|
||||
flatten_tree_all_profiles, is_archived_section_path, is_synthetic_project_header,
|
||||
is_trash_section_path, is_within_archived_section, is_within_trash_section,
|
||||
project_group_display_name, Group, GroupTree, Item, ARCHIVED_SECTION_NAME,
|
||||
ARCHIVED_SECTION_PATH, SCRATCH_GROUP_NAME, SCRATCH_GROUP_PATH, TRASH_SECTION_NAME,
|
||||
TRASH_SECTION_PATH,
|
||||
append_trash_section, flatten_sessions_by_attention, flatten_tree, flatten_tree_all_profiles,
|
||||
is_synthetic_project_header, is_trash_section_path, is_within_trash_section,
|
||||
project_group_display_name, Group, GroupTree, Item, SCRATCH_GROUP_NAME, SCRATCH_GROUP_PATH,
|
||||
TRASH_SECTION_NAME, TRASH_SECTION_PATH,
|
||||
};
|
||||
pub(crate) use instance::ResumeAttemptPolicy;
|
||||
pub(crate) use instance::{
|
||||
@@ -66,10 +62,9 @@ pub(crate) use instance::{
|
||||
NEWER_GENERATION_BUSY_REASON,
|
||||
};
|
||||
pub use instance::{
|
||||
is_valid_session_color, EnsureReadyError, EnsureReadyOutcome, Instance, LaunchSidOutcome,
|
||||
LifecycleOperation, LifecycleReservation, LifecycleReservationError, SessionBucket,
|
||||
StartOutcome, Status, TerminalInfo, View, WorkspaceInfo, WorkspaceRepo, WorktreeInfo,
|
||||
SESSION_COLORS, TMUX_SESSION_GONE_ERROR,
|
||||
EnsureReadyError, EnsureReadyOutcome, Instance, LaunchSidOutcome, LifecycleOperation,
|
||||
LifecycleReservation, LifecycleReservationError, SessionBucket, StartOutcome, Status,
|
||||
TerminalInfo, View, WorkspaceInfo, WorkspaceRepo, WorktreeInfo, TMUX_SESSION_GONE_ERROR,
|
||||
};
|
||||
pub(crate) use storage::acquire_session_identity_lock;
|
||||
|
||||
|
||||
+11
-13
@@ -111,7 +111,7 @@ fn recovery_lock_path() -> Result<PathBuf> {
|
||||
/// Pure predicate: should this instance go through the startup recovery
|
||||
/// cascade? Excludes structured view-mode sessions (handled by `acp_reconciler`),
|
||||
/// sessions whose agent has `ResumeStrategy::Unsupported`, sessions without
|
||||
/// a valid `agent_session_id`, and sunk rows (archived or explicitly
|
||||
/// a valid `agent_session_id`, and sunk rows (trashed or explicitly
|
||||
/// stopped). Live tmux panes are filtered separately by the caller
|
||||
/// using `Instance::has_live_tmux_pane()`.
|
||||
///
|
||||
@@ -125,7 +125,6 @@ fn recovery_lock_path() -> Result<PathBuf> {
|
||||
/// consulted.
|
||||
pub fn is_recovery_candidate(inst: &Instance) -> bool {
|
||||
!inst.is_structured()
|
||||
&& !inst.is_archived()
|
||||
&& !inst.is_trashed()
|
||||
&& inst.status != super::Status::Stopped
|
||||
&& inst.agent_session_id != inst.resume_probe_failed_sid
|
||||
@@ -778,28 +777,27 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression: archiving a session kills its tmux pane, so the next
|
||||
/// startup observes a dead pane on a resume-capable agent. Without an
|
||||
/// archive guard on `is_recovery_candidate`, the cascade respawns the
|
||||
/// row the user just dismissed (reported: "archive a session, leave
|
||||
/// and re-enter the TUI, it restarts").
|
||||
/// Regression: trashing a session kills its tmux pane, so the next
|
||||
/// startup observes a dead pane on a resume-capable agent. Without a
|
||||
/// trash guard on `is_recovery_candidate`, the cascade respawns the
|
||||
/// row the user just dismissed.
|
||||
#[test]
|
||||
fn archived_instance_is_not_recovery_candidate() {
|
||||
let mut inst = Instance::new("archived", "/tmp/test");
|
||||
fn trashed_instance_is_not_recovery_candidate() {
|
||||
let mut inst = Instance::new("trashed", "/tmp/test");
|
||||
inst.agent_session_id = Some("11111111-1111-4111-8111-111111111111".into());
|
||||
assert!(
|
||||
is_recovery_candidate(&inst),
|
||||
"baseline: claude + valid sid is a recovery candidate"
|
||||
);
|
||||
inst.archive();
|
||||
inst.trash();
|
||||
assert!(
|
||||
!is_recovery_candidate(&inst),
|
||||
"archived sessions must be excluded from startup recovery"
|
||||
"trashed sessions must be excluded from startup recovery"
|
||||
);
|
||||
inst.unarchive();
|
||||
inst.untrash();
|
||||
assert!(
|
||||
is_recovery_candidate(&inst),
|
||||
"unarchive must restore recovery eligibility"
|
||||
"restore must return recovery eligibility"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+8
-16
@@ -14,9 +14,8 @@ use super::Instance;
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum SessionScope {
|
||||
/// The default in every current caller: sessions that are neither
|
||||
/// archived nor trashed. Matches what a user thinks of as "my active
|
||||
/// sessions".
|
||||
/// The default in every current caller: sessions that are not trashed.
|
||||
/// Matches what a user thinks of as "my active sessions".
|
||||
Live,
|
||||
/// Sessions currently in the trash (`remove` but not yet purged). Kept
|
||||
/// separate because #3156 flagged that an external supervisor keying on
|
||||
@@ -36,7 +35,7 @@ impl SessionScope {
|
||||
pub fn matches(scope: Option<SessionScope>, inst: &Instance) -> bool {
|
||||
match scope {
|
||||
None | Some(SessionScope::All) => true,
|
||||
Some(SessionScope::Live) => !inst.is_archived() && !inst.is_trashed(),
|
||||
Some(SessionScope::Live) => !inst.is_trashed(),
|
||||
Some(SessionScope::Trashed) => inst.is_trashed(),
|
||||
}
|
||||
}
|
||||
@@ -66,7 +65,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn rejects_unrecognized_value() {
|
||||
assert!(serde_json::from_str::<SessionScope>("\"archived\"").is_err());
|
||||
assert!(serde_json::from_str::<SessionScope>("\"parked\"").is_err());
|
||||
assert!(serde_json::from_str::<SessionScope>("\"LIVE\"").is_err());
|
||||
assert!(serde_json::from_str::<SessionScope>("\"\"").is_err());
|
||||
}
|
||||
@@ -75,20 +74,17 @@ mod tests {
|
||||
fn matches_no_scope_returns_everything() {
|
||||
let mut live = Instance::new("live", "/repo");
|
||||
assert!(SessionScope::matches(None, &live));
|
||||
live.archive();
|
||||
live.trash();
|
||||
assert!(SessionScope::matches(None, &live));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matches_live_excludes_archived_and_trashed() {
|
||||
fn matches_live_excludes_trashed() {
|
||||
let live = Instance::new("live", "/repo");
|
||||
let mut archived = Instance::new("arch", "/repo");
|
||||
archived.archive();
|
||||
let mut trashed = Instance::new("trash", "/repo");
|
||||
trashed.trash();
|
||||
|
||||
assert!(SessionScope::matches(Some(SessionScope::Live), &live));
|
||||
assert!(!SessionScope::matches(Some(SessionScope::Live), &archived));
|
||||
assert!(!SessionScope::matches(Some(SessionScope::Live), &trashed));
|
||||
}
|
||||
|
||||
@@ -96,13 +92,9 @@ mod tests {
|
||||
fn matches_trashed_is_trashed_only() {
|
||||
let mut trashed = Instance::new("t", "/repo");
|
||||
trashed.trash();
|
||||
let mut archived = Instance::new("a", "/repo");
|
||||
archived.archive();
|
||||
let live = Instance::new("l", "/repo");
|
||||
|
||||
assert!(SessionScope::matches(Some(SessionScope::Trashed), &trashed));
|
||||
assert!(!SessionScope::matches(
|
||||
Some(SessionScope::Trashed),
|
||||
&archived
|
||||
));
|
||||
assert!(!SessionScope::matches(Some(SessionScope::Trashed), &live));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -288,12 +288,11 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn safe_sections_need_no_elevation() {
|
||||
// Theme, updates, web, logging, description and safe session
|
||||
// fields all save unelevated (the load-bearing UX of #1510).
|
||||
// Theme, logging, description and safe session fields all save
|
||||
// unelevated (the load-bearing UX of #1510).
|
||||
for body in [
|
||||
json!({"theme": {"idle_decay_minutes": 5}}),
|
||||
json!({"updates": {"update_check_mode": "notify"}}),
|
||||
json!({"web": {"notify_on_idle": true}}),
|
||||
json!({"session": {"yolo_mode_default": true, "strict_hotkeys": false}}),
|
||||
json!({"description": "my profile"}),
|
||||
] {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
use super::FieldDescriptor;
|
||||
use crate::session::config::{
|
||||
AcpConfig, LoggingConfig, SessionConfig, ThemeConfig, TmuxConfig, WebConfig, WorktreeConfig,
|
||||
AcpConfig, LoggingConfig, SessionConfig, ThemeConfig, TmuxConfig, WorktreeConfig,
|
||||
};
|
||||
use crate::status_hooks::StatusHookConfig;
|
||||
|
||||
@@ -16,7 +16,6 @@ pub fn schema() -> Vec<FieldDescriptor> {
|
||||
out.extend(TmuxConfig::settings_descriptors());
|
||||
out.extend(SessionConfig::settings_descriptors());
|
||||
out.extend(StatusHookConfig::settings_descriptors());
|
||||
out.extend(WebConfig::settings_descriptors());
|
||||
out.extend(AcpConfig::settings_descriptors());
|
||||
out.extend(LoggingConfig::settings_descriptors());
|
||||
out
|
||||
|
||||
+3
-3
@@ -481,7 +481,7 @@ pub(crate) fn live_any_kind_name_for_id<'a>(
|
||||
/// `derived` is the title-derived name and stays the answer unless it is absent
|
||||
/// from `live_names` while exactly one other live session fits `shape`. That
|
||||
/// one case is a session whose stored title moved without its tmux session
|
||||
/// being renamed: adopting the live name keeps stop / archive / trash / attach
|
||||
/// being renamed: adopting the live name keeps stop / trash / attach
|
||||
/// / status pointed at the running pane instead of a name that never existed,
|
||||
/// and keeps `create` from spawning a second pane beside it. Two candidates are
|
||||
/// ambiguous, so `derived` wins there as well.
|
||||
@@ -1354,7 +1354,7 @@ mod tests {
|
||||
#[test]
|
||||
fn resolve_agent_session_name_falls_back_when_two_candidates_are_ambiguous() {
|
||||
// Two stale agent-shaped sessions for one id, the duplicate state a
|
||||
// pre-fix unarchive could leave behind: there is no basis to pick one,
|
||||
// an older build could leave behind: there is no basis to pick one,
|
||||
// so keep the derived name rather than guess which pane to kill.
|
||||
let derived = format!("{P}Refactor_{ID8}");
|
||||
let names = [format!("{P}Vikings_{ID8}"), format!("{P}Aztecs_{ID8}")];
|
||||
@@ -1488,7 +1488,7 @@ mod tests {
|
||||
fn session_new_resolves_onto_a_retitled_sessions_live_name() {
|
||||
// End to end through the constructor every lifecycle op goes through
|
||||
// (`Instance::tmux_session`): with only the pre-rename session live,
|
||||
// `Session::new` under the NEW title must target it, so trash/archive
|
||||
// `Session::new` under the NEW title must target it, so trash
|
||||
// stop the running agent and `create` adopts it instead of spawning a
|
||||
// second one.
|
||||
let guard = SessionCacheGuard::capture();
|
||||
|
||||
+1
-1
@@ -314,7 +314,7 @@ impl Session {
|
||||
/// `_<id8>` tail when the stored title has moved out from under it (a
|
||||
/// smart rename, or a manual rename whose tmux rename failed). See
|
||||
/// `crate::tmux::live_session_name` (crate-private); every lifecycle operation
|
||||
/// resolves through here so trash/archive/attach/status target the pane
|
||||
/// resolves through here so trash/attach/status target the pane
|
||||
/// that is actually running and `create` adopts it instead of spawning a
|
||||
/// second agent beside it.
|
||||
///
|
||||
|
||||
+6
-7
@@ -2248,17 +2248,16 @@ impl App {
|
||||
async fn open_structured_view(&mut self, session_id: &str) -> Result<()> {
|
||||
use crate::acp::client::{require_daemon, ManagerError};
|
||||
|
||||
// An archived / trashed row renders its own placeholder page, so
|
||||
// an activated view here would capture the keyboard invisibly.
|
||||
// Restoring stays explicit (`z` / the trash menu), matching the
|
||||
// archive contract for terminal sessions.
|
||||
// A trashed row renders its own placeholder page, so an activated
|
||||
// view here would capture the keyboard invisibly. Restoring stays
|
||||
// explicit (the trash menu).
|
||||
if self
|
||||
.home
|
||||
.get_instance(session_id)
|
||||
.is_some_and(|inst| inst.is_archived() || inst.is_trashed())
|
||||
.is_some_and(|inst| inst.is_trashed())
|
||||
{
|
||||
self.update_status = Some(UpdateStatus::transient(
|
||||
"This session is archived; restore it first to open the structured view".into(),
|
||||
"This session is trashed; restore it first to open the structured view".into(),
|
||||
));
|
||||
return Ok(());
|
||||
}
|
||||
@@ -2369,7 +2368,7 @@ impl App {
|
||||
// An entered view owns the selection and keyboard; leave it be,
|
||||
// but only while its session is still a live structured row AND
|
||||
// still the selected one. A peer (web, another aoe) can delete
|
||||
// the session, flip it to a terminal view, or archive it out
|
||||
// the session, flip it to a terminal view, or trash it out
|
||||
// from under us, and a storage reload can move the selection;
|
||||
// an active view that no longer matches what the pane renders
|
||||
// would keep capturing every keystroke invisibly.
|
||||
|
||||
@@ -50,13 +50,6 @@ fn shortcuts(strict: bool, live_on_enter: bool) -> Vec<(&'static str, Vec<(Strin
|
||||
let mut other: Vec<(String, String)> = Vec::new();
|
||||
for b in bindings::BINDINGS {
|
||||
let Some(help) = &b.help else { continue };
|
||||
// The unread toggle is fully removed when the feature is off, so the
|
||||
// help overlay shouldn't advertise a dead key (unlike the sort-gated
|
||||
// Attention rows, which stay listed because that's a transient view
|
||||
// state, not a disabled feature).
|
||||
if b.id == bindings::ActionId::ToggleUnread && !crate::session::unread_enabled() {
|
||||
continue;
|
||||
}
|
||||
let mut label = bindings::label(b.id, strict);
|
||||
if label.is_empty() {
|
||||
label = bindings::label(b.id, false);
|
||||
@@ -116,11 +109,11 @@ fn shortcuts(strict: bool, live_on_enter: bool) -> Vec<(&'static str, Vec<(Strin
|
||||
};
|
||||
// Favorite is an Attention-section shortcut, but it also works outside the
|
||||
// Attention sort while `favorites_first` is on, so the heading lists it as
|
||||
// an exception only then. Archive is always an exception.
|
||||
// an exception only then.
|
||||
let attention_title = if crate::session::favorites_first() {
|
||||
"Attention (Attention sort only, except Archive and Favorite)"
|
||||
"Attention (Attention sort only, except Favorite)"
|
||||
} else {
|
||||
"Attention (Attention sort only, except Archive)"
|
||||
"Attention (Attention sort only)"
|
||||
};
|
||||
vec![
|
||||
("Navigation", navigation),
|
||||
@@ -433,7 +426,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn attention_section_groups_triage_bindings() {
|
||||
// The Attention section bundles archive/favorite/next-waiting so
|
||||
// The Attention section bundles favorite/next-waiting so
|
||||
// users see them together instead of scattered across Navigation
|
||||
// and Actions.
|
||||
for strict in [false, true] {
|
||||
@@ -443,7 +436,7 @@ mod tests {
|
||||
.find(|(name, _)| name.starts_with("Attention"))
|
||||
.expect("Attention section should exist");
|
||||
let (_, keys) = attention;
|
||||
for needle in ["favorite", "Archive", "waiting"] {
|
||||
for needle in ["favorite", "waiting"] {
|
||||
assert!(
|
||||
keys.iter().any(|(_, desc)| desc.contains(needle)),
|
||||
"Attention section should mention {needle} (strict={strict})"
|
||||
@@ -452,7 +445,7 @@ mod tests {
|
||||
// And those should no longer live in Actions or Navigation.
|
||||
for other_section in ["Navigation", "Actions", "Actions (strict mode)"] {
|
||||
if let Some((_, other)) = all.iter().find(|(n, _)| *n == other_section) {
|
||||
for needle in ["favorite", "Archive", "waiting"] {
|
||||
for needle in ["favorite", "waiting"] {
|
||||
assert!(
|
||||
!other.iter().any(|(_, desc)| desc.contains(needle)),
|
||||
"{other_section} should not still contain {needle} \
|
||||
|
||||
@@ -94,9 +94,6 @@ pub fn builtin_commands(serve_enabled: bool, strict_hotkeys: bool) -> Vec<Palett
|
||||
}
|
||||
// Drop the unread toggle entirely when the feature is off, so the
|
||||
// palette can't invoke a removed binding.
|
||||
if b.id == bindings::ActionId::ToggleUnread && !crate::session::unread_enabled() {
|
||||
return None;
|
||||
}
|
||||
Some(PaletteCommand {
|
||||
id: bindings::palette_id(b.id),
|
||||
title: meta.title.to_string(),
|
||||
|
||||
+39
-190
@@ -12,13 +12,6 @@ use crate::tui::styles::Theme;
|
||||
pub enum ContextMenuAction {
|
||||
Rename,
|
||||
Delete,
|
||||
/// Archive (or unarchive) the session (mirrors the `'z'` hotkey). The menu
|
||||
/// label flips to "Unarchive" when the row is already archived.
|
||||
ToggleArchive,
|
||||
/// Mark the session read or unread (mirrors the `'u'` hotkey). The menu
|
||||
/// label flips to "Mark read" when the row is already unread. Only shown
|
||||
/// when the unread feature is enabled.
|
||||
ToggleUnread,
|
||||
/// Open the new-session dialog (mirrors the `'n'` hotkey).
|
||||
NewSession,
|
||||
/// Open the new-session dialog prefilled from the right-clicked row
|
||||
@@ -45,8 +38,8 @@ pub enum ContextMenuAction {
|
||||
/// bulk action; mirrors `aoe session empty-trash`). Routes through a
|
||||
/// confirmation dialog because the purge is irreversible.
|
||||
EmptyTrash,
|
||||
/// Pull every session in the section back out: restore all from Trash, or
|
||||
/// unarchive all under the Archived section. Reversible, so no confirm.
|
||||
/// Pull every session in the Trash section back out. Reversible, so no
|
||||
/// confirm.
|
||||
RestoreAll,
|
||||
/// Collapse or expand the synthetic section the menu was opened on. The
|
||||
/// label flips to "Expand" when the section is already collapsed.
|
||||
@@ -111,28 +104,12 @@ impl ContextMenuDialog {
|
||||
/// terminal row while the structured-view opt-in is off), and
|
||||
/// `Some(is_structured)` when the entry should appear, with the label
|
||||
/// naming the view the switch lands on.
|
||||
pub fn for_session(
|
||||
anchor: (u16, u16),
|
||||
is_archived: bool,
|
||||
unread: Option<bool>,
|
||||
can_fork: bool,
|
||||
switch_view: Option<bool>,
|
||||
) -> Self {
|
||||
let archive_label = if is_archived { "Unarchive" } else { "Archive" };
|
||||
pub fn for_session(anchor: (u16, u16), can_fork: bool, switch_view: Option<bool>) -> Self {
|
||||
let mut items = vec![
|
||||
(ContextMenuAction::NewFromSelection, "New Session"),
|
||||
(ContextMenuAction::Rename, "Rename"),
|
||||
(ContextMenuAction::ToggleArchive, archive_label),
|
||||
(ContextMenuAction::Delete, "Delete"),
|
||||
];
|
||||
if let Some(is_unread) = unread {
|
||||
let unread_label = if is_unread {
|
||||
"Mark read"
|
||||
} else {
|
||||
"Mark unread"
|
||||
};
|
||||
items.push((ContextMenuAction::ToggleUnread, unread_label));
|
||||
}
|
||||
items.push((ContextMenuAction::Delete, "Delete"));
|
||||
if can_fork {
|
||||
items.push((ContextMenuAction::Fork, "Fork session"));
|
||||
}
|
||||
@@ -195,21 +172,6 @@ impl ContextMenuDialog {
|
||||
)
|
||||
}
|
||||
|
||||
/// Menu for the synthetic Archived section header. Archiving is reversible
|
||||
/// and archived rows are never purged from here, so it offers Restore All
|
||||
/// (unarchive everything) and the collapse toggle, but no destructive
|
||||
/// "empty" action.
|
||||
pub fn for_archived_section(anchor: (u16, u16), collapsed: bool) -> Self {
|
||||
let collapse_label = if collapsed { "Expand" } else { "Collapse" };
|
||||
Self::new(
|
||||
anchor,
|
||||
vec![
|
||||
(ContextMenuAction::RestoreAll, "Restore All"),
|
||||
(ContextMenuAction::ToggleSectionCollapse, collapse_label),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
/// Menu shown when the user right-clicks the empty area of the
|
||||
/// sidebar (below the last session row, or in an empty list).
|
||||
/// Holds the entry points the user would otherwise have to reach
|
||||
@@ -359,8 +321,6 @@ impl ContextMenuDialog {
|
||||
let action = match c {
|
||||
'r' | 'R' => Some(ContextMenuAction::Rename),
|
||||
'd' | 'D' => Some(ContextMenuAction::Delete),
|
||||
'z' | 'Z' => Some(ContextMenuAction::ToggleArchive),
|
||||
'u' | 'U' => Some(ContextMenuAction::ToggleUnread),
|
||||
// `n` opens a new session from whichever new-session entry
|
||||
// the current menu carries: the session/group/project menu
|
||||
// prefills from the row (NewFromSelection), the empty-sidebar
|
||||
@@ -466,14 +426,14 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn session_menu_starts_on_new_session() {
|
||||
let menu = ContextMenuDialog::for_session((0, 0), false, None, true, None);
|
||||
let menu = ContextMenuDialog::for_session((0, 0), true, None);
|
||||
assert_eq!(menu.selected_action(), ContextMenuAction::NewFromSelection);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn down_then_enter_selects_rename() {
|
||||
// NewFromSelection -> Rename is one Down in the session menu.
|
||||
let mut menu = ContextMenuDialog::for_session((0, 0), false, None, true, None);
|
||||
let mut menu = ContextMenuDialog::for_session((0, 0), true, None);
|
||||
assert!(matches!(
|
||||
menu.handle_key(key(KeyCode::Down)),
|
||||
DialogResult::Continue
|
||||
@@ -487,9 +447,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn down_three_times_then_enter_selects_delete() {
|
||||
let mut menu = ContextMenuDialog::for_session((0, 0), false, None, true, None);
|
||||
// NewSession -> Rename -> Archive -> Delete.
|
||||
for _ in 0..3 {
|
||||
let mut menu = ContextMenuDialog::for_session((0, 0), true, None);
|
||||
// NewSession -> Rename -> Delete.
|
||||
for _ in 0..2 {
|
||||
menu.handle_key(key(KeyCode::Down));
|
||||
}
|
||||
let result = menu.handle_key(key(KeyCode::Enter));
|
||||
@@ -541,44 +501,12 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn archived_session_menu_labels_unarchive() {
|
||||
let menu = ContextMenuDialog::for_session((0, 0), true, None, true, None);
|
||||
let labels: Vec<&str> = menu.items_for_test().iter().map(|(_, l)| *l).collect();
|
||||
assert_eq!(
|
||||
labels,
|
||||
vec![
|
||||
"New Session",
|
||||
"Rename",
|
||||
"Unarchive",
|
||||
"Delete",
|
||||
"Fork session"
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_session_menu_lists_all_actions() {
|
||||
let menu = ContextMenuDialog::for_session((0, 0), false, None, true, None);
|
||||
let items: Vec<ContextMenuAction> = menu.items_for_test().iter().map(|(a, _)| *a).collect();
|
||||
assert_eq!(
|
||||
items,
|
||||
vec![
|
||||
ContextMenuAction::NewFromSelection,
|
||||
ContextMenuAction::Rename,
|
||||
ContextMenuAction::ToggleArchive,
|
||||
ContextMenuAction::Delete,
|
||||
ContextMenuAction::Fork,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn n_hotkey_in_session_menu_submits_new_from_selection() {
|
||||
// The session menu carries the prefill-from-row entry, so `n`/`N` must
|
||||
// resolve to NewFromSelection, matching the `'N'` keybinding's
|
||||
// new-from-selection behavior on a session.
|
||||
let mut menu = ContextMenuDialog::for_session((0, 0), false, None, true, None);
|
||||
let mut menu = ContextMenuDialog::for_session((0, 0), true, None);
|
||||
// Pre-select Fork (Up wraps to the last item) to prove the hotkey wins over the cursor.
|
||||
menu.handle_key(key(KeyCode::Up));
|
||||
let result = menu.handle_key(key(KeyCode::Char('n')));
|
||||
@@ -588,77 +516,9 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unread_row_appears_and_label_flips() {
|
||||
// Feature on, row read -> "Mark unread"; row unread -> "Mark read".
|
||||
let read = ContextMenuDialog::for_session((0, 0), false, Some(false), true, None);
|
||||
let labels: Vec<&str> = read.items_for_test().iter().map(|(_, l)| *l).collect();
|
||||
assert_eq!(
|
||||
labels,
|
||||
vec![
|
||||
"New Session",
|
||||
"Rename",
|
||||
"Archive",
|
||||
"Mark unread",
|
||||
"Delete",
|
||||
"Fork session"
|
||||
]
|
||||
);
|
||||
|
||||
let unread = ContextMenuDialog::for_session((0, 0), false, Some(true), true, None);
|
||||
let labels: Vec<&str> = unread.items_for_test().iter().map(|(_, l)| *l).collect();
|
||||
assert_eq!(
|
||||
labels,
|
||||
vec![
|
||||
"New Session",
|
||||
"Rename",
|
||||
"Archive",
|
||||
"Mark read",
|
||||
"Delete",
|
||||
"Fork session"
|
||||
]
|
||||
);
|
||||
|
||||
// Feature off (None) -> no unread row.
|
||||
let off = ContextMenuDialog::for_session((0, 0), false, None, true, None);
|
||||
let labels: Vec<&str> = off.items_for_test().iter().map(|(_, l)| *l).collect();
|
||||
assert_eq!(
|
||||
labels,
|
||||
vec!["New Session", "Rename", "Archive", "Delete", "Fork session"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn u_hotkey_submits_toggle_unread() {
|
||||
// The unread quick-pick is case-insensitive like the menu's other
|
||||
// hotkeys (the home-view shortcut itself is Shift+U), and only fires
|
||||
// when the Unread row is present (feature enabled).
|
||||
let mut menu = ContextMenuDialog::for_session((0, 0), false, Some(false), true, None);
|
||||
assert!(matches!(
|
||||
menu.handle_key(key(KeyCode::Char('u'))),
|
||||
DialogResult::Submit(ContextMenuAction::ToggleUnread)
|
||||
));
|
||||
// With no Unread row (feature off), `u` is inert.
|
||||
let mut off = ContextMenuDialog::for_session((0, 0), false, None, true, None);
|
||||
assert!(matches!(
|
||||
off.handle_key(key(KeyCode::Char('u'))),
|
||||
DialogResult::Continue
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn z_hotkey_submits_toggle_archive() {
|
||||
let mut menu = ContextMenuDialog::for_session((0, 0), false, None, true, None);
|
||||
let result = menu.handle_key(key(KeyCode::Char('z')));
|
||||
assert!(matches!(
|
||||
result,
|
||||
DialogResult::Submit(ContextMenuAction::ToggleArchive)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enter_on_default_submits_new_session() {
|
||||
let mut menu = ContextMenuDialog::for_session((0, 0), false, None, true, None);
|
||||
let mut menu = ContextMenuDialog::for_session((0, 0), true, None);
|
||||
let result = menu.handle_key(key(KeyCode::Enter));
|
||||
assert!(matches!(
|
||||
result,
|
||||
@@ -668,14 +528,14 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn esc_cancels() {
|
||||
let mut menu = ContextMenuDialog::for_session((0, 0), false, None, true, None);
|
||||
let mut menu = ContextMenuDialog::for_session((0, 0), true, None);
|
||||
let result = menu.handle_key(key(KeyCode::Esc));
|
||||
assert!(matches!(result, DialogResult::Cancel));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn up_wraps_from_first_to_last() {
|
||||
let mut menu = ContextMenuDialog::for_session((0, 0), false, None, true, None);
|
||||
let mut menu = ContextMenuDialog::for_session((0, 0), true, None);
|
||||
menu.handle_key(key(KeyCode::Up));
|
||||
let result = menu.handle_key(key(KeyCode::Enter));
|
||||
assert!(matches!(
|
||||
@@ -686,10 +546,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn down_wraps_from_last_to_first() {
|
||||
let mut menu = ContextMenuDialog::for_session((0, 0), false, None, true, None);
|
||||
// 5 items: Down x5 walks NewSession -> Rename -> Archive ->
|
||||
// Delete -> Fork -> back to NewSession.
|
||||
for _ in 0..5 {
|
||||
let mut menu = ContextMenuDialog::for_session((0, 0), true, None);
|
||||
// 4 items: Down x4 walks NewSession -> Rename -> Delete -> Fork ->
|
||||
// back to NewSession.
|
||||
for _ in 0..4 {
|
||||
menu.handle_key(key(KeyCode::Down));
|
||||
}
|
||||
let result = menu.handle_key(key(KeyCode::Enter));
|
||||
@@ -701,7 +561,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn r_hotkey_submits_rename() {
|
||||
let mut menu = ContextMenuDialog::for_session((0, 0), false, None, true, None);
|
||||
let mut menu = ContextMenuDialog::for_session((0, 0), true, None);
|
||||
// Pre-select Fork (Up wraps to the last item) to prove the hotkey
|
||||
// wins over the cursor.
|
||||
menu.handle_key(key(KeyCode::Up));
|
||||
@@ -714,7 +574,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn d_hotkey_submits_delete() {
|
||||
let mut menu = ContextMenuDialog::for_session((0, 0), false, None, true, None);
|
||||
let mut menu = ContextMenuDialog::for_session((0, 0), true, None);
|
||||
let result = menu.handle_key(key(KeyCode::Char('d')));
|
||||
assert!(matches!(
|
||||
result,
|
||||
@@ -746,21 +606,21 @@ mod tests {
|
||||
#[test]
|
||||
fn p_hotkey_inert_on_session_menu() {
|
||||
// The session menu has no pin entry, so `p` must not fire it.
|
||||
let mut menu = ContextMenuDialog::for_session((0, 0), false, None, true, None);
|
||||
let mut menu = ContextMenuDialog::for_session((0, 0), true, None);
|
||||
let result = menu.handle_key(key(KeyCode::Char('p')));
|
||||
assert!(matches!(result, DialogResult::Continue));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_key_is_continue() {
|
||||
let mut menu = ContextMenuDialog::for_session((0, 0), false, None, true, None);
|
||||
let mut menu = ContextMenuDialog::for_session((0, 0), true, None);
|
||||
let result = menu.handle_key(key(KeyCode::Char('x')));
|
||||
assert!(matches!(result, DialogResult::Continue));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn click_is_outside_before_render_is_true() {
|
||||
let menu = ContextMenuDialog::for_session((10, 10), false, None, true, None);
|
||||
let menu = ContextMenuDialog::for_session((10, 10), true, None);
|
||||
// Before a render captures `last_area`, every point should count
|
||||
// as "outside" so a stray click can't be mis-classified as "inside
|
||||
// the menu" and accidentally kept open.
|
||||
@@ -775,7 +635,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn click_on_first_row_submits_new_session() {
|
||||
let mut menu = ContextMenuDialog::for_session((10, 10), false, None, true, None);
|
||||
let mut menu = ContextMenuDialog::for_session((10, 10), true, None);
|
||||
stub_render(&mut menu, 10, 10, 14, 7);
|
||||
// Item rows live inside the bordered block, so row y+1 is the
|
||||
// first item and y+2 is the second.
|
||||
@@ -788,7 +648,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn click_on_second_row_submits_rename() {
|
||||
let mut menu = ContextMenuDialog::for_session((10, 10), false, None, true, None);
|
||||
let mut menu = ContextMenuDialog::for_session((10, 10), true, None);
|
||||
stub_render(&mut menu, 10, 10, 14, 7);
|
||||
let result = menu.handle_click(12, 12);
|
||||
assert!(matches!(
|
||||
@@ -797,20 +657,9 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn click_on_third_row_submits_toggle_archive() {
|
||||
let mut menu = ContextMenuDialog::for_session((10, 10), false, None, true, None);
|
||||
stub_render(&mut menu, 10, 10, 14, 7);
|
||||
let result = menu.handle_click(12, 13);
|
||||
assert!(matches!(
|
||||
result,
|
||||
Some(DialogResult::Submit(ContextMenuAction::ToggleArchive))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn click_on_border_keeps_menu_open() {
|
||||
let mut menu = ContextMenuDialog::for_session((10, 10), false, None, true, None);
|
||||
let mut menu = ContextMenuDialog::for_session((10, 10), true, None);
|
||||
stub_render(&mut menu, 10, 10, 14, 4);
|
||||
// Top border row is y itself.
|
||||
let result = menu.handle_click(12, 10);
|
||||
@@ -825,7 +674,7 @@ mod tests {
|
||||
// The router must reject both vertical borders or a click on
|
||||
// the right edge of the menu, at an item's y, would fire
|
||||
// Rename / Delete the same as clicking the label.
|
||||
let mut menu = ContextMenuDialog::for_session((10, 10), false, None, true, None);
|
||||
let mut menu = ContextMenuDialog::for_session((10, 10), true, None);
|
||||
stub_render(&mut menu, 10, 10, 14, 4);
|
||||
// (10, 11) = left vertical border, first item's row.
|
||||
assert!(matches!(
|
||||
@@ -842,7 +691,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn click_outside_returns_none() {
|
||||
let mut menu = ContextMenuDialog::for_session((10, 10), false, None, true, None);
|
||||
let mut menu = ContextMenuDialog::for_session((10, 10), true, None);
|
||||
stub_render(&mut menu, 10, 10, 14, 4);
|
||||
let result = menu.handle_click(40, 40);
|
||||
assert!(result.is_none());
|
||||
@@ -850,7 +699,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn hover_moves_highlight() {
|
||||
let mut menu = ContextMenuDialog::for_session((10, 10), false, None, true, None);
|
||||
let mut menu = ContextMenuDialog::for_session((10, 10), true, None);
|
||||
stub_render(&mut menu, 10, 10, 14, 7);
|
||||
assert_eq!(menu.selected_action(), ContextMenuAction::NewFromSelection);
|
||||
let changed = menu.handle_hover(12, 12);
|
||||
@@ -860,7 +709,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn hover_on_same_row_returns_false() {
|
||||
let mut menu = ContextMenuDialog::for_session((10, 10), false, None, true, None);
|
||||
let mut menu = ContextMenuDialog::for_session((10, 10), true, None);
|
||||
stub_render(&mut menu, 10, 10, 14, 4);
|
||||
// First hover lands on row 1 (Rename, already selected).
|
||||
assert!(!menu.handle_hover(12, 11));
|
||||
@@ -870,10 +719,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn hover_off_menu_clears_the_highlight() {
|
||||
let mut menu = ContextMenuDialog::for_session((10, 10), false, None, true, None);
|
||||
let mut menu = ContextMenuDialog::for_session((10, 10), true, None);
|
||||
stub_render(&mut menu, 10, 10, 14, 7);
|
||||
menu.handle_hover(12, 15); // Delete (fifth row, index 4)
|
||||
assert_eq!(menu.highlight_for_test(), Some(4));
|
||||
menu.handle_hover(12, 13); // Delete (third row, index 2)
|
||||
assert_eq!(menu.highlight_for_test(), Some(2));
|
||||
// Moving the cursor off every item must drop the highlight so no
|
||||
// row stays lit once the pointer is no longer over any of them.
|
||||
assert!(menu.handle_hover(40, 40));
|
||||
@@ -891,7 +740,7 @@ mod tests {
|
||||
// Regression for the reported bug: hovering an item then sliding
|
||||
// onto the menu's own border (still inside `last_area`, but not an
|
||||
// item row) must unhighlight rather than leave the item lit.
|
||||
let mut menu = ContextMenuDialog::for_session((10, 10), false, None, true, None);
|
||||
let mut menu = ContextMenuDialog::for_session((10, 10), true, None);
|
||||
stub_render(&mut menu, 10, 10, 14, 7);
|
||||
menu.handle_hover(12, 12); // Rename (second row)
|
||||
assert_eq!(menu.highlight_for_test(), Some(1));
|
||||
@@ -904,7 +753,7 @@ mod tests {
|
||||
fn enter_after_hover_cleared_keeps_menu_open() {
|
||||
// With nothing highlighted, Enter has no item to submit, so the
|
||||
// menu stays open instead of firing a stale action.
|
||||
let mut menu = ContextMenuDialog::for_session((10, 10), false, None, true, None);
|
||||
let mut menu = ContextMenuDialog::for_session((10, 10), true, None);
|
||||
stub_render(&mut menu, 10, 10, 14, 7);
|
||||
menu.handle_hover(40, 40); // off every item -> None
|
||||
assert!(matches!(
|
||||
@@ -915,7 +764,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn arrow_down_after_hover_cleared_starts_at_first_item() {
|
||||
let mut menu = ContextMenuDialog::for_session((10, 10), false, None, true, None);
|
||||
let mut menu = ContextMenuDialog::for_session((10, 10), true, None);
|
||||
stub_render(&mut menu, 10, 10, 14, 7);
|
||||
menu.handle_hover(40, 40); // off every item -> None
|
||||
menu.handle_key(key(KeyCode::Down));
|
||||
@@ -924,7 +773,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn arrow_up_after_hover_cleared_starts_at_last_item() {
|
||||
let mut menu = ContextMenuDialog::for_session((10, 10), false, None, true, None);
|
||||
let mut menu = ContextMenuDialog::for_session((10, 10), true, None);
|
||||
stub_render(&mut menu, 10, 10, 14, 7);
|
||||
menu.handle_hover(40, 40); // off every item -> None
|
||||
menu.handle_key(key(KeyCode::Up));
|
||||
@@ -942,15 +791,15 @@ mod switch_view_tests {
|
||||
|
||||
#[test]
|
||||
fn switch_view_row_hidden_when_none() {
|
||||
let menu = ContextMenuDialog::for_session((0, 0), false, None, false, None);
|
||||
let menu = ContextMenuDialog::for_session((0, 0), false, None);
|
||||
assert!(!labels(&menu).iter().any(|l| l.starts_with("Switch to")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn switch_view_label_names_the_target_view() {
|
||||
let terminal_row = ContextMenuDialog::for_session((0, 0), false, None, false, Some(false));
|
||||
let terminal_row = ContextMenuDialog::for_session((0, 0), false, Some(false));
|
||||
assert!(labels(&terminal_row).contains(&"Switch to structured"));
|
||||
let structured_row = ContextMenuDialog::for_session((0, 0), false, None, false, Some(true));
|
||||
let structured_row = ContextMenuDialog::for_session((0, 0), false, Some(true));
|
||||
assert!(labels(&structured_row).contains(&"Switch to terminal"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,12 +48,7 @@ pub enum ActionId {
|
||||
Profiles,
|
||||
Projects,
|
||||
Restart,
|
||||
ToggleArchive,
|
||||
ToggleFavorite,
|
||||
/// Toggle the selected session's unread marker (read -> manual-unread;
|
||||
/// unread -> read). Gated behind the `session.unread_indicator` config
|
||||
/// toggle (on by default); a no-op when disabled.
|
||||
ToggleUnread,
|
||||
TogglePreviewInfo,
|
||||
/// Toggle the system diagnostics strip (live CPU and memory pressure plus
|
||||
/// running agent/process counts). Persisted via
|
||||
@@ -121,10 +116,6 @@ pub enum Context {
|
||||
SearchActive,
|
||||
/// The cursor is on a real (non-synthetic) project header in project view.
|
||||
ProjectGroupSelected,
|
||||
/// The unread-session feature is enabled (`session.unread_indicator`). When
|
||||
/// off, the binding is removed from dispatch so the key isn't swallowed by
|
||||
/// a dead action; help and the command palette skip it separately.
|
||||
UnreadEnabled,
|
||||
}
|
||||
|
||||
/// Help-overlay section. Ordering mirrors `components/help.rs`.
|
||||
@@ -183,7 +174,6 @@ fn context_holds(context: Context, ctx: &Ctx) -> bool {
|
||||
}
|
||||
Context::SearchActive => ctx.has_search,
|
||||
Context::ProjectGroupSelected => ctx.project_group_selected,
|
||||
Context::UnreadEnabled => crate::session::unread_enabled(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -595,44 +585,6 @@ pub static BINDINGS: &[Binding] = &[
|
||||
serve_only: false,
|
||||
}),
|
||||
},
|
||||
// `U` toggles read/unread, pinned to Shift+u in BOTH modes (matches the
|
||||
// macOS Mail "mark unread" muscle memory and keeps the key stable). It does
|
||||
// NOT participate in the strict relocation: `U` is already a modified key,
|
||||
// so it satisfies strict mode's "no bare action letters" rule as-is.
|
||||
// `u` updates (when available) and relocates the usual way: bare `u` in
|
||||
// non-strict, `Ctrl+u` in strict.
|
||||
Binding {
|
||||
id: ActionId::ToggleUnread,
|
||||
non_strict: &[k('U')],
|
||||
strict: &[k('U')],
|
||||
context: Context::UnreadEnabled,
|
||||
help: Some(HelpMeta {
|
||||
section: HelpSection::Actions,
|
||||
desc: "Mark read/unread (toggle)",
|
||||
}),
|
||||
palette: Some(PaletteMeta {
|
||||
title: "Toggle read/unread",
|
||||
keywords: &["read", "unread", "seen", "flag", "viewed"],
|
||||
group: PaletteGroup::Actions,
|
||||
serve_only: false,
|
||||
}),
|
||||
},
|
||||
Binding {
|
||||
id: ActionId::ToggleArchive,
|
||||
non_strict: &[k('z')],
|
||||
strict: &[k('Z')],
|
||||
context: Context::Always,
|
||||
help: Some(HelpMeta {
|
||||
section: HelpSection::Attention,
|
||||
desc: "Archive (toggle, any sort)",
|
||||
}),
|
||||
palette: Some(PaletteMeta {
|
||||
title: "Toggle archive",
|
||||
keywords: &["park", "stash", "done", "zzz"],
|
||||
group: PaletteGroup::Actions,
|
||||
serve_only: false,
|
||||
}),
|
||||
},
|
||||
Binding {
|
||||
id: ActionId::TogglePreviewInfo,
|
||||
non_strict: &[k('i')],
|
||||
@@ -755,9 +707,7 @@ pub fn palette_id(id: ActionId) -> &'static str {
|
||||
ActionId::Profiles => "profiles",
|
||||
ActionId::Projects => "projects",
|
||||
ActionId::Restart => "restart",
|
||||
ActionId::ToggleArchive => "archive",
|
||||
ActionId::ToggleFavorite => "favorite",
|
||||
ActionId::ToggleUnread => "toggle-unread",
|
||||
ActionId::TogglePreviewInfo => "toggle-preview-info",
|
||||
ActionId::ToggleDiagnostics => "toggle-diagnostics",
|
||||
ActionId::OpenSystemHealth => "open-system-health",
|
||||
@@ -866,22 +816,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_u_toggles_unread_in_both_modes_and_u_updates() {
|
||||
let c = ctx();
|
||||
// Unread is pinned to Shift+U regardless of strict mode.
|
||||
assert_eq!(
|
||||
resolve(&key('U'), false, &c),
|
||||
Some(ActionId::ToggleUnread),
|
||||
"non-strict U = unread"
|
||||
);
|
||||
assert_eq!(
|
||||
resolve(&key('U'), true, &c),
|
||||
Some(ActionId::ToggleUnread),
|
||||
"strict U = unread"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_relocation_is_consistent() {
|
||||
let c = ctx();
|
||||
@@ -894,7 +828,6 @@ mod tests {
|
||||
('N', ActionId::NewSession),
|
||||
('P', ActionId::Projects),
|
||||
('O', ActionId::SortPicker),
|
||||
('U', ActionId::ToggleUnread),
|
||||
];
|
||||
for (ch, want) in shifted {
|
||||
assert_eq!(resolve(&key(ch), true, &c), Some(want), "strict '{ch}'");
|
||||
|
||||
+27
-159
@@ -28,14 +28,13 @@ use crate::tui::settings::{SettingsAction, SettingsView};
|
||||
/// fast for trackpads or too slow on remote sessions.
|
||||
const DOUBLE_CLICK_THRESHOLD: std::time::Duration = std::time::Duration::from_millis(400);
|
||||
|
||||
/// The two synthetic bottom-of-sidebar sections. Their headers look like
|
||||
/// groups in `flat_items` but carry sentinel paths, so the section-scoped
|
||||
/// context-menu actions (Restore All, collapse) resolve which one the cursor
|
||||
/// is on through this rather than duplicating the path checks per call site.
|
||||
/// The synthetic bottom-of-sidebar section. Its header looks like a group in
|
||||
/// `flat_items` but carries a sentinel path, so the section-scoped
|
||||
/// context-menu actions (Restore All, collapse) resolve it through this
|
||||
/// rather than duplicating the path check per call site.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(super) enum SidebarSection {
|
||||
Trash,
|
||||
Archived,
|
||||
}
|
||||
|
||||
/// Decompose pasted text into a series of `TmuxKey`s safe for the
|
||||
@@ -908,12 +907,6 @@ impl HomeView {
|
||||
}
|
||||
None
|
||||
}
|
||||
"archive_group" => {
|
||||
if let Err(e) = self.archive_selected_group() {
|
||||
tracing::error!(target: "tui.input", "Failed to archive group: {}", e);
|
||||
}
|
||||
None
|
||||
}
|
||||
"stop_session" => self.pending_stop_session.take().map(Action::StopSession),
|
||||
"stop_terminal" => {
|
||||
if let Some(session_id) = self.pending_stop_terminal.take() {
|
||||
@@ -959,7 +952,7 @@ impl HomeView {
|
||||
|
||||
/// Confirm before permanently purging every trashed session (the Trash
|
||||
/// section's "Empty Trash" bulk action). The purge is irreversible, so it
|
||||
/// keeps the destructive red tone rather than the neutral archive one. A
|
||||
/// keeps the destructive red tone. A
|
||||
/// no-op info dialog when the trash is already empty avoids a confirm that
|
||||
/// would delete nothing.
|
||||
pub(super) fn prompt_empty_trash(&mut self) {
|
||||
@@ -979,41 +972,6 @@ impl HomeView {
|
||||
));
|
||||
}
|
||||
|
||||
/// Confirm before archiving every active session under the focused group.
|
||||
/// Archiving a whole project or organization at once is a bigger hammer than the single-row
|
||||
/// `z`, so it routes through a prompt. Archiving is reversible, hence the
|
||||
/// calmer neutral tone rather than the destructive red. No-ops silently
|
||||
/// (no prompt) when the group has no active sessions left to archive.
|
||||
pub(super) fn prompt_archive_selected_group(&mut self) {
|
||||
let Some(group_path) = self.selected_group.clone() else {
|
||||
return;
|
||||
};
|
||||
let count = self.active_sessions_in_selected_group().len();
|
||||
if count == 0 {
|
||||
return;
|
||||
}
|
||||
// Project/Org mode groups by repo/owner, Manual mode by
|
||||
// user-assigned path; name the scope accordingly and show the full
|
||||
// path so nested groups that share a leaf segment aren't ambiguous.
|
||||
let (title, scope) = match self.group_by {
|
||||
crate::session::config::GroupByMode::Project => ("Archive project", "project"),
|
||||
crate::session::config::GroupByMode::Org => ("Archive org", "org"),
|
||||
crate::session::config::GroupByMode::Manual => ("Archive group", "group"),
|
||||
};
|
||||
let noun = if count == 1 { "session" } else { "sessions" };
|
||||
self.confirm_dialog = Some(
|
||||
ConfirmDialog::new(
|
||||
title,
|
||||
&format!(
|
||||
"Archive all {count} {noun} in {scope} \"{}\"?",
|
||||
crate::session::project_group_display_name(&group_path)
|
||||
),
|
||||
"archive_group",
|
||||
)
|
||||
.neutral(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Discard unsaved Settings changes: force-close the view, clear the
|
||||
/// confirm state, and revert any live theme preview back to the saved
|
||||
/// config theme. Shared by the keyboard (Esc/q -> confirm) and mouse
|
||||
@@ -2180,23 +2138,11 @@ impl HomeView {
|
||||
self.projects_dialog = Some(ProjectsDialog::new(&profile));
|
||||
}
|
||||
ActionId::Restart => self.open_restart_dialog(),
|
||||
ActionId::ToggleArchive => {
|
||||
if self.selected_group.is_some() {
|
||||
self.prompt_archive_selected_group();
|
||||
} else if let Err(e) = self.toggle_archive_at_cursor() {
|
||||
tracing::error!("toggle_archive_at_cursor failed: {}", e);
|
||||
}
|
||||
}
|
||||
ActionId::ToggleFavorite => {
|
||||
if let Err(e) = self.toggle_favorite_at_cursor() {
|
||||
tracing::error!("toggle_favorite_at_cursor failed: {}", e);
|
||||
}
|
||||
}
|
||||
ActionId::ToggleUnread => {
|
||||
if let Err(e) = self.toggle_unread_at_cursor() {
|
||||
tracing::error!("toggle_unread_at_cursor failed: {}", e);
|
||||
}
|
||||
}
|
||||
ActionId::TogglePreviewInfo => self.toggle_preview_info(),
|
||||
ActionId::ToggleDiagnostics => self.toggle_diagnostics(),
|
||||
ActionId::OpenSystemHealth => self.open_system_health(),
|
||||
@@ -2328,17 +2274,14 @@ impl HomeView {
|
||||
/// opt-in is on (the structured view is still maturing, so switching into it
|
||||
/// is gated the same way the new-session toggle is). Serve builds only (the swap runs
|
||||
/// through the daemon and the result needs a structured view to open).
|
||||
/// Archived / trashed / mid-lifecycle rows are excluded: their agent is
|
||||
/// Trashed / mid-lifecycle rows are excluded: their agent is
|
||||
/// deliberately stopped, and the swap would boot a worker or tmux pane
|
||||
/// behind the parked state.
|
||||
pub(super) fn session_switch_view_target(&self, id: &str) -> Option<bool> {
|
||||
#[cfg(feature = "serve")]
|
||||
{
|
||||
let inst = self.get_instance(id)?;
|
||||
if inst.is_archived()
|
||||
|| inst.is_trashed()
|
||||
|| matches!(inst.status, Status::Creating | Status::Deleting)
|
||||
{
|
||||
if inst.is_trashed() || matches!(inst.status, Status::Creating | Status::Deleting) {
|
||||
return None;
|
||||
}
|
||||
if inst.is_structured() {
|
||||
@@ -2443,8 +2386,8 @@ impl HomeView {
|
||||
|
||||
/// The selected session's id when it is a structured-view row that
|
||||
/// can be previewed: not mid create/delete, and not parked in the
|
||||
/// archive or trash (those render their own placeholder pages, so a
|
||||
/// mounted view would be invisible while still streaming). Drives
|
||||
/// trash (which renders its own placeholder page, so a mounted view
|
||||
/// would be invisible while still streaming). Drives
|
||||
/// preview-on-select and the active-view liveness check.
|
||||
#[cfg(feature = "serve")]
|
||||
pub(in crate::tui) fn selected_structured_session(&self) -> Option<String> {
|
||||
@@ -2452,7 +2395,6 @@ impl HomeView {
|
||||
let inst = self.get_instance(&id)?;
|
||||
let previewable = inst.is_structured()
|
||||
&& !matches!(inst.status, Status::Creating | Status::Deleting)
|
||||
&& !inst.is_archived()
|
||||
&& !inst.is_trashed();
|
||||
previewable.then_some(id)
|
||||
}
|
||||
@@ -2925,15 +2867,11 @@ impl HomeView {
|
||||
});
|
||||
}
|
||||
Item::Group { name, path, .. } => {
|
||||
// The synthetic Archived section header (and any
|
||||
// sub-folder rendered under it in Project mode) is
|
||||
// not a real group; skip it so the palette doesn't
|
||||
// surface the sentinel path or invite Jump-to-group
|
||||
// navigation that the rest of the codebase
|
||||
// intentionally disarms.
|
||||
if crate::session::is_within_archived_section(path)
|
||||
|| crate::session::is_within_trash_section(path)
|
||||
{
|
||||
// The synthetic Trash section header is not a real
|
||||
// group; skip it so the palette doesn't surface the
|
||||
// sentinel path or invite Jump-to-group navigation that
|
||||
// the rest of the codebase intentionally disarms.
|
||||
if crate::session::is_within_trash_section(path) {
|
||||
continue;
|
||||
}
|
||||
// The parenthetical disambiguates org keys (owner vs
|
||||
@@ -3016,7 +2954,6 @@ impl HomeView {
|
||||
self.select_and_reveal_session(id);
|
||||
if self.selected_session != previous {
|
||||
self.preview_scroll_offset = 0;
|
||||
self.manual_unread_hold = None;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3053,9 +2990,6 @@ impl HomeView {
|
||||
// Trashed rows are stopped and only surface under the collapsed
|
||||
// Trash section; they never "need attention", so skip them even
|
||||
// when a stale unread flag survived the trash (#2489).
|
||||
// Archived rows are likewise an explicit "don't bother me"
|
||||
// sink state, same as everywhere else that checks
|
||||
// `is_archived()`.
|
||||
let is_actionable = !inst.is_dismissed()
|
||||
&& (inst.status == Status::Waiting
|
||||
|| matches!(inst.idle_age(), Some(age) if age < window)
|
||||
@@ -3297,18 +3231,14 @@ impl HomeView {
|
||||
}
|
||||
Item::Group { path, .. } => {
|
||||
self.selected_session = None;
|
||||
if crate::session::is_within_archived_section(path)
|
||||
|| crate::session::is_within_trash_section(path)
|
||||
{
|
||||
// The synthetic Archived section (and any
|
||||
// project sub-folder rendered under it in
|
||||
// Project mode) is not a real group: it can't
|
||||
// be renamed, deleted, archived, or moved.
|
||||
if crate::session::is_within_trash_section(path) {
|
||||
// The synthetic Trash section is not a real
|
||||
// group: it can't be renamed, deleted, or moved.
|
||||
// Leaving `selected_group` unset disarms every
|
||||
// keybind that branches on
|
||||
// `selected_group.is_some()` (rename, delete,
|
||||
// archive group, etc.) without each one having
|
||||
// to special-case the sentinel.
|
||||
// `selected_group.is_some()` (rename, delete, etc.)
|
||||
// without each one having to special-case the
|
||||
// sentinel.
|
||||
self.selected_group = None;
|
||||
self.selected_group_profile = None;
|
||||
} else {
|
||||
@@ -3328,12 +3258,6 @@ impl HomeView {
|
||||
// drop it upstream; clearing here also covers programmatic
|
||||
// reselects (e.g. a poller) that never ran those paths.
|
||||
self.clear_preview_selection();
|
||||
// Moving off a hand-flagged row ends its manual-unread hold, so
|
||||
// returning to it later dwell-clears like any other unread. Done
|
||||
// here at the cursor->selection sync (every navigation path runs
|
||||
// through it) so the release doesn't hinge on a dwell tick
|
||||
// happening to fire during a quick hop to another row.
|
||||
self.manual_unread_hold = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3412,14 +3336,6 @@ impl HomeView {
|
||||
}
|
||||
|
||||
fn toggle_group_collapsed(&mut self, path: &str) {
|
||||
// The synthetic Archived section is not a member of any
|
||||
// GroupTree; its collapsed state lives on HomeView and persists
|
||||
// separately. Route here before either branch tries to mutate a
|
||||
// nonexistent group.
|
||||
if crate::session::is_archived_section_path(path) {
|
||||
self.toggle_archived_section();
|
||||
return;
|
||||
}
|
||||
if crate::session::is_trash_section_path(path) {
|
||||
self.toggle_trashed_section();
|
||||
return;
|
||||
@@ -3887,11 +3803,6 @@ impl HomeView {
|
||||
Some(ContextMenuDialog::for_trash_section(anchor, *collapsed));
|
||||
return true;
|
||||
}
|
||||
if crate::session::is_archived_section_path(path) {
|
||||
self.context_menu =
|
||||
Some(ContextMenuDialog::for_archived_section(anchor, *collapsed));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
let is_group = matches!(self.flat_items[idx], super::Item::Group { .. });
|
||||
// A real project header in project view gets the pin menu; the
|
||||
@@ -3903,16 +3814,6 @@ impl HomeView {
|
||||
} else if is_group {
|
||||
ContextMenuDialog::for_group(anchor)
|
||||
} else {
|
||||
let (is_archived, is_unread) = match &self.flat_items[idx] {
|
||||
super::Item::Session { id, .. } => self
|
||||
.get_instance(id)
|
||||
.map(|inst| (inst.is_archived(), inst.is_unread()))
|
||||
.unwrap_or((false, false)),
|
||||
super::Item::Group { .. } => (false, false),
|
||||
};
|
||||
// The unread toggle is always-on (any sort), so it shows
|
||||
// whenever the feature is enabled.
|
||||
let unread = crate::session::unread_enabled().then_some(is_unread);
|
||||
// Show "Fork session" only when the agent can actually fork, so
|
||||
// a resume-only agent doesn't offer an action the palette would
|
||||
// refuse. Matches the web sidebar's `acp_can_fork` gating.
|
||||
@@ -3929,7 +3830,7 @@ impl HomeView {
|
||||
super::Item::Session { id, .. } => self.session_switch_view_target(id),
|
||||
super::Item::Group { .. } => None,
|
||||
};
|
||||
ContextMenuDialog::for_session(anchor, is_archived, unread, can_fork, switch_view)
|
||||
ContextMenuDialog::for_session(anchor, can_fork, switch_view)
|
||||
});
|
||||
return true;
|
||||
}
|
||||
@@ -3990,19 +3891,6 @@ impl HomeView {
|
||||
match action {
|
||||
ContextMenuAction::Rename => self.open_rename_for_selected(),
|
||||
ContextMenuAction::Delete => self.open_delete_for_selected(),
|
||||
ContextMenuAction::ToggleArchive => {
|
||||
// The right-click already moved the cursor onto the row, so the
|
||||
// toggle acts on the same session the menu was opened for.
|
||||
if let Err(e) = self.toggle_archive_at_cursor() {
|
||||
tracing::error!("toggle_archive_at_cursor (context menu) failed: {}", e);
|
||||
}
|
||||
}
|
||||
ContextMenuAction::ToggleUnread => {
|
||||
// Same cursor-on-the-clicked-row guarantee as ToggleArchive.
|
||||
if let Err(e) = self.toggle_unread_at_cursor() {
|
||||
tracing::error!("toggle_unread_at_cursor (context menu) failed: {}", e);
|
||||
}
|
||||
}
|
||||
ContextMenuAction::NewSession => self.open_new_session_dialog(),
|
||||
// The right-click already moved the cursor onto the row, so reuse
|
||||
// the "new from selection" path: a session row prefills its own repo
|
||||
@@ -4025,12 +3913,10 @@ impl HomeView {
|
||||
ContextMenuAction::EmptyTrash => self.prompt_empty_trash(),
|
||||
ContextMenuAction::RestoreAll => match self.section_at_cursor() {
|
||||
Some(SidebarSection::Trash) => self.restore_all_from_trash(),
|
||||
Some(SidebarSection::Archived) => self.unarchive_all(),
|
||||
None => {}
|
||||
},
|
||||
ContextMenuAction::ToggleSectionCollapse => match self.section_at_cursor() {
|
||||
Some(SidebarSection::Trash) => self.toggle_trashed_section(),
|
||||
Some(SidebarSection::Archived) => self.toggle_archived_section(),
|
||||
None => {}
|
||||
},
|
||||
}
|
||||
@@ -4044,8 +3930,6 @@ impl HomeView {
|
||||
Some(super::Item::Group { path, .. }) => {
|
||||
if crate::session::is_trash_section_path(path) {
|
||||
Some(SidebarSection::Trash)
|
||||
} else if crate::session::is_archived_section_path(path) {
|
||||
Some(SidebarSection::Archived)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
@@ -4496,20 +4380,7 @@ impl HomeView {
|
||||
self.cursor = abs_idx;
|
||||
self.update_selected();
|
||||
}
|
||||
// An archived row is parked: its pane was killed on archive.
|
||||
// A single click is a "let me look at this" gesture, so it
|
||||
// must NOT enter live-send, because `start_live_send` would
|
||||
// respawn the pane (ensure_pane_ready) and the live-send path
|
||||
// would auto-unarchive it (touch_last_accessed), silently
|
||||
// resurrecting a session the user deliberately parked. Stop at
|
||||
// the cursor update so the row just gets selected. Bringing it
|
||||
// back stays explicit: `z` to unarchive, or a deliberate
|
||||
// double-click / Enter to open it.
|
||||
let archived = self
|
||||
.get_instance(&id)
|
||||
.map(|inst| inst.is_archived())
|
||||
.unwrap_or(false);
|
||||
// Single-click behavior is otherwise user-configurable via
|
||||
// Single-click behavior is user-configurable via
|
||||
// `SessionConfig::click_action`. `LiveSend` (default,
|
||||
// historical behavior) enters live-send for the clicked
|
||||
// row, or switches the live target when already in live
|
||||
@@ -4522,12 +4393,10 @@ impl HomeView {
|
||||
// for structured view-mode sessions, where `start_live_send`
|
||||
// already short-circuits, so the historical fall-through
|
||||
// is fine.
|
||||
if archived
|
||||
|| matches!(
|
||||
self.click_action(&id),
|
||||
Some(crate::session::ClickAction::SelectOnly)
|
||||
)
|
||||
{
|
||||
if matches!(
|
||||
self.click_action(&id),
|
||||
Some(crate::session::ClickAction::SelectOnly)
|
||||
) {
|
||||
// The click only moves the cursor and, if we're live-sending,
|
||||
// leaves live mode. This holds whether the click lands on a
|
||||
// *different* row (keystrokes were aimed at the old session
|
||||
@@ -4536,8 +4405,7 @@ impl HomeView {
|
||||
// let me look at this" gesture, so clicking the active session
|
||||
// exits live mode rather than doing nothing. In `LiveSend`
|
||||
// mode the `start_live_send` branch below retargets instead;
|
||||
// this exit only runs for the select-only (and archived)
|
||||
// gesture.
|
||||
// this exit only runs for the select-only gesture.
|
||||
if let Some(state) = self.live_send.clone() {
|
||||
self.exit_live_send_and_restore_sizing(&state);
|
||||
}
|
||||
|
||||
+55
-213
@@ -23,7 +23,7 @@ use ratatui::prelude::Rect;
|
||||
use tui_input::Input;
|
||||
|
||||
use crate::session::{
|
||||
append_archived_section, append_archived_section_by_project, append_trash_section,
|
||||
append_trash_section,
|
||||
config::{load_config, update_app_state, update_config, GroupByMode, SortOrder},
|
||||
flatten_sessions_by_attention, flatten_tree, flatten_tree_all_profiles, resolve_config_or_warn,
|
||||
EnsureReadyOutcome, Group, GroupTree, Instance, Item, Storage,
|
||||
@@ -424,7 +424,6 @@ pub(super) const ICON_PINNED: &str = "◆";
|
||||
/// DESIGN.md (emoji would break column alignment and the shelf's mouse
|
||||
/// hit-testing on terminals that render them double-width or as tofu).
|
||||
pub(super) const ICON_TRASH_SECTION: &str = "⊘";
|
||||
pub(super) const ICON_ARCHIVED_SECTION: &str = "▤";
|
||||
|
||||
/// Hook progress for a session being created in the background
|
||||
pub(super) struct CreatingHookProgress {
|
||||
@@ -821,9 +820,9 @@ pub struct HomeView {
|
||||
/// the border row.
|
||||
pub(super) list_inner_area: Rect,
|
||||
/// Inner content rect of the pinned bottom "shelf" that holds the
|
||||
/// synthetic Trash / Archived sections, rendered below the scrolling list
|
||||
/// and its divider. Zeroed on frames with no shelf (nothing trashed or
|
||||
/// archived) or while the sidebar is collapsed, so a stale rect can't
|
||||
/// synthetic Trash section, rendered below the scrolling list and its
|
||||
/// divider. Zeroed on frames with no shelf (nothing trashed) or while
|
||||
/// the sidebar is collapsed, so a stale rect can't
|
||||
/// resolve a click to a shelf row that isn't drawn. Clicks inside it map
|
||||
/// to the `flat_items` shelf suffix; see `resolve_row_to_index`.
|
||||
pub(super) shelf_inner_area: Rect,
|
||||
@@ -872,15 +871,6 @@ pub struct HomeView {
|
||||
/// from "actually read." Reset on selection change and on leaving the list.
|
||||
pub(super) unread_dwell: Option<(String, std::time::Instant)>,
|
||||
|
||||
/// Session id the user just flagged unread by hand (`u`) and has not yet
|
||||
/// navigated away from. While this row stays selected, dwell-to-read won't
|
||||
/// undo the mark, so flagging a session and keeping the cursor on it
|
||||
/// sticks. It is released the moment the cursor leaves the row (see
|
||||
/// `tick_unread_dwell`), so returning to it later lets the dwell clear it
|
||||
/// like any other unread row. In-memory only: a manual flag is a
|
||||
/// this-visit hint, not a durable badge.
|
||||
pub(super) manual_unread_hold: Option<String>,
|
||||
|
||||
// Terminal mode for sandboxed sessions (per-session, ephemeral)
|
||||
// Default terminal mode from config
|
||||
pub(super) status_hook_config: crate::status_hooks::StatusHookConfig,
|
||||
@@ -984,12 +974,6 @@ pub struct HomeView {
|
||||
/// `app_state.show_preview_info`.
|
||||
pub(super) show_preview_info: bool,
|
||||
|
||||
/// Collapsed state of the synthetic "Archived" sidebar section.
|
||||
/// Defaults to `true` (collapsed) so archived rows stay tucked at the
|
||||
/// bottom until the user opts to see them. Persisted to
|
||||
/// `app_state.archived_section_collapsed`.
|
||||
pub(super) archived_section_collapsed: bool,
|
||||
|
||||
/// Collapsed state of the synthetic "Trash" sidebar section. Defaults to
|
||||
/// `true` (collapsed): trash is a recovery shelf, kept out of the way
|
||||
/// until the user opens it. In-memory only (not persisted): a fresh
|
||||
@@ -2205,7 +2189,6 @@ impl HomeView {
|
||||
last_click: None,
|
||||
last_preview_click: None,
|
||||
unread_dwell: None,
|
||||
manual_unread_hold: None,
|
||||
status_hook_config,
|
||||
status_hook_configs,
|
||||
strict_hotkeys,
|
||||
@@ -2232,10 +2215,6 @@ impl HomeView {
|
||||
.as_ref()
|
||||
.and_then(|c| c.app_state.show_preview_info)
|
||||
.unwrap_or(true),
|
||||
archived_section_collapsed: user_config
|
||||
.as_ref()
|
||||
.and_then(|c| c.app_state.archived_section_collapsed)
|
||||
.unwrap_or(true),
|
||||
trashed_section_collapsed: true,
|
||||
recovery_rx: None,
|
||||
recovery_lock: None,
|
||||
@@ -2821,28 +2800,13 @@ impl HomeView {
|
||||
/// (`recovery::is_recovery_candidate`), so in practice this is the restart
|
||||
/// guard, but both are checked so the two producers stay symmetrical.
|
||||
///
|
||||
/// Archived and trashed rows are also excluded. `/api/sessions` returns
|
||||
/// them unfiltered, and the `is_archived()` short-circuit that keeps the
|
||||
/// tmux producer off a sunk row lives in `update_status_with_metadata_inner`
|
||||
/// (`instance.rs`), which this daemon path never reaches; without this a
|
||||
/// sunk row would be restamped and re-marked unread. See #3201 / #1868 /
|
||||
/// #2206.
|
||||
///
|
||||
/// The cost of that exclusion: a sunk structured row that is already in
|
||||
/// `Status::Error` now has no producer able to clear it. The daemon is
|
||||
/// excluded here, the tmux poller bails on structured rows before probing
|
||||
/// (`status_poller.rs`), and `reload_storage_only` carries `prev.status`
|
||||
/// forward across reloads, so the stale value survives. It stays visible
|
||||
/// because `agent_row_icon` lets `Error` and `Deleting` punch through the
|
||||
/// sunk-row mask on purpose (a failed permanent delete has to remain
|
||||
/// legible). The tmux producer has the same property via its own
|
||||
/// `is_archived()` short-circuit, so this is consistent rather than new,
|
||||
/// but unarchiving is the only way back.
|
||||
/// Trashed rows are also excluded. `/api/sessions` returns them
|
||||
/// unfiltered; without this a sunk row would be restamped and re-marked
|
||||
/// unread. See #3201 / #2206.
|
||||
#[cfg(feature = "serve")]
|
||||
fn daemon_status_applies_to(&self, inst: &Instance) -> bool {
|
||||
!self.recovery_in_flight.contains(&inst.id)
|
||||
&& !self.restart_in_flight.contains(&inst.id)
|
||||
&& !inst.is_archived()
|
||||
&& !inst.is_trashed()
|
||||
}
|
||||
|
||||
@@ -4663,8 +4627,8 @@ impl HomeView {
|
||||
/// Folder paths that currently map to a real project-mode folder: the
|
||||
/// project group of every live session (across all profiles, so switching
|
||||
/// the active profile never prunes another profile's collapse state),
|
||||
/// registered empty projects, and the per-project archived sub-folders.
|
||||
/// Used to drop collapse entries for projects that no longer exist.
|
||||
/// and registered empty projects. Used to drop collapse entries for
|
||||
/// projects that no longer exist.
|
||||
fn known_project_group_paths(&self) -> std::collections::HashSet<String> {
|
||||
let mut paths = std::collections::HashSet::new();
|
||||
for inst in self.instances.values() {
|
||||
@@ -4672,11 +4636,7 @@ impl HomeView {
|
||||
if group.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if inst.is_archived() {
|
||||
paths.insert(crate::session::archived_project_sub_path(&group));
|
||||
} else {
|
||||
paths.insert(group);
|
||||
}
|
||||
paths.insert(group);
|
||||
}
|
||||
for project in &self.registered_projects {
|
||||
// Only pinned registered projects surface as empty folder headers,
|
||||
@@ -4708,9 +4668,9 @@ impl HomeView {
|
||||
}
|
||||
|
||||
/// Org-mode counterpart of `known_project_group_paths`: the org group of
|
||||
/// every live session (across all profiles) and the per-org archived
|
||||
/// sub-folders. No registered-projects loop: org headers have no
|
||||
/// registry to surface empty "pinned" headers from (#3283 out of scope).
|
||||
/// every live session (across all profiles). No registered-projects
|
||||
/// loop: org headers have no registry to surface empty "pinned" headers
|
||||
/// from (#3283 out of scope).
|
||||
fn known_org_group_paths(&self) -> std::collections::HashSet<String> {
|
||||
let mut paths = std::collections::HashSet::new();
|
||||
for inst in self.instances.values() {
|
||||
@@ -4718,11 +4678,7 @@ impl HomeView {
|
||||
if group.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if inst.is_archived() {
|
||||
paths.insert(crate::session::archived_project_sub_path(&group));
|
||||
} else {
|
||||
paths.insert(group);
|
||||
}
|
||||
paths.insert(group);
|
||||
}
|
||||
paths
|
||||
}
|
||||
@@ -4755,22 +4711,6 @@ impl HomeView {
|
||||
self.preview_pane_synced = None;
|
||||
}
|
||||
|
||||
/// Expand the synthetic Archived section if it is collapsed, persisting
|
||||
/// the change. Used when archiving a whole group, where the rows the
|
||||
/// user was looking at all sink at once and revealing the section shows
|
||||
/// where they went. Single-row archive does NOT reveal: the cursor
|
||||
/// advances to the next active session instead and the section header's
|
||||
/// count is the feedback. No-op (and no save) when already open.
|
||||
pub(super) fn reveal_archived_section(&mut self) {
|
||||
if !self.archived_section_collapsed {
|
||||
return;
|
||||
}
|
||||
self.archived_section_collapsed = false;
|
||||
Self::persist_app_state("archived section", |s| {
|
||||
s.archived_section_collapsed = Some(false)
|
||||
});
|
||||
}
|
||||
|
||||
pub fn toggle_trashed_section(&mut self) {
|
||||
self.trashed_section_collapsed = !self.trashed_section_collapsed;
|
||||
self.rebuild_flat_items();
|
||||
@@ -4780,26 +4720,6 @@ impl HomeView {
|
||||
self.update_selected();
|
||||
}
|
||||
|
||||
pub fn toggle_archived_section(&mut self) {
|
||||
self.archived_section_collapsed = !self.archived_section_collapsed;
|
||||
let collapsed = self.archived_section_collapsed;
|
||||
Self::persist_app_state("archived section", |s| {
|
||||
s.archived_section_collapsed = Some(collapsed)
|
||||
});
|
||||
self.rebuild_flat_items();
|
||||
// Defensive cursor clamp + selection refresh. Today the only
|
||||
// call site routes through `toggle_group_collapsed` after the
|
||||
// cursor lands on the section header, and the header survives
|
||||
// the rebuild at the same end-of-list index, so the cursor stays
|
||||
// valid. Programmatic callers (palette command, future macros)
|
||||
// wouldn't have that invariant, so clamp here rather than rely
|
||||
// on every caller to know about it.
|
||||
if !self.flat_items.is_empty() && self.cursor >= self.flat_items.len() {
|
||||
self.cursor = self.flat_items.len() - 1;
|
||||
}
|
||||
self.update_selected();
|
||||
}
|
||||
|
||||
pub fn show_no_agents(&mut self) {
|
||||
tracing::info!(target: "tui.dialog", dialog = "no_agents", "opening");
|
||||
self.no_agents_dialog = Some(NoAgentsDialog::new());
|
||||
@@ -4902,19 +4822,15 @@ impl HomeView {
|
||||
}
|
||||
|
||||
/// Index of the first `flat_items` row that belongs to the pinned bottom
|
||||
/// shelf (the synthetic Archived / Trash sections), or `None` when neither
|
||||
/// section is present. The shelf is always a contiguous suffix: both
|
||||
/// sections are appended last (Archived then Trash) by `build_flat_items`,
|
||||
/// and nothing non-shelf follows them, so the first row whose path sits
|
||||
/// within either section marks where the workspace list ends and the shelf
|
||||
/// begins. The renderer splits the sidebar here and hit-testing maps clicks
|
||||
/// in the shelf region back to this suffix.
|
||||
/// shelf (the synthetic Trash section), or `None` when it is not present.
|
||||
/// The shelf is always a contiguous suffix: the section is appended last
|
||||
/// by `build_flat_items` and nothing non-shelf follows it, so the first
|
||||
/// row whose path sits within it marks where the workspace list ends and
|
||||
/// the shelf begins. The renderer splits the sidebar here and hit-testing
|
||||
/// maps clicks in the shelf region back to this suffix.
|
||||
pub(super) fn shelf_start(&self) -> Option<usize> {
|
||||
self.flat_items.iter().position(|it| match it {
|
||||
Item::Group { path, .. } => {
|
||||
crate::session::is_within_archived_section(path)
|
||||
|| crate::session::is_within_trash_section(path)
|
||||
}
|
||||
Item::Group { path, .. } => crate::session::is_within_trash_section(path),
|
||||
Item::Session { .. } => false,
|
||||
})
|
||||
}
|
||||
@@ -4942,32 +4858,28 @@ impl HomeView {
|
||||
if self.sort_order == SortOrder::Attention {
|
||||
let filtered: Vec<Instance> = self.cloned_instances_in_active_view();
|
||||
let mut items = flatten_sessions_by_attention(&filtered);
|
||||
append_archived_section(&mut items, &filtered, self.archived_section_collapsed);
|
||||
append_trash_section(&mut items, &filtered, self.trashed_section_collapsed);
|
||||
return items;
|
||||
}
|
||||
|
||||
let archive_pool: Vec<Instance> = self.cloned_instances_in_active_view();
|
||||
let pool: Vec<Instance> = self.cloned_instances_in_active_view();
|
||||
let mut items = if let Some(profile) = &self.active_profile {
|
||||
match self.group_trees.get(profile) {
|
||||
Some(tree) => flatten_tree(tree, &archive_pool, self.sort_order),
|
||||
Some(tree) => flatten_tree(tree, &pool, self.sort_order),
|
||||
None => Vec::new(),
|
||||
}
|
||||
} else if self.storages.len() <= 1 {
|
||||
match self.group_trees.values().next() {
|
||||
Some(tree) => flatten_tree(tree, &archive_pool, self.sort_order),
|
||||
Some(tree) => flatten_tree(tree, &pool, self.sort_order),
|
||||
None => Vec::new(),
|
||||
}
|
||||
} else {
|
||||
flatten_tree_all_profiles(&archive_pool, &self.group_trees, self.sort_order)
|
||||
flatten_tree_all_profiles(&pool, &self.group_trees, self.sort_order)
|
||||
};
|
||||
|
||||
// Pin the synthetic Archived section to the bottom regardless of
|
||||
// sort order. Archived rows were filtered out of the natural flow
|
||||
// inside `flatten_tree` / `flatten_tree_all_profiles`.
|
||||
append_archived_section(&mut items, &archive_pool, self.archived_section_collapsed);
|
||||
// Trash sits below Archived, also pinned to the bottom.
|
||||
append_trash_section(&mut items, &archive_pool, self.trashed_section_collapsed);
|
||||
// Pin the synthetic Trash section to the bottom regardless of sort
|
||||
// order.
|
||||
append_trash_section(&mut items, &pool, self.trashed_section_collapsed);
|
||||
items
|
||||
}
|
||||
|
||||
@@ -4985,16 +4897,16 @@ impl HomeView {
|
||||
.collect();
|
||||
|
||||
// Project headers are derived purely from the live sessions, not a
|
||||
// persisted group list, so build the tree from non-archived members
|
||||
// only. An archived session already shows under the synthetic
|
||||
// Archived section (nested by project below); if it also seeded a
|
||||
// project node here, a project whose only remaining member is
|
||||
// archived would render an empty phantom header in the main flow.
|
||||
// That header is undeletable in project mode ("Project groups are
|
||||
// automatic"), leaving the user no way to clear it.
|
||||
// persisted group list, so build the tree from non-trashed members
|
||||
// only. A trashed session already shows under the synthetic Trash
|
||||
// section; if it also seeded a project node here, a project whose
|
||||
// only remaining member is trashed would render an empty phantom
|
||||
// header in the main flow. That header is undeletable in project
|
||||
// mode ("Project groups are automatic"), leaving the user no way to
|
||||
// clear it.
|
||||
let tree_seed: Vec<Instance> = grouped
|
||||
.iter()
|
||||
.filter(|i| !i.is_archived() && !i.is_trashed())
|
||||
.filter(|i| !i.is_trashed())
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
@@ -5002,7 +4914,7 @@ impl HomeView {
|
||||
// headers, so a project can persist in the view without any sessions,
|
||||
// matching the WebUI where an empty project is just a registry entry.
|
||||
// Seed them as empty groups; their headers render even with zero
|
||||
// members (the phantom-header guard above only excludes archived-only
|
||||
// members (the phantom-header guard above only excludes trashed-only
|
||||
// session groups, not deliberately pinned ones).
|
||||
let populated_labels: std::collections::HashSet<String> = tree_seed
|
||||
.iter()
|
||||
@@ -5020,7 +4932,7 @@ impl HomeView {
|
||||
// The synthetic scratch bucket keys on a sentinel path so a real repo
|
||||
// named `scratch` keeps its own identity (#3237); seed its display name
|
||||
// the way org seeds host-scoped keys, but only when a live scratch
|
||||
// session populates it (an archived-only scratch group would otherwise
|
||||
// session populates it (a trashed-only scratch group would otherwise
|
||||
// render an undeletable empty header, per the phantom-header note above).
|
||||
if populated_labels.contains(crate::session::SCRATCH_GROUP_PATH) {
|
||||
seed_groups.push(crate::session::Group::new(
|
||||
@@ -5035,15 +4947,8 @@ impl HomeView {
|
||||
}
|
||||
}
|
||||
let mut items = flatten_tree(&tree, &grouped, self.sort_order);
|
||||
append_archived_section_by_project(
|
||||
&mut items,
|
||||
&grouped,
|
||||
self.archived_section_collapsed,
|
||||
&self.project_group_collapsed,
|
||||
self.sort_order,
|
||||
);
|
||||
// Trash is a flat shelf even in project mode (recovery list, not a
|
||||
// workspace), pinned below the Archived section.
|
||||
// workspace), pinned to the bottom.
|
||||
append_trash_section(&mut items, &grouped, self.trashed_section_collapsed);
|
||||
items
|
||||
}
|
||||
@@ -5091,7 +4996,7 @@ impl HomeView {
|
||||
/// used for `group_path`, collapse-state matching, and group-membership
|
||||
/// filters. Host-scoped so same-named owners on different hosts (GitHub
|
||||
/// "acme" vs GitLab "acme") never merge into one bucket or one
|
||||
/// bulk-archive scope.
|
||||
/// bulk scope.
|
||||
fn org_group_key(&self, inst: &Instance) -> String {
|
||||
self.resolve_org(inst).1
|
||||
}
|
||||
@@ -5114,12 +5019,12 @@ impl HomeView {
|
||||
.collect();
|
||||
|
||||
// Same phantom-header rationale as project mode: seed the tree from
|
||||
// non-archived, non-trashed members only, so an org whose only
|
||||
// remaining member is archived doesn't render an empty, undeletable
|
||||
// header in the main flow.
|
||||
// non-trashed members only, so an org whose only remaining member is
|
||||
// trashed doesn't render an empty, undeletable header in the main
|
||||
// flow.
|
||||
let tree_seed: Vec<Instance> = grouped
|
||||
.iter()
|
||||
.filter(|i| !i.is_archived() && !i.is_trashed())
|
||||
.filter(|i| !i.is_trashed())
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
@@ -5143,15 +5048,8 @@ impl HomeView {
|
||||
}
|
||||
}
|
||||
let mut items = flatten_tree(&tree, &grouped, self.sort_order);
|
||||
append_archived_section_by_project(
|
||||
&mut items,
|
||||
&grouped,
|
||||
self.archived_section_collapsed,
|
||||
&self.org_group_collapsed,
|
||||
self.sort_order,
|
||||
);
|
||||
// Trash is a flat shelf even in org mode (recovery list, not a
|
||||
// workspace), pinned below the Archived section.
|
||||
// workspace), pinned to the bottom.
|
||||
append_trash_section(&mut items, &grouped, self.trashed_section_collapsed);
|
||||
items
|
||||
}
|
||||
@@ -5303,33 +5201,10 @@ impl HomeView {
|
||||
}
|
||||
|
||||
/// Stamp `last_accessed_at` on a session (user-initiated interaction).
|
||||
///
|
||||
/// Archived rows take the heavier `apply_user_action` path so the
|
||||
/// auto-unarchive side effect in `touch_last_accessed` is persisted
|
||||
/// (merge_from_tui doesn't carry that field; without this, reload would
|
||||
/// resurrect the sink from disk) and the row leaves the Archived section
|
||||
/// visually on the same frame. Non-archived rows stay on the cheap
|
||||
/// mutate_instance path; their only mutation is the timestamp, which
|
||||
/// save() already mirrors via merge_from_tui.
|
||||
/// The only mutation is the timestamp, which `save()` already mirrors
|
||||
/// via `merge_from_tui`, so the cheap `mutate_instance` path suffices.
|
||||
pub fn stamp_last_accessed(&mut self, id: &str) {
|
||||
let was_sunk = self
|
||||
.instances
|
||||
.get(id)
|
||||
.map(|i| i.is_archived())
|
||||
.unwrap_or(false);
|
||||
if was_sunk {
|
||||
if let Err(e) = self.apply_user_action(id, |inst| inst.touch_last_accessed()) {
|
||||
tracing::warn!(
|
||||
target: "tui.home",
|
||||
session_id = %id,
|
||||
error = %e,
|
||||
"stamp_last_accessed: failed to persist auto-unsink"
|
||||
);
|
||||
}
|
||||
self.rebuild_flat_items();
|
||||
} else {
|
||||
self.mutate_instance(id, |inst| inst.touch_last_accessed());
|
||||
}
|
||||
self.mutate_instance(id, |inst| inst.touch_last_accessed());
|
||||
}
|
||||
|
||||
/// Run the send-message work after the dialog has been dismissed: call
|
||||
@@ -5890,7 +5765,6 @@ impl HomeView {
|
||||
if let Some(disk_g) = disk_groups.iter_mut().find(|g| g.path == tui_g.path) {
|
||||
disk_g.name = tui_g.name.clone();
|
||||
disk_g.collapsed = tui_g.collapsed;
|
||||
disk_g.archived_at = tui_g.archived_at;
|
||||
} else {
|
||||
disk_groups.push(tui_g.clone());
|
||||
}
|
||||
@@ -6299,11 +6173,6 @@ impl HomeView {
|
||||
/// actually unread, so an already-read session doesn't churn the storage
|
||||
/// flock.
|
||||
pub(crate) fn clear_unread_on_view(&mut self, id: &str) {
|
||||
// Engaging with the row ends its manual-flag visit, so drop any hold;
|
||||
// otherwise a stale hold could later suppress an auto mark on this row.
|
||||
if self.manual_unread_hold.as_deref() == Some(id) {
|
||||
self.manual_unread_hold = None;
|
||||
}
|
||||
let is_unread = self.get_instance(id).is_some_and(|i| i.is_unread());
|
||||
if is_unread {
|
||||
let _ = self.apply_user_action(id, |i| i.mark_read());
|
||||
@@ -6319,10 +6188,7 @@ impl HomeView {
|
||||
/// The clock is suspended (and reset) whenever the feature is off, a
|
||||
/// dialog or live-send is up (the list isn't being read then), or nothing
|
||||
/// is selected, and it restarts whenever the selection moves to a
|
||||
/// different row. A row the user just flagged unread by hand is held until
|
||||
/// the cursor leaves it (`manual_unread_hold`), so flagging it and sitting
|
||||
/// there doesn't instantly undo the mark; once you leave and come back, it
|
||||
/// clears on dwell like any other unread row.
|
||||
/// different row.
|
||||
pub fn tick_unread_dwell(&mut self, now: std::time::Instant) -> bool {
|
||||
if !crate::session::unread_enabled() || self.has_dialog() {
|
||||
self.unread_dwell = None;
|
||||
@@ -6332,16 +6198,6 @@ impl HomeView {
|
||||
self.unread_dwell = None;
|
||||
return false;
|
||||
};
|
||||
// The manual hold only protects the row while it stays selected; the
|
||||
// moment the cursor moves elsewhere, release it so a later return reads
|
||||
// normally.
|
||||
if self
|
||||
.manual_unread_hold
|
||||
.as_deref()
|
||||
.is_some_and(|held| held != id)
|
||||
{
|
||||
self.manual_unread_hold = None;
|
||||
}
|
||||
let started = match &self.unread_dwell {
|
||||
Some((prev, started)) if *prev == id => *started,
|
||||
// First tick on this row (or selection moved): start the clock.
|
||||
@@ -6353,12 +6209,6 @@ impl HomeView {
|
||||
if now.duration_since(started) < UNREAD_DWELL {
|
||||
return false;
|
||||
}
|
||||
// A row the user just flagged by hand is held for this visit, so the
|
||||
// dwell doesn't undo the mark while they sit on it. The clock stays
|
||||
// parked on this row either way so we don't re-evaluate every tick.
|
||||
if self.manual_unread_hold.as_deref() == Some(id.as_str()) {
|
||||
return false;
|
||||
}
|
||||
if self.get_instance(&id).is_some_and(|i| i.is_unread()) {
|
||||
self.clear_unread_on_view(&id);
|
||||
return true;
|
||||
@@ -6604,19 +6454,11 @@ impl HomeView {
|
||||
self.registered_projects = merged;
|
||||
}
|
||||
|
||||
/// The canonical repo path of the first live (non-archived) session under
|
||||
/// project header `label`, or `None` when no live session populates the
|
||||
/// header (an empty pinned header). This is the header's stable repo
|
||||
/// identity, so two repos that merely share a basename are judged against
|
||||
/// their own paths rather than the shared display label.
|
||||
///
|
||||
/// Archived sessions are excluded on purpose: an empty main-flow header is
|
||||
/// injected by LABEL match against the registry, so its pin state and
|
||||
/// unpin toggle must resolve by the same rule. Letting an archived row
|
||||
/// lend the header its path made a registry entry with a different
|
||||
/// recorded path (repo deleted or moved, so `canonical_key` compares raw
|
||||
/// strings) render an unpinnable phantom header: pinned by label, judged
|
||||
/// by path.
|
||||
/// The canonical repo path of the first session under project header
|
||||
/// `label`, or `None` when no session populates the header (an empty
|
||||
/// pinned header). This is the header's stable repo identity, so two
|
||||
/// repos that merely share a basename are judged against their own paths
|
||||
/// rather than the shared display label.
|
||||
///
|
||||
/// A scratch session never matches here: its `project_group_key` is the
|
||||
/// `SCRATCH_GROUP_PATH` sentinel, which no display label equals, so the
|
||||
@@ -6624,7 +6466,7 @@ impl HomeView {
|
||||
pub(super) fn project_header_repo_path(&self, label: &str) -> Option<String> {
|
||||
self.instances
|
||||
.values()
|
||||
.find(|i| !i.is_archived() && project_group_key(i) == label)
|
||||
.find(|i| project_group_key(i) == label)
|
||||
.map(|i| crate::session::projects::canonical_key(i.repo_path()))
|
||||
}
|
||||
|
||||
|
||||
+8
-291
@@ -3,7 +3,7 @@
|
||||
use crate::session::builder::{self, InstanceParams};
|
||||
use crate::session::{
|
||||
acquire_session_identity_lock, duplicate_session_error, is_duplicate_session, list_profiles,
|
||||
GroupTree, Instance, Item, LifecycleOperation, Status, Storage,
|
||||
GroupTree, Instance, LifecycleOperation, Status, Storage,
|
||||
};
|
||||
use crate::tui::deletion_poller::DeletionRequest;
|
||||
use crate::tui::dialogs::{DeleteOptions, GroupDeleteOptions, InfoDialog, NewSessionData};
|
||||
@@ -250,11 +250,9 @@ impl HomeView {
|
||||
/// Guards (apply to bare `e` / `E` / `F5` and dialog-submitted restarts):
|
||||
/// - No selection: no-op.
|
||||
/// - Transient lifecycle (`Creating` / `Deleting`): drop.
|
||||
/// - Sunk rows: archived and trashed rows refuse with an info dialog
|
||||
/// pointing at the restore key (archive's contract is "do not
|
||||
/// auto-revive", but a silent drop read as a swallowed failure);
|
||||
/// pane-dead rows still drop silently (they have a dedicated revive
|
||||
/// path).
|
||||
/// - Sunk rows: trashed rows refuse with an info dialog pointing at the
|
||||
/// restore key (a silent drop read as a swallowed failure); pane-dead
|
||||
/// rows still drop silently (they have a dedicated revive path).
|
||||
/// - Spam-debounce: if the same session was restarted within the last
|
||||
/// 1.5s, the press is dropped. Without this guard rapid `e` presses
|
||||
/// would each spawn a wake-up worker AND tear down the still-booting
|
||||
@@ -301,20 +299,18 @@ impl HomeView {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// A trashed/archived row's refusal must be visible: its agent was
|
||||
// A trashed row's refusal must be visible: its agent was
|
||||
// deliberately stopped, so a silent no-op here read as a swallowed
|
||||
// failure (the row just sits there). Point at the restore key instead.
|
||||
let shelved = self.get_instance(&id).and_then(|inst| {
|
||||
// A row mid-purge gets no restore/unarchive hint: same rationale
|
||||
// as `render_shelf_deleting_preview`, which drops those hints so
|
||||
// A row mid-purge gets no restore hint: same rationale as
|
||||
// `render_shelf_deleting_preview`, which drops those hints so
|
||||
// they don't race the in-flight delete. Falls through to the
|
||||
// transient skip below, which drops Deleting silently.
|
||||
if inst.status == Status::Deleting {
|
||||
None
|
||||
} else if inst.is_trashed() {
|
||||
Some(("Session in trash", "in the trash", "restore"))
|
||||
} else if inst.is_archived() {
|
||||
Some(("Session archived", "archived", "unarchive"))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
@@ -1167,10 +1163,6 @@ impl HomeView {
|
||||
/// Attention sort, and render with bold + underline plus a leading
|
||||
/// `* ` glyph (see `render.rs`).
|
||||
///
|
||||
/// Favorite is orthogonal to archive: archiving clears it because
|
||||
/// archive is the strongest dismiss signal and a stale star on a buried
|
||||
/// row is just visual noise.
|
||||
/// Mutual exclusion lives in `Instance::archive()`, not here.
|
||||
pub(super) fn toggle_favorite_at_cursor(&mut self) -> anyhow::Result<()> {
|
||||
let Some(id) = self.selected_session.clone() else {
|
||||
return Ok(());
|
||||
@@ -1188,162 +1180,9 @@ impl HomeView {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The session the cursor should land on after the cursor's row is
|
||||
/// archived away: the nearest non-archived session below the cursor,
|
||||
/// else the nearest one above. `None` when no other active session is
|
||||
/// VISIBLE (the caller falls back to an index clamp); active sessions
|
||||
/// hidden inside collapsed groups are deliberately not candidates, so
|
||||
/// archiving never yanks the cursor into a group the user folded away.
|
||||
/// Scans the pre-archive flat list, so it walks the rows the
|
||||
/// user sees; archived rows already parked under the Archived section
|
||||
/// are skipped so the cursor never advances into it.
|
||||
fn archive_successor_session(&self, archiving_id: &str) -> Option<String> {
|
||||
let candidate = |item: &Item| -> Option<String> {
|
||||
let Item::Session { id, .. } = item else {
|
||||
return None;
|
||||
};
|
||||
if id == archiving_id {
|
||||
return None;
|
||||
}
|
||||
let inst = self.instances.get(id)?;
|
||||
(!inst.is_archived() && !inst.is_trashed()).then(|| id.clone())
|
||||
};
|
||||
for item in self.flat_items.iter().skip(self.cursor + 1) {
|
||||
if let Some(id) = candidate(item) {
|
||||
return Some(id);
|
||||
}
|
||||
}
|
||||
for item in self.flat_items.iter().take(self.cursor).rev() {
|
||||
if let Some(id) = candidate(item) {
|
||||
return Some(id);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Manual unread toggle (`U`). Symmetric: a read row becomes unread (put
|
||||
/// it back in the attention queue), an unread row becomes read. The row's
|
||||
/// `theme.unread` color is the feedback, so there is no toast. No-op when
|
||||
/// the feature is disabled.
|
||||
pub(super) fn toggle_unread_at_cursor(&mut self) -> anyhow::Result<()> {
|
||||
if !crate::session::unread_enabled() {
|
||||
return Ok(());
|
||||
}
|
||||
let Some(id) = self.selected_session.clone() else {
|
||||
return Ok(());
|
||||
};
|
||||
if !self.instances.contains_key(&id) {
|
||||
return Ok(());
|
||||
}
|
||||
self.apply_user_action(&id, |inst| inst.toggle_unread())?;
|
||||
// Hold this row for the current visit so the dwell doesn't undo a fresh
|
||||
// `u` while the cursor stays on it; the hold is released once the cursor
|
||||
// leaves (see `tick_unread_dwell`). Toggling back to read drops it.
|
||||
if self.get_instance(&id).is_some_and(|i| i.is_unread()) {
|
||||
self.manual_unread_hold = Some(id.clone());
|
||||
} else if self.manual_unread_hold.as_deref() == Some(id.as_str()) {
|
||||
self.manual_unread_hold = None;
|
||||
}
|
||||
self.rebuild_flat_items();
|
||||
// In Attention sort, toggling unread changes the row's rank, so the
|
||||
// rebuild can move it; reseat the cursor by id so the next action
|
||||
// still targets this session.
|
||||
self.select_session_by_id(&id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Toggle the cursor's session: archive or unarchive. Archive tears down
|
||||
/// all tmux sessions (agent + ancillary); worktree, branch, container
|
||||
/// preserved. Unarchive does NOT respawn; press `e` to restart, or send
|
||||
/// a message to auto-unarchive. See #1868.
|
||||
pub(super) fn toggle_archive_at_cursor(&mut self) -> anyhow::Result<()> {
|
||||
let Some(id) = self.selected_session.clone() else {
|
||||
return Ok(());
|
||||
};
|
||||
// The shelve/unshelve key doubles as restore for the Trash section: a
|
||||
// trashed row can't be meaningfully archived, so `z` on it pulls the
|
||||
// session back out of the trash instead. See #2489.
|
||||
if matches!(self.instances.get(&id), Some(i) if i.is_trashed()) {
|
||||
self.restore_selected_from_trash();
|
||||
return Ok(());
|
||||
}
|
||||
let is_archived = match self.instances.get(&id) {
|
||||
Some(i) => i.is_archived(),
|
||||
None => return Ok(()),
|
||||
};
|
||||
if is_archived {
|
||||
self.apply_user_action(&id, |inst| inst.unarchive())?;
|
||||
self.rebuild_flat_items();
|
||||
// Re-seat the cursor on the just-unarchived session. After the
|
||||
// flat_items rebuild the row jumps from tier 99 to its real
|
||||
// tier, so without this the cursor stays at the old index and
|
||||
// ends up on whatever row slid into that slot. The session stays
|
||||
// Stopped (archive killed its panes); the user restarts it with
|
||||
// `e` when they want it back, same as any other stopped session.
|
||||
self.select_session_by_id(&id);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Tear down all tmux before flipping archived. #1868.
|
||||
if let Some(inst) = self.instances.get(&id) {
|
||||
inst.kill_all_tmux_sessions();
|
||||
}
|
||||
|
||||
// Decide where the cursor lands BEFORE the row sinks, against the
|
||||
// pre-archive list the user is actually looking at. Only the
|
||||
// non-Attention branch consumes it; Attention re-picks from the top.
|
||||
let successor = (self.sort_order != crate::session::config::SortOrder::Attention)
|
||||
.then(|| self.archive_successor_session(&id))
|
||||
.flatten();
|
||||
|
||||
self.apply_user_action(&id, |inst| inst.archive())?;
|
||||
if self.sort_order == crate::session::config::SortOrder::Attention {
|
||||
// Attention sort is a triage flow: archiving sinks the row and the
|
||||
// cursor advances to the next item that needs attention. That path
|
||||
// already lands selection on a live row, so it never showed the
|
||||
// dead-pane/selection-swap jank the default sort did.
|
||||
self.rebuild_flat_items();
|
||||
self.select_top_attention(None);
|
||||
// select_top_attention is a no-op when no session row is visible
|
||||
// (the archived row sank into a collapsed Archived section and
|
||||
// nothing else is left), which would strand `selected_session`
|
||||
// on the now-invisible archived row and leave the cursor index
|
||||
// past the shrunken list. Clamp and re-resolve, mirroring the
|
||||
// non-Attention fallback below.
|
||||
if self.selected_session.as_deref() == Some(id.as_str()) {
|
||||
self.cursor = self.cursor.min(self.flat_items.len().saturating_sub(1));
|
||||
self.update_selected();
|
||||
}
|
||||
} else {
|
||||
// Advance to the next session instead of following the archived
|
||||
// row into the Archived section: archiving reads as "I'm done
|
||||
// with this one", so the cursor stays up in the active list and
|
||||
// moves on. The preview retargets on its own: `render_preview`
|
||||
// re-derives the capture target from `selected_session` every
|
||||
// frame, the cache gates on a session-id mismatch, and the
|
||||
// capture worker drops stale frames on retarget, so the pane
|
||||
// tracks the new selection without the dead-pane flash that
|
||||
// motivated the old follow-the-row behavior (#2025). The
|
||||
// Archived section is not auto-revealed; its header already
|
||||
// shows the updated count as feedback.
|
||||
self.rebuild_flat_items();
|
||||
match successor {
|
||||
Some(next) => self.select_session_by_id(&next),
|
||||
None => {
|
||||
// No other active session: clamp and let
|
||||
// `update_selected` resolve whatever sits at the cursor
|
||||
// now (typically the Archived section header).
|
||||
self.cursor = self.cursor.min(self.flat_items.len().saturating_sub(1));
|
||||
self.update_selected();
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Move a session to the trash and set `trashed_at`. Durable artifacts are
|
||||
/// kept so it can be restored. The Trash section's collapse state is left
|
||||
/// untouched: like single-row archive, the section header's count is the
|
||||
/// untouched: the section header's count is the
|
||||
/// feedback, so a user who collapsed it stays collapsed (#2489).
|
||||
///
|
||||
/// The durable trash marker is written inline (a fast local write) so the
|
||||
@@ -1518,30 +1357,6 @@ impl HomeView {
|
||||
}
|
||||
}
|
||||
|
||||
/// Unarchive every archived session. The synthetic Archived section's
|
||||
/// "Restore All" bulk action. Archived rows stay Stopped (archiving killed
|
||||
/// their panes); the user restarts them with `e` when wanted, same as any
|
||||
/// single unarchive. Reversible, so no confirmation upstream.
|
||||
pub(super) fn unarchive_all(&mut self) {
|
||||
let ids: Vec<String> = self
|
||||
.instances
|
||||
.values()
|
||||
.filter(|i| i.is_archived() && !i.is_trashed())
|
||||
.map(|i| i.id.clone())
|
||||
.collect();
|
||||
if ids.is_empty() {
|
||||
return;
|
||||
}
|
||||
if let Err(e) = self.bulk_apply_user_action(&ids, |inst| inst.unarchive()) {
|
||||
tracing::error!(target: "tui.home", "unarchive_all failed: {e}");
|
||||
}
|
||||
self.rebuild_flat_items();
|
||||
if !self.flat_items.is_empty() && self.cursor >= self.flat_items.len() {
|
||||
self.cursor = self.flat_items.len() - 1;
|
||||
}
|
||||
self.update_selected();
|
||||
}
|
||||
|
||||
/// Permanently purge every trashed session. The Trash section's "Empty
|
||||
/// Trash" bulk action, reached only after the confirm dialog. Each row runs
|
||||
/// the same off-thread deletion path as a single permanent delete:
|
||||
@@ -1597,104 +1412,6 @@ impl HomeView {
|
||||
}
|
||||
self.update_selected();
|
||||
}
|
||||
|
||||
/// Collect the active (non-archived) session ids under the currently
|
||||
/// selected group header, honoring the active group-by mode. Archived
|
||||
/// sessions are excluded: they already live under the synthetic Archived
|
||||
/// section, and re-archiving them is a no-op. Returns empty when no group
|
||||
/// is selected.
|
||||
pub(super) fn active_sessions_in_selected_group(&self) -> Vec<String> {
|
||||
let Some(group_path) = self.selected_group.as_deref() else {
|
||||
return Vec::new();
|
||||
};
|
||||
match self.group_by {
|
||||
// Project headers are derived from each session's repo name and
|
||||
// unified across profiles, narrowed only by the active profile
|
||||
// filter, exactly as `build_flat_items_by_project` builds them.
|
||||
crate::session::config::GroupByMode::Project => self
|
||||
.instances
|
||||
.values()
|
||||
.filter(|i| !i.is_archived() && !i.is_trashed())
|
||||
.filter(|i| {
|
||||
self.active_profile
|
||||
.as_ref()
|
||||
.is_none_or(|p| &i.source_profile == p)
|
||||
})
|
||||
.filter(|i| super::project_group_key(i) == group_path)
|
||||
.map(|i| i.id.clone())
|
||||
.collect(),
|
||||
// Org headers are derived from each session's resolved remote
|
||||
// owner key (host-scoped, not just the bare owner, so same-named
|
||||
// owners on different hosts stay separate), same
|
||||
// unification-across-profiles rationale as Project.
|
||||
crate::session::config::GroupByMode::Org => self
|
||||
.instances
|
||||
.values()
|
||||
.filter(|i| !i.is_archived() && !i.is_trashed())
|
||||
.filter(|i| {
|
||||
self.active_profile
|
||||
.as_ref()
|
||||
.is_none_or(|p| &i.source_profile == p)
|
||||
})
|
||||
.filter(|i| self.org_group_key(i) == group_path)
|
||||
.map(|i| i.id.clone())
|
||||
.collect(),
|
||||
// Manual groups can nest, so a session belongs when its path
|
||||
// matches exactly or sits beneath the group. Scope to the group's
|
||||
// owning profile the same way `delete_selected_group` does.
|
||||
crate::session::config::GroupByMode::Manual => {
|
||||
let prefix = format!("{}/", group_path);
|
||||
let is_member =
|
||||
group_membership(group_path, &prefix, self.selected_group_profile.as_deref());
|
||||
self.instances
|
||||
.values()
|
||||
.filter(|i| !i.is_archived() && !i.is_trashed())
|
||||
.filter(|i| is_member(i))
|
||||
.map(|i| i.id.clone())
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Archive every active session under the selected group: tmux teardown
|
||||
/// runs off-thread, persist runs inline. Confirmation upstream. See #1868.
|
||||
pub(super) fn archive_selected_group(&mut self) -> anyhow::Result<()> {
|
||||
let ids = self.active_sessions_in_selected_group();
|
||||
if ids.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
// Off-thread tmux teardown so N x 4 shellouts don't block the input
|
||||
// thread. Mirrors `force_remove_session`.
|
||||
let kill_targets: Vec<_> = ids
|
||||
.iter()
|
||||
.filter_map(|id| self.instances.get(id).cloned())
|
||||
.collect();
|
||||
std::thread::spawn(move || {
|
||||
for inst in kill_targets {
|
||||
if let Err(panic) = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
inst.kill_all_tmux_sessions()
|
||||
})) {
|
||||
tracing::error!(
|
||||
target: "session.tmux_cleanup",
|
||||
session_id = %inst.id,
|
||||
"archive_selected_group tmux teardown panicked: {:?}",
|
||||
panic
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
self.bulk_apply_user_action(&ids, |inst| inst.archive())?;
|
||||
self.reveal_archived_section();
|
||||
self.rebuild_flat_items();
|
||||
// The project header vanishes once its last active member is archived
|
||||
// (project headers are seeded from live sessions only), so the cursor's
|
||||
// old index may now point past the list end; clamp and re-resolve.
|
||||
if !self.flat_items.is_empty() && self.cursor >= self.flat_items.len() {
|
||||
self.cursor = self.flat_items.len() - 1;
|
||||
}
|
||||
self.update_selected();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Outcome of a TUI restore-from-trash driven directly against storage. See #2541.
|
||||
|
||||
+39
-129
@@ -9,9 +9,9 @@ use std::time::{Duration, Instant};
|
||||
use rattles::presets::prelude as spinners;
|
||||
|
||||
use super::{
|
||||
get_indent, live_send, HomeView, ViewMode, ICON_ARCHIVED_SECTION, ICON_COLLAPSED,
|
||||
ICON_DELETING, ICON_DORMANT, ICON_ERROR, ICON_EXPANDED, ICON_IDLE, ICON_PINNED, ICON_STOPPED,
|
||||
ICON_TRASH_SECTION, ICON_UNKNOWN, ICON_UNREAD,
|
||||
get_indent, live_send, HomeView, ViewMode, ICON_COLLAPSED, ICON_DELETING, ICON_DORMANT,
|
||||
ICON_ERROR, ICON_EXPANDED, ICON_IDLE, ICON_PINNED, ICON_STOPPED, ICON_TRASH_SECTION,
|
||||
ICON_UNKNOWN, ICON_UNREAD,
|
||||
};
|
||||
use crate::session::config::{GroupByMode, RowTagMode, SortOrder};
|
||||
use crate::session::{Item, Status};
|
||||
@@ -357,14 +357,14 @@ fn spinner_idle_fresh(
|
||||
}
|
||||
|
||||
/// Pick the structured view row icon for a session instance. Centralizes the
|
||||
/// archive override that kills the live spinner for sunk rows so the
|
||||
/// trash override that kills the live spinner for sunk rows so the
|
||||
/// list reads as parked instead of "still alive." Exposed at crate visibility
|
||||
/// so tests can pin the override behavior without going through the full
|
||||
/// render pipeline.
|
||||
pub(crate) fn agent_row_icon(inst: &crate::session::Instance) -> &'static str {
|
||||
// A dormant (idle-reaped, resumable) structured worker gets its own glyph,
|
||||
// taking precedence over the raw status but still yielding to the
|
||||
// archived/trashed sink override below. See #2250.
|
||||
// trashed sink override below. See #2250.
|
||||
let icon = if inst.is_shown_dormant() {
|
||||
ICON_DORMANT
|
||||
} else {
|
||||
@@ -387,7 +387,7 @@ pub(crate) fn agent_row_icon(inst: &crate::session::Instance) -> &'static str {
|
||||
if matches!(inst.status, Status::Error | Status::Deleting) {
|
||||
return icon;
|
||||
}
|
||||
if inst.is_archived() || inst.is_trashed() {
|
||||
if inst.is_trashed() {
|
||||
ICON_STOPPED
|
||||
} else {
|
||||
icon
|
||||
@@ -396,7 +396,7 @@ pub(crate) fn agent_row_icon(inst: &crate::session::Instance) -> &'static str {
|
||||
|
||||
/// Append the selected row's `last_error` (in red) to a shelf placeholder's
|
||||
/// lines when the row sits in `Status::Error`. A failed permanent delete
|
||||
/// parks a trashed/archived row exactly here (`apply_deletion_results`);
|
||||
/// parks a trashed row exactly here (`apply_deletion_results`);
|
||||
/// without this the calm placeholder swallowed the failure entirely.
|
||||
fn push_shelf_error_lines(
|
||||
lines: &mut Vec<Line<'static>>,
|
||||
@@ -1306,7 +1306,6 @@ impl HomeView {
|
||||
name,
|
||||
collapsed,
|
||||
session_count,
|
||||
archived_at,
|
||||
..
|
||||
} => {
|
||||
let icon = if *collapsed {
|
||||
@@ -1321,16 +1320,10 @@ impl HomeView {
|
||||
let pinned = self.group_by == GroupByMode::Project
|
||||
&& !crate::session::is_synthetic_project_header(path)
|
||||
&& self.is_project_label_pinned(name);
|
||||
// The top-level shelf section headers get a leading type glyph
|
||||
// so they read as system shelves, not user groups. Project
|
||||
// sub-folders nested under Archived keep the plain label.
|
||||
let section_glyph = if crate::session::is_trash_section_path(path) {
|
||||
Some(ICON_TRASH_SECTION)
|
||||
} else if crate::session::is_archived_section_path(path) {
|
||||
Some(ICON_ARCHIVED_SECTION)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// The top-level shelf section header gets a leading type
|
||||
// glyph so it reads as a system shelf, not a user group.
|
||||
let section_glyph =
|
||||
crate::session::is_trash_section_path(path).then_some(ICON_TRASH_SECTION);
|
||||
let text = if let Some(glyph) = section_glyph {
|
||||
Cow::Owned(format!("{} {} ({})", glyph, name, session_count))
|
||||
} else if pinned {
|
||||
@@ -1338,22 +1331,12 @@ impl HomeView {
|
||||
} else {
|
||||
Cow::Owned(format!("{} ({})", name, session_count))
|
||||
};
|
||||
let mut style = Style::default().fg(theme.group).bold();
|
||||
// Both the top-level Trash / Archived shelf headers and any
|
||||
// project sub-folders nested under them (Project mode) now live
|
||||
// below the sort divider in the pinned bottom shelf, so their
|
||||
// physical placement already reads as "shelved". They no longer
|
||||
// need the muted divider treatment to separate them from active
|
||||
// groups; render them like regular folder headers (theme.group
|
||||
// + bold) so they read as real, clickable folders. The leading
|
||||
// section glyph still marks the top-level shelves as system
|
||||
// shelves.
|
||||
if archived_at.is_some() {
|
||||
// Archived user groups: italic + dim, still visible.
|
||||
style = style
|
||||
.add_modifier(ratatui::style::Modifier::ITALIC)
|
||||
.add_modifier(ratatui::style::Modifier::DIM);
|
||||
}
|
||||
// The Trash shelf header lives below the sort divider in the
|
||||
// pinned bottom shelf, so its physical placement already reads
|
||||
// as "shelved"; render it like a regular folder header
|
||||
// (theme.group + bold). The leading section glyph marks it as
|
||||
// a system shelf.
|
||||
let style = Style::default().fg(theme.group).bold();
|
||||
(icon, text, style)
|
||||
}
|
||||
Item::Session { id, .. } => {
|
||||
@@ -1408,13 +1391,8 @@ impl HomeView {
|
||||
// on Idle; a manual flag on a live row defers to
|
||||
// the live state. Archived/urgent below still
|
||||
// override on top.
|
||||
// Archived rows never paint unread: the user
|
||||
// dismissed the row, so surfacing it as unread
|
||||
// contradicts that. The flag stays on disk, so
|
||||
// unarchiving restores it.
|
||||
let unread_resting = crate::session::unread_enabled()
|
||||
&& inst.is_unread()
|
||||
&& !inst.is_archived()
|
||||
&& matches!(inst.status, Status::Idle | Status::Unknown);
|
||||
let color = if is_shown_dormant && !unread_resting {
|
||||
theme.dormant()
|
||||
@@ -1445,7 +1423,7 @@ impl HomeView {
|
||||
icon = ICON_UNREAD;
|
||||
style = style.add_modifier(ratatui::style::Modifier::BOLD);
|
||||
}
|
||||
if (inst.is_archived() || inst.is_trashed())
|
||||
if inst.is_trashed()
|
||||
&& !matches!(inst.status, Status::Error | Status::Deleting)
|
||||
{
|
||||
// Archived and trashed rows render with one
|
||||
@@ -1476,14 +1454,14 @@ impl HomeView {
|
||||
.add_modifier(ratatui::style::Modifier::BOLD)
|
||||
.add_modifier(ratatui::style::Modifier::UNDERLINED);
|
||||
}
|
||||
// Prefix priority: archive (no prefix) wins
|
||||
// Prefix priority: trash (no prefix) wins
|
||||
// over urgent (`! `) wins over favorite (`* `).
|
||||
// Urgent is Attention-mode-only so users in
|
||||
// Newest / AZ / etc. don't see decoration for
|
||||
// state they didn't opt into managing; the
|
||||
// favorite star also shows elsewhere, because
|
||||
// favorites-first pins the row there too.
|
||||
let title_text = if inst.is_archived() || inst.is_trashed() {
|
||||
let title_text = if inst.is_trashed() {
|
||||
Cow::Owned(inst.title.clone())
|
||||
} else if in_attention && inst.is_urgent() {
|
||||
Cow::Owned(format!("! {}", inst.title))
|
||||
@@ -1510,8 +1488,8 @@ impl HomeView {
|
||||
(ICON_IDLE, theme.dimmed)
|
||||
};
|
||||
let mut style = Style::default().fg(color);
|
||||
if inst.is_archived() || inst.is_trashed() {
|
||||
// Archive/trash lifecycle override mirrors the
|
||||
if inst.is_trashed() {
|
||||
// Trash lifecycle override mirrors the
|
||||
// Agent-view path: dim color, stopped
|
||||
// icon, no italic/dim modifier; the
|
||||
// Archived section header is the cue.
|
||||
@@ -1527,7 +1505,7 @@ impl HomeView {
|
||||
.add_modifier(ratatui::style::Modifier::BOLD)
|
||||
.add_modifier(ratatui::style::Modifier::UNDERLINED);
|
||||
}
|
||||
let title_text = if inst.is_archived() || inst.is_trashed() {
|
||||
let title_text = if inst.is_trashed() {
|
||||
Cow::Owned(inst.title.clone())
|
||||
} else if in_attention && inst.is_urgent() {
|
||||
Cow::Owned(format!("! {}", inst.title))
|
||||
@@ -2206,14 +2184,14 @@ impl HomeView {
|
||||
let idle_age = inst.idle_age();
|
||||
let is_fresh_idle =
|
||||
matches!(idle_age, Some(age) if age < self.idle_decay_window);
|
||||
// An archived/trashed row is parked; its preview body
|
||||
// renders the "Archived" / "Trash" placeholder. Force the
|
||||
// A trashed row is parked; its preview body renders
|
||||
// the "Trash" placeholder. Force the
|
||||
// compact title icon to the stopped glyph so the hoisted
|
||||
// title can't show a live spinner from a stale (pre-poll)
|
||||
// status and contradict it. Error/Deleting are live
|
||||
// delete-operation states (the placeholder surfaces them
|
||||
// too), so they keep their icon.
|
||||
let (icon, icon_color) = if (inst.is_archived() || inst.is_trashed())
|
||||
let (icon, icon_color) = if inst.is_trashed()
|
||||
&& !matches!(inst.status, Status::Error | Status::Deleting)
|
||||
{
|
||||
(ICON_STOPPED, theme.dimmed)
|
||||
@@ -2339,36 +2317,24 @@ impl HomeView {
|
||||
self.preview_text_view = crate::tui::home::PreviewTextView::default();
|
||||
frame.render_widget(block, area);
|
||||
|
||||
// An archived session's pane was killed on archive, so there's nothing
|
||||
// live to capture. Short-circuit every view mode to a calm "Archived"
|
||||
// placeholder instead of forking captures that come back empty and
|
||||
// surface as "No output available".
|
||||
let selected_archived = self
|
||||
.selected_session
|
||||
.as_ref()
|
||||
.and_then(|id| self.get_instance(id))
|
||||
.is_some_and(|inst| inst.is_archived());
|
||||
|
||||
// A session whose pane is simply gone (killed, exited, server reboot)
|
||||
// with no diagnostic detail carries the generic gone-error. Present
|
||||
// that as a calm "Stopped" placeholder rather than the red crash error;
|
||||
// a real crash leaves a specific message and still renders red. Covers
|
||||
// the just-unarchived row, which sits Stopped until restarted.
|
||||
// a real crash leaves a specific message and still renders red.
|
||||
//
|
||||
// Only in Structured view: the gone-error is about the agent pane, but
|
||||
// Tool / Terminal views show a different, independently-live pane (a tool
|
||||
// session can be running while the agent has exited), so the placeholder
|
||||
// must not hide that pane's output there.
|
||||
// A trashed session's pane was also killed (on trash). Same calm
|
||||
// placeholder treatment as archived, with a restore hint.
|
||||
// A trashed session's pane was killed on trash: a calm placeholder
|
||||
// with a restore hint.
|
||||
let selected_trashed = self
|
||||
.selected_session
|
||||
.as_ref()
|
||||
.and_then(|id| self.get_instance(id))
|
||||
.is_some_and(|inst| inst.is_trashed());
|
||||
|
||||
let selected_stopped = !selected_archived
|
||||
&& !selected_trashed
|
||||
let selected_stopped = !selected_trashed
|
||||
&& matches!(self.view_mode, ViewMode::Structured)
|
||||
&& self
|
||||
.selected_session
|
||||
@@ -2384,8 +2350,7 @@ impl HomeView {
|
||||
// pane forever, so short-circuit to an explanatory placeholder
|
||||
// instead. Only in the Structured (agent output) view; Terminal
|
||||
// and Tool views show their own, independently-live panes.
|
||||
let selected_structured = !selected_archived
|
||||
&& !selected_trashed
|
||||
let selected_structured = !selected_trashed
|
||||
&& matches!(self.view_mode, ViewMode::Structured)
|
||||
&& self
|
||||
.selected_session
|
||||
@@ -2396,22 +2361,15 @@ impl HomeView {
|
||||
// Keep the off-thread capture worker pointed at whatever pane this
|
||||
// view shows (and tuned to live-send vs. idle cadence) before any
|
||||
// refresh reads from it. Done once here, not per-branch, so the
|
||||
// creating / no-selection / archived / stopped paths also retarget or
|
||||
// creating / no-selection / trashed / stopped paths also retarget or
|
||||
// tear it down (no live pane feeds `None` so the worker stops capturing).
|
||||
let desired =
|
||||
if selected_archived || selected_trashed || selected_stopped || selected_structured {
|
||||
None
|
||||
} else {
|
||||
self.displayed_pane_tmux_name()
|
||||
};
|
||||
let desired = if selected_trashed || selected_stopped || selected_structured {
|
||||
None
|
||||
} else {
|
||||
self.displayed_pane_tmux_name()
|
||||
};
|
||||
self.sync_preview_capture_worker(desired);
|
||||
|
||||
if selected_archived {
|
||||
self.render_archived_preview(frame, inner, theme);
|
||||
self.paint_preview_selection(frame, theme);
|
||||
return;
|
||||
}
|
||||
|
||||
if selected_trashed {
|
||||
self.render_trashed_preview(frame, inner, theme);
|
||||
self.paint_preview_selection(frame, theme);
|
||||
@@ -2870,54 +2828,7 @@ impl HomeView {
|
||||
}
|
||||
}
|
||||
|
||||
/// Calm placeholder shown in the preview pane when the selected session is
|
||||
/// archived. Archiving kills the pane, so the normal capture path would
|
||||
/// render an empty body ("No output available"); this explains the state
|
||||
/// instead and points at `z` to bring the row back to the active list.
|
||||
fn render_archived_preview(&self, frame: &mut Frame, area: Rect, theme: &Theme) {
|
||||
let inst = self
|
||||
.selected_session
|
||||
.as_ref()
|
||||
.and_then(|id| self.get_instance(id));
|
||||
let title = inst.map(|i| i.title.clone()).unwrap_or_default();
|
||||
|
||||
// A permanent delete in flight is a live operation on this row; say
|
||||
// so instead of the parked placeholder (whose unarchive hint would
|
||||
// race the purge).
|
||||
if inst.is_some_and(|i| i.status == Status::Deleting) {
|
||||
self.render_shelf_deleting_preview(frame, area, theme, &title);
|
||||
return;
|
||||
}
|
||||
|
||||
let key = if self.strict_hotkeys { "Z" } else { "z" };
|
||||
let parked = if title.is_empty() {
|
||||
"This session is parked. Its agent was stopped.".to_string()
|
||||
} else {
|
||||
format!("\"{}\" is parked. Its agent was stopped.", title)
|
||||
};
|
||||
let mut lines = vec![
|
||||
Line::from(""),
|
||||
Line::from(Span::styled(
|
||||
"Archived",
|
||||
Style::default().fg(theme.text).bold(),
|
||||
)),
|
||||
Line::from(""),
|
||||
Line::from(Span::styled(parked, Style::default().fg(theme.dimmed))),
|
||||
];
|
||||
push_shelf_error_lines(&mut lines, inst, theme);
|
||||
lines.push(Line::from(""));
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled("Press ", Style::default().fg(theme.dimmed)),
|
||||
Span::styled(key, Style::default().fg(theme.hint).bold()),
|
||||
Span::styled(" to unarchive it.", Style::default().fg(theme.dimmed)),
|
||||
]));
|
||||
let para = Paragraph::new(lines)
|
||||
.alignment(Alignment::Center)
|
||||
.wrap(Wrap { trim: false });
|
||||
frame.render_widget(para, area);
|
||||
}
|
||||
|
||||
/// Shared "Deleting" takeover for the archived/trashed placeholders while
|
||||
/// Shared "Deleting" takeover for the trashed placeholder while
|
||||
/// a permanent delete runs on the deletion worker. No restore/delete
|
||||
/// hints: acting on the row would race the in-flight purge.
|
||||
fn render_shelf_deleting_preview(
|
||||
@@ -2957,8 +2868,7 @@ impl HomeView {
|
||||
.and_then(|id| self.get_instance(id));
|
||||
let title = inst.map(|i| i.title.clone()).unwrap_or_default();
|
||||
|
||||
// See `render_archived_preview`: an in-flight permanent delete takes
|
||||
// over the placeholder.
|
||||
// An in-flight permanent delete takes over the placeholder.
|
||||
if inst.is_some_and(|i| i.status == Status::Deleting) {
|
||||
self.render_shelf_deleting_preview(frame, area, theme, &title);
|
||||
return;
|
||||
|
||||
+249
-2085
File diff suppressed because it is too large
Load Diff
@@ -1,485 +0,0 @@
|
||||
use serial_test::parallel;
|
||||
use std::process::Command;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::harness::{require_tmux, TuiTestHarness};
|
||||
|
||||
/// Read sessions.json from the harness's isolated home.
|
||||
fn read_sessions_json(h: &TuiTestHarness) -> serde_json::Value {
|
||||
let sessions_path =
|
||||
crate::harness::app_dir_in(h.home_path()).join("profiles/default/sessions.json");
|
||||
let content = std::fs::read_to_string(&sessions_path)
|
||||
.unwrap_or_else(|e| panic!("failed to read {}: {}", sessions_path.display(), e));
|
||||
serde_json::from_str(&content).expect("invalid sessions JSON")
|
||||
}
|
||||
|
||||
/// Best-effort cleanup so failures don't leak into the next `#[parallel]` test.
|
||||
fn kill_tmux(sock: &std::path::Path, name: &str) {
|
||||
let _ = Command::new("tmux")
|
||||
.arg("-S")
|
||||
.arg(sock)
|
||||
.args(["kill-session", "-t", name])
|
||||
.output();
|
||||
}
|
||||
|
||||
fn tmux_has_session(sock: &std::path::Path, name: &str) -> bool {
|
||||
Command::new("tmux")
|
||||
.arg("-S")
|
||||
.arg(sock)
|
||||
.args(["has-session", "-t", name])
|
||||
.output()
|
||||
.map(|o| o.status.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Seed sessions in the default profile under a single manual group, pointing
|
||||
/// at a real project dir. Manual mode (the default) renders a group header from
|
||||
/// each session's `group_path`, so the TUI shows one selectable group row.
|
||||
fn seed_group_sessions(h: &TuiTestHarness, project: &str, group: &str, sessions: &[(&str, &str)]) {
|
||||
let config_dir = crate::harness::app_dir_in(h.home_path());
|
||||
let profile_dir = config_dir.join("profiles").join("default");
|
||||
std::fs::create_dir_all(&profile_dir).expect("create profile dir");
|
||||
let rows: Vec<String> = sessions
|
||||
.iter()
|
||||
.map(|(id, title)| {
|
||||
format!(
|
||||
r#"{{"id":"{id}","title":"{title}","project_path":"{project}","group_path":"{group}","command":"","tool":"claude","yolo_mode":false,"status":"idle","created_at":"2026-01-01T00:00:00Z"}}"#,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
std::fs::write(
|
||||
profile_dir.join("sessions.json"),
|
||||
format!("[{}]", rows.join(",")),
|
||||
)
|
||||
.expect("write sessions.json");
|
||||
}
|
||||
|
||||
/// Seed sessions in the default profile pointing at a real project dir, so
|
||||
/// startup recovery / restore can actually launch their (persistent) agent.
|
||||
fn seed_sessions(h: &TuiTestHarness, project: &str, titles: &[(&str, &str)]) {
|
||||
let config_dir = crate::harness::app_dir_in(h.home_path());
|
||||
let profile_dir = config_dir.join("profiles").join("default");
|
||||
std::fs::create_dir_all(&profile_dir).expect("create profile dir");
|
||||
let rows: Vec<String> = titles
|
||||
.iter()
|
||||
.map(|(id, title)| {
|
||||
format!(
|
||||
r#"{{"id":"{id}","title":"{title}","project_path":"{project}","group_path":"","command":"","tool":"claude","yolo_mode":false,"status":"idle","created_at":"2026-01-01T00:00:00Z"}}"#,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
std::fs::write(
|
||||
profile_dir.join("sessions.json"),
|
||||
format!("[{}]", rows.join(",")),
|
||||
)
|
||||
.expect("write sessions.json");
|
||||
}
|
||||
|
||||
/// Install a persistent `claude` (shadows the exit-0 stub) so a revived session
|
||||
/// stays Running instead of dying immediately.
|
||||
fn install_persistent_claude(h: &mut TuiTestHarness) {
|
||||
let bin = h.install_path_command("claude");
|
||||
let claude = bin.join("claude");
|
||||
std::fs::write(&claude, "#!/bin/sh\nexec sleep 600\n").expect("write persistent claude");
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(&claude, std::fs::Permissions::from_mode(0o755))
|
||||
.expect("chmod claude");
|
||||
}
|
||||
}
|
||||
|
||||
/// Drive a full archive -> unarchive cycle through the real TUI.
|
||||
///
|
||||
/// Verifies the user-visible contract end to end: archiving advances the
|
||||
/// cursor to the next active session (the preview follows it; no "parked"
|
||||
/// placeholder for a row the user just dismissed) while the collapsed
|
||||
/// Archived section header appears with the count as feedback; navigating
|
||||
/// into the section and unarchiving brings the row back to the active list
|
||||
/// and keeps it selected.
|
||||
#[test]
|
||||
#[parallel]
|
||||
fn test_archive_then_unarchive_cycle() {
|
||||
require_tmux!();
|
||||
|
||||
let mut h = TuiTestHarness::new("archive_restore");
|
||||
install_persistent_claude(&mut h);
|
||||
|
||||
let project = h.project_path();
|
||||
// Two sessions so "cursor advances to the neighbour" is meaningful.
|
||||
seed_sessions(
|
||||
&h,
|
||||
project.to_str().unwrap(),
|
||||
&[("arch_a", "Archivo"), ("arch_b", "Neighbor")],
|
||||
);
|
||||
|
||||
h.spawn_tui();
|
||||
h.wait_for_ready();
|
||||
h.wait_for("Archivo");
|
||||
h.wait_for("Neighbor");
|
||||
// Cursor starts on the top row (Archivo); give startup recovery a beat.
|
||||
std::thread::sleep(Duration::from_millis(1200));
|
||||
|
||||
// Archive the selected session.
|
||||
h.send_keys("z");
|
||||
h.wait_for("Archived (");
|
||||
let after_archive = h.capture_screen();
|
||||
|
||||
// The selection advanced to Neighbor, so the preview must NOT render the
|
||||
// archived "parked" placeholder; the collapsed Archived section header
|
||||
// (with its count) is the only trace of the dismissed row.
|
||||
assert!(
|
||||
!after_archive.contains("is parked"),
|
||||
"preview must follow the cursor to the next session, not the archived row\n{after_archive}"
|
||||
);
|
||||
assert!(
|
||||
after_archive.contains("Archived ("),
|
||||
"the Archived section header should appear with the count\n{after_archive}"
|
||||
);
|
||||
|
||||
// Navigate into the Archived section: down to the header, expand it,
|
||||
// down onto the parked row. Its preview shows the calm placeholder.
|
||||
h.send_keys("j");
|
||||
h.send_keys("l");
|
||||
h.send_keys("j");
|
||||
h.wait_for("is parked");
|
||||
let parked = h.capture_screen();
|
||||
assert!(
|
||||
parked.contains("to unarchive"),
|
||||
"archived preview should point at z to unarchive\n{parked}"
|
||||
);
|
||||
|
||||
// Unarchive it; the row returns to the active list, still selected.
|
||||
h.send_keys("z");
|
||||
h.wait_for_absent("is parked", Duration::from_secs(5));
|
||||
// The unarchive triggers a full clear+redraw. `wait_for_absent` above can
|
||||
// satisfy on the transient blank frame mid-redraw, so a bare capture here
|
||||
// races the repaint and sometimes catches an empty screen (the same blank
|
||||
// capture `assert_screen_contains` retries around). Poll for the row to
|
||||
// actually repaint into the active list before asserting on a single frame.
|
||||
h.wait_for("Archivo");
|
||||
let after_unarchive = h.capture_screen();
|
||||
assert!(
|
||||
after_unarchive.contains("Archivo"),
|
||||
"unarchived row should be back in the active list\n{after_unarchive}"
|
||||
);
|
||||
assert!(
|
||||
!after_unarchive.contains("Archived ("),
|
||||
"the Archived section should be gone once empty\n{after_unarchive}"
|
||||
);
|
||||
|
||||
// The unarchived row is Stopped (archive killed its pane). Once the poller
|
||||
// stamps the gone-error, the preview must show the calm Stopped placeholder,
|
||||
// not the red "tmux session is gone" crash error.
|
||||
h.wait_for("isn't running");
|
||||
let stopped = h.capture_screen();
|
||||
assert!(
|
||||
!stopped.contains("tmux session is gone"),
|
||||
"stopped preview must not show the red corpse error\n{stopped}"
|
||||
);
|
||||
assert!(
|
||||
stopped.contains("Stopped") && stopped.contains("Press Enter to start"),
|
||||
"stopped preview should explain the state and point at Enter\n{stopped}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Locks the #2179 widened TUI bulk-archive teardown (#2186). Archiving a whole
|
||||
/// group runs the tmux teardown off-thread (`std::thread::spawn` +
|
||||
/// `catch_unwind`, mirroring `force_remove_session`) while the persist stays on
|
||||
/// the input thread. The off-thread half was structurally correct but had no
|
||||
/// deterministic assertion: unit tests only proved the persist completes
|
||||
/// synchronously, and a wall-clock "returned within Nms" check is flaky.
|
||||
///
|
||||
/// This drives the real TUI group-archive confirm flow against pre-created real
|
||||
/// tmux sessions, then polls for the end state (every member's tmux session
|
||||
/// gone) rather than asserting a timing deadline, so it stays deterministic on
|
||||
/// slow CI. It verifies all N rows complete teardown off-thread, closing the
|
||||
/// gap called out in #2186.
|
||||
#[test]
|
||||
#[parallel]
|
||||
fn test_tui_bulk_archive_group_tears_down_all_tmux_off_thread() {
|
||||
require_tmux!();
|
||||
|
||||
let mut h = TuiTestHarness::new("tui_bulk_archive_group");
|
||||
let project = h.project_path();
|
||||
|
||||
let group = "bulkarch";
|
||||
// Distinct within the first 8 chars so the truncated-id tmux names don't
|
||||
// collide.
|
||||
let sessions = [
|
||||
("barch1id", "BulkAlpha"),
|
||||
("barch2id", "BulkBeta"),
|
||||
("barch3id", "BulkGamma"),
|
||||
];
|
||||
seed_group_sessions(&h, project.to_str().unwrap(), group, &sessions);
|
||||
|
||||
// The exact agent tmux name the archive teardown targets for each member.
|
||||
let sock = h.home_path().join("tmux.sock");
|
||||
let names: Vec<String> = sessions
|
||||
.iter()
|
||||
.map(|(id, title)| agent_of_empires::tmux::Session::generate_name(id, title))
|
||||
.collect();
|
||||
|
||||
// Pre-create long-lived agent sessions on the harness socket so they are
|
||||
// demonstrably alive right up until archive; because the tmux session
|
||||
// already exists under the name the instance computes, TUI startup detects
|
||||
// it as running and does not relaunch it. These run before `spawn_tui` and
|
||||
// so start the tmux server, which is why they must go through the harness
|
||||
// helper: it pins the same env `spawn_tui` uses onto the server.
|
||||
for name in &names {
|
||||
h.tmux_new_detached(name, "sleep 600");
|
||||
}
|
||||
|
||||
h.spawn_tui();
|
||||
h.wait_for_ready();
|
||||
// The group header renders as "name (count)"; its presence proves the group
|
||||
// loaded with all three members.
|
||||
h.wait_for("bulkarch (3)");
|
||||
|
||||
// Pre-condition: every agent session is alive before we archive, so a later
|
||||
// "all gone" cannot pass trivially.
|
||||
for name in &names {
|
||||
assert!(
|
||||
tmux_has_session(&sock, name),
|
||||
"precondition: tmux session '{}' should be alive before archive",
|
||||
name
|
||||
);
|
||||
}
|
||||
|
||||
// Groups render before ungrouped rows, so the group header is the top row.
|
||||
// `Home` jumps the cursor there in one keystroke; `z` on a selected group
|
||||
// opens the archive-confirm dialog and `y` submits it.
|
||||
//
|
||||
// Deliberately `Home` rather than a run of `k` presses. The app coalesces
|
||||
// printable keys arriving less than PASTE_BURST_INTER_KEY_MS apart into a
|
||||
// paste burst (src/tui/app.rs), for Mosh clients that strip bracketed-paste
|
||||
// markers. A burst of identical keys is exempt via `is_auto_repeat_burst`,
|
||||
// but "kkz" is not: when a loaded CI box stalls the TUI long enough for the
|
||||
// queued keystrokes to be read back to back, the mixed run is routed to
|
||||
// `handle_paste`, the `z` lands in `pending_paste` instead of opening the
|
||||
// dialog, and the wait below times out. `Home` is not a burst candidate
|
||||
// (only `Char` and `Enter` are), and two keys can never reach
|
||||
// PASTE_BURST_MIN_LEN, so this sequence cannot be misread as a paste.
|
||||
h.send_keys("Home");
|
||||
h.send_keys("z");
|
||||
h.wait_for("Archive all 3 sessions");
|
||||
h.send_keys("y");
|
||||
|
||||
// The teardown is fire-and-forget on a spawned thread, so poll for the end
|
||||
// state rather than asserting a timing deadline. Generous 10s ceiling.
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(10);
|
||||
while std::time::Instant::now() < deadline {
|
||||
if names.iter().all(|n| !tmux_has_session(&sock, n)) {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
|
||||
let alive: Vec<(&String, bool)> = names
|
||||
.iter()
|
||||
.map(|n| (n, tmux_has_session(&sock, n)))
|
||||
.collect();
|
||||
|
||||
// Clean up any survivors BEFORE asserting so a single failure cannot leak
|
||||
// into the next serial test.
|
||||
for (name, is_alive) in &alive {
|
||||
if *is_alive {
|
||||
kill_tmux(&sock, name);
|
||||
}
|
||||
}
|
||||
|
||||
for (name, is_alive) in &alive {
|
||||
assert!(
|
||||
!is_alive,
|
||||
"off-thread bulk-archive teardown must kill group member tmux session '{}' (#2186)",
|
||||
name
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Locks #1868: archive kills all four tmux session kinds. Pre-creates real
|
||||
/// sessions, runs the CLI, asserts each kind is gone.
|
||||
#[test]
|
||||
#[parallel]
|
||||
fn test_cli_archive_kills_agent_and_terminal_tmux_sessions() {
|
||||
require_tmux!();
|
||||
|
||||
let h = TuiTestHarness::new("cli_archive_full_teardown");
|
||||
let project = h.project_path();
|
||||
|
||||
let add_output = h.run_cli(&["add", project.to_str().unwrap(), "-t", "ArchiveTeardown"]);
|
||||
assert!(
|
||||
add_output.status.success(),
|
||||
"aoe add failed: {}",
|
||||
String::from_utf8_lossy(&add_output.stderr)
|
||||
);
|
||||
|
||||
let sessions = read_sessions_json(&h);
|
||||
let session_id = sessions[0]["id"]
|
||||
.as_str()
|
||||
.expect("session should have id")
|
||||
.to_string();
|
||||
let truncated_id = session_id[..8.min(session_id.len())].to_string();
|
||||
|
||||
let agent_tmux_name = format!(
|
||||
"{}ArchiveTeardown_{}",
|
||||
agent_of_empires::tmux::SESSION_PREFIX,
|
||||
truncated_id
|
||||
);
|
||||
let terminal_tmux_name =
|
||||
agent_of_empires::tmux::TerminalSession::generate_name(&session_id, "ArchiveTeardown");
|
||||
|
||||
// Pre-create both tmux kinds so archive can find and kill them. Created
|
||||
// on the harness's tmux socket (`AOE_TMUX_SOCKET`), the same one the CLI
|
||||
// resolves (#2608), so archive's teardown sweeps them.
|
||||
let sock = h.home_path().join("tmux.sock");
|
||||
let names = [&agent_tmux_name, &terminal_tmux_name];
|
||||
for name in names {
|
||||
let create = Command::new("tmux")
|
||||
.arg("-S")
|
||||
.arg(&sock)
|
||||
.args([
|
||||
"new-session",
|
||||
"-d",
|
||||
"-s",
|
||||
name,
|
||||
"-x",
|
||||
"80",
|
||||
"-y",
|
||||
"24",
|
||||
"sleep",
|
||||
"600",
|
||||
])
|
||||
.output()
|
||||
.expect("tmux new-session");
|
||||
assert!(
|
||||
create.status.success(),
|
||||
"failed to create tmux session {}: {}",
|
||||
name,
|
||||
String::from_utf8_lossy(&create.stderr)
|
||||
);
|
||||
}
|
||||
|
||||
let archive_output = h.run_cli(&["session", "archive", &session_id]);
|
||||
assert!(
|
||||
archive_output.status.success(),
|
||||
"aoe session archive failed: {}",
|
||||
String::from_utf8_lossy(&archive_output.stderr)
|
||||
);
|
||||
|
||||
let alive: Vec<(&&String, bool)> = names
|
||||
.iter()
|
||||
.map(|n| (n, tmux_has_session(&sock, n)))
|
||||
.collect();
|
||||
|
||||
// Cleanup BEFORE asserting so a single failure cannot leak survivors
|
||||
// into the next serial test.
|
||||
for (name, is_alive) in &alive {
|
||||
if *is_alive {
|
||||
kill_tmux(&sock, name);
|
||||
}
|
||||
}
|
||||
|
||||
for (name, is_alive) in &alive {
|
||||
assert!(
|
||||
!is_alive,
|
||||
"tmux session '{}' must be killed by archive (#1868)",
|
||||
name
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Locks the widened `--no-kill` semantic from #1868: skip ALL tmux
|
||||
/// teardown. The agent assertion is the pre/post differentiator.
|
||||
#[test]
|
||||
#[parallel]
|
||||
fn test_cli_archive_no_kill_preserves_all_tmux_sessions() {
|
||||
require_tmux!();
|
||||
|
||||
let h = TuiTestHarness::new("cli_archive_no_kill");
|
||||
let project = h.project_path();
|
||||
|
||||
let add_output = h.run_cli(&["add", project.to_str().unwrap(), "-t", "ArchiveNoKill"]);
|
||||
assert!(
|
||||
add_output.status.success(),
|
||||
"aoe add failed: {}",
|
||||
String::from_utf8_lossy(&add_output.stderr)
|
||||
);
|
||||
|
||||
let sessions = read_sessions_json(&h);
|
||||
let session_id = sessions[0]["id"]
|
||||
.as_str()
|
||||
.expect("session should have id")
|
||||
.to_string();
|
||||
let truncated_id = session_id[..8.min(session_id.len())].to_string();
|
||||
|
||||
let agent_tmux_name = format!(
|
||||
"{}ArchiveNoKill_{}",
|
||||
agent_of_empires::tmux::SESSION_PREFIX,
|
||||
truncated_id
|
||||
);
|
||||
let terminal_tmux_name =
|
||||
agent_of_empires::tmux::TerminalSession::generate_name(&session_id, "ArchiveNoKill");
|
||||
|
||||
let sock = h.home_path().join("tmux.sock");
|
||||
let names = [&agent_tmux_name, &terminal_tmux_name];
|
||||
for name in names {
|
||||
let create = Command::new("tmux")
|
||||
.arg("-S")
|
||||
.arg(&sock)
|
||||
.args([
|
||||
"new-session",
|
||||
"-d",
|
||||
"-s",
|
||||
name,
|
||||
"-x",
|
||||
"80",
|
||||
"-y",
|
||||
"24",
|
||||
"sleep",
|
||||
"600",
|
||||
])
|
||||
.output()
|
||||
.expect("tmux new-session");
|
||||
assert!(
|
||||
create.status.success(),
|
||||
"failed to create tmux session {}: {}",
|
||||
name,
|
||||
String::from_utf8_lossy(&create.stderr)
|
||||
);
|
||||
}
|
||||
|
||||
let archive_output = h.run_cli(&["session", "archive", &session_id, "--no-kill"]);
|
||||
assert!(
|
||||
archive_output.status.success(),
|
||||
"aoe session archive --no-kill failed: {}",
|
||||
String::from_utf8_lossy(&archive_output.stderr)
|
||||
);
|
||||
|
||||
let alive: Vec<(&&String, bool)> = names
|
||||
.iter()
|
||||
.map(|n| (n, tmux_has_session(&sock, n)))
|
||||
.collect();
|
||||
|
||||
// Cleanup explicitly: --no-kill leaves the survivors so they would
|
||||
// pollute the next serial test.
|
||||
for name in &names {
|
||||
kill_tmux(&sock, name);
|
||||
}
|
||||
|
||||
for (name, is_alive) in &alive {
|
||||
assert!(
|
||||
*is_alive,
|
||||
"tmux session '{}' must survive --no-kill archive (#1868)",
|
||||
name
|
||||
);
|
||||
}
|
||||
|
||||
let post_archive = read_sessions_json(&h);
|
||||
let archived_at = post_archive[0]["archived_at"].as_str();
|
||||
assert!(
|
||||
archived_at.is_some() && !archived_at.unwrap().is_empty(),
|
||||
"session must still be archived on disk even with --no-kill: archived_at = {:?}",
|
||||
archived_at
|
||||
);
|
||||
}
|
||||
@@ -1,366 +0,0 @@
|
||||
//! Full-stack e2e: the web archive handler's structured-view teardown branch
|
||||
//! (`src/server/api/sessions.rs` `update_session_archive`, #1868 / #2179).
|
||||
//!
|
||||
//! The handler shuts the ACP worker down via `acp_supervisor.shutdown()`
|
||||
//! (unconditional, regardless of `kill_pane`) and then, only when `kill_pane`
|
||||
//! is set, calls `kill_ancillary_tmux_sessions()` to tear down the tool
|
||||
//! sub-sessions. `shutdown()` deliberately does NOT fire `session/delete`, so
|
||||
//! the agent transcript stays resumable (#1710).
|
||||
//!
|
||||
//! `archive_restore.rs` covers the four-kind tmux teardown via the CLI; the
|
||||
//! Playwright live spec covers the dashboard path. Neither drives a real
|
||||
//! ACP/structured-view session through the web archive handler, which is the
|
||||
//! gap this test closes (#2185).
|
||||
//!
|
||||
//! Oracles:
|
||||
//! - Worker shut down: `aoe ps --acp --dead --json` no longer lists the session
|
||||
//! (`shutdown()` removes the registry record). Asserted in BOTH kill_pane
|
||||
//! modes, so it catches a regression that skips the unconditional shutdown
|
||||
//! when `kill_pane = false`.
|
||||
//! - Tool sub-session: a pre-created `aoe_tool_*` tmux session is killed when
|
||||
//! `kill_pane = true` and survives when `kill_pane = false`.
|
||||
//! - Transcript preserved: the shared fake-ACP agent logs every inbound RPC
|
||||
//! to `FAKE_ACP_DEBUG_LOG`. A regression that swaps `shutdown()` for
|
||||
//! `shutdown_and_delete()` dispatches `session/delete` unconditionally
|
||||
//! (`acp_client::delete_session` sends regardless of advertised
|
||||
//! capability), so asserting the log never contains `session/delete`
|
||||
//! locks the #1710 invariant. The session row also stays on disk with
|
||||
//! `archived_at` set (archived, not deleted).
|
||||
//!
|
||||
//! The shutdown -> ancillary-kill ORDER is not directly observable end-to-end
|
||||
//! without intrusive hooks; it stays covered by the handler's code comment and
|
||||
//! the `acp.rs:1304-1310` precedent.
|
||||
//!
|
||||
//! Compiled only with `--features serve`. Run via:
|
||||
//!
|
||||
//! ```sh
|
||||
//! cargo test --features serve,e2e-tests --test e2e -- archive_structured
|
||||
//! ```
|
||||
#![cfg(feature = "serve")]
|
||||
|
||||
use std::process::Command;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use serial_test::parallel;
|
||||
|
||||
use crate::harness::{
|
||||
app_dir_in, pick_free_port, require_node, require_tmux, wait_for_port, TuiTestHarness,
|
||||
};
|
||||
|
||||
/// One-turn fake-ACP script: complete the ACP handshake, accept a prompt, and
|
||||
/// end the turn. The worker stays alive (idle) after the turn, so the archive
|
||||
/// teardown has a live worker to shut down.
|
||||
const SCRIPT: &str = r#"{
|
||||
"turns": [
|
||||
{ "updates": [], "stopReason": "end_turn" }
|
||||
]
|
||||
}"#;
|
||||
|
||||
fn parse_session_id(add_stdout: &str) -> String {
|
||||
add_stdout
|
||||
.lines()
|
||||
.find_map(|l| l.trim().strip_prefix("ID:"))
|
||||
.map(|rest| rest.trim().to_string())
|
||||
.unwrap_or_else(|| panic!("could not find session ID in `aoe add` output:\n{add_stdout}"))
|
||||
}
|
||||
|
||||
/// `aoe acp prompt` 404s until the worker is live and handshaked, so a
|
||||
/// successful call is the readiness oracle: by the time it returns the worker
|
||||
/// is running and (importantly for the `session/delete` oracle) has a stored
|
||||
/// ACP session id in the registry.
|
||||
fn prompt_until_accepted(h: &TuiTestHarness, session_id: &str, timeout: Duration) {
|
||||
let deadline = Instant::now() + timeout;
|
||||
loop {
|
||||
let out = h.run_cli(&["acp", "prompt", session_id, "hello"]);
|
||||
if out.status.success() {
|
||||
return;
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
let ps = h.run_cli(&["ps", "--acp", "--dead", "--json"]);
|
||||
panic!(
|
||||
"structured view worker never accepted a prompt within {timeout:?}.\n\
|
||||
stdout: {}\nstderr: {}\nps --acp: {}",
|
||||
String::from_utf8_lossy(&out.stdout),
|
||||
String::from_utf8_lossy(&out.stderr),
|
||||
String::from_utf8_lossy(&ps.stdout),
|
||||
);
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(250));
|
||||
}
|
||||
}
|
||||
|
||||
/// True while `aoe ps --acp --dead --json` still lists `session_id` as a live worker.
|
||||
fn worker_listed(h: &TuiTestHarness, session_id: &str) -> bool {
|
||||
let out = h.run_cli(&["ps", "--acp", "--dead", "--json"]);
|
||||
let body = String::from_utf8_lossy(&out.stdout);
|
||||
let records: serde_json::Value = serde_json::from_str(&body).unwrap_or(serde_json::json!([]));
|
||||
records
|
||||
.as_array()
|
||||
.map(|rs| rs.iter().any(|r| r["session_id"] == session_id))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Poll until the worker disappears from the worker registry. Panics with the last
|
||||
/// listing on timeout.
|
||||
fn wait_for_worker_gone(h: &TuiTestHarness, session_id: &str, timeout: Duration) {
|
||||
let deadline = Instant::now() + timeout;
|
||||
loop {
|
||||
if !worker_listed(h, session_id) {
|
||||
return;
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
let ps = h.run_cli(&["ps", "--acp", "--dead", "--json"]);
|
||||
panic!(
|
||||
"worker {session_id} still listed after archive within {timeout:?}.\nps --acp: {}",
|
||||
String::from_utf8_lossy(&ps.stdout),
|
||||
);
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(200));
|
||||
}
|
||||
}
|
||||
|
||||
fn tmux_has_session(sock: &std::path::Path, name: &str) -> bool {
|
||||
Command::new("tmux")
|
||||
.arg("-S")
|
||||
.arg(sock)
|
||||
.args(["has-session", "-t", name])
|
||||
.output()
|
||||
.map(|o| o.status.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn kill_tmux(sock: &std::path::Path, name: &str) {
|
||||
let _ = Command::new("tmux")
|
||||
.arg("-S")
|
||||
.arg(sock)
|
||||
.args(["kill-session", "-t", name])
|
||||
.output();
|
||||
}
|
||||
|
||||
/// Pre-create an ancillary (paired-terminal) sub-session whose name matches
|
||||
/// what `kill_ancillary_tmux_sessions()` sweeps for `session_id` (by `_<id8>`
|
||||
/// suffix). Created on the harness's tmux socket (`AOE_TMUX_SOCKET`), the same
|
||||
/// one the daemon resolves (#2608), so the daemon's teardown sweeps it.
|
||||
/// Mirrors the precedent in `archive_restore.rs`.
|
||||
fn create_ancillary_session(sock: &std::path::Path, session_id: &str, title: &str) -> String {
|
||||
let name = agent_of_empires::tmux::TerminalSession::generate_name(session_id, title);
|
||||
let create = Command::new("tmux")
|
||||
.arg("-S")
|
||||
.arg(sock)
|
||||
.args([
|
||||
"new-session",
|
||||
"-d",
|
||||
"-s",
|
||||
&name,
|
||||
"-x",
|
||||
"80",
|
||||
"-y",
|
||||
"24",
|
||||
"sleep",
|
||||
"600",
|
||||
])
|
||||
.output()
|
||||
.expect("tmux new-session");
|
||||
assert!(
|
||||
create.status.success(),
|
||||
"failed to create ancillary tmux session {name}: {}",
|
||||
String::from_utf8_lossy(&create.stderr)
|
||||
);
|
||||
name
|
||||
}
|
||||
|
||||
/// PATCH `/api/sessions/:id/archive` with `{archived: true, kill_pane}` against
|
||||
/// the daemon. Asserts a 2xx so a routing/handler regression fails the
|
||||
/// test rather than silently skipping the teardown.
|
||||
fn archive_via_api(port: u16, session_id: &str, kill_pane: bool) {
|
||||
let rt = tokio::runtime::Runtime::new().expect("tokio runtime");
|
||||
rt.block_on(async {
|
||||
let client = reqwest::Client::new();
|
||||
let resp = client
|
||||
.patch(format!(
|
||||
"http://127.0.0.1:{port}/api/sessions/{session_id}/archive"
|
||||
))
|
||||
.json(&serde_json::json!({ "archived": true, "kill_pane": kill_pane }))
|
||||
.send()
|
||||
.await
|
||||
.expect("PATCH archive send");
|
||||
assert!(
|
||||
resp.status().is_success(),
|
||||
"archive PATCH failed: {} {}",
|
||||
resp.status(),
|
||||
resp.text().await.unwrap_or_default(),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// Read the archived_at for `session_id` from the isolated sessions.json.
|
||||
fn archived_at(h: &TuiTestHarness, session_id: &str) -> Option<String> {
|
||||
let path = app_dir_in(h.home_path()).join("profiles/default/sessions.json");
|
||||
let content =
|
||||
std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("read {}: {e}", path.display()));
|
||||
let rows: serde_json::Value = serde_json::from_str(&content).expect("invalid sessions JSON");
|
||||
rows.as_array()
|
||||
.and_then(|rs| rs.iter().find(|r| r["id"] == session_id))
|
||||
.and_then(|r| r["archived_at"].as_str())
|
||||
.map(|s| s.to_string())
|
||||
}
|
||||
|
||||
/// Assert the fake-ACP agent never received a `session/delete` RPC, which
|
||||
/// would mean the handler fired `shutdown_and_delete` and dropped the agent
|
||||
/// transcript (#1710).
|
||||
fn assert_no_session_delete(h: &TuiTestHarness) {
|
||||
let log = app_dir_in(h.home_path()).join("fake-acp.log");
|
||||
let body = std::fs::read_to_string(&log).unwrap_or_default();
|
||||
assert!(
|
||||
!body.contains("session/delete"),
|
||||
"archive must NOT fire session/delete (transcript loss #1710).\nfake-acp.log:\n{body}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Stand up a live daemon with a structured-view session, drive a prompt so the
|
||||
/// worker is live, pre-create a tool sub-session, and return the pieces the
|
||||
/// per-mode assertions need.
|
||||
fn setup(h: &mut TuiTestHarness, title: &str) -> (u16, String, String) {
|
||||
let script_path = h.home_path().join("archive-script.json");
|
||||
std::fs::write(&script_path, SCRIPT).expect("write fake-acp script");
|
||||
h.install_acp_shim(&script_path);
|
||||
h.stop_daemon_on_drop();
|
||||
|
||||
// A structured view session needs a git repo as its workspace.
|
||||
let project = h.project_path();
|
||||
for args in [
|
||||
vec!["init", "-q"],
|
||||
vec!["commit", "--allow-empty", "-q", "-m", "init"],
|
||||
] {
|
||||
let out = Command::new("git")
|
||||
.args(&args)
|
||||
.current_dir(&project)
|
||||
.env("GIT_AUTHOR_NAME", "t")
|
||||
.env("GIT_AUTHOR_EMAIL", "t@t")
|
||||
.env("GIT_COMMITTER_NAME", "t")
|
||||
.env("GIT_COMMITTER_EMAIL", "t@t")
|
||||
.output()
|
||||
.expect("run git");
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"git {:?} failed: {}",
|
||||
args,
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
}
|
||||
|
||||
let port = pick_free_port();
|
||||
let port_s = port.to_string();
|
||||
let start = h.run_cli(&["serve", "--daemon", "--port", &port_s]);
|
||||
assert!(
|
||||
start.status.success(),
|
||||
"aoe serve --daemon failed.\nstdout: {}\nstderr: {}",
|
||||
String::from_utf8_lossy(&start.stdout),
|
||||
String::from_utf8_lossy(&start.stderr),
|
||||
);
|
||||
assert!(
|
||||
wait_for_port(port, Duration::from_secs(10)),
|
||||
"daemon never bound port {port}"
|
||||
);
|
||||
|
||||
let add = h.run_cli(&[
|
||||
"add",
|
||||
project.to_str().unwrap(),
|
||||
"-t",
|
||||
title,
|
||||
"-c",
|
||||
"claude",
|
||||
"--structured-view",
|
||||
]);
|
||||
assert!(
|
||||
add.status.success(),
|
||||
"aoe add --structured-view failed.\nstdout: {}\nstderr: {}",
|
||||
String::from_utf8_lossy(&add.stdout),
|
||||
String::from_utf8_lossy(&add.stderr),
|
||||
);
|
||||
let session_id = parse_session_id(&String::from_utf8_lossy(&add.stdout));
|
||||
|
||||
prompt_until_accepted(h, &session_id, Duration::from_secs(30));
|
||||
// Worker is live and registered. Pre-create the ancillary sub-session now
|
||||
// so archive's `kill_ancillary_tmux_sessions()` has something to find.
|
||||
let aux_name = create_ancillary_session(&h.home_path().join("tmux.sock"), &session_id, title);
|
||||
|
||||
(port, session_id, aux_name)
|
||||
}
|
||||
|
||||
/// `kill_pane = true`: worker shut down, ancillary sub-session killed,
|
||||
/// transcript preserved (no `session/delete`), session row archived on disk.
|
||||
#[test]
|
||||
#[parallel]
|
||||
fn archive_kills_worker_and_ancillary_session() {
|
||||
require_tmux!();
|
||||
require_node!();
|
||||
|
||||
let mut h = TuiTestHarness::new_in_tmp("archive_structured_kill");
|
||||
let title = "ArchiveStructKill";
|
||||
let (port, session_id, aux_name) = setup(&mut h, title);
|
||||
let sock = h.home_path().join("tmux.sock");
|
||||
|
||||
assert!(
|
||||
tmux_has_session(&sock, &aux_name),
|
||||
"precondition: ancillary session {aux_name} should exist before archive"
|
||||
);
|
||||
|
||||
archive_via_api(port, &session_id, true);
|
||||
|
||||
wait_for_worker_gone(&h, &session_id, Duration::from_secs(10));
|
||||
|
||||
let aux_alive = tmux_has_session(&sock, &aux_name);
|
||||
// Clean up before asserting so a failure can't leak into the next test.
|
||||
if aux_alive {
|
||||
kill_tmux(&sock, &aux_name);
|
||||
}
|
||||
assert!(
|
||||
!aux_alive,
|
||||
"ancillary sub-session {aux_name} must be killed by archive with kill_pane=true"
|
||||
);
|
||||
|
||||
assert_no_session_delete(&h);
|
||||
assert!(
|
||||
archived_at(&h, &session_id).is_some_and(|s| !s.is_empty()),
|
||||
"session must be archived (not deleted) on disk"
|
||||
);
|
||||
}
|
||||
|
||||
/// `kill_pane = false`: the worker is STILL shut down (unconditional), but the
|
||||
/// ancillary sub-session survives. Transcript preserved, session row archived.
|
||||
#[test]
|
||||
#[parallel]
|
||||
fn archive_no_kill_shuts_worker_but_keeps_ancillary_session() {
|
||||
require_tmux!();
|
||||
require_node!();
|
||||
|
||||
let mut h = TuiTestHarness::new_in_tmp("archive_structured_nokill");
|
||||
let title = "ArchiveStructNoKill";
|
||||
let (port, session_id, aux_name) = setup(&mut h, title);
|
||||
let sock = h.home_path().join("tmux.sock");
|
||||
|
||||
assert!(
|
||||
tmux_has_session(&sock, &aux_name),
|
||||
"precondition: ancillary session {aux_name} should exist before archive"
|
||||
);
|
||||
|
||||
archive_via_api(port, &session_id, false);
|
||||
|
||||
// Worker shutdown is unconditional, so it must be gone even with kill_pane=false.
|
||||
wait_for_worker_gone(&h, &session_id, Duration::from_secs(10));
|
||||
|
||||
let aux_alive = tmux_has_session(&sock, &aux_name);
|
||||
// The survivor would pollute the next serial test; kill it before asserting.
|
||||
kill_tmux(&sock, &aux_name);
|
||||
assert!(
|
||||
aux_alive,
|
||||
"ancillary sub-session {aux_name} must survive archive with kill_pane=false"
|
||||
);
|
||||
|
||||
assert_no_session_delete(&h);
|
||||
assert!(
|
||||
archived_at(&h, &session_id).is_some_and(|s| !s.is_empty()),
|
||||
"session must be archived (not deleted) on disk"
|
||||
);
|
||||
}
|
||||
@@ -1792,7 +1792,6 @@ fn test_cli_session_show_json_carries_state() {
|
||||
.expect("session show --json must emit JSON");
|
||||
assert_eq!(live["state"], "live");
|
||||
assert!(live.get("trashed_at").is_none());
|
||||
assert!(live.get("archived_at").is_none());
|
||||
|
||||
let rm = h.run_cli(&["rm", "Show Probe"]);
|
||||
assert!(
|
||||
@@ -1809,55 +1808,3 @@ fn test_cli_session_show_json_carries_state() {
|
||||
assert_eq!(trashed["state"], "trashed");
|
||||
assert!(trashed["trashed_at"].is_string());
|
||||
}
|
||||
|
||||
/// The archived half of the same contract, and the precedence between the two
|
||||
/// states: `trash()` deliberately keeps `archived_at`, so a row that was
|
||||
/// archived first must still report `trashed`.
|
||||
#[test]
|
||||
#[parallel]
|
||||
fn test_cli_session_show_json_reports_archived_and_precedence() {
|
||||
let h = TuiTestHarness::new("cli_show_archived");
|
||||
let project = h.project_path();
|
||||
|
||||
let add = h.run_cli(&["add", project.to_str().unwrap(), "-t", "Archive Probe"]);
|
||||
assert!(
|
||||
add.status.success(),
|
||||
"aoe add failed: {}",
|
||||
String::from_utf8_lossy(&add.stderr)
|
||||
);
|
||||
|
||||
let archive = h.run_cli(&["session", "archive", "Archive Probe"]);
|
||||
assert!(
|
||||
archive.status.success(),
|
||||
"aoe session archive failed: {}",
|
||||
String::from_utf8_lossy(&archive.stderr)
|
||||
);
|
||||
|
||||
let archived: serde_json::Value = serde_json::from_slice(
|
||||
&h.run_cli(&["session", "show", "Archive Probe", "--json"])
|
||||
.stdout,
|
||||
)
|
||||
.expect("session show --json must emit JSON for an archived row");
|
||||
assert_eq!(archived["state"], "archived");
|
||||
assert!(archived["archived_at"].is_string());
|
||||
assert!(archived.get("trashed_at").is_none());
|
||||
|
||||
let rm = h.run_cli(&["rm", "Archive Probe"]);
|
||||
assert!(
|
||||
rm.status.success(),
|
||||
"aoe rm failed: {}",
|
||||
String::from_utf8_lossy(&rm.stderr)
|
||||
);
|
||||
|
||||
let both: serde_json::Value = serde_json::from_slice(
|
||||
&h.run_cli(&["session", "show", "Archive Probe", "--json"])
|
||||
.stdout,
|
||||
)
|
||||
.expect("session show --json must emit JSON for an archived, trashed row");
|
||||
assert_eq!(
|
||||
both["state"], "trashed",
|
||||
"trashed outranks archived, and archived_at survives the trash"
|
||||
);
|
||||
assert!(both["trashed_at"].is_string());
|
||||
assert!(both["archived_at"].is_string());
|
||||
}
|
||||
|
||||
@@ -538,25 +538,25 @@ fn read_command_self_heal_skips_mid_operation_rows() {
|
||||
);
|
||||
}
|
||||
|
||||
/// An archived (or trashed) row is a sink a read command must not mutate, and
|
||||
/// `--no-kill` archiving or a not-yet-torn-down trashed row can still own a
|
||||
/// live tmux pane, so neither the status gate nor the live-tmux gate catches
|
||||
/// it. The active-bucket gate in `self_heal_session_id` must: `aoe status`
|
||||
/// must not write `agent_session_id` back into an archived row even with a
|
||||
/// lingering rollout and a live pane.
|
||||
/// A trashed row is a sink a read command must not mutate, and a
|
||||
/// not-yet-torn-down trashed row can still own a live tmux pane, so neither
|
||||
/// the status gate nor the live-tmux gate catches it. The active-bucket gate
|
||||
/// in `self_heal_session_id` must: `aoe status` must not write
|
||||
/// `agent_session_id` back into a trashed row even with a lingering rollout
|
||||
/// and a live pane.
|
||||
#[test]
|
||||
#[parallel]
|
||||
fn read_command_self_heal_skips_archived_rows() {
|
||||
fn read_command_self_heal_skips_trashed_rows() {
|
||||
require_tmux!();
|
||||
assert_self_heal_skips(
|
||||
"cli_sid_archived",
|
||||
"CliSidArchivedE2E",
|
||||
"cli_sid_trashed",
|
||||
"CliSidTrashedE2E",
|
||||
"019342ab-1234-7def-8901-ffffffffffff",
|
||||
"`aoe status` self-heal must NOT touch an archived (non-active bucket) row",
|
||||
"`aoe status` self-heal must NOT touch a trashed (non-active bucket) row",
|
||||
|obj| {
|
||||
obj.insert("agent_session_id".to_string(), Value::Null);
|
||||
obj.insert(
|
||||
"archived_at".to_string(),
|
||||
"trashed_at".to_string(),
|
||||
Value::String("2026-01-01T00:00:00Z".to_string()),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -21,8 +21,6 @@ mod acp_host_environment_e2e;
|
||||
mod acp_orphan_runner_recovery_e2e;
|
||||
mod acp_session_log_tee_e2e;
|
||||
mod acp_tool_cards_e2e;
|
||||
mod archive_restore;
|
||||
mod archive_structured;
|
||||
mod claude_shared_project_correlation_e2e;
|
||||
mod cli;
|
||||
mod cli_session_id_capture;
|
||||
|
||||
@@ -122,9 +122,9 @@ fn test_update_with_groups_and_instances_round_trip() -> Result<()> {
|
||||
/// Concurrent per-field updates on the same instance must not clobber each
|
||||
/// other. Mirrors the structured view-handler / status-poll pattern: thread A
|
||||
/// mutates one field (`title`, simulating status poll), thread B mutates
|
||||
/// a different field (`notify_on_idle`, standing in for the structured view
|
||||
/// a different field (`unread`, standing in for the structured view
|
||||
/// handler's `structured_view`; the latter is `#[cfg(feature = "serve")]`,
|
||||
/// `notify_on_idle` is always available and exhibits the same lost-update
|
||||
/// `unread` is always available and exhibits the same lost-update
|
||||
/// class). Both should land. Regression guard for review #5: a
|
||||
/// wholesale-replace closure (using a pre-lock snapshot) would lose one
|
||||
/// of the writes.
|
||||
@@ -169,7 +169,7 @@ fn test_concurrent_per_field_updates_no_clobber() -> Result<()> {
|
||||
for _ in 0..n_iterations {
|
||||
storage.update(|all, _groups| {
|
||||
if let Some(slot) = all.iter_mut().find(|i| i.id == id_for_b) {
|
||||
slot.notify_on_idle = Some(true);
|
||||
slot.unread = true;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
@@ -187,10 +187,9 @@ fn test_concurrent_per_field_updates_no_clobber() -> Result<()> {
|
||||
"thread A's title write must be preserved (got: {})",
|
||||
loaded[0].title
|
||||
);
|
||||
assert_eq!(
|
||||
loaded[0].notify_on_idle,
|
||||
Some(true),
|
||||
"thread B's notify_on_idle write must be preserved"
|
||||
assert!(
|
||||
loaded[0].unread,
|
||||
"thread B's unread write must be preserved"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
Generated
-40
@@ -15,11 +15,8 @@
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@pierre/diffs": "^1.3.5",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"dompurify": "^3.4.13",
|
||||
"lucide-react": "^1.28.0",
|
||||
"marked": "^18.0.9",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.8",
|
||||
"react-markdown": "^10.1.0",
|
||||
@@ -4218,13 +4215,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/trusted-types": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@types/unist": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
|
||||
@@ -5051,15 +5041,6 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/clsx": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/cmdk": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.1.1.tgz",
|
||||
@@ -5294,15 +5275,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.4.13",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.13.tgz",
|
||||
"integrity": "sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
}
|
||||
},
|
||||
"node_modules/eight-colors": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/eight-colors/-/eight-colors-1.3.3.tgz",
|
||||
@@ -6689,18 +6661,6 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/marked": {
|
||||
"version": "18.0.9",
|
||||
"resolved": "https://registry.npmjs.org/marked/-/marked-18.0.9.tgz",
|
||||
"integrity": "sha512-/Sa4qiiHZxf0/FQdBBowr9q4r10krCwMvpK48FUBdXdUXScDxiQGR9zCPrFgRVR5LU3iySOiIjy09ZQvADir1w==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"marked": "bin/marked.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/mdast-util-find-and-replace": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz",
|
||||
|
||||
@@ -24,11 +24,8 @@
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@pierre/diffs": "^1.3.5",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"dompurify": "^3.4.13",
|
||||
"lucide-react": "^1.28.0",
|
||||
"marked": "^18.0.9",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.8",
|
||||
"react-markdown": "^10.1.0",
|
||||
|
||||
@@ -21,144 +21,3 @@ self.addEventListener("activate", (e) => {
|
||||
// No fetch handler: requests go to the network directly. The Vite build
|
||||
// output is content-hashed, so HTTP caching headers handle offline/cache
|
||||
// behavior without us re-implementing cache-first logic.
|
||||
|
||||
// Web Push receiver. The server POSTs an AES-128-GCM encrypted payload
|
||||
// to the browser's push endpoint; the browser decrypts it and fires
|
||||
// this event with the plaintext JSON. Shape:
|
||||
// { title, body, url, tag, session_id }
|
||||
// renotify:true on showNotification is required for iOS to re-buzz the
|
||||
// lock screen when a notification with a matching tag is already present.
|
||||
//
|
||||
// Focused-client suppression: if any PWA window is currently visible
|
||||
// and focused when the push arrives, we skip the OS notification and
|
||||
// postMessage the payload to the client so it can show an in-app toast
|
||||
// instead. userVisibleOnly demands SOMETHING user-visible per push;
|
||||
// iOS may warn if it stays silent indefinitely, but in practice a
|
||||
// focused tab is rare enough for pushes that revocation hasn't been
|
||||
// an issue. If it becomes one, fall back to showing the notification
|
||||
// anyway and let the client suppress its own toast.
|
||||
// Per-tag high-water mark of the latest seq we've shown or cleared. Lets an
|
||||
// out-of-order older notify (delivered AFTER a newer clear) be dropped instead
|
||||
// of resurrecting a handled request's notification: getNotifications only sees
|
||||
// what is open right now, so a clear cannot close a notify that has not arrived
|
||||
// yet. See #2491.
|
||||
// ponytail: in-memory only, lost if the browser terminates the worker between
|
||||
// pushes; reordering only matters when pushes are near-simultaneous (the worker
|
||||
// stays warm), so IndexedDB persistence would buy a moot window.
|
||||
const seqHighWater = new Map();
|
||||
function isStaleSeq(tag, seq) {
|
||||
if (seq == null || !tag) return false;
|
||||
const hw = seqHighWater.get(tag);
|
||||
return hw != null && seq < hw;
|
||||
}
|
||||
function recordSeq(tag, seq) {
|
||||
if (seq == null || !tag) return;
|
||||
const hw = seqHighWater.get(tag);
|
||||
if (hw == null || seq > hw) seqHighWater.set(tag, seq);
|
||||
}
|
||||
|
||||
self.addEventListener("push", (event) => {
|
||||
let payload = {};
|
||||
if (event.data) {
|
||||
try {
|
||||
payload = event.data.json();
|
||||
} catch {
|
||||
payload = { title: "Agent of Empires", body: event.data.text() };
|
||||
}
|
||||
}
|
||||
// Retract path: a "clear" push closes a previously shown approval or
|
||||
// question notification once that request was handled on another device,
|
||||
// so a backgrounded phone or second computer stops showing a stale alert.
|
||||
// It shows nothing (the request is gone) and is NOT focus-gated. The seq
|
||||
// guard skips closing a NEWER notification when a clear for an earlier
|
||||
// request in the same session is delivered out of order. See #2491.
|
||||
if (payload.kind === "clear") {
|
||||
const tag = payload.tag;
|
||||
// Record the clear's seq even when getNotifications is unsupported, so a
|
||||
// later older notify for this tag is dropped rather than shown.
|
||||
recordSeq(tag, payload.seq);
|
||||
event.waitUntil(
|
||||
(async () => {
|
||||
if (!tag || !self.registration.getNotifications) return;
|
||||
try {
|
||||
const existing = await self.registration.getNotifications({ tag });
|
||||
for (const n of existing) {
|
||||
const nseq = n.data && n.data.seq;
|
||||
if (nseq == null || payload.seq == null || nseq <= payload.seq) {
|
||||
n.close();
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* getNotifications may be unsupported or throw; never fail the push */
|
||||
}
|
||||
})(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Drop an out-of-order notify that is older than a clear (or newer notify)
|
||||
// already seen for this tag: the request it announces was already handled.
|
||||
const tag = payload.tag || "aoe";
|
||||
if (isStaleSeq(tag, payload.seq)) return;
|
||||
recordSeq(tag, payload.seq);
|
||||
|
||||
const title = payload.title || "Agent of Empires";
|
||||
const options = {
|
||||
body: payload.body || "",
|
||||
tag,
|
||||
renotify: true,
|
||||
// Store tag + seq so a later "clear" push can match this notification
|
||||
// and the seq guard can avoid closing it if a newer one supersedes it.
|
||||
data: { url: payload.url || "/", tag: payload.tag, seq: payload.seq },
|
||||
icon: "/icon-192.png",
|
||||
badge: "/icon-192.png",
|
||||
};
|
||||
|
||||
event.waitUntil(
|
||||
(async () => {
|
||||
const clientList = await self.clients.matchAll({
|
||||
type: "window",
|
||||
includeUncontrolled: true,
|
||||
});
|
||||
const focused = clientList.find((c) => c.visibilityState === "visible" && c.focused);
|
||||
if (focused) {
|
||||
// User is already in the app, forward the payload for an in-app
|
||||
// toast, skip the OS notification. If the client has no handler,
|
||||
// the message is silently dropped which is fine.
|
||||
try {
|
||||
focused.postMessage({ type: "aoe-push", payload });
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return;
|
||||
}
|
||||
await self.registration.showNotification(title, options);
|
||||
})(),
|
||||
);
|
||||
});
|
||||
|
||||
// Tap-to-open. Look for an existing PWA window first so we focus it
|
||||
// (and navigate if needed) rather than opening a second instance.
|
||||
self.addEventListener("notificationclick", (event) => {
|
||||
event.notification.close();
|
||||
const target = (event.notification.data && event.notification.data.url) || "/";
|
||||
event.waitUntil(
|
||||
self.clients.matchAll({ type: "window", includeUncontrolled: true }).then(async (clientList) => {
|
||||
for (const client of clientList) {
|
||||
if ("focus" in client) {
|
||||
if (client.url !== target && "navigate" in client) {
|
||||
try {
|
||||
await client.navigate(target);
|
||||
} catch {
|
||||
/* SW may not be able to navigate across origins etc; ignore */
|
||||
}
|
||||
}
|
||||
return client.focus();
|
||||
}
|
||||
}
|
||||
if (self.clients.openWindow) {
|
||||
return self.clients.openWindow(target);
|
||||
}
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
+1
-21
@@ -12,7 +12,6 @@ import {
|
||||
import { useMatch, useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { IDLE_DECAY_WINDOW_MS, isSessionActive } from "./lib/session";
|
||||
import { useSessions } from "./hooks/useSessions";
|
||||
import { useDashboardPresence } from "./hooks/useDashboardPresence";
|
||||
import { clearAcpCache } from "./hooks/useAcpSession";
|
||||
import { clearDraft, sweepOrphanDrafts } from "./lib/acpDrafts";
|
||||
import { AcpPrefsProvider } from "./lib/acpPrefs";
|
||||
@@ -60,8 +59,6 @@ import {
|
||||
deleteProject,
|
||||
setSessionUnread,
|
||||
killTerminal,
|
||||
setSessionPin,
|
||||
setSessionArchive,
|
||||
trashSession,
|
||||
restoreSession,
|
||||
} from "./lib/api";
|
||||
@@ -70,7 +67,6 @@ import { normalizeProjectPathKey } from "./lib/registeredProjects";
|
||||
import { IdleDecayWindowContext, parseIdleDecayWindowMs, useIdleDecayWindowMs } from "./lib/idleDecay";
|
||||
import { parseUnreadIndicatorEnabled, UnreadIndicatorContext, useUnreadIndicatorEnabled } from "./lib/unreadIndicator";
|
||||
import { parseSessionRowTagMode, SessionRowTagContext, type SessionRowTagMode } from "./lib/sessionRowTag";
|
||||
import { parseSessionColorsEnabled, SessionColorsContext } from "./lib/sessionColors";
|
||||
import { toastBus, reportError } from "./lib/toastBus";
|
||||
import { isAbsolutePath, resolveToRepoRelative, type FileRef } from "./lib/fileRef";
|
||||
import { OPEN_SESSION_EVENT } from "./lib/sessionRoute";
|
||||
@@ -131,13 +127,11 @@ export default function App() {
|
||||
const [idleDecayWindowMs, setIdleDecayWindowMs] = useState(IDLE_DECAY_WINDOW_MS);
|
||||
const [unreadIndicatorEnabled, setUnreadIndicatorEnabled] = useState(true);
|
||||
const [sessionRowTagMode, setSessionRowTagMode] = useState<SessionRowTagMode>("branch");
|
||||
const [sessionColorsEnabled, setSessionColorsEnabled] = useState(true);
|
||||
|
||||
const applyAppSettings = useCallback((settings: Record<string, unknown> | null | undefined) => {
|
||||
setIdleDecayWindowMs(parseIdleDecayWindowMs(settings));
|
||||
setUnreadIndicatorEnabled(parseUnreadIndicatorEnabled(settings));
|
||||
setSessionRowTagMode(parseSessionRowTagMode(settings));
|
||||
setSessionColorsEnabled(parseSessionColorsEnabled(settings));
|
||||
}, []);
|
||||
|
||||
const refreshAppSettings = useCallback(async () => {
|
||||
@@ -152,9 +146,7 @@ export default function App() {
|
||||
<IdleDecayWindowContext.Provider value={idleDecayWindowMs}>
|
||||
<UnreadIndicatorContext.Provider value={unreadIndicatorEnabled}>
|
||||
<SessionRowTagContext.Provider value={sessionRowTagMode}>
|
||||
<SessionColorsContext.Provider value={sessionColorsEnabled}>
|
||||
<AppContent onSettingsRefresh={refreshAppSettings} />
|
||||
</SessionColorsContext.Provider>
|
||||
<AppContent onSettingsRefresh={refreshAppSettings} />
|
||||
</SessionRowTagContext.Provider>
|
||||
</UnreadIndicatorContext.Provider>
|
||||
</IdleDecayWindowContext.Provider>
|
||||
@@ -162,7 +154,6 @@ export default function App() {
|
||||
}
|
||||
|
||||
function AppContent({ onSettingsRefresh }: { onSettingsRefresh: () => Promise<void> | void }) {
|
||||
useDashboardPresence();
|
||||
// Wire the localStorage write chokepoint and pull the server-side UI-state
|
||||
// blob into localStorage. AppContent only mounts past auth, so this runs as
|
||||
// the authenticated user. Background (does NOT gate render): blocking first
|
||||
@@ -1057,11 +1048,6 @@ function AppContent({ onSettingsRefresh }: { onSettingsRefresh: () => Promise<vo
|
||||
setShowSessionWizard(true);
|
||||
}, [serverAbout?.read_only]);
|
||||
|
||||
const handleCloneFromUrl = useCallback(() => {
|
||||
setWizardPrefill({ initialTab: "clone" });
|
||||
setShowSessionWizard(true);
|
||||
}, []);
|
||||
|
||||
const handleToggleTerminalFocus = useCallback(() => {
|
||||
if (!activeSessionId) return;
|
||||
// Probe by data-term attribute rather than a component ref: it is
|
||||
@@ -1219,10 +1205,6 @@ function AppContent({ onSettingsRefresh }: { onSettingsRefresh: () => Promise<vo
|
||||
const handleSessionStateAction = useCallback(
|
||||
async (id: string, action: SessionStateAction) => {
|
||||
const run: Record<SessionStateAction, () => Promise<SessionResponse | null>> = {
|
||||
pin: () => setSessionPin(id, true),
|
||||
unpin: () => setSessionPin(id, false),
|
||||
archive: () => setSessionArchive(id, true),
|
||||
unarchive: () => setSessionArchive(id, false),
|
||||
trash: () => trashSession(id),
|
||||
untrash: () => restoreSession(id),
|
||||
};
|
||||
@@ -1311,7 +1293,6 @@ function AppContent({ onSettingsRefresh }: { onSettingsRefresh: () => Promise<vo
|
||||
sessions={sessions}
|
||||
onSelectSession={handleSelectSession}
|
||||
onNewSession={handleNewSession}
|
||||
onCloneFromUrl={handleCloneFromUrl}
|
||||
onToggleSidebar={handleToggleSidebar}
|
||||
readOnly={serverAbout?.read_only}
|
||||
/>
|
||||
@@ -1374,7 +1355,6 @@ function AppContent({ onSettingsRefresh }: { onSettingsRefresh: () => Promise<vo
|
||||
tool={activeSession.tool}
|
||||
acpAgent={activeSession.acp_agent ?? null}
|
||||
clearAliases={activeSession.clear_aliases}
|
||||
archivedAt={activeSession.archived_at ?? null}
|
||||
trashedAt={activeSession.trashed_at ?? null}
|
||||
onRestore={
|
||||
activeSession.trashed_at
|
||||
|
||||
@@ -1,149 +0,0 @@
|
||||
// Unit coverage for the push/clear contract in web/public/sw.js (#2491).
|
||||
// The service worker is a plain classic worker file, not a module, so we
|
||||
// load its source into a fresh VM context with the small slice of the SW
|
||||
// global surface it touches (self, registration, clients) stubbed, then
|
||||
// dispatch synthetic push events and await their waitUntil promises.
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import vm from "node:vm";
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const swPath = fileURLToPath(new URL("../../public/sw.js", import.meta.url));
|
||||
const swSource = readFileSync(swPath, "utf8");
|
||||
|
||||
type Handler = (event: unknown) => void;
|
||||
|
||||
function loadSw() {
|
||||
const handlers = new Map<string, Handler[]>();
|
||||
const showNotification = vi.fn();
|
||||
const getNotifications = vi.fn().mockResolvedValue([]);
|
||||
const matchAll = vi.fn().mockResolvedValue([]);
|
||||
const self = {
|
||||
addEventListener: (type: string, fn: Handler) => {
|
||||
const list = handlers.get(type) ?? [];
|
||||
list.push(fn);
|
||||
handlers.set(type, list);
|
||||
},
|
||||
skipWaiting: vi.fn(),
|
||||
location: { origin: "https://aoe.test" },
|
||||
clients: { matchAll, claim: vi.fn(), openWindow: vi.fn() },
|
||||
registration: { showNotification, getNotifications },
|
||||
};
|
||||
// install/activate handlers register here but only run if dispatched; we
|
||||
// only dispatch "push", so caches/skipWaiting are never exercised.
|
||||
vm.runInNewContext(swSource, { self, caches: { keys: vi.fn(), delete: vi.fn() } });
|
||||
return { handlers, showNotification, getNotifications, matchAll };
|
||||
}
|
||||
|
||||
async function dispatchPush(handlers: Map<string, Handler[]>, payload: unknown) {
|
||||
const pending: Promise<unknown>[] = [];
|
||||
const event = {
|
||||
data: { json: () => payload, text: () => JSON.stringify(payload) },
|
||||
waitUntil: (p: Promise<unknown>) => pending.push(Promise.resolve(p)),
|
||||
};
|
||||
for (const fn of handlers.get("push") ?? []) fn(event);
|
||||
await Promise.all(pending);
|
||||
}
|
||||
|
||||
const APPROVAL_TAG = "acp-approval-s1";
|
||||
const QUESTION_TAG = "acp-question-s1";
|
||||
|
||||
describe("service worker push handler (#2491)", () => {
|
||||
it("shows a notification for a normal payload and stores tag + seq", async () => {
|
||||
const { handlers, showNotification, getNotifications } = loadSw();
|
||||
await dispatchPush(handlers, {
|
||||
kind: "notify",
|
||||
title: "needs approval",
|
||||
body: "Bash",
|
||||
url: "/sessions/s1/acp",
|
||||
tag: APPROVAL_TAG,
|
||||
seq: 5,
|
||||
});
|
||||
expect(showNotification).toHaveBeenCalledTimes(1);
|
||||
const [title, options] = showNotification.mock.calls[0];
|
||||
expect(title).toBe("needs approval");
|
||||
expect(options.tag).toBe(APPROVAL_TAG);
|
||||
expect(options.data).toMatchObject({ tag: APPROVAL_TAG, seq: 5, url: "/sessions/s1/acp" });
|
||||
expect(getNotifications).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("forwards a normal payload to a focused client instead of showing", async () => {
|
||||
const { handlers, showNotification, matchAll } = loadSw();
|
||||
const postMessage = vi.fn();
|
||||
matchAll.mockResolvedValue([{ visibilityState: "visible", focused: true, postMessage }]);
|
||||
const payload = { kind: "notify", title: "t", tag: APPROVAL_TAG, seq: 1 };
|
||||
await dispatchPush(handlers, payload);
|
||||
expect(postMessage).toHaveBeenCalledWith({ type: "aoe-push", payload });
|
||||
expect(showNotification).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("closes matching notifications on a clear and shows nothing", async () => {
|
||||
const { handlers, showNotification, getNotifications, matchAll } = loadSw();
|
||||
const closeA = vi.fn();
|
||||
const closeB = vi.fn();
|
||||
getNotifications.mockResolvedValue([
|
||||
{ data: { seq: 5 }, close: closeA },
|
||||
{ data: { seq: 6 }, close: closeB },
|
||||
]);
|
||||
await dispatchPush(handlers, { kind: "clear", tag: APPROVAL_TAG, seq: 10 });
|
||||
expect(getNotifications).toHaveBeenCalledWith({ tag: APPROVAL_TAG });
|
||||
expect(closeA).toHaveBeenCalled();
|
||||
expect(closeB).toHaveBeenCalled();
|
||||
expect(showNotification).not.toHaveBeenCalled();
|
||||
// clear is not focus-gated, so it never inspects clients.
|
||||
expect(matchAll).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not close a newer notification than the clear's seq", async () => {
|
||||
const { handlers, getNotifications } = loadSw();
|
||||
const closeNewer = vi.fn();
|
||||
const closeOlder = vi.fn();
|
||||
getNotifications.mockResolvedValue([
|
||||
{ data: { seq: 20 }, close: closeNewer },
|
||||
{ data: { seq: 5 }, close: closeOlder },
|
||||
]);
|
||||
await dispatchPush(handlers, { kind: "clear", tag: APPROVAL_TAG, seq: 10 });
|
||||
expect(closeOlder).toHaveBeenCalled();
|
||||
expect(closeNewer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still shows a fresh notification after a clear for the same tag", async () => {
|
||||
const { handlers, showNotification, getNotifications } = loadSw();
|
||||
getNotifications.mockResolvedValue([{ data: { seq: 1 }, close: vi.fn() }]);
|
||||
await dispatchPush(handlers, { kind: "clear", tag: APPROVAL_TAG, seq: 1 });
|
||||
await dispatchPush(handlers, { kind: "notify", title: "new", tag: APPROVAL_TAG, seq: 2 });
|
||||
expect(showNotification).toHaveBeenCalledTimes(1);
|
||||
expect(showNotification.mock.calls[0][1].tag).toBe(APPROVAL_TAG);
|
||||
});
|
||||
|
||||
it("clears only the targeted (approval) tag, not the question tag", async () => {
|
||||
const { handlers, getNotifications } = loadSw();
|
||||
await dispatchPush(handlers, { kind: "clear", tag: APPROVAL_TAG, seq: 1 });
|
||||
expect(getNotifications).toHaveBeenCalledWith({ tag: APPROVAL_TAG });
|
||||
expect(getNotifications).not.toHaveBeenCalledWith({ tag: QUESTION_TAG });
|
||||
});
|
||||
|
||||
it("drops an older notify delivered after a newer clear for the same tag", async () => {
|
||||
// Out-of-order delivery: the clear arrives first, so getNotifications sees
|
||||
// nothing to close, then the stale notify lands. Without a high-water mark
|
||||
// it would resurrect a handled request's notification. See #2491.
|
||||
const { handlers, showNotification } = loadSw();
|
||||
await dispatchPush(handlers, { kind: "clear", tag: APPROVAL_TAG, seq: 10 });
|
||||
await dispatchPush(handlers, {
|
||||
kind: "notify",
|
||||
title: "stale",
|
||||
tag: APPROVAL_TAG,
|
||||
seq: 5,
|
||||
});
|
||||
expect(showNotification).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("ignores a clear with no tag without throwing or showing", async () => {
|
||||
const { handlers, showNotification, getNotifications } = loadSw();
|
||||
await dispatchPush(handlers, { kind: "clear", seq: 1 });
|
||||
expect(getNotifications).not.toHaveBeenCalled();
|
||||
expect(showNotification).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -10,19 +10,11 @@ interface Props {
|
||||
sessions: SessionResponse[];
|
||||
onSelectSession: (sessionId: string) => void;
|
||||
onNewSession: () => void;
|
||||
onCloneFromUrl: () => void;
|
||||
onToggleSidebar: () => void;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
export function Dashboard({
|
||||
sessions,
|
||||
onSelectSession,
|
||||
onNewSession,
|
||||
onCloneFromUrl,
|
||||
onToggleSidebar,
|
||||
readOnly,
|
||||
}: Props) {
|
||||
export function Dashboard({ sessions, onSelectSession, onNewSession, onToggleSidebar, readOnly }: Props) {
|
||||
const idleDecayWindowMs = useIdleDecayWindowMs();
|
||||
const isWideViewport = useIsWideViewport();
|
||||
const stats = useMemo(() => {
|
||||
@@ -223,7 +215,6 @@ export function Dashboard({
|
||||
icon="folder"
|
||||
featured
|
||||
/>
|
||||
<ActionPane title="Clone URL" subtitle="Clone a repo from a URL" onClick={onCloneFromUrl} icon="git" />
|
||||
<ActionPane
|
||||
title="Docs"
|
||||
subtitle="Guides and reference"
|
||||
|
||||
@@ -72,7 +72,6 @@ export function MobileMainPane({
|
||||
tool={activeSession.tool}
|
||||
acpAgent={activeSession.acp_agent ?? null}
|
||||
clearAliases={activeSession.clear_aliases}
|
||||
archivedAt={activeSession.archived_at ?? null}
|
||||
trashedAt={activeSession.trashed_at ?? null}
|
||||
onOpenFileRef={onOpenFileRef}
|
||||
fileRefSession={activeSession}
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
import { usePushSubscription } from "../hooks/usePushSubscription";
|
||||
|
||||
// Push-notifications settings section. Rendered inside SettingsView.
|
||||
// State machine lives in usePushSubscription; this component is a view
|
||||
// on top of it with a single Enable/Disable primary action and a
|
||||
// secondary test-send button when enabled.
|
||||
|
||||
export function NotificationSettings() {
|
||||
const { state, enable, disable, sendTest, resubscribe } = usePushSubscription();
|
||||
|
||||
const isBusy =
|
||||
state.kind === "asking" ||
|
||||
state.kind === "subscribing" ||
|
||||
state.kind === "sending-test" ||
|
||||
state.kind === "disabling" ||
|
||||
state.kind === "loading";
|
||||
|
||||
return (
|
||||
<section className="rounded-lg border border-surface-700/50 bg-surface-900 p-4">
|
||||
<h3 className="font-mono text-sm uppercase tracking-widest text-text-muted mb-3">Notifications</h3>
|
||||
|
||||
<StatusRow state={state} />
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{(state.kind === "off" || state.kind === "denied" || state.kind === "error") && (
|
||||
<button
|
||||
onClick={enable}
|
||||
disabled={isBusy}
|
||||
className="px-3 py-2 rounded-md bg-brand-600 hover:bg-brand-500 disabled:opacity-50 disabled:cursor-not-allowed text-sm font-medium text-surface-950 transition-colors"
|
||||
>
|
||||
{isBusy ? "Working..." : "Enable notifications"}
|
||||
</button>
|
||||
)}
|
||||
{state.kind === "enabled" && (
|
||||
<>
|
||||
<button
|
||||
onClick={sendTest}
|
||||
className="px-3 py-2 rounded-md bg-surface-700 hover:bg-surface-700/70 text-sm font-medium text-text-primary transition-colors"
|
||||
>
|
||||
Send test notification
|
||||
</button>
|
||||
<button
|
||||
onClick={resubscribe}
|
||||
title="Re-register this device. Use after changing the server port or hostname so notifications open the right URL."
|
||||
className="px-3 py-2 rounded-md border border-surface-700 hover:bg-surface-700/40 text-sm font-medium text-text-secondary transition-colors"
|
||||
>
|
||||
Re-subscribe
|
||||
</button>
|
||||
<button
|
||||
onClick={disable}
|
||||
className="px-3 py-2 rounded-md border border-surface-700 hover:bg-surface-700/40 text-sm font-medium text-text-secondary transition-colors"
|
||||
>
|
||||
Turn off
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{state.kind === "unsupported" && state.reason === "ios-not-standalone" && <IOSInstallHelp />}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusRow({ state }: { state: ReturnType<typeof usePushSubscription>["state"] }) {
|
||||
switch (state.kind) {
|
||||
case "loading":
|
||||
return <p className="text-sm text-text-secondary">Checking...</p>;
|
||||
case "off":
|
||||
return (
|
||||
<p className="text-sm text-text-secondary">
|
||||
Off. Enable to receive a browser notification when an agent is waiting for your input.
|
||||
</p>
|
||||
);
|
||||
case "asking":
|
||||
return <p className="text-sm text-text-secondary">Asking your browser for permission...</p>;
|
||||
case "subscribing":
|
||||
return <p className="text-sm text-text-secondary">Registering device...</p>;
|
||||
case "enabled":
|
||||
return (
|
||||
<p className="text-sm text-status-running">
|
||||
Enabled. This device will get a lock-screen notification when an agent is waiting.
|
||||
</p>
|
||||
);
|
||||
case "sending-test":
|
||||
return (
|
||||
<p className="text-sm text-text-secondary">
|
||||
Sending test notification in a few seconds. On a phone, lock the screen now to see it land on the Lock Screen.
|
||||
</p>
|
||||
);
|
||||
case "disabling":
|
||||
return <p className="text-sm text-text-secondary">Turning off...</p>;
|
||||
case "denied":
|
||||
return (
|
||||
<p className="text-sm text-status-error">
|
||||
Permission was denied. Re-enable in your browser's site settings before turning this back on.
|
||||
</p>
|
||||
);
|
||||
case "disabled-by-server":
|
||||
return (
|
||||
<p className="text-sm text-text-secondary">
|
||||
Push notifications are turned off by the server. Contact the operator, or enable `web.notifications_enabled`
|
||||
in TUI settings.
|
||||
</p>
|
||||
);
|
||||
case "unsupported":
|
||||
if (state.reason === "insecure-origin") {
|
||||
return (
|
||||
<p className="text-sm text-text-secondary">
|
||||
Push notifications require HTTPS. On mobile, reach this dashboard through a TLS-terminating reverse proxy
|
||||
(or a VPN that provides HTTPS) rather than the plain{" "}
|
||||
<code className="font-mono text-text-primary">http://</code> LAN URL.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
if (state.reason === "ios-not-standalone") {
|
||||
return (
|
||||
<p className="text-sm text-text-secondary">
|
||||
Push notifications on iPhone require the app to be installed from Safari via Share, then Add to Home Screen.
|
||||
Open the installed app to enable.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
return <p className="text-sm text-text-secondary">Your browser does not support Web Push.</p>;
|
||||
case "error":
|
||||
return <p className="text-sm text-status-error">Error: {state.message}</p>;
|
||||
}
|
||||
}
|
||||
|
||||
function IOSInstallHelp() {
|
||||
return (
|
||||
<details className="mt-3 text-sm text-text-secondary">
|
||||
<summary className="cursor-pointer select-none">How to install on iPhone</summary>
|
||||
<ol className="mt-2 ml-5 list-decimal space-y-1">
|
||||
<li>Tap the Share icon at the bottom of Safari.</li>
|
||||
<li>
|
||||
Scroll down and tap <em>Add to Home Screen</em>.
|
||||
</li>
|
||||
<li>Open the app from your Home Screen (not Safari), then come back to this page.</li>
|
||||
<li>Tap Enable notifications.</li>
|
||||
</ol>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { useServerDown, OFFLINE_TITLE } from "../lib/connectionState";
|
||||
import { NotificationSettings } from "./NotificationSettings";
|
||||
import { TerminalSettings } from "./TerminalSettings";
|
||||
import {
|
||||
fetchProfiles,
|
||||
@@ -28,7 +27,6 @@ export type TabId =
|
||||
| "theme"
|
||||
| "tmux"
|
||||
| "updates"
|
||||
| "notifications"
|
||||
| "panels"
|
||||
| "terminal"
|
||||
| "structured-view"
|
||||
@@ -83,8 +81,6 @@ export function buildSidebar(): SidebarItem[] {
|
||||
{ kind: "tab", id: "sandbox", label: "Sandbox" },
|
||||
{ kind: "tab", id: "worktree", label: "Worktree" },
|
||||
{ kind: "tab", id: "tmux", label: "Tmux" },
|
||||
{ kind: "divider", label: "Notifications" },
|
||||
{ kind: "tab", id: "notifications", label: "Notifications" },
|
||||
{ kind: "divider", label: "Web Dashboard" },
|
||||
{ kind: "tab", id: "panels", label: "Panels" },
|
||||
{ kind: "tab", id: "terminal", label: "Terminal" },
|
||||
@@ -119,7 +115,6 @@ const ALL_TAB_IDS = new Set<TabId>([
|
||||
"theme",
|
||||
"tmux",
|
||||
"updates",
|
||||
"notifications",
|
||||
"panels",
|
||||
"terminal",
|
||||
"structured-view",
|
||||
@@ -143,7 +138,6 @@ const SCHEMA_BACKED_TABS = new Set<TabId>([
|
||||
"tmux",
|
||||
"updates",
|
||||
"logging",
|
||||
"notifications",
|
||||
"structured-view",
|
||||
]);
|
||||
|
||||
@@ -321,7 +315,6 @@ export function SettingsView({
|
||||
const session = (settings?.session ?? {}) as Record<string, unknown>;
|
||||
const sandbox = (settings?.sandbox ?? {}) as Record<string, unknown>;
|
||||
const worktree = (settings?.worktree ?? {}) as Record<string, unknown>;
|
||||
const web = (settings?.web ?? {}) as Record<string, unknown>;
|
||||
|
||||
const saveField = useCallback(
|
||||
(section: string, sectionData: Record<string, unknown>, field: string, value: unknown): Promise<boolean> => {
|
||||
@@ -368,7 +361,6 @@ export function SettingsView({
|
||||
if (
|
||||
!settings &&
|
||||
activeTab !== "profiles" &&
|
||||
activeTab !== "notifications" &&
|
||||
activeTab !== "terminal" &&
|
||||
activeTab !== "structured-view" &&
|
||||
activeTab !== "panels"
|
||||
@@ -400,10 +392,10 @@ export function SettingsView({
|
||||
};
|
||||
|
||||
// Pure schema tabs (whole body is one SchemaSection) short-circuit on the
|
||||
// guard. Mixed tabs (session, notifications) render their non-schema rows
|
||||
// regardless and guard only the SchemaSection slot, so a slow or failed
|
||||
// schema fetch never hides the default-profile selector or the push block.
|
||||
if (SCHEMA_BACKED_TABS.has(activeTab) && activeTab !== "session" && activeTab !== "notifications") {
|
||||
// guard. The mixed `session` tab renders its non-schema rows regardless
|
||||
// and guards only the SchemaSection slot, so a slow or failed schema
|
||||
// fetch never hides the default-profile selector.
|
||||
if (SCHEMA_BACKED_TABS.has(activeTab) && activeTab !== "session") {
|
||||
const guard = schemaGuard();
|
||||
if (guard) return guard;
|
||||
}
|
||||
@@ -515,30 +507,6 @@ export function SettingsView({
|
||||
/>
|
||||
);
|
||||
|
||||
case "notifications":
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Browser-push controls render regardless of schema state. */}
|
||||
<NotificationSettings />
|
||||
<div className="space-y-4">
|
||||
<h4 className="text-xs font-mono uppercase tracking-widest text-text-muted">Server Defaults</h4>
|
||||
<p className="text-xs text-text-dim">
|
||||
Controls which session events trigger push notifications on the server.
|
||||
</p>
|
||||
{schemaGuard() ??
|
||||
(settings && (
|
||||
<SchemaSection
|
||||
section="web"
|
||||
schema={schema}
|
||||
focusRequest={focusRequest}
|
||||
values={web}
|
||||
onSaveField={saveSubField}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case "terminal":
|
||||
return <TerminalSettings />;
|
||||
case "structured-view": {
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { toastBus, type ToastApi, type ToastKind } from "../lib/toastBus";
|
||||
import { requestOpenSession } from "../lib/sessionRoute";
|
||||
import { isExternalHttpUrl, openExternal } from "../lib/url";
|
||||
|
||||
interface Toast {
|
||||
id: number;
|
||||
kind: ToastKind;
|
||||
message: string;
|
||||
sessionId?: string;
|
||||
href?: string;
|
||||
}
|
||||
|
||||
@@ -32,20 +30,8 @@ export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
[dismiss],
|
||||
);
|
||||
|
||||
// Pushes a toast with an associated session so tapping it jumps to
|
||||
// that session. Kept internal to this module since only the SW push
|
||||
// handler uses it.
|
||||
const pushWithSession = useCallback(
|
||||
(message: string, sessionId: string) => {
|
||||
const id = nextId.current++;
|
||||
setToasts((t) => [...t, { id, kind: "info", message, sessionId }]);
|
||||
setTimeout(() => dismiss(id), TOAST_LIFETIME_MS);
|
||||
},
|
||||
[dismiss],
|
||||
);
|
||||
|
||||
// A click-to-open toast (worker `ui.open_url`): a browser blocks `window.open`
|
||||
// from an async push, so opening waits for the tap.
|
||||
// A click-to-open toast (worker `ui.open_url`): a browser blocks
|
||||
// `window.open` outside a user gesture, so opening waits for the tap.
|
||||
const pushWithHref = useCallback(
|
||||
(message: string, href: string) => {
|
||||
const id = nextId.current++;
|
||||
@@ -65,55 +51,18 @@ export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
[push, pushWithHref],
|
||||
);
|
||||
|
||||
// Service worker forwards incoming push payloads here when the PWA
|
||||
// is already visible and focused, so we show the notification as an
|
||||
// in-app toast instead of an OS lock-screen buzz. Matches the
|
||||
// "don't bug me if I'm already looking at the app" requirement.
|
||||
useEffect(() => {
|
||||
if (typeof navigator === "undefined" || !navigator.serviceWorker) return;
|
||||
const handler = (event: MessageEvent) => {
|
||||
const data = event.data as {
|
||||
type?: string;
|
||||
payload?: {
|
||||
title?: string;
|
||||
body?: string;
|
||||
session_id?: string;
|
||||
};
|
||||
} | null;
|
||||
if (!data || data.type !== "aoe-push" || !data.payload) return;
|
||||
const title = data.payload.title ?? "Agent of Empires";
|
||||
const body = data.payload.body ?? "";
|
||||
const message = body ? `${title}: ${body}` : title;
|
||||
const sessionId = data.payload.session_id;
|
||||
if (sessionId) {
|
||||
pushWithSession(message, sessionId);
|
||||
} else {
|
||||
push(message, "info");
|
||||
}
|
||||
};
|
||||
navigator.serviceWorker.addEventListener("message", handler);
|
||||
return () => {
|
||||
navigator.serviceWorker.removeEventListener("message", handler);
|
||||
};
|
||||
}, [push, pushWithSession]);
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={api}>
|
||||
{children}
|
||||
<div className="fixed bottom-4 right-4 z-[80] flex flex-col gap-2 max-w-[92vw] sm:max-w-sm">
|
||||
{toasts.map((t) => {
|
||||
const clickable = !!t.sessionId || !!t.href;
|
||||
const clickable = !!t.href;
|
||||
const onToastClick = () => {
|
||||
if (t.href) {
|
||||
// Defensive re-check: the server already rejects non-http(s)
|
||||
// notification hrefs, but never hand an unvalidated scheme to
|
||||
// window.open in case a future push path skips that gate.
|
||||
if (isExternalHttpUrl(t.href)) openExternal(t.href);
|
||||
} else if (t.sessionId) {
|
||||
requestOpenSession(t.sessionId);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
if (!t.href) return;
|
||||
// Defensive re-check: the server already rejects non-http(s)
|
||||
// hrefs, but never hand an unvalidated scheme to window.open in
|
||||
// case a future caller skips that gate.
|
||||
if (isExternalHttpUrl(t.href)) openExternal(t.href);
|
||||
dismiss(t.id);
|
||||
};
|
||||
return (
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -43,7 +43,6 @@ describe("Dashboard mobile recent sessions", () => {
|
||||
sessions={sessions}
|
||||
onSelectSession={onSelectSession}
|
||||
onNewSession={vi.fn()}
|
||||
onCloneFromUrl={vi.fn()}
|
||||
onToggleSidebar={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -29,13 +29,7 @@ function session(over: Partial<SessionResponse>): SessionResponse {
|
||||
|
||||
function renderDashboard(sessions: SessionResponse[]) {
|
||||
return render(
|
||||
<Dashboard
|
||||
sessions={sessions}
|
||||
onSelectSession={vi.fn()}
|
||||
onNewSession={vi.fn()}
|
||||
onCloneFromUrl={vi.fn()}
|
||||
onToggleSidebar={vi.fn()}
|
||||
/>,
|
||||
<Dashboard sessions={sessions} onSelectSession={vi.fn()} onNewSession={vi.fn()} onToggleSidebar={vi.fn()} />,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -9,20 +9,11 @@
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { useMemo, useRef, type ReactNode } from "react";
|
||||
import { useRef, type ReactNode } from "react";
|
||||
|
||||
import { DragSuppressContext, SessionRow, type RowBulkApi } from "../WorkspaceSidebar";
|
||||
import { useSidebarTriage } from "../../hooks/useSidebarTriage";
|
||||
import { DragSuppressContext, SessionRow } from "../WorkspaceSidebar";
|
||||
import type { SessionResponse, Workspace } from "../../lib/types";
|
||||
|
||||
// Single-row stub for the bulk-triage bridge: these tests mount one unselected
|
||||
// row, so the context menu is always single-scope.
|
||||
const SINGLE_BULK_API: RowBulkApi = {
|
||||
prepareScope: () => ({ kind: "single" }),
|
||||
pin: () => {},
|
||||
archive: () => {},
|
||||
};
|
||||
|
||||
function session(over: Partial<SessionResponse> = {}): SessionResponse {
|
||||
return {
|
||||
id: "s1",
|
||||
@@ -49,9 +40,6 @@ function session(over: Partial<SessionResponse> = {}): SessionResponse {
|
||||
delete_sandbox: false,
|
||||
},
|
||||
remote_owner: null,
|
||||
notify_on_waiting: null,
|
||||
notify_on_idle: null,
|
||||
notify_on_error: null,
|
||||
claude_fullscreen: false,
|
||||
workspace_repos: [],
|
||||
...over,
|
||||
@@ -77,22 +65,7 @@ function Wrap({ children }: { children: ReactNode }) {
|
||||
}
|
||||
|
||||
function Row({ ws, readOnly }: { ws: Workspace; readOnly?: boolean }) {
|
||||
const workspaces = useMemo(() => [ws], [ws]);
|
||||
const triage = useSidebarTriage(workspaces);
|
||||
return (
|
||||
<SessionRow
|
||||
workspace={ws}
|
||||
isActive={false}
|
||||
isSelected={false}
|
||||
onActivate={() => {}}
|
||||
readOnly={readOnly}
|
||||
optimistic={triage.optimisticFor(ws.id)}
|
||||
onPinToggle={triage.pinToggle}
|
||||
onArchiveToggle={triage.archiveToggle}
|
||||
onUnreadToggle={triage.unreadToggle}
|
||||
bulkApi={SINGLE_BULK_API}
|
||||
/>
|
||||
);
|
||||
return <SessionRow workspace={ws} isActive={false} isSelected={false} onActivate={() => {}} readOnly={readOnly} />;
|
||||
}
|
||||
|
||||
const fetchSpy = vi.fn<typeof fetch>();
|
||||
|
||||
@@ -1,135 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// Contract test for the NotificationSettings panel. The real Web Push
|
||||
// flow is non-trivial to run in jsdom (no ServiceWorker, no PushManager,
|
||||
// no Notification.requestPermission), so this suite mocks the
|
||||
// usePushSubscription hook entirely and asserts the rendered UI matches
|
||||
// the hook state plus that user actions invoke the corresponding hook
|
||||
// primitives. Part of #1217.
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { fireEvent, render } from "@testing-library/react";
|
||||
|
||||
import type { PushState } from "../../hooks/usePushSubscription";
|
||||
|
||||
const enable = vi.fn();
|
||||
const disable = vi.fn();
|
||||
const sendTest = vi.fn();
|
||||
const resubscribe = vi.fn();
|
||||
const refresh = vi.fn();
|
||||
let currentState: PushState = { kind: "off" };
|
||||
|
||||
vi.mock("../../hooks/usePushSubscription", () => ({
|
||||
usePushSubscription: () => ({
|
||||
state: currentState,
|
||||
enable,
|
||||
disable,
|
||||
sendTest,
|
||||
resubscribe,
|
||||
refresh,
|
||||
}),
|
||||
}));
|
||||
|
||||
import { NotificationSettings } from "../NotificationSettings";
|
||||
|
||||
function setState(s: PushState) {
|
||||
currentState = s;
|
||||
enable.mockClear();
|
||||
disable.mockClear();
|
||||
sendTest.mockClear();
|
||||
resubscribe.mockClear();
|
||||
refresh.mockClear();
|
||||
}
|
||||
|
||||
function buttonByText(container: HTMLElement, match: string): HTMLButtonElement | null {
|
||||
const buttons = container.querySelectorAll("button");
|
||||
for (const b of buttons) {
|
||||
if (b.textContent && b.textContent.includes(match)) {
|
||||
return b as HTMLButtonElement;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
describe("NotificationSettings", () => {
|
||||
it("renders an Enable button when state is 'off'", () => {
|
||||
setState({ kind: "off" });
|
||||
const { container } = render(<NotificationSettings />);
|
||||
expect(buttonByText(container, "Enable notifications")).not.toBeNull();
|
||||
expect(buttonByText(container, "Send test notification")).toBeNull();
|
||||
});
|
||||
|
||||
it("clicking Enable calls hook.enable()", () => {
|
||||
setState({ kind: "off" });
|
||||
const { container } = render(<NotificationSettings />);
|
||||
const btn = buttonByText(container, "Enable notifications")!;
|
||||
fireEvent.click(btn);
|
||||
expect(enable).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("when 'enabled', shows Send test, Re-subscribe, Turn off; hides Enable", () => {
|
||||
setState({ kind: "enabled" });
|
||||
const { container } = render(<NotificationSettings />);
|
||||
expect(buttonByText(container, "Send test notification")).not.toBeNull();
|
||||
expect(buttonByText(container, "Re-subscribe")).not.toBeNull();
|
||||
expect(buttonByText(container, "Turn off")).not.toBeNull();
|
||||
expect(buttonByText(container, "Enable notifications")).toBeNull();
|
||||
});
|
||||
|
||||
it("clicking Send test, Re-subscribe, Turn off invokes the right primitives", () => {
|
||||
setState({ kind: "enabled" });
|
||||
const { container } = render(<NotificationSettings />);
|
||||
fireEvent.click(buttonByText(container, "Send test notification")!);
|
||||
fireEvent.click(buttonByText(container, "Re-subscribe")!);
|
||||
fireEvent.click(buttonByText(container, "Turn off")!);
|
||||
expect(sendTest).toHaveBeenCalledTimes(1);
|
||||
expect(resubscribe).toHaveBeenCalledTimes(1);
|
||||
expect(disable).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("'denied' state still renders the Enable button", () => {
|
||||
setState({ kind: "denied" });
|
||||
const { container } = render(<NotificationSettings />);
|
||||
expect(buttonByText(container, "Enable notifications")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("'error' state renders the message and the Enable button", () => {
|
||||
setState({ kind: "error", message: "boom" });
|
||||
const { container } = render(<NotificationSettings />);
|
||||
expect(container.textContent).toContain("boom");
|
||||
expect(buttonByText(container, "Enable notifications")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("'unsupported / ios-not-standalone' renders the install help block", () => {
|
||||
setState({ kind: "unsupported", reason: "ios-not-standalone" });
|
||||
const { container } = render(<NotificationSettings />);
|
||||
expect(container.textContent).toContain("How to install on iPhone");
|
||||
expect(buttonByText(container, "Enable notifications")).toBeNull();
|
||||
});
|
||||
|
||||
it("'unsupported / insecure-origin' surfaces the HTTPS hint", () => {
|
||||
setState({ kind: "unsupported", reason: "insecure-origin" });
|
||||
const { container } = render(<NotificationSettings />);
|
||||
expect(container.textContent).toContain("require HTTPS");
|
||||
});
|
||||
|
||||
it("'disabled-by-server' surfaces the server-disabled hint", () => {
|
||||
setState({ kind: "disabled-by-server" });
|
||||
const { container } = render(<NotificationSettings />);
|
||||
expect(container.textContent).toContain("turned off by the server");
|
||||
expect(buttonByText(container, "Enable notifications")).toBeNull();
|
||||
});
|
||||
|
||||
it("disables the Enable button while a transition is in flight", () => {
|
||||
setState({ kind: "off" });
|
||||
const { container, rerender } = render(<NotificationSettings />);
|
||||
const beforeBtn = buttonByText(container, "Enable notifications")!;
|
||||
expect(beforeBtn.disabled).toBe(false);
|
||||
setState({ kind: "asking" });
|
||||
rerender(<NotificationSettings />);
|
||||
// 'asking' has no button; verify we switched away from the actionable
|
||||
// 'off' UI (the stale Enable button is gone) onto the status text.
|
||||
expect(container.textContent).toContain("Asking your browser");
|
||||
expect(buttonByText(container, "Enable notifications")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,808 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// RTL coverage for the new triage affordances on the sidebar
|
||||
// `SessionRow`: the Pin glyph, the Archive chip, and the optimistic
|
||||
// flip invariants. Each case wires the smallest possible Workspace + a
|
||||
// DragSuppressContext stub so the row mounts without dragging into
|
||||
// the dnd-kit plumbing that the production tree provides.
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { useMemo, useRef, type ReactNode } from "react";
|
||||
|
||||
import { DragSuppressContext, SessionRow, type RowBulkApi } from "../WorkspaceSidebar";
|
||||
|
||||
// Single-row stub for the bulk-triage bridge: these tests mount one
|
||||
// unselected row, so the context menu is always single-scope. See #2312.
|
||||
const SINGLE_BULK_API: RowBulkApi = {
|
||||
prepareScope: () => ({ kind: "single" }),
|
||||
pin: () => {},
|
||||
archive: () => {},
|
||||
};
|
||||
import { UnreadIndicatorContext } from "../../lib/unreadIndicator";
|
||||
import { SessionRowTagContext, type SessionRowTagMode } from "../../lib/sessionRowTag";
|
||||
import { SessionColorsContext } from "../../lib/sessionColors";
|
||||
import { useSidebarTriage } from "../../hooks/useSidebarTriage";
|
||||
import type { SessionResponse, Workspace } from "../../lib/types";
|
||||
import { OPEN_SESSION_EVENT } from "../../lib/sessionRoute";
|
||||
import { OPEN_SWITCH_AGENT_EVENT, consumePendingSwitchAgent } from "../../lib/switchAgentTrigger";
|
||||
|
||||
function session(over: Partial<SessionResponse> = {}): SessionResponse {
|
||||
return {
|
||||
id: "s1",
|
||||
title: "row title",
|
||||
project_path: "/p",
|
||||
group_path: "/p",
|
||||
tool: "claude",
|
||||
status: "Idle",
|
||||
yolo_mode: false,
|
||||
created_at: "2025-01-01T00:00:00Z",
|
||||
last_accessed_at: null,
|
||||
idle_entered_at: null,
|
||||
last_error: null,
|
||||
branch: null,
|
||||
main_repo_path: null,
|
||||
is_sandboxed: false,
|
||||
favorited: false,
|
||||
has_managed_worktree: false,
|
||||
has_terminal: true,
|
||||
profile: "default",
|
||||
cleanup_defaults: {
|
||||
delete_worktree: false,
|
||||
delete_branch: false,
|
||||
delete_sandbox: false,
|
||||
},
|
||||
remote_owner: null,
|
||||
notify_on_waiting: null,
|
||||
notify_on_idle: null,
|
||||
notify_on_error: null,
|
||||
claude_fullscreen: false,
|
||||
workspace_repos: [],
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function workspace(id: string, sessions: SessionResponse[]): Workspace {
|
||||
return {
|
||||
id,
|
||||
branch: null,
|
||||
projectPath: "/p",
|
||||
displayName: id,
|
||||
agents: ["claude"],
|
||||
primaryAgent: "claude",
|
||||
status: "idle",
|
||||
sessions,
|
||||
};
|
||||
}
|
||||
|
||||
function Wrap({
|
||||
children,
|
||||
rowTagMode = "branch",
|
||||
colorsEnabled = true,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
rowTagMode?: SessionRowTagMode;
|
||||
colorsEnabled?: boolean;
|
||||
}) {
|
||||
const ref = useRef(0);
|
||||
return (
|
||||
<DragSuppressContext.Provider value={ref}>
|
||||
<SessionRowTagContext.Provider value={rowTagMode}>
|
||||
<SessionColorsContext.Provider value={colorsEnabled}>{children}</SessionColorsContext.Provider>
|
||||
</SessionRowTagContext.Provider>
|
||||
</DragSuppressContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
// Mounts a SessionRow wired to the real `useSidebarTriage` controller, the
|
||||
// same way `WorkspaceSidebar` wires it in production. Triage state and the
|
||||
// pin/archive PATCH calls live in the hook now (lifted out of the row
|
||||
// so bulk actions can share them, see #1724), so the row + hook are
|
||||
// exercised together here rather than the row owning the mutation. Returns
|
||||
// `null` while the workspace has no row to render.
|
||||
function Row({
|
||||
ws,
|
||||
readOnly,
|
||||
onCreateSession,
|
||||
isActive = false,
|
||||
}: {
|
||||
ws: Workspace;
|
||||
readOnly?: boolean;
|
||||
onCreateSession?: (repoPath: string) => void;
|
||||
isActive?: boolean;
|
||||
}) {
|
||||
const workspaces = useMemo(() => [ws], [ws]);
|
||||
const triage = useSidebarTriage(workspaces);
|
||||
return (
|
||||
<SessionRow
|
||||
workspace={ws}
|
||||
isActive={isActive}
|
||||
isSelected={false}
|
||||
onActivate={() => {}}
|
||||
onCreateSession={onCreateSession}
|
||||
readOnly={readOnly}
|
||||
optimistic={triage.optimisticFor(ws.id)}
|
||||
onPinToggle={triage.pinToggle}
|
||||
onArchiveToggle={triage.archiveToggle}
|
||||
onUnreadToggle={triage.unreadToggle}
|
||||
bulkApi={SINGLE_BULK_API}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const fetchSpy = vi.fn<typeof fetch>();
|
||||
|
||||
beforeEach(() => {
|
||||
fetchSpy.mockReset();
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
fetchSpy.mockImplementation(
|
||||
async () =>
|
||||
new Response(JSON.stringify({ id: "s1" }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
// Drain any switch-agent latch a click left behind so tests stay
|
||||
// independent.
|
||||
consumePendingSwitchAgent("sess-switch-it");
|
||||
});
|
||||
|
||||
describe("SessionRow chips", () => {
|
||||
it("renders the Pin glyph when any session is pinned", () => {
|
||||
const ws = workspace("w-pinned", [session({ pinned_at: "2026-01-01T00:00:00Z" })]);
|
||||
render(
|
||||
<Wrap>
|
||||
<Row ws={ws} />
|
||||
</Wrap>,
|
||||
);
|
||||
expect(screen.queryByLabelText("Pinned")).not.toBeNull();
|
||||
expect(screen.queryByLabelText("Archived")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders the monitoring badge when the first session has an armed monitor", () => {
|
||||
// A monitor-parked session would otherwise look like a plain idle dot;
|
||||
// the badge signals it is waiting on a background watch, not dead.
|
||||
const ws = workspace("w-monitor", [session({ monitor_active: true, monitor_description: "clippy passes" })]);
|
||||
render(
|
||||
<Wrap>
|
||||
<Row ws={ws} />
|
||||
</Wrap>,
|
||||
);
|
||||
const badge = screen.getByLabelText("Monitoring clippy passes");
|
||||
expect(badge.textContent).toContain("monitoring");
|
||||
});
|
||||
|
||||
it("renders no monitoring badge when no monitor is armed", () => {
|
||||
const ws = workspace("w-none", [session()]);
|
||||
render(
|
||||
<Wrap>
|
||||
<Row ws={ws} />
|
||||
</Wrap>,
|
||||
);
|
||||
expect(screen.queryByLabelText(/^Monitoring/)).toBeNull();
|
||||
});
|
||||
|
||||
it("renders the Archived chip when any session is archived", () => {
|
||||
const ws = workspace("w-archived", [session({ archived_at: "2026-01-01T00:00:00Z" })]);
|
||||
render(
|
||||
<Wrap>
|
||||
<Row ws={ws} />
|
||||
</Wrap>,
|
||||
);
|
||||
expect(screen.queryByLabelText("Archived")).not.toBeNull();
|
||||
expect(screen.queryByLabelText("Pinned")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("SessionRow row tags", () => {
|
||||
it("renders a compact branch tag and removes the hardcoded branch subtitle", () => {
|
||||
const ws = {
|
||||
...workspace("w-branch", [session({ branch: "feature/web-row-tag" })]),
|
||||
branch: "feature/web-row-tag",
|
||||
};
|
||||
render(
|
||||
<Wrap rowTagMode="branch">
|
||||
<Row ws={ws} />
|
||||
</Wrap>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("sidebar-session-row-tag").textContent).toBe("[web-row-tag]");
|
||||
expect(screen.queryByText("feature/web-row-tag")).toBeNull();
|
||||
});
|
||||
|
||||
it("hides all session suffix metadata when row_tag is none", () => {
|
||||
const ws = {
|
||||
...workspace("w-none", [session({ branch: "feature/hidden" })]),
|
||||
branch: "feature/hidden",
|
||||
};
|
||||
render(
|
||||
<Wrap rowTagMode="none">
|
||||
<Row ws={ws} />
|
||||
</Wrap>,
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId("sidebar-session-row-tag")).toBeNull();
|
||||
expect(screen.queryByText("feature/hidden")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders profile and auto tags from the first session", () => {
|
||||
const ws = workspace("w-profile", [session({ profile: "forit-backup" })]);
|
||||
|
||||
const { rerender } = render(
|
||||
<Wrap rowTagMode="profile">
|
||||
<Row ws={ws} />
|
||||
</Wrap>,
|
||||
);
|
||||
expect(screen.getByTestId("sidebar-session-row-tag").textContent).toBe("[fb]");
|
||||
|
||||
rerender(
|
||||
<Wrap rowTagMode="auto">
|
||||
<Row ws={ws} />
|
||||
</Wrap>,
|
||||
);
|
||||
expect(screen.getByTestId("sidebar-session-row-tag").textContent).toBe("[fb]");
|
||||
});
|
||||
|
||||
it("renders multi-repo workspace branch tags without removing repo chips", () => {
|
||||
const ws = workspace("w-workspace", [
|
||||
session({
|
||||
workspace_repos: [
|
||||
{ name: "api", source_path: "/repo/api", branch: "feature/web-tags" },
|
||||
{ name: "web", source_path: "/repo/web", branch: "feature/web-tags" },
|
||||
],
|
||||
}),
|
||||
]);
|
||||
|
||||
render(
|
||||
<Wrap rowTagMode="branch">
|
||||
<Row ws={ws} />
|
||||
</Wrap>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("sidebar-session-row-tag").textContent).toBe("[web-tags+2]");
|
||||
expect(screen.getByText("api")).not.toBeNull();
|
||||
expect(screen.getByText("web")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("SessionRow unread dot", () => {
|
||||
// Positive control: an idle unread row that is not sunk paints the dot,
|
||||
// so the absence assertions below mean suppression, not a broken probe.
|
||||
it("renders the unread dot on a live idle unread row", () => {
|
||||
const ws = workspace("w-unread", [session({ unread: true })]);
|
||||
render(
|
||||
<Wrap>
|
||||
<UnreadIndicatorContext.Provider value={true}>
|
||||
<Row ws={ws} />
|
||||
</UnreadIndicatorContext.Provider>
|
||||
</Wrap>,
|
||||
);
|
||||
expect(screen.queryByTestId("sidebar-unread-dot")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("suppresses the unread dot when the row is archived (#2571)", () => {
|
||||
const ws = workspace("w-unread-archived", [session({ unread: true, archived_at: "2026-01-01T00:00:00Z" })]);
|
||||
render(
|
||||
<Wrap>
|
||||
<UnreadIndicatorContext.Provider value={true}>
|
||||
<Row ws={ws} />
|
||||
</UnreadIndicatorContext.Provider>
|
||||
</Wrap>,
|
||||
);
|
||||
expect(screen.queryByTestId("sidebar-unread-dot")).toBeNull();
|
||||
});
|
||||
|
||||
it("suppresses the unread dot when the row is archived (#2571)", () => {
|
||||
const ws = workspace("w-unread-archived", [session({ unread: true, archived_at: "2026-01-01T00:00:00Z" })]);
|
||||
render(
|
||||
<Wrap>
|
||||
<UnreadIndicatorContext.Provider value={true}>
|
||||
<Row ws={ws} />
|
||||
</UnreadIndicatorContext.Provider>
|
||||
</Wrap>,
|
||||
);
|
||||
expect(screen.queryByTestId("sidebar-unread-dot")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("SessionRow smart-rename chip", () => {
|
||||
it("renders the Auto-name chip when smart_rename is pending", () => {
|
||||
const ws = workspace("w-pending", [session({ view: "structured", smart_rename: "pending" })]);
|
||||
render(
|
||||
<Wrap>
|
||||
<Row ws={ws} />
|
||||
</Wrap>,
|
||||
);
|
||||
expect(screen.queryByLabelText("Will auto-name")).not.toBeNull();
|
||||
expect(screen.queryByLabelText("Naming")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders the Naming chip when smart_rename is running", () => {
|
||||
const ws = workspace("w-running", [session({ view: "structured", smart_rename: "running" })]);
|
||||
render(
|
||||
<Wrap>
|
||||
<Row ws={ws} />
|
||||
</Wrap>,
|
||||
);
|
||||
expect(screen.queryByLabelText("Naming")).not.toBeNull();
|
||||
expect(screen.queryByLabelText("Will auto-name")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders no smart-rename chip when inactive", () => {
|
||||
const ws = workspace("w-inactive", [session({ view: "structured", smart_rename: "inactive" })]);
|
||||
render(
|
||||
<Wrap>
|
||||
<Row ws={ws} />
|
||||
</Wrap>,
|
||||
);
|
||||
expect(screen.queryByLabelText("Will auto-name")).toBeNull();
|
||||
expect(screen.queryByLabelText("Naming")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("SessionRow context menu", () => {
|
||||
it("offers Unpin plus Archive when pinned", () => {
|
||||
const ws = workspace("w-pinned", [session({ pinned_at: "2026-01-01T00:00:00Z" })]);
|
||||
render(
|
||||
<Wrap>
|
||||
<Row ws={ws} />
|
||||
</Wrap>,
|
||||
);
|
||||
const row = screen.getByTestId("sidebar-session-row");
|
||||
fireEvent.contextMenu(row);
|
||||
const menu = screen.getByTestId("sidebar-context-menu");
|
||||
// Archiving a pinned session clears the pin on the backend,
|
||||
// matching the TUI, so the menu must not force unpin-first.
|
||||
expect(menu.textContent).toContain("Unpin");
|
||||
expect(menu.textContent).toContain("Archive");
|
||||
});
|
||||
|
||||
it("shows only the Unarchive toggle when archived", () => {
|
||||
const ws = workspace("w-archived", [session({ archived_at: "2026-01-01T00:00:00Z" })]);
|
||||
render(
|
||||
<Wrap>
|
||||
<Row ws={ws} />
|
||||
</Wrap>,
|
||||
);
|
||||
fireEvent.contextMenu(screen.getByTestId("sidebar-session-row"));
|
||||
const menu = screen.getByTestId("sidebar-context-menu");
|
||||
expect(menu.textContent).toContain("Unarchive");
|
||||
expect(menu.textContent).not.toContain("Pin");
|
||||
});
|
||||
|
||||
it("shows Pin / Archive for a live row", () => {
|
||||
const ws = workspace("w-live", [session({})]);
|
||||
render(
|
||||
<Wrap>
|
||||
<Row ws={ws} />
|
||||
</Wrap>,
|
||||
);
|
||||
fireEvent.contextMenu(screen.getByTestId("sidebar-session-row"));
|
||||
const menu = screen.getByTestId("sidebar-context-menu");
|
||||
expect(menu.textContent).toContain("Pin");
|
||||
expect(menu.textContent).toContain("Archive");
|
||||
});
|
||||
|
||||
it("shows Switch agent for a structured view row", () => {
|
||||
const ws = workspace("w-structured view", [session({ id: "sess-structured view", view: "structured" })]);
|
||||
render(
|
||||
<Wrap>
|
||||
<Row ws={ws} />
|
||||
</Wrap>,
|
||||
);
|
||||
fireEvent.contextMenu(screen.getByTestId("sidebar-session-row"));
|
||||
expect(screen.queryByTestId("sidebar-context-menu-switch-agent")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("hides Switch agent for a non-structured view (tmux) row", () => {
|
||||
const ws = workspace("w-tmux", [session({ view: "terminal" })]);
|
||||
render(
|
||||
<Wrap>
|
||||
<Row ws={ws} />
|
||||
</Wrap>,
|
||||
);
|
||||
fireEvent.contextMenu(screen.getByTestId("sidebar-session-row"));
|
||||
expect(screen.queryByTestId("sidebar-context-menu-switch-agent")).toBeNull();
|
||||
});
|
||||
|
||||
it("hides the triage section in read-only mode", () => {
|
||||
// structured_view is set so the Switch agent gate is also exercised:
|
||||
// it must stay hidden in read-only even on a structured view row.
|
||||
const ws = workspace("w-live", [session({ view: "structured" })]);
|
||||
render(
|
||||
<Wrap>
|
||||
<Row ws={ws} readOnly />
|
||||
</Wrap>,
|
||||
);
|
||||
fireEvent.contextMenu(screen.getByTestId("sidebar-session-row"));
|
||||
const menu = screen.getByTestId("sidebar-context-menu");
|
||||
expect(menu.textContent).not.toContain("Pin");
|
||||
expect(menu.textContent).not.toContain("Archive");
|
||||
expect(menu.textContent).not.toContain("Delete");
|
||||
expect(screen.queryByTestId("sidebar-context-menu-switch-agent")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("SessionRow triage actions", () => {
|
||||
it("Pin click fires PATCH /api/sessions/:id/pin with { pinned: true }", async () => {
|
||||
const ws = workspace("w-live", [session({ id: "sess-pin-it" })]);
|
||||
render(
|
||||
<Wrap>
|
||||
<Row ws={ws} />
|
||||
</Wrap>,
|
||||
);
|
||||
fireEvent.contextMenu(screen.getByTestId("sidebar-session-row"));
|
||||
fireEvent.click(screen.getByTestId("sidebar-context-menu-pin"));
|
||||
// Wait for the async handler.
|
||||
await vi.waitFor(() => expect(fetchSpy).toHaveBeenCalled());
|
||||
const [url, init] = fetchSpy.mock.calls[0]!;
|
||||
expect(url).toBe("/api/sessions/sess-pin-it/pin");
|
||||
expect(init?.method).toBe("PATCH");
|
||||
expect(JSON.parse(init!.body as string)).toEqual({ pinned: true });
|
||||
});
|
||||
|
||||
it("Archive click fires PATCH /api/sessions/:id/archive with { archived: true, kill_pane: true }", async () => {
|
||||
const ws = workspace("w-live", [session({ id: "sess-arch-it" })]);
|
||||
render(
|
||||
<Wrap>
|
||||
<Row ws={ws} />
|
||||
</Wrap>,
|
||||
);
|
||||
fireEvent.contextMenu(screen.getByTestId("sidebar-session-row"));
|
||||
fireEvent.click(screen.getByTestId("sidebar-context-menu-archive"));
|
||||
await vi.waitFor(() => expect(fetchSpy).toHaveBeenCalled());
|
||||
const [url, init] = fetchSpy.mock.calls[0]!;
|
||||
expect(url).toBe("/api/sessions/sess-arch-it/archive");
|
||||
expect(JSON.parse(init!.body as string)).toEqual({
|
||||
archived: true,
|
||||
kill_pane: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("optimistically shows the Archived chip immediately on click", async () => {
|
||||
// Regression: the chip render used `isArchived` (the prop)
|
||||
// instead of `effectiveArchived` (the optimistic override). On
|
||||
// click the chip didn't appear until the next sessions-poll
|
||||
// confirmed the archive, which felt laggy compared to the
|
||||
// immediate pin glyph flip. See CodeRabbit review on #1585.
|
||||
const ws = workspace("w-live", [session({ id: "sess-opt-archive" })]);
|
||||
render(
|
||||
<Wrap>
|
||||
<Row ws={ws} />
|
||||
</Wrap>,
|
||||
);
|
||||
fireEvent.contextMenu(screen.getByTestId("sidebar-session-row"));
|
||||
fireEvent.click(screen.getByTestId("sidebar-context-menu-archive"));
|
||||
// The chip should appear synchronously from the optimistic
|
||||
// state flip, before the PATCH response would have time to
|
||||
// round-trip.
|
||||
await vi.waitFor(() => expect(screen.queryByLabelText("Archived")).not.toBeNull());
|
||||
});
|
||||
|
||||
it("Unpin click fires PATCH /api/sessions/:id/pin with { pinned: false }", async () => {
|
||||
const ws = workspace("w-pinned", [session({ id: "sess-unpin", pinned_at: "2026-01-01T00:00:00Z" })]);
|
||||
render(
|
||||
<Wrap>
|
||||
<Row ws={ws} />
|
||||
</Wrap>,
|
||||
);
|
||||
fireEvent.contextMenu(screen.getByTestId("sidebar-session-row"));
|
||||
fireEvent.click(screen.getByTestId("sidebar-context-menu-pin"));
|
||||
await vi.waitFor(() => expect(fetchSpy).toHaveBeenCalled());
|
||||
const [url, init] = fetchSpy.mock.calls[0]!;
|
||||
expect(url).toBe("/api/sessions/sess-unpin/pin");
|
||||
expect(JSON.parse(init!.body as string)).toEqual({ pinned: false });
|
||||
});
|
||||
|
||||
it("Unarchive click fires PATCH /api/sessions/:id/archive with { archived: false }", async () => {
|
||||
const ws = workspace("w-archived", [session({ id: "sess-unarc", archived_at: "2026-01-01T00:00:00Z" })]);
|
||||
render(
|
||||
<Wrap>
|
||||
<Row ws={ws} />
|
||||
</Wrap>,
|
||||
);
|
||||
fireEvent.contextMenu(screen.getByTestId("sidebar-session-row"));
|
||||
fireEvent.click(screen.getByTestId("sidebar-context-menu-archive"));
|
||||
await vi.waitFor(() => expect(fetchSpy).toHaveBeenCalled());
|
||||
const [url, init] = fetchSpy.mock.calls[0]!;
|
||||
expect(url).toBe("/api/sessions/sess-unarc/archive");
|
||||
expect(JSON.parse(init!.body as string)).toEqual({
|
||||
archived: false,
|
||||
kill_pane: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("reverts optimistic pin override on PATCH failure", async () => {
|
||||
// Branch coverage: the wake-call-failed path through togglePin.
|
||||
fetchSpy.mockImplementation(async () => new Response("nope", { status: 500 }));
|
||||
const ws = workspace("w-live", [session({ id: "sess-pin-fail" })]);
|
||||
render(
|
||||
<Wrap>
|
||||
<Row ws={ws} />
|
||||
</Wrap>,
|
||||
);
|
||||
fireEvent.contextMenu(screen.getByTestId("sidebar-session-row"));
|
||||
fireEvent.click(screen.getByTestId("sidebar-context-menu-pin"));
|
||||
await vi.waitFor(() => expect(fetchSpy).toHaveBeenCalled());
|
||||
// The optimistic pin flipped on, then reverted off. The glyph
|
||||
// should not be visible after the failure settles.
|
||||
await vi.waitFor(() => expect(screen.queryByLabelText("Pinned")).toBeNull());
|
||||
});
|
||||
|
||||
it("reverts optimistic archive override on PATCH failure", async () => {
|
||||
fetchSpy.mockImplementation(async () => new Response("nope", { status: 500 }));
|
||||
const ws = workspace("w-live", [session({ id: "sess-arch-fail" })]);
|
||||
render(
|
||||
<Wrap>
|
||||
<Row ws={ws} />
|
||||
</Wrap>,
|
||||
);
|
||||
fireEvent.contextMenu(screen.getByTestId("sidebar-session-row"));
|
||||
fireEvent.click(screen.getByTestId("sidebar-context-menu-archive"));
|
||||
await vi.waitFor(() => expect(fetchSpy).toHaveBeenCalled());
|
||||
await vi.waitFor(() => expect(screen.queryByLabelText("Archived")).toBeNull());
|
||||
});
|
||||
|
||||
it("Switch agent click navigates to the session and requests the dialog", () => {
|
||||
const ws = workspace("w-structured view", [session({ id: "sess-switch-it", view: "structured" })]);
|
||||
const opened: string[] = [];
|
||||
const switched: string[] = [];
|
||||
const onOpen = (e: Event) => opened.push((e as CustomEvent).detail.sessionId);
|
||||
const onSwitch = (e: Event) => switched.push((e as CustomEvent).detail.sessionId);
|
||||
window.addEventListener(OPEN_SESSION_EVENT, onOpen);
|
||||
window.addEventListener(OPEN_SWITCH_AGENT_EVENT, onSwitch);
|
||||
try {
|
||||
render(
|
||||
<Wrap>
|
||||
<Row ws={ws} />
|
||||
</Wrap>,
|
||||
);
|
||||
fireEvent.contextMenu(screen.getByTestId("sidebar-session-row"));
|
||||
fireEvent.click(screen.getByTestId("sidebar-context-menu-switch-agent"));
|
||||
expect(opened).toEqual(["sess-switch-it"]);
|
||||
expect(switched).toEqual(["sess-switch-it"]);
|
||||
// No PATCH: switching is deferred to the dialog in the composer.
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
window.removeEventListener(OPEN_SESSION_EVENT, onOpen);
|
||||
window.removeEventListener(OPEN_SWITCH_AGENT_EVENT, onSwitch);
|
||||
}
|
||||
});
|
||||
|
||||
it("New Session click calls onCreateSession with the row's repo path", () => {
|
||||
// main_repo_path wins over project_path, matching handleCreateSession's
|
||||
// own project key (`main_repo_path || project_path`), so the wizard
|
||||
// prefills from the right-clicked session's project (issue #2023).
|
||||
const ws = workspace("w-new", [session({ id: "sess-new", project_path: "/p", main_repo_path: "/repos/work" })]);
|
||||
const onCreateSession = vi.fn();
|
||||
render(
|
||||
<Wrap>
|
||||
<Row ws={ws} onCreateSession={onCreateSession} />
|
||||
</Wrap>,
|
||||
);
|
||||
fireEvent.contextMenu(screen.getByTestId("sidebar-session-row"));
|
||||
fireEvent.click(screen.getByTestId("sidebar-context-menu-new-session"));
|
||||
expect(onCreateSession).toHaveBeenCalledWith("/repos/work");
|
||||
// It's a client-side wizard open, not a server mutation.
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("hides New Session in read-only mode", () => {
|
||||
const ws = workspace("w-ro", [session({ id: "sess-ro", project_path: "/p" })]);
|
||||
render(
|
||||
<Wrap>
|
||||
<Row ws={ws} readOnly onCreateSession={vi.fn()} />
|
||||
</Wrap>,
|
||||
);
|
||||
fireEvent.contextMenu(screen.getByTestId("sidebar-session-row"));
|
||||
expect(screen.queryByTestId("sidebar-context-menu-new-session")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("SessionRow unread", () => {
|
||||
it("renders the unread dot for an unread row", () => {
|
||||
const ws = workspace("w-unread", [session({ id: "s-u", unread: true })]);
|
||||
render(
|
||||
<Wrap>
|
||||
<Row ws={ws} />
|
||||
</Wrap>,
|
||||
);
|
||||
expect(screen.queryByTestId("sidebar-unread-dot")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("suppresses the unread dot on the active row (opening reads it)", () => {
|
||||
const ws = workspace("w-unread", [session({ id: "s-u", unread: true })]);
|
||||
render(
|
||||
<Wrap>
|
||||
<Row ws={ws} isActive />
|
||||
</Wrap>,
|
||||
);
|
||||
expect(screen.queryByTestId("sidebar-unread-dot")).toBeNull();
|
||||
});
|
||||
|
||||
it("menu offers 'Mark as unread' for a read row and 'Mark as read' for an unread row", () => {
|
||||
const read = workspace("w-read", [session({ id: "s-read" })]);
|
||||
const { unmount } = render(
|
||||
<Wrap>
|
||||
<Row ws={read} />
|
||||
</Wrap>,
|
||||
);
|
||||
fireEvent.contextMenu(screen.getByTestId("sidebar-session-row"));
|
||||
expect(screen.getByTestId("sidebar-context-menu-unread").textContent).toContain("Mark as unread");
|
||||
unmount();
|
||||
|
||||
const unread = workspace("w-unread", [session({ id: "s-unread", unread: true })]);
|
||||
render(
|
||||
<Wrap>
|
||||
<Row ws={unread} />
|
||||
</Wrap>,
|
||||
);
|
||||
fireEvent.contextMenu(screen.getByTestId("sidebar-session-row"));
|
||||
expect(screen.getByTestId("sidebar-context-menu-unread").textContent).toContain("Mark as read");
|
||||
});
|
||||
|
||||
it("'Mark as unread' fires PATCH /api/sessions/:id/unread with { unread: true } and shows the dot", async () => {
|
||||
const ws = workspace("w-live", [session({ id: "sess-unread-it" })]);
|
||||
render(
|
||||
<Wrap>
|
||||
<Row ws={ws} />
|
||||
</Wrap>,
|
||||
);
|
||||
fireEvent.contextMenu(screen.getByTestId("sidebar-session-row"));
|
||||
fireEvent.click(screen.getByTestId("sidebar-context-menu-unread"));
|
||||
// Optimistic dot appears immediately, before the PATCH round-trips.
|
||||
await vi.waitFor(() => expect(screen.queryByTestId("sidebar-unread-dot")).not.toBeNull());
|
||||
await vi.waitFor(() => expect(fetchSpy).toHaveBeenCalled());
|
||||
const [url, init] = fetchSpy.mock.calls[0]!;
|
||||
expect(url).toBe("/api/sessions/sess-unread-it/unread");
|
||||
expect(init?.method).toBe("PATCH");
|
||||
expect(JSON.parse(init!.body as string)).toEqual({ unread: true });
|
||||
});
|
||||
|
||||
it("'Mark as read' on an unread row fires { unread: false }", async () => {
|
||||
const ws = workspace("w-unread", [session({ id: "sess-read-it", unread: true })]);
|
||||
render(
|
||||
<Wrap>
|
||||
<Row ws={ws} />
|
||||
</Wrap>,
|
||||
);
|
||||
fireEvent.contextMenu(screen.getByTestId("sidebar-session-row"));
|
||||
fireEvent.click(screen.getByTestId("sidebar-context-menu-unread"));
|
||||
await vi.waitFor(() => expect(fetchSpy).toHaveBeenCalled());
|
||||
const [url, init] = fetchSpy.mock.calls[0]!;
|
||||
expect(url).toBe("/api/sessions/sess-read-it/unread");
|
||||
expect(JSON.parse(init!.body as string)).toEqual({ unread: false });
|
||||
});
|
||||
|
||||
it("hides the unread dot and menu item when the feature is disabled", () => {
|
||||
const ws = workspace("w-unread", [session({ id: "s-off", unread: true })]);
|
||||
render(
|
||||
<Wrap>
|
||||
<UnreadIndicatorContext.Provider value={false}>
|
||||
<Row ws={ws} />
|
||||
</UnreadIndicatorContext.Provider>
|
||||
</Wrap>,
|
||||
);
|
||||
expect(screen.queryByTestId("sidebar-unread-dot")).toBeNull();
|
||||
fireEvent.contextMenu(screen.getByTestId("sidebar-session-row"));
|
||||
expect(screen.queryByTestId("sidebar-context-menu-unread")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("SessionRow color label (#2383)", () => {
|
||||
it("renders the color dot when a session carries a color", () => {
|
||||
const ws = workspace("w-color", [session({ id: "s-color", color: "red" })]);
|
||||
render(
|
||||
<Wrap>
|
||||
<Row ws={ws} />
|
||||
</Wrap>,
|
||||
);
|
||||
const dot = screen.getByTestId("sidebar-session-color-dot");
|
||||
expect(dot.getAttribute("data-color")).toBe("red");
|
||||
expect(dot.className).toContain("bg-red-500");
|
||||
});
|
||||
|
||||
it("renders no color dot when color is unset", () => {
|
||||
const ws = workspace("w-nocolor", [session({ id: "s-nocolor" })]);
|
||||
render(
|
||||
<Wrap>
|
||||
<Row ws={ws} />
|
||||
</Wrap>,
|
||||
);
|
||||
expect(screen.queryByTestId("sidebar-session-color-dot")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders no color dot for an unknown color value", () => {
|
||||
const ws = workspace("w-badcolor", [session({ id: "s-bad", color: "chartreuse" })]);
|
||||
render(
|
||||
<Wrap>
|
||||
<Row ws={ws} />
|
||||
</Wrap>,
|
||||
);
|
||||
expect(screen.queryByTestId("sidebar-session-color-dot")).toBeNull();
|
||||
});
|
||||
|
||||
it("Color swatch click fires PATCH /api/sessions/:id/color with { color: 'red' }", async () => {
|
||||
const ws = workspace("w-live", [session({ id: "sess-color-it" })]);
|
||||
render(
|
||||
<Wrap>
|
||||
<Row ws={ws} />
|
||||
</Wrap>,
|
||||
);
|
||||
fireEvent.contextMenu(screen.getByTestId("sidebar-session-row"));
|
||||
fireEvent.click(screen.getByTestId("sidebar-context-menu-color-red"));
|
||||
await vi.waitFor(() => expect(fetchSpy).toHaveBeenCalled());
|
||||
const [url, init] = fetchSpy.mock.calls[0]!;
|
||||
expect(url).toBe("/api/sessions/sess-color-it/color");
|
||||
expect(init?.method).toBe("PATCH");
|
||||
expect(JSON.parse(init!.body as string)).toEqual({ color: "red" });
|
||||
});
|
||||
|
||||
it("shows a Clear color item on a colored row that fires { color: null }", async () => {
|
||||
const ws = workspace("w-colored", [session({ id: "sess-clear-it", color: "green" })]);
|
||||
render(
|
||||
<Wrap>
|
||||
<Row ws={ws} />
|
||||
</Wrap>,
|
||||
);
|
||||
fireEvent.contextMenu(screen.getByTestId("sidebar-session-row"));
|
||||
fireEvent.click(screen.getByTestId("sidebar-context-menu-color-clear"));
|
||||
await vi.waitFor(() => expect(fetchSpy).toHaveBeenCalled());
|
||||
const [url, init] = fetchSpy.mock.calls[0]!;
|
||||
expect(url).toBe("/api/sessions/sess-clear-it/color");
|
||||
expect(JSON.parse(init!.body as string)).toEqual({ color: null });
|
||||
});
|
||||
|
||||
it("hides the Clear color item when no color is set", () => {
|
||||
const ws = workspace("w-nocolor", [session({ id: "sess-noclear" })]);
|
||||
render(
|
||||
<Wrap>
|
||||
<Row ws={ws} />
|
||||
</Wrap>,
|
||||
);
|
||||
fireEvent.contextMenu(screen.getByTestId("sidebar-session-row"));
|
||||
expect(screen.queryByTestId("sidebar-context-menu-color-clear")).toBeNull();
|
||||
});
|
||||
|
||||
it("hides the color section in read-only mode", () => {
|
||||
const ws = workspace("w-ro", [session({ id: "sess-ro-color", color: "amber" })]);
|
||||
render(
|
||||
<Wrap>
|
||||
<Row ws={ws} readOnly />
|
||||
</Wrap>,
|
||||
);
|
||||
fireEvent.contextMenu(screen.getByTestId("sidebar-session-row"));
|
||||
expect(screen.queryByTestId("sidebar-context-menu-color-red")).toBeNull();
|
||||
expect(screen.queryByTestId("sidebar-context-menu-color-clear")).toBeNull();
|
||||
});
|
||||
|
||||
// `session.show_session_colors = false` (#3104). The gate hides, it does not
|
||||
// forbid: the stored value is untouched, so re-enabling brings the dot back.
|
||||
it("renders no color dot when session colors are disabled, even with a color stored", () => {
|
||||
const ws = workspace("w-off-dot", [session({ id: "sess-off-dot", color: "red" })]);
|
||||
render(
|
||||
<Wrap colorsEnabled={false}>
|
||||
<Row ws={ws} />
|
||||
</Wrap>,
|
||||
);
|
||||
expect(screen.queryByTestId("sidebar-session-color-dot")).toBeNull();
|
||||
});
|
||||
|
||||
it("hides the whole color section when session colors are disabled", () => {
|
||||
const ws = workspace("w-off-menu", [session({ id: "sess-off-menu", color: "green" })]);
|
||||
render(
|
||||
<Wrap colorsEnabled={false}>
|
||||
<Row ws={ws} />
|
||||
</Wrap>,
|
||||
);
|
||||
fireEvent.contextMenu(screen.getByTestId("sidebar-session-row"));
|
||||
for (const key of ["red", "amber", "green", "clear"]) {
|
||||
expect(screen.queryByTestId(`sidebar-context-menu-color-${key}`)).toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -34,8 +34,6 @@ describe("buildSidebar", () => {
|
||||
{ kind: "tab", id: "sandbox", label: "Sandbox" },
|
||||
{ kind: "tab", id: "worktree", label: "Worktree" },
|
||||
{ kind: "tab", id: "tmux", label: "Tmux" },
|
||||
{ kind: "divider", label: "Notifications" },
|
||||
{ kind: "tab", id: "notifications", label: "Notifications" },
|
||||
{ kind: "divider", label: "Web Dashboard" },
|
||||
{ kind: "tab", id: "panels", label: "Panels" },
|
||||
{ kind: "tab", id: "terminal", label: "Terminal" },
|
||||
|
||||
@@ -36,9 +36,6 @@ function session(id: string): SessionResponse {
|
||||
profile: "default",
|
||||
cleanup_defaults: { delete_worktree: false, delete_branch: false, delete_sandbox: false },
|
||||
remote_owner: null,
|
||||
notify_on_waiting: null,
|
||||
notify_on_idle: null,
|
||||
notify_on_error: null,
|
||||
claude_fullscreen: false,
|
||||
workspace_repos: [],
|
||||
};
|
||||
@@ -57,14 +54,12 @@ function workspace(id: string, count: number): Workspace {
|
||||
};
|
||||
}
|
||||
|
||||
// A workspace whose every session is sunk (archived or trashed) is "sunk"
|
||||
// per workspaceIsSunk, drops out of the live row list, and must not count
|
||||
// toward the header badge. See #2372.
|
||||
function sunkWorkspace(id: string, count: number, kind: "archived" | "trashed"): Workspace {
|
||||
// A workspace whose every session is trashed is "sunk" per workspaceIsSunk,
|
||||
// drops out of the live row list, and must not count toward the header
|
||||
// badge. See #2372.
|
||||
function sunkWorkspace(id: string, count: number): Workspace {
|
||||
const ws = workspace(id, count);
|
||||
ws.sessions = ws.sessions.map((s) =>
|
||||
kind === "archived" ? { ...s, archived_at: "2025-01-02T00:00:00Z" } : { ...s, trashed_at: "2099-01-01T00:00:00Z" },
|
||||
);
|
||||
ws.sessions = ws.sessions.map((s) => ({ ...s, trashed_at: "2099-01-01T00:00:00Z" }));
|
||||
return ws;
|
||||
}
|
||||
|
||||
@@ -127,13 +122,13 @@ describe("SidebarGroupHeader", () => {
|
||||
expect(screen.getByTestId("sidebar-group-session-count").textContent).toBe("(2)");
|
||||
});
|
||||
|
||||
it("excludes archived and trashed workspaces from the count (#2372)", () => {
|
||||
it("excludes trashed workspaces from the count (#2372)", () => {
|
||||
renderHeader({
|
||||
group: group({
|
||||
workspaces: [
|
||||
{ key: "live", workspace: workspace("live", 1) },
|
||||
{ key: "arch", workspace: sunkWorkspace("arch", 1, "archived") },
|
||||
{ key: "trashed", workspace: sunkWorkspace("trashed", 1, "trashed") },
|
||||
{ key: "arch", workspace: sunkWorkspace("arch", 1) },
|
||||
{ key: "trashed", workspace: sunkWorkspace("trashed", 1) },
|
||||
],
|
||||
}),
|
||||
});
|
||||
@@ -145,8 +140,8 @@ describe("SidebarGroupHeader", () => {
|
||||
renderHeader({
|
||||
group: group({
|
||||
workspaces: [
|
||||
{ key: "arch", workspace: sunkWorkspace("arch", 2, "archived") },
|
||||
{ key: "trashed", workspace: sunkWorkspace("trashed", 1, "trashed") },
|
||||
{ key: "arch", workspace: sunkWorkspace("arch", 2) },
|
||||
{ key: "trashed", workspace: sunkWorkspace("trashed", 1) },
|
||||
],
|
||||
}),
|
||||
});
|
||||
@@ -255,7 +250,7 @@ describe("SidebarGroupHeader", () => {
|
||||
|
||||
it("does not count sunk sessions toward the attention badge", () => {
|
||||
// Archived but Waiting: the sunk short-circuit must win, so no badge.
|
||||
const ws = sunkWorkspace("arch", 2, "archived");
|
||||
const ws = sunkWorkspace("arch", 2);
|
||||
ws.sessions = ws.sessions.map((s) => ({ ...s, status: "Waiting" }));
|
||||
renderHeader({ group: group({ workspaces: [{ key: "arch", workspace: ws }] }) });
|
||||
expect(screen.queryByTestId("sidebar-group-attention-badge")).toBeNull();
|
||||
|
||||
@@ -45,9 +45,6 @@ function makeSession(id: string): SessionResponse {
|
||||
delete_sandbox: false,
|
||||
},
|
||||
remote_owner: null,
|
||||
notify_on_waiting: null,
|
||||
notify_on_idle: null,
|
||||
notify_on_error: null,
|
||||
claude_fullscreen: false,
|
||||
workspace_repos: [],
|
||||
};
|
||||
|
||||
@@ -1,32 +1,13 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// Covers ToastProvider + ToastBusBridge: rendering info/error variants,
|
||||
// the auto-dismiss timer, manual dismiss, the empty state, the
|
||||
// service-worker push -> in-app toast path, and the clickable
|
||||
// session-jump toast.
|
||||
// the auto-dismiss timer, manual dismiss, the empty state, and the
|
||||
// click-to-open link toast.
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { ToastBusBridge, ToastProvider } from "../Toasts";
|
||||
import { toastBus } from "../../lib/toastBus";
|
||||
import { OPEN_SESSION_EVENT } from "../../lib/sessionRoute";
|
||||
|
||||
// jsdom ships no navigator.serviceWorker; the ToastProvider effect bails
|
||||
// out without one. Install a real EventTarget once at module load so the
|
||||
// SW push -> in-app toast path is exercised. It must stay installed across
|
||||
// the global RTL cleanup (test-setup.ts) because React's passive unmount
|
||||
// calls navigator.serviceWorker.removeEventListener.
|
||||
const swTarget = new EventTarget();
|
||||
Object.defineProperty(navigator, "serviceWorker", {
|
||||
value: swTarget,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
function dispatchPush(data: unknown) {
|
||||
act(() => {
|
||||
swTarget.dispatchEvent(new MessageEvent("message", { data }));
|
||||
});
|
||||
}
|
||||
|
||||
// Render the provider with the bridge so the module-level toastBus.handler
|
||||
// is wired to the live React context, matching the real app composition.
|
||||
@@ -122,48 +103,6 @@ describe("manual dismiss", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("service-worker push toasts", () => {
|
||||
it("turns an aoe-push message with a session id into a clickable toast", () => {
|
||||
renderProvider();
|
||||
dispatchPush({ type: "aoe-push", payload: { title: "Done", body: "ready", session_id: "sess-1" } });
|
||||
|
||||
const toast = screen.getByText("Done: ready").closest("div");
|
||||
expect(toast).not.toBeNull();
|
||||
expect(toast?.className).toContain("cursor-pointer");
|
||||
|
||||
const onOpen = vi.fn();
|
||||
window.addEventListener(OPEN_SESSION_EVENT, onOpen);
|
||||
act(() => fireEvent.click(toast as HTMLElement));
|
||||
expect(onOpen).toHaveBeenCalledOnce();
|
||||
expect((onOpen.mock.calls[0][0] as CustomEvent).detail).toEqual({ sessionId: "sess-1" });
|
||||
expect(screen.queryByText("Done: ready")).toBeNull();
|
||||
window.removeEventListener(OPEN_SESSION_EVENT, onOpen);
|
||||
});
|
||||
|
||||
it("falls back to the title when the push payload has no body", () => {
|
||||
renderProvider();
|
||||
dispatchPush({ type: "aoe-push", payload: { title: "Heads up", session_id: "s2" } });
|
||||
expect(screen.getByText("Heads up")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("uses the default title and a plain info toast when payload omits title and session", () => {
|
||||
renderProvider();
|
||||
dispatchPush({ type: "aoe-push", payload: { body: "just a body" } });
|
||||
const toast = screen.getByText("Agent of Empires: just a body").closest("div");
|
||||
expect(toast?.getAttribute("role")).toBe("status");
|
||||
expect(toast?.className).not.toContain("cursor-pointer");
|
||||
});
|
||||
|
||||
it("ignores messages that are not aoe-push", () => {
|
||||
renderProvider();
|
||||
dispatchPush({ type: "something-else", payload: { title: "nope" } });
|
||||
dispatchPush(null);
|
||||
dispatchPush({ type: "aoe-push" });
|
||||
expect(screen.queryByRole("status")).toBeNull();
|
||||
expect(screen.queryByRole("alert")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("click-to-open (ui.open_url) toast", () => {
|
||||
it("opens the href in a new tab on click and dismisses", () => {
|
||||
const open = vi.spyOn(window, "open").mockReturnValue(null);
|
||||
|
||||
@@ -10,21 +10,12 @@ import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { useRef, type ReactNode } from "react";
|
||||
|
||||
import { reportError } from "../../lib/toastBus";
|
||||
import { DragSuppressContext, SessionRow, type RowBulkApi } from "../WorkspaceSidebar";
|
||||
|
||||
// Single-row stub for the bulk-triage bridge; this harness mounts one
|
||||
// unselected row, so the menu is always single-scope. See #2312.
|
||||
const SINGLE_BULK_API: RowBulkApi = {
|
||||
prepareScope: () => ({ kind: "single" }),
|
||||
pin: () => {},
|
||||
archive: () => {},
|
||||
};
|
||||
import { DragSuppressContext, SessionRow } from "../WorkspaceSidebar";
|
||||
|
||||
vi.mock("../../lib/toastBus", () => ({
|
||||
reportError: vi.fn(),
|
||||
reportInfo: vi.fn(),
|
||||
}));
|
||||
import { EMPTY_OPTIMISTIC } from "../../lib/sidebarOptimistic";
|
||||
import type { SessionResponse, Workspace } from "../../lib/types";
|
||||
|
||||
function session(over: Partial<SessionResponse> = {}): SessionResponse {
|
||||
@@ -53,9 +44,6 @@ function session(over: Partial<SessionResponse> = {}): SessionResponse {
|
||||
delete_sandbox: false,
|
||||
},
|
||||
remote_owner: null,
|
||||
notify_on_waiting: null,
|
||||
notify_on_idle: null,
|
||||
notify_on_error: null,
|
||||
claude_fullscreen: false,
|
||||
workspace_repos: [],
|
||||
...over,
|
||||
@@ -102,16 +90,7 @@ afterEach(() => {
|
||||
function openMenu(ws: Workspace) {
|
||||
render(
|
||||
<Wrap>
|
||||
<SessionRow
|
||||
workspace={ws}
|
||||
isActive={false}
|
||||
isSelected={false}
|
||||
onActivate={() => {}}
|
||||
optimistic={EMPTY_OPTIMISTIC}
|
||||
onPinToggle={() => {}}
|
||||
onArchiveToggle={() => {}}
|
||||
bulkApi={SINGLE_BULK_API}
|
||||
/>
|
||||
<SessionRow workspace={ws} isActive={false} isSelected={false} onActivate={() => {}} />
|
||||
</Wrap>,
|
||||
);
|
||||
fireEvent.contextMenu(screen.getByTestId("sidebar-session-row"));
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
// control in the sidebar footer next to Settings, which opens a wider panel
|
||||
// with Open / Restore / Delete actions. Trash is no longer an inline scrolling
|
||||
// section.
|
||||
// Also asserts the Projects section renders below "Archived" (#2512).
|
||||
// Also asserts the Projects section renders below the sunk section (#2512).
|
||||
// Vitest (accurate per-file V8) rather than Playwright, whose bundle->source
|
||||
// remap is lossy for this large file.
|
||||
|
||||
@@ -38,13 +38,9 @@ function session(over: Partial<SessionResponse> = {}): SessionResponse {
|
||||
profile: "default",
|
||||
cleanup_defaults: { delete_worktree: false, delete_branch: false, delete_sandbox: false },
|
||||
remote_owner: null,
|
||||
notify_on_waiting: null,
|
||||
notify_on_idle: null,
|
||||
notify_on_error: null,
|
||||
claude_fullscreen: false,
|
||||
workspace_repos: [],
|
||||
scratch: false,
|
||||
archived_at: null,
|
||||
trashed_at: null,
|
||||
...over,
|
||||
} as SessionResponse;
|
||||
@@ -321,20 +317,4 @@ describe("WorkspaceSidebar Trash control (#2489, #2512)", () => {
|
||||
});
|
||||
expect(screen.queryByTestId("sidebar-trash-toggle")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders the Projects section below 'Archived' (#2512)", () => {
|
||||
// An archived (sunk) workspace surfaces the sunk section; the Projects
|
||||
// section header always renders when CRUD is available. Assert DOM order.
|
||||
const archivedWs = workspace("archived-ws", [session({ id: "a1", archived_at: "2026-01-01T00:00:00Z" })]);
|
||||
renderSidebar({
|
||||
groups: buildSessionGroups([archivedWs], {
|
||||
idleDecayWindowMs: 60_000,
|
||||
sortMode: "lastActivity",
|
||||
isCollapsed: () => false,
|
||||
}),
|
||||
});
|
||||
const sunk = screen.getByTestId("sidebar-sunk-section");
|
||||
const projects = screen.getByTestId("sidebar-projects-section");
|
||||
expect(sunk.compareDocumentPosition(projects) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -61,7 +61,6 @@ interface Props {
|
||||
* so `sendPrompt` can auto-unarchive the session before enqueueing,
|
||||
* matching the `touch_last_accessed` invariant the server enforces
|
||||
* for tmux sends. See #1581. */
|
||||
archivedAt?: string | null;
|
||||
/** When true, every row is rendered including those preceding the
|
||||
* most recent `/clear`. When false (the default), rows before the
|
||||
* latest `session_cleared` divider are folded out of the message
|
||||
@@ -126,14 +125,8 @@ export interface AcpContext {
|
||||
* acp state + actions for things assistant-ui doesn't own
|
||||
* (approvals, plan strip, system notices).
|
||||
*/
|
||||
export function AcpRuntime({
|
||||
sessionId,
|
||||
acpWorkerState = "running",
|
||||
archivedAt = null,
|
||||
showClearedTurns = false,
|
||||
children,
|
||||
}: Props) {
|
||||
const acp = useAcpSession(sessionId, acpWorkerState, archivedAt);
|
||||
export function AcpRuntime({ sessionId, acpWorkerState = "running", showClearedTurns = false, children }: Props) {
|
||||
const acp = useAcpSession(sessionId, acpWorkerState);
|
||||
const agentProfile = useAgentProfile();
|
||||
// Staged attachments for the next prompt. A ref mirror keeps `onNew`
|
||||
// (recreated each render by useExternalStoreRuntime) reading the
|
||||
|
||||
@@ -111,7 +111,6 @@ interface Props {
|
||||
* generic `aoe acp stop`-style message when the user has
|
||||
* explicitly parked the session via the sidebar archive action.
|
||||
* See #1581. */
|
||||
archivedAt: string | null;
|
||||
/** RFC3339 trashed-at timestamp, or null. Drives the trash-specific
|
||||
* "worker stopped" banner: a trashed session is recoverable only by
|
||||
* restoring it, so the banner replaces the composer and points at the
|
||||
@@ -155,7 +154,6 @@ export function StructuredView(props: Props) {
|
||||
tool,
|
||||
acpAgent,
|
||||
clearAliases,
|
||||
archivedAt,
|
||||
trashedAt,
|
||||
onRestore,
|
||||
onOpenFileRef,
|
||||
@@ -175,12 +173,7 @@ export function StructuredView(props: Props) {
|
||||
<AcpFileRefContext.Provider value={{ onOpenFileRef, fileRefSession }}>
|
||||
<AgentProfileProvider toolKey={tool} clearAliases={clearAliases}>
|
||||
<ToolDisplayModeProvider density={toolDensity}>
|
||||
<AcpRuntime
|
||||
sessionId={sessionId}
|
||||
acpWorkerState={acpWorkerState}
|
||||
archivedAt={archivedAt}
|
||||
showClearedTurns={showClearedTurns}
|
||||
>
|
||||
<AcpRuntime sessionId={sessionId} acpWorkerState={acpWorkerState} showClearedTurns={showClearedTurns}>
|
||||
{(ctx) => (
|
||||
<BackgroundAgentsContext.Provider
|
||||
value={{ agents: ctx.state.backgroundAgents, openPane: onOpenAgentsPane }}
|
||||
@@ -193,7 +186,6 @@ export function StructuredView(props: Props) {
|
||||
onToggleClearedTurns={() => setShowClearedTurns((v) => !v)}
|
||||
toolDensity={toolDensity}
|
||||
onToggleToolDensity={toggleToolDensity}
|
||||
archivedAt={archivedAt}
|
||||
trashedAt={trashedAt}
|
||||
onRestore={onRestore}
|
||||
isSandboxed={isSandboxed}
|
||||
@@ -278,7 +270,6 @@ function AcpChrome({
|
||||
onToggleClearedTurns,
|
||||
toolDensity,
|
||||
onToggleToolDensity,
|
||||
archivedAt,
|
||||
trashedAt,
|
||||
onRestore,
|
||||
state,
|
||||
@@ -321,7 +312,6 @@ function AcpChrome({
|
||||
onToggleClearedTurns: () => void;
|
||||
toolDensity: "detailed" | "compact";
|
||||
onToggleToolDensity: () => void;
|
||||
archivedAt: string | null;
|
||||
trashedAt: string | null;
|
||||
onRestore?: () => Promise<boolean> | void;
|
||||
isSandboxed?: boolean;
|
||||
@@ -739,14 +729,10 @@ function AcpChrome({
|
||||
workerStopped: state.workerStopped,
|
||||
startupError: state.startupError,
|
||||
trashedAt,
|
||||
archivedAt,
|
||||
});
|
||||
if (variant === "trashed") {
|
||||
return <TrashedWorkerStoppedBanner sessionId={sessionId} onRestore={onRestore} />;
|
||||
}
|
||||
if (variant === "archived") {
|
||||
return <ArchivedWorkerStoppedBanner sessionId={sessionId} />;
|
||||
}
|
||||
if (variant === "generic") {
|
||||
return <WorkerStoppedBanner sessionId={sessionId} />;
|
||||
}
|
||||
@@ -2141,21 +2127,6 @@ export function TrashedWorkerStoppedBanner({
|
||||
);
|
||||
}
|
||||
|
||||
export function ArchivedWorkerStoppedBanner({ sessionId }: { sessionId: string }) {
|
||||
return (
|
||||
<div
|
||||
className="border-b border-status-warning/30 bg-status-warning/10 px-4 py-3 text-status-warning"
|
||||
data-testid={`acp-archived-banner-${sessionId}`}
|
||||
>
|
||||
<div className="text-sm font-medium">Session archived</div>
|
||||
<div className="mt-1 text-xs text-status-warning/90">
|
||||
This session is parked. The structured view worker was shut down and the reconciler will not respawn it.
|
||||
Unarchive from the sidebar (right-click the row, then Unarchive) to bring it back.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function StartupErrorBanner({ sessionId, message }: { sessionId: string; message: string }) {
|
||||
const isAuth = /authentic|login|api[_ -]?key/i.test(message);
|
||||
const isCapacity = /capacity full|max_concurrent_workers/i.test(message);
|
||||
|
||||
@@ -596,7 +596,7 @@ describe("ToolCards memory_recall (claude-agent-acp v0.37.0)", () => {
|
||||
expect(body.querySelectorAll("li").length).toBe(2);
|
||||
});
|
||||
|
||||
it("sanitizes dangerous HTML in synthesized memory before rendering", () => {
|
||||
it("keeps raw HTML in synthesized memory inert", () => {
|
||||
const tool = makeToolCall({
|
||||
id: "mem-xss",
|
||||
name: "Recalled synthesized memory",
|
||||
@@ -614,10 +614,12 @@ describe("ToolCards memory_recall (claude-agent-acp v0.37.0)", () => {
|
||||
);
|
||||
fireEvent.click(getByRole("button"));
|
||||
const body = getByTestId("memory-recall-synthesized");
|
||||
// DOMPurify strips the event handler and the javascript: URL.
|
||||
expect(body.querySelector("img")?.getAttribute("onerror")).toBeNull();
|
||||
expect(body.innerHTML).not.toContain("onerror");
|
||||
expect(body.innerHTML).not.toContain("javascript:");
|
||||
// react-markdown renders to React elements and does not enable
|
||||
// rehype-raw, so agent-surfaced HTML stays escaped text instead of
|
||||
// becoming live DOM: no <img> to fire onerror, no javascript: anchor.
|
||||
expect(body.querySelector("img")).toBeNull();
|
||||
expect(body.querySelector("a")).toBeNull();
|
||||
expect(body.textContent).toContain("<img");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -55,8 +55,8 @@ import { StringDiff } from "../diff/StringDiff";
|
||||
import { ToolErrorBody } from "./ToolErrorBody";
|
||||
import { classifyMcp, humanizeServer, humanizeVerb } from "../../lib/mcpClassify";
|
||||
import { cleanRecalledMemory, classifyMemory, parseMemoryFrontmatter, type MemoryHit } from "../../lib/memoryClassify";
|
||||
import DOMPurify from "dompurify";
|
||||
import { marked } from "marked";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { reclassifyBash } from "../../lib/toolReclassify";
|
||||
import { useAgentProfile } from "../../lib/agentProfileContext";
|
||||
import { useAcpFileRef } from "./AcpFileRefContext";
|
||||
@@ -1879,15 +1879,13 @@ function MemoryRecallCard({ tool, result }: Props) {
|
||||
const paths = recall.paths ?? [];
|
||||
const synthesized = cleanRecalledMemory(recall.synthesized_text ?? "");
|
||||
const isSynthesize = recall.mode === "synthesize";
|
||||
// Render the cleaned memory body as markdown. We use `marked` rather
|
||||
// than the structured-view `<Markdown>` component because that one
|
||||
// requires the assistant-ui runtime provider (it throws "requires an
|
||||
// AuiProvider" outside the transcript), the same reason CommentMarkdown
|
||||
// exists. The `.acp-markdown` class reuses the panel's prose styling.
|
||||
// `marked` does not sanitize HTML, so the parsed output is run through
|
||||
// DOMPurify before it reaches dangerouslySetInnerHTML; the recalled
|
||||
// text is agent-surfaced and could carry an injected payload.
|
||||
const synthesizedHtml = isSynthesize ? DOMPurify.sanitize(marked.parse(synthesized, { async: false }) as string) : "";
|
||||
// Render the cleaned memory body with plain `react-markdown`, the same
|
||||
// renderer MarkdownFileView uses, rather than the structured-view
|
||||
// `<Markdown>` component: that one requires the assistant-ui runtime
|
||||
// provider and throws "requires an AuiProvider" outside the transcript.
|
||||
// Rendering to React elements escapes the agent-surfaced recall text
|
||||
// without going through `dangerouslySetInnerHTML`. The `.acp-markdown`
|
||||
// class reuses the panel's prose styling.
|
||||
|
||||
const primary = isSynthesize ? (
|
||||
<span>Synthesised memory</span>
|
||||
@@ -1920,8 +1918,9 @@ function MemoryRecallCard({ tool, result }: Props) {
|
||||
<div
|
||||
data-testid="memory-recall-synthesized"
|
||||
className="acp-markdown break-words text-[11px] text-text-secondary"
|
||||
dangerouslySetInnerHTML={{ __html: synthesizedHtml }}
|
||||
/>
|
||||
>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{synthesized}</ReactMarkdown>
|
||||
</div>
|
||||
) : (
|
||||
<ul data-testid="memory-recall-paths" className="space-y-0.5 text-[11px] text-text-secondary">
|
||||
{paths.map((p) => (
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// Vitest coverage for the acp-view banner variants introduced in
|
||||
// #1581. Each banner is a tiny presentational component, but they
|
||||
// own the copy the user sees the moment they archive a
|
||||
// session, and any drift between the testid + the banner switch in
|
||||
// StructuredView's variant picker would silently swap which message
|
||||
// renders.
|
||||
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
|
||||
import { ArchivedWorkerStoppedBanner } from "../StructuredView";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe("ArchivedWorkerStoppedBanner", () => {
|
||||
it("renders the archived copy with the sessionId-scoped testid", () => {
|
||||
render(<ArchivedWorkerStoppedBanner sessionId="abc-123" />);
|
||||
const banner = screen.getByTestId("acp-archived-banner-abc-123");
|
||||
expect(banner).not.toBeNull();
|
||||
expect(banner.textContent).toContain("Session archived");
|
||||
// Mentions the sidebar path so users know how to unblock.
|
||||
expect(banner.textContent).toContain("Unarchive");
|
||||
});
|
||||
|
||||
it("isolates banners by sessionId", () => {
|
||||
render(<ArchivedWorkerStoppedBanner sessionId="alpha" />);
|
||||
expect(screen.queryByTestId("acp-archived-banner-alpha")).not.toBeNull();
|
||||
expect(screen.queryByTestId("acp-archived-banner-beta")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -8,7 +8,6 @@ describe("pickWorkerStoppedVariant", () => {
|
||||
workerStopped: false,
|
||||
startupError: null,
|
||||
trashedAt: null,
|
||||
archivedAt: null,
|
||||
}),
|
||||
).toBe("none");
|
||||
});
|
||||
@@ -19,7 +18,6 @@ describe("pickWorkerStoppedVariant", () => {
|
||||
workerStopped: true,
|
||||
startupError: "missing API key",
|
||||
trashedAt: null,
|
||||
archivedAt: null,
|
||||
}),
|
||||
).toBe("none");
|
||||
});
|
||||
@@ -27,69 +25,23 @@ describe("pickWorkerStoppedVariant", () => {
|
||||
it("returns 'trashed' when the session is in the trash", () => {
|
||||
// A trashed structured view session is recoverable only by restoring
|
||||
// it; reconnecting would race the reconciler (which skips trashed
|
||||
// sessions) just like the archived case. See #2489.
|
||||
// sessions). See #2489.
|
||||
expect(
|
||||
pickWorkerStoppedVariant({
|
||||
workerStopped: true,
|
||||
startupError: null,
|
||||
trashedAt: "2026-01-01T00:00:00Z",
|
||||
archivedAt: null,
|
||||
}),
|
||||
).toBe("trashed");
|
||||
});
|
||||
|
||||
it("prefers 'trashed' over 'archived'", () => {
|
||||
// Trash supersedes the other sink state (Instance::trash leaves the
|
||||
// sibling flag intact, and effective_bucket makes trash win), so the
|
||||
// banner must too. See #2489.
|
||||
expect(
|
||||
pickWorkerStoppedVariant({
|
||||
workerStopped: true,
|
||||
startupError: null,
|
||||
trashedAt: "2026-03-01T00:00:00Z",
|
||||
archivedAt: "2026-01-01T00:00:00Z",
|
||||
}),
|
||||
).toBe("trashed");
|
||||
});
|
||||
|
||||
it("returns 'archived' when the session is archived", () => {
|
||||
// Regression: an archived structured view session must not show the
|
||||
// generic `aoe acp stop` reconnect banner. Reconnecting from
|
||||
// the structured view would race the reconciler, which skips
|
||||
// archived sessions, and the user would see the spawn flicker
|
||||
// and then disappear. See #1581.
|
||||
expect(
|
||||
pickWorkerStoppedVariant({
|
||||
workerStopped: true,
|
||||
startupError: null,
|
||||
trashedAt: null,
|
||||
archivedAt: "2026-01-01T00:00:00Z",
|
||||
}),
|
||||
).toBe("archived");
|
||||
});
|
||||
|
||||
it("returns 'generic' for the `aoe acp stop` / external-teardown case", () => {
|
||||
expect(
|
||||
pickWorkerStoppedVariant({
|
||||
workerStopped: true,
|
||||
startupError: null,
|
||||
trashedAt: null,
|
||||
archivedAt: null,
|
||||
}),
|
||||
).toBe("generic");
|
||||
});
|
||||
|
||||
it("returns 'archived' when startupError is empty string (not truthy)", () => {
|
||||
// Branch coverage: a non-null but falsy startupError must fall
|
||||
// through to the archived check rather than short-circuiting on
|
||||
// the second `if`.
|
||||
expect(
|
||||
pickWorkerStoppedVariant({
|
||||
workerStopped: true,
|
||||
startupError: "",
|
||||
trashedAt: null,
|
||||
archivedAt: "2026-01-01T00:00:00Z",
|
||||
}),
|
||||
).toBe("archived");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,18 +1,14 @@
|
||||
/** Which "worker stopped" banner variant to render in the structured view
|
||||
* view, given the session's triage state. The variant matches the
|
||||
* reason the worker was torn down so the user sees a banner that
|
||||
* actually explains their situation (and offers the right next
|
||||
* step) instead of the generic `aoe acp stop` message. See
|
||||
* #1581.
|
||||
/** Which "worker stopped" banner variant to render in the structured view,
|
||||
* given the session's state. The variant matches the reason the worker was
|
||||
* torn down so the user sees a banner that actually explains their
|
||||
* situation (and offers the right next step) instead of the generic
|
||||
* `aoe acp stop` message. See #1581.
|
||||
*
|
||||
* Returns:
|
||||
* - `"none"` : worker is not stopped, no banner.
|
||||
* - `"trashed"` : the session is in the trash (#2489); reconnect
|
||||
* is not the right next step (the user must restore
|
||||
* it first). Takes precedence over archived.
|
||||
* - `"archived"` : worker was torn down by the sidebar archive
|
||||
* action; reconnect is not the right next step
|
||||
* (the user must unarchive first).
|
||||
* it first).
|
||||
* - `"generic"` : everything else (`aoe acp stop`, manual
|
||||
* teardown, etc.).
|
||||
*
|
||||
@@ -21,19 +17,15 @@
|
||||
* callers should bail before invoking this helper when a startup
|
||||
* error is in flight, but we still defensively return `"none"` to
|
||||
* stay safe under refactors. */
|
||||
export type WorkerStoppedVariant = "none" | "trashed" | "archived" | "generic";
|
||||
export type WorkerStoppedVariant = "none" | "trashed" | "generic";
|
||||
|
||||
export function pickWorkerStoppedVariant(args: {
|
||||
workerStopped: boolean;
|
||||
startupError: string | null;
|
||||
trashedAt: string | null;
|
||||
archivedAt: string | null;
|
||||
}): WorkerStoppedVariant {
|
||||
if (!args.workerStopped) return "none";
|
||||
if (args.startupError) return "none";
|
||||
// Trash supersedes archive: a trashed session is recoverable only
|
||||
// by restoring it, so its banner must win even if archived_at is also set.
|
||||
if (args.trashedAt) return "trashed";
|
||||
if (args.archivedAt) return "archived";
|
||||
return "generic";
|
||||
}
|
||||
|
||||
@@ -94,7 +94,7 @@ export interface WizardPrefill {
|
||||
profile?: string;
|
||||
group?: string;
|
||||
/** Which tab to show initially on the project section */
|
||||
initialTab?: "recent" | "browse" | "clone";
|
||||
initialTab?: "recent" | "browse";
|
||||
/** Open the wizard pre-configured for a scratch session: the
|
||||
* `scratch` flag is on, no path is required, worktree controls are
|
||||
* hidden. The single screen is already one Cmd+Enter from launch. */
|
||||
@@ -295,9 +295,6 @@ export function SessionWizard({ onClose, onCreated, prefill }: Props) {
|
||||
agent_model: selectedAgentAcpCapable && d.useStructuredView && d.agentModel ? d.agentModel : undefined,
|
||||
agent_effort: selectedAgentAcpCapable && d.useStructuredView && d.agentEffort ? d.agentEffort : undefined,
|
||||
scratch: d.scratch || undefined,
|
||||
// #2276: importing an existing Claude session. The server adopts this
|
||||
// id as the session's acp_session_id and resumes it via session/load.
|
||||
import_acp_session_id: d.importAcpSessionId || undefined,
|
||||
};
|
||||
|
||||
await runCreate(body, d.tool);
|
||||
@@ -357,12 +354,7 @@ export function SessionWizard({ onClose, onCreated, prefill }: Props) {
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto px-5 py-5 space-y-6">
|
||||
<ProjectStep
|
||||
data={state.data}
|
||||
onChange={handleChange}
|
||||
initialTab={prefill?.initialTab}
|
||||
agents={state.agents}
|
||||
/>
|
||||
<ProjectStep data={state.data} onChange={handleChange} initialTab={prefill?.initialTab} />
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-text-dim mb-1.5">Session title</label>
|
||||
|
||||
@@ -1,129 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// Vitest coverage for the ProjectStep "Import Claude" tab (#2276): the
|
||||
// picker lists on-disk Claude Code sessions, disables ones whose cwd is
|
||||
// gone, and selecting one prefills a structured-view claude session that
|
||||
// resumes the chosen id (path = original cwd, worktree off,
|
||||
// importAcpSessionId set). Live Playwright exercises the end-to-end
|
||||
// resume + transcript render; this isolates the field-wiring so it does
|
||||
// not depend on a real `aoe serve`.
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, render, fireEvent, waitFor } from "@testing-library/react";
|
||||
|
||||
import { ProjectStep } from "../steps/ProjectStep";
|
||||
import { initialData } from "../wizardReducer";
|
||||
import type { AgentInfo, ClaudeSessionSummary } from "../../../lib/types";
|
||||
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
fetchSessions: vi.fn().mockResolvedValue({ sessions: [], workspace_ordering: [] }),
|
||||
fetchRecentProjects: vi.fn(),
|
||||
fetchProjects: vi.fn().mockResolvedValue([]),
|
||||
cloneRepo: vi.fn(),
|
||||
getHomePath: vi.fn().mockResolvedValue(null),
|
||||
browseFilesystem: vi.fn().mockResolvedValue({ ok: false, entries: [] }),
|
||||
listClaudeSessions: vi.fn(),
|
||||
}));
|
||||
|
||||
import { listClaudeSessions } from "../../../lib/api";
|
||||
|
||||
const SESSIONS: ClaudeSessionSummary[] = [
|
||||
{
|
||||
session_id: "713b7f46-d0f2-454e-91be-a3305d35660c",
|
||||
cwd: "/Users/me/projects/alpha",
|
||||
title: "Fix the spinner bug",
|
||||
last_modified_ms: 1_700_000_000_000,
|
||||
cwd_exists: true,
|
||||
},
|
||||
{
|
||||
session_id: "dead-beef",
|
||||
cwd: "/Users/me/gone",
|
||||
title: "Old work",
|
||||
last_modified_ms: 1_600_000_000_000,
|
||||
cwd_exists: false,
|
||||
},
|
||||
];
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const CLAUDE_INSTALLED: AgentInfo = {
|
||||
name: "claude",
|
||||
kind: "builtin",
|
||||
binary: "claude",
|
||||
host_only: false,
|
||||
installed: true,
|
||||
install_hint: "",
|
||||
acp_capable: true,
|
||||
acp_installed: true,
|
||||
acp_command: "claude-agent-acp",
|
||||
};
|
||||
|
||||
function renderStep(importAcpSessionId = "", agents: AgentInfo[] = [CLAUDE_INSTALLED]) {
|
||||
const onChange = vi.fn();
|
||||
const utils = render(
|
||||
<ProjectStep
|
||||
data={{ ...initialData, path: "", extraRepoPaths: [], scratch: false, importAcpSessionId }}
|
||||
onChange={onChange}
|
||||
initialTab="import"
|
||||
agents={agents}
|
||||
/>,
|
||||
);
|
||||
return { onChange, ...utils };
|
||||
}
|
||||
|
||||
describe("ProjectStep Import from Claude tab (#2276)", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(listClaudeSessions).mockResolvedValue(SESSIONS);
|
||||
});
|
||||
|
||||
it("lists discovered sessions and hides missing-cwd ones until toggled", async () => {
|
||||
const { findByText, getByText, queryByText, getByLabelText } = renderStep();
|
||||
await findByText("Fix the spinner bug");
|
||||
expect(getByText("/Users/me/projects/alpha")).toBeTruthy();
|
||||
// Missing-cwd session hidden by default.
|
||||
expect(queryByText("Old work")).toBeNull();
|
||||
// Toggle reveals it, disabled.
|
||||
fireEvent.click(getByLabelText("Show sessions with missing directories"));
|
||||
const missingRow = (await findByText("Old work")).closest("button") as HTMLButtonElement;
|
||||
expect(missingRow.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("selecting a session prefills a structured claude import", async () => {
|
||||
const { onChange, findByText } = renderStep();
|
||||
const row = (await findByText("Fix the spinner bug")).closest("button") as HTMLButtonElement;
|
||||
fireEvent.click(row);
|
||||
|
||||
const calls = Object.fromEntries(onChange.mock.calls);
|
||||
expect(calls.importAcpSessionId).toBe("713b7f46-d0f2-454e-91be-a3305d35660c");
|
||||
expect(calls.path).toBe("/Users/me/projects/alpha");
|
||||
expect(calls.tool).toBe("claude");
|
||||
expect(calls.useStructuredView).toBe(true);
|
||||
expect(calls.useWorktree).toBe(false);
|
||||
});
|
||||
|
||||
it("does not render the import picker when claude-agent-acp is not installed", async () => {
|
||||
const notInstalled: AgentInfo = { ...CLAUDE_INSTALLED, acp_installed: false };
|
||||
const { queryByLabelText } = renderStep("", [notInstalled]);
|
||||
// Let any pending effects settle, then confirm the picker never mounted.
|
||||
await Promise.resolve();
|
||||
expect(queryByLabelText("Filter Claude sessions")).toBeNull();
|
||||
});
|
||||
|
||||
it("highlights the currently selected session", async () => {
|
||||
const { findByText } = renderStep("713b7f46-d0f2-454e-91be-a3305d35660c");
|
||||
const row = (await findByText("Fix the spinner bug")).closest("button") as HTMLButtonElement;
|
||||
expect(row.getAttribute("aria-pressed")).toBe("true");
|
||||
});
|
||||
|
||||
it("filters by title", async () => {
|
||||
const { findByText, getByLabelText, queryByText } = renderStep();
|
||||
await findByText("Fix the spinner bug");
|
||||
fireEvent.change(getByLabelText("Filter Claude sessions"), { target: { value: "spinner" } });
|
||||
expect(queryByText("Fix the spinner bug")).toBeTruthy();
|
||||
fireEvent.change(getByLabelText("Filter Claude sessions"), { target: { value: "zzznomatch" } });
|
||||
await waitFor(() => expect(queryByText("Fix the spinner bug")).toBeNull());
|
||||
});
|
||||
});
|
||||
@@ -34,9 +34,6 @@ function mockSession(overrides: Partial<SessionResponse> = {}): SessionResponse
|
||||
profile: "default",
|
||||
cleanup_defaults: { delete_worktree: false, delete_branch: false, delete_sandbox: false },
|
||||
remote_owner: null,
|
||||
notify_on_waiting: null,
|
||||
notify_on_idle: null,
|
||||
notify_on_error: null,
|
||||
claude_fullscreen: false,
|
||||
workspace_repos: overrides.workspace_repos ?? [],
|
||||
scratch: overrides.scratch ?? false,
|
||||
|
||||
@@ -19,7 +19,6 @@ vi.mock("../../../lib/api", () => ({
|
||||
fetchSessions: vi.fn(),
|
||||
fetchRecentProjects: vi.fn(),
|
||||
fetchProjects: vi.fn().mockResolvedValue([]),
|
||||
cloneRepo: vi.fn(),
|
||||
}));
|
||||
|
||||
import { fetchSessions, fetchRecentProjects } from "../../../lib/api";
|
||||
@@ -51,9 +50,6 @@ function mockSession(overrides: Partial<SessionResponse> = {}): SessionResponse
|
||||
profile: "default",
|
||||
cleanup_defaults: { delete_worktree: false, delete_branch: false, delete_sandbox: false },
|
||||
remote_owner: null,
|
||||
notify_on_waiting: null,
|
||||
notify_on_idle: null,
|
||||
notify_on_error: null,
|
||||
claude_fullscreen: false,
|
||||
workspace_repos: overrides.workspace_repos ?? [],
|
||||
scratch: overrides.scratch ?? false,
|
||||
|
||||
@@ -40,9 +40,6 @@ function mockSession(overrides: Partial<SessionResponse> = {}): SessionResponse
|
||||
delete_sandbox: false,
|
||||
},
|
||||
remote_owner: null,
|
||||
notify_on_waiting: null,
|
||||
notify_on_idle: null,
|
||||
notify_on_error: null,
|
||||
claude_fullscreen: false,
|
||||
workspace_repos: overrides.workspace_repos ?? [],
|
||||
scratch: overrides.scratch ?? false,
|
||||
|
||||
@@ -23,7 +23,6 @@ vi.mock("../../../lib/api", () => ({
|
||||
// Persisted recent-projects store (#2141); these tests drive recents
|
||||
// through fetchSessions, so default it to an empty store.
|
||||
fetchRecentProjects: vi.fn().mockResolvedValue({ projects: [] }),
|
||||
cloneRepo: vi.fn(),
|
||||
// The Browse-fallback test mounts DirectoryBrowser, which probes the
|
||||
// filesystem on mount. Stub both so it renders without a network hit.
|
||||
getHomePath: vi.fn().mockResolvedValue(null),
|
||||
@@ -79,9 +78,6 @@ function mockSession(overrides: Partial<SessionResponse> = {}): SessionResponse
|
||||
profile: "default",
|
||||
cleanup_defaults: { delete_worktree: false, delete_branch: false, delete_sandbox: false },
|
||||
remote_owner: null,
|
||||
notify_on_waiting: null,
|
||||
notify_on_idle: null,
|
||||
notify_on_error: null,
|
||||
claude_fullscreen: false,
|
||||
workspace_repos: [],
|
||||
scratch: false,
|
||||
|
||||
@@ -23,7 +23,6 @@ vi.mock("../../../lib/api", () => ({
|
||||
fetchSessions: vi.fn(),
|
||||
fetchRecentProjects: vi.fn(),
|
||||
fetchProjects: vi.fn().mockResolvedValue([]),
|
||||
cloneRepo: vi.fn(),
|
||||
// The Browse tab mounts DirectoryBrowser, which probes the filesystem on
|
||||
// mount (getHomePath -> browseFilesystem). Stub both so the tab renders
|
||||
// without hitting the network. ok:false makes navigate() bail cleanly.
|
||||
@@ -64,9 +63,6 @@ function mockSession(overrides: Partial<SessionResponse> = {}): SessionResponse
|
||||
delete_sandbox: false,
|
||||
},
|
||||
remote_owner: null,
|
||||
notify_on_waiting: null,
|
||||
notify_on_idle: null,
|
||||
notify_on_error: null,
|
||||
claude_fullscreen: false,
|
||||
workspace_repos: [],
|
||||
scratch: overrides.scratch ?? false,
|
||||
|
||||
@@ -21,7 +21,6 @@ vi.mock("../../../lib/api", () => ({
|
||||
fetchSessions: vi.fn(),
|
||||
fetchRecentProjects: vi.fn(),
|
||||
fetchProjects: vi.fn().mockResolvedValue([]),
|
||||
cloneRepo: vi.fn(),
|
||||
}));
|
||||
|
||||
import { fetchSessions } from "../../../lib/api";
|
||||
@@ -57,9 +56,6 @@ function mockSession(overrides: Partial<SessionResponse> = {}): SessionResponse
|
||||
delete_sandbox: false,
|
||||
},
|
||||
remote_owner: null,
|
||||
notify_on_waiting: null,
|
||||
notify_on_idle: null,
|
||||
notify_on_error: null,
|
||||
claude_fullscreen: false,
|
||||
workspace_repos: overrides.workspace_repos ?? [],
|
||||
scratch: overrides.scratch ?? false,
|
||||
|
||||
@@ -1,132 +0,0 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { listClaudeSessions } from "../../../lib/api";
|
||||
import type { ClaudeSessionSummary } from "../../../lib/types";
|
||||
|
||||
/** Picker for importing an existing Claude Code session into a structured-view
|
||||
* session (#2276). Lists on-disk sessions newest-first with a filter box;
|
||||
* selecting one hands the caller the id + cwd to prefill the wizard. Sessions
|
||||
* whose recorded cwd no longer exists are shown disabled, since `claude
|
||||
* --resume` has no valid working directory for them. */
|
||||
export function ClaudeSessionPicker({
|
||||
onSelect,
|
||||
selectedSessionId,
|
||||
}: {
|
||||
onSelect: (session: ClaudeSessionSummary) => void;
|
||||
/** Currently-picked session id, so the chosen row stays highlighted. */
|
||||
selectedSessionId?: string;
|
||||
}) {
|
||||
const [sessions, setSessions] = useState<ClaudeSessionSummary[] | null>(null);
|
||||
const [filter, setFilter] = useState("");
|
||||
// Sessions whose recorded cwd is gone cannot be resumed, so hide them by
|
||||
// default; the toggle reveals them (shown disabled). See #2276.
|
||||
const [showMissing, setShowMissing] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
listClaudeSessions().then((s) => {
|
||||
if (active) setSessions(s);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const hasMissing = useMemo(() => (sessions ?? []).some((s) => !s.cwd_exists), [sessions]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!sessions) return [];
|
||||
const q = filter.trim().toLowerCase();
|
||||
return sessions.filter((s) => {
|
||||
if (!showMissing && !s.cwd_exists) return false;
|
||||
if (!q) return true;
|
||||
return (s.title ?? "").toLowerCase().includes(q) || s.cwd.toLowerCase().includes(q);
|
||||
});
|
||||
}, [sessions, filter, showMissing]);
|
||||
|
||||
if (sessions === null) {
|
||||
return <div className="p-4 text-sm text-content-subtle">Scanning for Claude Code sessions…</div>;
|
||||
}
|
||||
|
||||
if (sessions.length === 0) {
|
||||
return (
|
||||
<div className="p-4 text-sm text-content-subtle">
|
||||
No existing Claude Code sessions found. Run <code>claude</code> in a project first, then import it here.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
placeholder="Filter by title or path"
|
||||
aria-label="Filter Claude sessions"
|
||||
className="w-full rounded-md border border-surface-700 bg-surface-900 px-3 py-2 text-sm focus-visible:outline-2 focus-visible:outline-brand-600"
|
||||
/>
|
||||
{hasMissing && (
|
||||
<label className="flex items-center gap-2 text-xs text-content-subtle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showMissing}
|
||||
onChange={(e) => setShowMissing(e.target.checked)}
|
||||
aria-label="Show sessions with missing directories"
|
||||
/>
|
||||
Show sessions whose directory is missing
|
||||
</label>
|
||||
)}
|
||||
<ul className="flex max-h-80 flex-col gap-1 overflow-y-auto" aria-label="Claude sessions">
|
||||
{filtered.map((s) => {
|
||||
const selected = s.session_id === selectedSessionId;
|
||||
return (
|
||||
<li key={s.session_id}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!s.cwd_exists}
|
||||
aria-pressed={selected}
|
||||
onClick={() => onSelect(s)}
|
||||
title={s.cwd_exists ? s.cwd : `${s.cwd} (directory no longer exists)`}
|
||||
className={`flex w-full flex-col items-start gap-0.5 rounded-md border px-3 py-2 text-left transition-colors ${
|
||||
selected ? "border-brand-500 bg-surface-800 ring-1 ring-brand-500" : "border-surface-700"
|
||||
} ${
|
||||
s.cwd_exists
|
||||
? "cursor-pointer hover:border-brand-600 hover:bg-surface-800"
|
||||
: "cursor-not-allowed opacity-50"
|
||||
}`}
|
||||
>
|
||||
<span className="line-clamp-1 text-sm font-medium">
|
||||
{s.title || <span className="italic text-content-subtle">(no prompt yet)</span>}
|
||||
</span>
|
||||
<span className="line-clamp-1 text-xs text-content-subtle">{s.cwd}</span>
|
||||
<span className="text-xs text-content-subtle">
|
||||
{formatRelative(s.last_modified_ms)}
|
||||
{!s.cwd_exists && " · directory missing"}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
{filtered.length === 0 && (
|
||||
<li className="px-3 py-2 text-sm text-content-subtle">No sessions match "{filter}".</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Coarse "x ago" stamp for the last-modified time. Avoids a date library;
|
||||
* the picker only needs rough recency. */
|
||||
function formatRelative(ms: number): string {
|
||||
if (!ms) return "unknown";
|
||||
const diff = Date.now() - ms;
|
||||
const min = Math.floor(diff / 60000);
|
||||
if (min < 1) return "just now";
|
||||
if (min < 60) return `${min}m ago`;
|
||||
const hr = Math.floor(min / 60);
|
||||
if (hr < 24) return `${hr}h ago`;
|
||||
const day = Math.floor(hr / 24);
|
||||
if (day < 30) return `${day}d ago`;
|
||||
const mon = Math.floor(day / 30);
|
||||
return `${mon}mo ago`;
|
||||
}
|
||||
@@ -1,11 +1,10 @@
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { fetchSessions, fetchRecentProjects, fetchProjects, cloneRepo } from "../../../lib/api";
|
||||
import { fetchSessions, fetchRecentProjects, fetchProjects } from "../../../lib/api";
|
||||
import type { RecentProjectEntry } from "../../../lib/api";
|
||||
import type { AgentInfo, ClaudeSessionSummary, ProjectInfo, SessionResponse } from "../../../lib/types";
|
||||
import type { ProjectInfo, SessionResponse } from "../../../lib/types";
|
||||
import { DirectoryBrowser } from "../../DirectoryBrowser";
|
||||
import { ExtraReposPicker } from "./ExtraReposPicker";
|
||||
import { ClaudeSessionPicker } from "./ClaudeSessionPicker";
|
||||
|
||||
interface WizardData {
|
||||
path: string;
|
||||
@@ -15,7 +14,6 @@ interface WizardData {
|
||||
useWorktree: boolean;
|
||||
attachExisting: boolean;
|
||||
scratch: boolean;
|
||||
importAcpSessionId?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
@@ -52,15 +50,12 @@ function Toggle({
|
||||
);
|
||||
}
|
||||
|
||||
type Tab = "recent" | "browse" | "clone" | "import";
|
||||
type Tab = "recent" | "browse";
|
||||
|
||||
interface Props {
|
||||
data: WizardData;
|
||||
onChange: (field: string, value: unknown) => void;
|
||||
initialTab?: Tab;
|
||||
/** Built-in + custom agents, used only to gate the Claude import tab.
|
||||
* Optional so render sites that never reach import (and tests) can omit it. */
|
||||
agents?: AgentInfo[];
|
||||
}
|
||||
|
||||
interface RecentProject {
|
||||
@@ -164,21 +159,12 @@ function timeAgo(ts: string | null): string {
|
||||
return `${days}d ago`;
|
||||
}
|
||||
|
||||
export function ProjectStep({ data, onChange, initialTab, agents = [] }: Props) {
|
||||
export function ProjectStep({ data, onChange, initialTab }: Props) {
|
||||
const [recent, setRecent] = useState<RecentProject[]>([]);
|
||||
const [saved, setSaved] = useState<ProjectInfo[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activeTab, setActiveTab] = useState<Tab>(initialTab ?? "recent");
|
||||
|
||||
// Clone state
|
||||
const [cloneUrl, setCloneUrl] = useState("");
|
||||
const [cloneDestination, setCloneDestination] = useState("");
|
||||
const [shallowClone, setShallowClone] = useState(false);
|
||||
const [bareClone, setBareClone] = useState(false);
|
||||
const [cloning, setCloning] = useState(false);
|
||||
const [cloneError, setCloneError] = useState<string | null>(null);
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([fetchSessions(), fetchRecentProjects(), fetchProjects()]).then(
|
||||
([envelope, recentEnvelope, savedProjects]) => {
|
||||
@@ -215,7 +201,7 @@ export function ProjectStep({ data, onChange, initialTab, agents = [] }: Props)
|
||||
// A selected path that already shows up as a (border-highlighted) saved or
|
||||
// recent row needs no separate "Selected project" box; that would just
|
||||
// duplicate the row. Keep the box only for a path with no row to highlight
|
||||
// (e.g. a freshly cloned repo not yet in the lists). Normalize trailing
|
||||
// (e.g. a path typed into the browser). Normalize trailing
|
||||
// slashes (the recents/saved lists already are) so "/repo" and "/repo/"
|
||||
// match, mirroring the dedup convention in splitSavedAndRecent.
|
||||
const normalizePath = (p: string) => p.replace(/\/+$/, "") || "/";
|
||||
@@ -230,62 +216,15 @@ export function ProjectStep({ data, onChange, initialTab, agents = [] }: Props)
|
||||
setActiveTab("recent");
|
||||
};
|
||||
|
||||
const handleClone = async () => {
|
||||
const url = cloneUrl.trim();
|
||||
if (!url) return;
|
||||
setCloning(true);
|
||||
setCloneError(null);
|
||||
const dest = cloneDestination.trim() || undefined;
|
||||
const result = await cloneRepo(url, {
|
||||
destination: dest,
|
||||
shallow: shallowClone,
|
||||
bare: bareClone,
|
||||
});
|
||||
setCloning(false);
|
||||
if (result.ok && result.path) {
|
||||
onChange("path", result.path);
|
||||
setCloneUrl("");
|
||||
setCloneDestination("");
|
||||
setActiveTab("recent");
|
||||
} else {
|
||||
setCloneError(result.error || "Clone failed");
|
||||
}
|
||||
};
|
||||
|
||||
// Claude import needs both the claude CLI and the claude-agent-acp adapter
|
||||
// resolvable on the host; gate the tab on both so it never shows when
|
||||
// either is missing. See #2276.
|
||||
const claudeImportAvailable = agents.some((a) => a.name === "claude" && a.installed && a.acp_installed);
|
||||
|
||||
const tabs: { id: Tab; label: string }[] = [
|
||||
...(hasPicks ? [{ id: "recent" as Tab, label: "Recent" }] : []),
|
||||
{ id: "browse", label: "Browse" },
|
||||
{ id: "clone", label: "Clone URL" },
|
||||
// Only offer the Claude import when claude and its ACP adapter are both
|
||||
// installed; importing resumes via claude-agent-acp, so without it the
|
||||
// tab can only ever fail at spawn. See #2276.
|
||||
...(claudeImportAvailable ? [{ id: "import" as Tab, label: "Import from Claude" }] : []),
|
||||
];
|
||||
|
||||
// #2276: importing an existing Claude Code session prefills the original
|
||||
// cwd and forces a structured-view claude session that resumes it. Worktree
|
||||
// and scratch are cleared: the on-disk session id only resolves in its
|
||||
// recorded cwd.
|
||||
const handleImportSelect = (s: ClaudeSessionSummary) => {
|
||||
onChange("scratch", false);
|
||||
onChange("path", s.cwd);
|
||||
onChange("tool", "claude");
|
||||
onChange("useStructuredView", true);
|
||||
onChange("useWorktree", false);
|
||||
onChange("attachExisting", false);
|
||||
onChange("importAcpSessionId", s.session_id);
|
||||
if (s.title) onChange("title", s.title.slice(0, 60));
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-text-primary mb-1">Project folder</h2>
|
||||
<p className="text-sm text-text-muted mb-4">Pick a recent project, browse for one, or clone from a URL.</p>
|
||||
<p className="text-sm text-text-muted mb-4">Pick a recent project or browse for one.</p>
|
||||
|
||||
{/* Scratch-session toggle. Sits above the project-source tabs
|
||||
because it is a mode (skip the path picker entirely) rather
|
||||
@@ -424,165 +363,8 @@ export function ProjectStep({ data, onChange, initialTab, agents = [] }: Props)
|
||||
{/* Browse tab */}
|
||||
{!loading && activeTab === "browse" && <DirectoryBrowser onSelect={handleBrowseSelect} />}
|
||||
|
||||
{/* Import an existing Claude Code session (#2276) */}
|
||||
{!loading && activeTab === "import" && claudeImportAvailable && (
|
||||
<ClaudeSessionPicker onSelect={handleImportSelect} selectedSessionId={data.importAcpSessionId} />
|
||||
)}
|
||||
|
||||
{/* Clone from URL tab */}
|
||||
{!loading && activeTab === "clone" && (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label htmlFor="clone-url" className="block text-sm text-text-secondary mb-1.5">
|
||||
Repository URL
|
||||
</label>
|
||||
<input
|
||||
id="clone-url"
|
||||
type="text"
|
||||
value={cloneUrl}
|
||||
onChange={(e) => {
|
||||
setCloneUrl(e.target.value);
|
||||
setCloneError(null);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && cloneUrl.trim() && !cloning) handleClone();
|
||||
}}
|
||||
placeholder="https://github.com/user/repo.git"
|
||||
className="w-full px-3 py-2.5 text-sm bg-surface-900 border border-surface-700/40 rounded-md text-text-primary placeholder:text-text-dim focus:outline-none focus:border-brand-600 font-mono"
|
||||
disabled={cloning}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Advanced options (collapsed by default) */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||
className="text-[12px] text-text-dim hover:text-text-secondary cursor-pointer flex items-center gap-1 transition-colors"
|
||||
>
|
||||
<svg
|
||||
className={`w-3 h-3 transition-transform ${showAdvanced ? "rotate-90" : ""}`}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<polyline points="9 18 15 12 9 6" />
|
||||
</svg>
|
||||
Advanced
|
||||
</button>
|
||||
|
||||
{showAdvanced && (
|
||||
<div className="space-y-3 pl-1 border-l-2 border-surface-700/30 ml-1">
|
||||
<div>
|
||||
<label htmlFor="clone-dest" className="block text-[12px] text-text-dim mb-1">
|
||||
Destination path (optional)
|
||||
</label>
|
||||
<input
|
||||
id="clone-dest"
|
||||
type="text"
|
||||
value={cloneDestination}
|
||||
onChange={(e) => {
|
||||
setCloneDestination(e.target.value);
|
||||
setCloneError(null);
|
||||
}}
|
||||
placeholder="~/my-repo"
|
||||
className="w-full px-3 py-2 text-sm bg-surface-900 border border-surface-700/40 rounded-md text-text-primary placeholder:text-text-dim focus:outline-none focus:border-brand-600 font-mono"
|
||||
disabled={cloning}
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={shallowClone}
|
||||
onChange={(e) => setShallowClone(e.target.checked)}
|
||||
className="accent-brand-600"
|
||||
disabled={cloning || bareClone}
|
||||
/>
|
||||
<span className={`text-sm ${bareClone ? "text-text-dim" : "text-text-secondary"}`}>
|
||||
Shallow clone (--depth 1)
|
||||
</span>
|
||||
<span className="text-[10px] text-text-dim">faster for large repos</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={bareClone}
|
||||
onChange={(e) => {
|
||||
setBareClone(e.target.checked);
|
||||
if (e.target.checked) setShallowClone(false);
|
||||
}}
|
||||
className="accent-brand-600"
|
||||
disabled={cloning}
|
||||
/>
|
||||
<span className="text-sm text-text-secondary">Clone as bare repository</span>
|
||||
<span className="text-[10px] text-text-dim">recommended for worktrees</span>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{cloneError && (
|
||||
<div className="px-3 py-2 bg-red-900/20 border border-red-700/30 rounded-md">
|
||||
<p className="text-sm text-red-400">{cloneError}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClone}
|
||||
disabled={!cloneUrl.trim() || cloning}
|
||||
className={`w-full px-4 py-2.5 text-sm rounded-md font-medium transition-colors ${
|
||||
!cloneUrl.trim() || cloning
|
||||
? "bg-brand-600/50 text-surface-900/50 cursor-not-allowed"
|
||||
: "bg-brand-600 hover:bg-brand-700 active:bg-brand-800 text-surface-900 cursor-pointer"
|
||||
}`}
|
||||
>
|
||||
{cloning ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24" fill="none">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
||||
/>
|
||||
</svg>
|
||||
Cloning...
|
||||
</span>
|
||||
) : (
|
||||
"Clone repository"
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div className="flex items-start gap-1.5 text-[11px] text-text-dim">
|
||||
<span>The repository will be cloned into your home directory.</span>
|
||||
<span className="relative group/info inline-flex shrink-0 mt-px">
|
||||
<svg
|
||||
className="w-3.5 h-3.5 text-text-dim cursor-help"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M12 16v-4" />
|
||||
<path d="M12 8h.01" />
|
||||
</svg>
|
||||
<span className="pointer-events-none absolute right-0 bottom-full mb-1.5 w-56 px-2.5 py-2 rounded bg-surface-950 border border-surface-700 text-[11px] leading-relaxed text-text-secondary opacity-0 scale-95 transition-all duration-100 group-hover/info:opacity-100 group-hover/info:scale-100 z-50">
|
||||
Uses the git credentials from the environment where the server is running (SSH keys, credential
|
||||
helpers, GH_TOKEN, etc). Private repos work if your git is already authenticated.
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Selected path display, only when no saved/recent row already
|
||||
highlights it (e.g. a freshly cloned repo). */}
|
||||
highlights it. */}
|
||||
{data.path && activeTab !== "browse" && !selectedPathHasRow && (
|
||||
<div className="mt-4 px-3 py-2 bg-surface-900 border border-brand-600/30 rounded-md">
|
||||
<p className="text-[10px] font-mono uppercase tracking-wider text-text-dim mb-1">Selected project</p>
|
||||
|
||||
@@ -259,52 +259,6 @@ describe("SessionWizard reducer / SUBMIT_CANCEL (#2045)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("SessionWizard reducer / import id clearing (#2276)", () => {
|
||||
it("clears importAcpSessionId when a non-import path is chosen", () => {
|
||||
const imported = reducer(makeState(), {
|
||||
type: "SET_FIELD",
|
||||
field: "importAcpSessionId",
|
||||
value: "abc-123",
|
||||
});
|
||||
expect(imported.data.importAcpSessionId).toBe("abc-123");
|
||||
|
||||
// User switches to Browse and picks a different path.
|
||||
const browsed = reducer(imported, {
|
||||
type: "SET_FIELD",
|
||||
field: "path",
|
||||
value: "/Users/me/other-repo",
|
||||
});
|
||||
expect(browsed.data.importAcpSessionId).toBe("");
|
||||
expect(browsed.data.path).toBe("/Users/me/other-repo");
|
||||
});
|
||||
|
||||
it("clears importAcpSessionId when scratch is enabled", () => {
|
||||
const imported = reducer(makeState(), {
|
||||
type: "SET_FIELD",
|
||||
field: "importAcpSessionId",
|
||||
value: "abc-123",
|
||||
});
|
||||
const scratch = reducer(imported, { type: "SET_FIELD", field: "scratch", value: true });
|
||||
expect(scratch.data.importAcpSessionId).toBe("");
|
||||
});
|
||||
|
||||
it("preserves importAcpSessionId across the import picker's path-then-id dispatch order", () => {
|
||||
// ProjectStep.handleImportSelect dispatches path first, then the id.
|
||||
const withPath = reducer(makeState(), {
|
||||
type: "SET_FIELD",
|
||||
field: "path",
|
||||
value: "/Users/me/imported-cwd",
|
||||
});
|
||||
const withId = reducer(withPath, {
|
||||
type: "SET_FIELD",
|
||||
field: "importAcpSessionId",
|
||||
value: "imp-789",
|
||||
});
|
||||
expect(withId.data.importAcpSessionId).toBe("imp-789");
|
||||
expect(withId.data.path).toBe("/Users/me/imported-cwd");
|
||||
});
|
||||
});
|
||||
|
||||
describe("SessionWizard reducer / worktree default from config (#2423)", () => {
|
||||
it("defaults useWorktree to false so it matches the backend worktree.enabled default", () => {
|
||||
// Before #2423 this was hardcoded true, so the wizard ignored both
|
||||
|
||||
@@ -67,11 +67,6 @@ export interface WizardData {
|
||||
useStructuredView: boolean;
|
||||
agentModel: string;
|
||||
agentEffort: string;
|
||||
/** When non-empty, this create is importing an existing Claude Code
|
||||
* session: the on-disk session id to resume via `session/load`. Set by
|
||||
* the ProjectStep import tab, which also forces `tool: "claude"`,
|
||||
* structured view on, and worktree off. See #2276. */
|
||||
importAcpSessionId: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
@@ -136,7 +131,6 @@ export const initialData: WizardData = {
|
||||
useStructuredView: true,
|
||||
agentModel: "",
|
||||
agentEffort: "",
|
||||
importAcpSessionId: "",
|
||||
};
|
||||
|
||||
export function reducer(state: WizardState, action: Action): WizardState {
|
||||
@@ -164,20 +158,12 @@ export function reducer(state: WizardState, action: Action): WizardState {
|
||||
// off doesn't show "not a git repository" for a not-yet-chosen path.
|
||||
// Keeps the "no path selected == optimistically a repo" invariant.
|
||||
newData.pathIsGitRepo = true;
|
||||
// Leaving the import flow for scratch: drop the import id so it
|
||||
// can't ride along on the submit. See #2276.
|
||||
newData.importAcpSessionId = "";
|
||||
}
|
||||
if (
|
||||
(action.field === "path" && typeof action.value === "string" && action.value.length > 0) ||
|
||||
(action.field === "extraRepoPaths" && Array.isArray(action.value) && action.value.length > 0)
|
||||
) {
|
||||
newData.scratch = false;
|
||||
// A path chosen from Browse / Recent / Clone is not an import; clear
|
||||
// the stale import id so it isn't submitted with the wrong path
|
||||
// (#2276). The import picker dispatches `importAcpSessionId` AFTER
|
||||
// `path`, so its own selection survives this.
|
||||
newData.importAcpSessionId = "";
|
||||
}
|
||||
// A new path is optimistically a repo until the SessionWizard probe
|
||||
// says otherwise, so a stale non-repo result can't leave the worktree
|
||||
|
||||
@@ -190,12 +190,12 @@ describe("SchemaSection custom widgets, hooks, and validators (#1792)", () => {
|
||||
it("flags non-overridable (global-only) fields in their description", () => {
|
||||
render(
|
||||
<SchemaSection
|
||||
section="web"
|
||||
section="tmux"
|
||||
schema={[
|
||||
descriptor({
|
||||
section: "web",
|
||||
field: "notify_on_idle",
|
||||
label: "Notify on idle",
|
||||
section: "tmux",
|
||||
field: "mouse",
|
||||
label: "Mouse support",
|
||||
widget: { kind: "toggle" },
|
||||
profile_overridable: false,
|
||||
}),
|
||||
|
||||
@@ -30,17 +30,14 @@ describe("buildSettingsSearchIndex", () => {
|
||||
const index = buildSettingsSearchIndex([
|
||||
descriptor({ section: "sandbox", field: "enabled_by_default", label: "Enabled by Default" }),
|
||||
descriptor({ section: "acp", field: "show_tool_durations", label: "Show tool-call durations" }),
|
||||
descriptor({ section: "web", field: "notify_on_idle", label: "Notify on idle" }),
|
||||
]);
|
||||
|
||||
expect(index.map((h) => `${h.section}.${h.field}`)).toEqual([
|
||||
"sandbox.enabled_by_default",
|
||||
"acp.show_tool_durations",
|
||||
"web.notify_on_idle",
|
||||
]);
|
||||
// acp and web are the non-identity mappings.
|
||||
// acp is the one non-identity mapping.
|
||||
expect(index.find((h) => h.section === "acp")?.tab).toBe("structured-view");
|
||||
expect(index.find((h) => h.section === "web")?.tab).toBe("notifications");
|
||||
expect(index.find((h) => h.section === "sandbox")?.tab).toBe("sandbox");
|
||||
});
|
||||
|
||||
|
||||
@@ -2,10 +2,9 @@ import type { SettingsFieldDescriptor } from "../../lib/types";
|
||||
import type { TabId } from "../SettingsView";
|
||||
|
||||
// Maps a schema section to the settings tab that renders it. Identity for most
|
||||
// sections; `web` lives under the Notifications tab and `acp` under the
|
||||
// Structured view tab (see renderTabContent in SettingsView). Sections absent
|
||||
// here have no web tab, so their fields are excluded from search: a hit must be
|
||||
// able to jump somewhere.
|
||||
// sections; `acp` lives under the Structured view tab (see renderTabContent in
|
||||
// SettingsView). Sections absent here have no web tab, so their fields are
|
||||
// excluded from search: a hit must be able to jump somewhere.
|
||||
export const SECTION_TO_TAB: Record<string, TabId> = {
|
||||
session: "session",
|
||||
sandbox: "sandbox",
|
||||
@@ -14,7 +13,6 @@ export const SECTION_TO_TAB: Record<string, TabId> = {
|
||||
tmux: "tmux",
|
||||
updates: "updates",
|
||||
logging: "logging",
|
||||
web: "notifications",
|
||||
acp: "structured-view",
|
||||
};
|
||||
|
||||
|
||||
@@ -128,8 +128,6 @@ describe("buildConversationActions", () => {
|
||||
);
|
||||
expect(trashed[0]!.title).toBe("My Session · trashed");
|
||||
expect(trashed[0]!.subtitle).toBe("matched text (3 matches)");
|
||||
const archived = buildConversationActions([hit()], [session({ archived_at: "2026-01-02T00:00:00Z" })], null);
|
||||
expect(archived[0]!.title).toBe("My Session · archived");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -138,44 +136,37 @@ describe("useCommandActions: active-session triage toggles", () => {
|
||||
({ id: "act", title: "Alpha", status: "idle", created_at: "2026-01-01T00:00:00Z", ...over }) as SessionResponse;
|
||||
const ids = (actions: ReturnType<typeof useCommandActions>) => actions.map((a) => a.id);
|
||||
|
||||
it("offers the forward toggles when the session is in no sunk state", () => {
|
||||
it("offers the forward toggle when the session is in no sunk state", () => {
|
||||
const { result } = renderHook(() => useCommandActions(baseArgs({ activeSession: active() })));
|
||||
const got = ids(result.current);
|
||||
expect(got).toContain("session-state:pin:act");
|
||||
expect(got).toContain("session-state:archive:act");
|
||||
expect(got).toContain("session-state:trash:act");
|
||||
expect(got).not.toContain("session-state:unpin:act");
|
||||
expect(got).not.toContain("session-state:unarchive:act");
|
||||
const pin = result.current.find((a) => a.id === "session-state:pin:act");
|
||||
expect(pin?.title).toBe("Pin Alpha");
|
||||
expect(pin?.group).toBe("Actions");
|
||||
expect(got).not.toContain("session-state:untrash:act");
|
||||
const trash = result.current.find((a) => a.id === "session-state:trash:act");
|
||||
expect(trash?.title).toBe("Trash Alpha");
|
||||
expect(trash?.group).toBe("Actions");
|
||||
});
|
||||
|
||||
it("flips each toggle to its restore direction per state", () => {
|
||||
it("flips the toggle to its restore direction when trashed", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useCommandActions(
|
||||
baseArgs({
|
||||
activeSession: active({
|
||||
pinned_at: "2026-01-02T00:00:00Z",
|
||||
archived_at: "2026-01-02T00:00:00Z",
|
||||
trashed_at: "2026-01-02T00:00:00Z",
|
||||
}),
|
||||
}),
|
||||
),
|
||||
);
|
||||
const got = ids(result.current);
|
||||
expect(got).toContain("session-state:unpin:act");
|
||||
expect(got).toContain("session-state:unarchive:act");
|
||||
expect(got).toContain("session-state:untrash:act");
|
||||
expect(got).not.toContain("session-state:pin:act");
|
||||
expect(got).not.toContain("session-state:trash:act");
|
||||
expect(result.current.find((a) => a.id === "session-state:untrash:act")?.title).toBe("Untrash Alpha");
|
||||
});
|
||||
|
||||
it("routes a toggle's perform to onSessionStateAction with the session id and action", () => {
|
||||
const onSessionStateAction = vi.fn();
|
||||
const { result } = renderHook(() => useCommandActions(baseArgs({ activeSession: active(), onSessionStateAction })));
|
||||
result.current.find((a) => a.id === "session-state:archive:act")!.perform();
|
||||
expect(onSessionStateAction).toHaveBeenCalledWith("act", "archive");
|
||||
result.current.find((a) => a.id === "session-state:trash:act")!.perform();
|
||||
expect(onSessionStateAction).toHaveBeenCalledWith("act", "trash");
|
||||
});
|
||||
|
||||
it("omits the toggles in read-only mode", () => {
|
||||
|
||||
@@ -37,9 +37,6 @@ function session(group_path: string): SessionResponse {
|
||||
profile: "default",
|
||||
cleanup_defaults: { delete_worktree: false, delete_branch: false, delete_sandbox: false },
|
||||
remote_owner: null,
|
||||
notify_on_waiting: null,
|
||||
notify_on_idle: null,
|
||||
notify_on_error: null,
|
||||
claude_fullscreen: false,
|
||||
workspace_repos: [],
|
||||
} as SessionResponse;
|
||||
|
||||
@@ -39,9 +39,6 @@ function session(): SessionResponse {
|
||||
cleanup_defaults: { delete_worktree: false, delete_branch: false, delete_sandbox: false },
|
||||
remote_owner: "acme",
|
||||
remote_owner_key: "acme@example.com",
|
||||
notify_on_waiting: null,
|
||||
notify_on_idle: null,
|
||||
notify_on_error: null,
|
||||
claude_fullscreen: false,
|
||||
workspace_repos: [],
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user