feat!: remove push, triage, import, clone, and dead deps
Nix / Nix Eval + Web Frontend (push) Failing after 1m3s
CI / Rust (push) Failing after 6m4s
CI / Web (push) Failing after 17s

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:
Miguel Palhas
2026-08-22 16:29:13 +01:00
parent 2f50a83b01
commit 770d5bf459
140 changed files with 3089 additions and 18294 deletions
Generated
+7 -536
View File
@@ -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
View File
@@ -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,
+5 -5
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
);
}
}
-1
View File
@@ -133,7 +133,6 @@ impl MetricsSampler {
fn eligible_instance(inst: &Instance) -> bool {
!inst.is_structured()
&& !inst.is_archived()
&& !inst.is_trashed()
&& matches!(
inst.status,
+9 -25
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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();
File diff suppressed because it is too large Load Diff
+1 -33
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
-343
View File
@@ -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");
}
}
-1
View File
@@ -771,7 +771,6 @@ impl SessionService {
return;
};
if !inst.is_structured()
|| inst.is_archived()
|| inst.is_trashed()
|| inst.status != crate::session::Status::Idle
{
-17
View File
@@ -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
+26
View File
@@ -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;
+3 -4
View File
@@ -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;
}
-518
View File
@@ -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());
}
}
-63
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+287 -909
View File
File diff suppressed because it is too large Load Diff
+7 -12
View File
@@ -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
View File
@@ -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
View File
@@ -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));
}
}
+2 -3
View File
@@ -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"}),
] {
+1 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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.
+6 -13
View File
@@ -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} \
-3
View File
@@ -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
View File
@@ -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"));
}
}
-67
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
-485
View File
@@ -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
);
}
-366
View File
@@ -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"
);
}
-53
View File
@@ -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());
}
+11 -11
View File
@@ -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()),
);
},
-2
View File
@@ -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;
+6 -7
View File
@@ -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(())
}
-40
View File
@@ -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",
-3
View File
@@ -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",
-141
View File
@@ -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
View File
@@ -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
-149
View File
@@ -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();
});
});
+1 -10
View File
@@ -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"
-1
View File
@@ -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}
-143
View File
@@ -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>
);
}
+4 -36
View File
@@ -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": {
+8 -59
View File
@@ -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: [],
};
+2 -63
View File
@@ -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();
});
});
+2 -9
View File
@@ -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
+1 -30
View File
@@ -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");
});
});
+12 -13
View File
@@ -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");
});
});
+7 -15
View File
@@ -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