diff --git a/.agents/agent-console.md b/.agents/agent-console.md index 94c13e03..68bce008 100644 --- a/.agents/agent-console.md +++ b/.agents/agent-console.md @@ -1,28 +1,36 @@ -# Agent console presentation +# Agent cockpit presentation -The Dev Agents surface combines provider JSONL control records with a Markdown -conversation. Keep those two layers visually and semantically separate. +The Dev Agents surface is a worktree-first cockpit: project/worktree +navigation, named concurrent sessions, a compact tab strip, and one ordered +conversation/execution timeline. -- Claude `type="system"` is not synonymous with Ready. Only - `subtype="init"` becomes `AgentStreamEvent::Ready`; hook and status records - must not flood the visible trace. -- Consecutive identical activity labels collapse. Keep technical activity out - of the transcript flow: a round header button opens a bounded popover with - the twelve newest labels, so progress never pushes the conversation away. -- The transcript is chat prose, not a document. Render it with - `Markdown::compact()` to retain the shared 8 px conversation rhythm used by - Support, Monique, and the assistant. -- Bound the Markdown renderer to the transcript column with a zero minimum - width and clipped overflow. Its horizontal-rule child is absolutely sized - and otherwise escapes the 860 px conversation measure on wide windows. -- Keep the transcript/activity column as the single scroll owner and the - composer as its non-shrinking sibling. Do not nest another transcript scroll - or place the composer inside it. -- Keep the prompt on the shared `Composer`; its frame inherits the same theme - radius, border, shadow, hover and focus tokens as ShellDeck `Input`. Do not - add Agent-only card chrome around it. -- Treat provider, target, permissions, and workdir as one execution context. - The first three use adabraka `Select::context_label` inside one divided - frame; workdir is the editable final row. The model override belongs in the - Composer footer because it applies to the next message, and must remain a - free-form CLI value rather than a hard-coded shortlist. +- Use `shelldeck_core::agent_session::AgentSessionCollection` as the canonical + session, status, attention, message, and trace model. Keep only GPUI draft + state and run-to-session routing in the view. +- Route provider events by run UUID. The compatibility singleton event method + is insufficient for parallel agents and background-session attention. +- Render every structured `AgentTraceKind` inline with conversation messages: + commands, file reads, diffs, tests, tools, and fallback activity. Do not hide + technical work in a popover. +- Render agent prose with `Markdown::compact()` and technical details with the + shared monospace style. Bound both through the core session/runtime model. +- Keep provider, status, unread attention, and identity visible in navigator + rows and tabs. Selecting an actually visible session marks it read and + restores its context and composer draft; hidden selection is navigation + identity only. +- Treat provider, target, permissions, workdir, and model as one execution + context, collapsed into the compact session header by default. +- Change execution context through the core context setter so an opaque resume + token never crosses target, permission, workdir, model, or provider changes. +- Lock execution-context controls once Workspace binds a session to a checkout; + worktree navigation creates or selects a separately bound session instead of + mutating the context underneath an existing workspace tab. +- A catalog-bound local run enters its retained Workspace cockpit. Keep its + agent tabs in one pane, create the authorized checkout file pane alongside + it, and let the typed split controls expose the retained terminal without + duplicating one AgentConsole host for different session bindings. +- Mutating access always requires confirmation. Running sessions cannot be + removed, and concurrency limits remain enforced by the core model/runtime. +- Keep the timeline as the single scroll owner and the composer as its + non-shrinking sibling. Do not nest a transcript scroll or put the composer + inside it. diff --git a/.cargo/audit.toml b/.cargo/audit.toml index a8391082..415541b0 100644 --- a/.cargo/audit.toml +++ b/.cargo/audit.toml @@ -5,21 +5,11 @@ ignore = [ # GPUI / Linux platform stack. Track removal in # docs/security-advisory-baseline.md instead of one bot issue per crate. - "RUSTSEC-2024-0370", # proc-macro-error through GTK3 macros - "RUSTSEC-2024-0384", # instant through GPUI's futures-lite 1 stack - "RUSTSEC-2024-0412", # GTK3 bindings required by the Linux tray - "RUSTSEC-2024-0413", # GTK3 bindings required by the Linux tray - "RUSTSEC-2024-0415", # GTK3 bindings required by the Linux tray - "RUSTSEC-2024-0416", # GTK3 bindings required by the Linux tray - "RUSTSEC-2024-0418", # GTK3 bindings required by the Linux tray - "RUSTSEC-2024-0419", # GTK3 bindings required by the Linux tray - "RUSTSEC-2024-0420", # GTK3 bindings required by the Linux tray - "RUSTSEC-2024-0429", # glib VariantStrIter path is unused by ShellDeck - "RUSTSEC-2024-0436", # paste through image codecs - "RUSTSEC-2025-0052", # async-std through GPUI's HTTP/tar stack + "RUSTSEC-2024-0384", # instant through futures-lite 1 in adabraka_util + "RUSTSEC-2024-0436", # paste through metal, pulp/exr and rav1e/ravif + "RUSTSEC-2025-0052", # async-std through async-tar 0.5 in adabraka_http_client "RUSTSEC-2025-0134", # rustls-pemfile through zed-reqwest - "RUSTSEC-2026-0173", # proc-macro-error2 through GPUI's stacksafe - "RUSTSEC-2026-0192", # ttf-parser through cosmic-text/fontdb and lopdf + "RUSTSEC-2026-0192", # ttf-parser through cosmic-text/fontdb; pop-os/cosmic-text#526 # Existing protocol/backend constraints. "RUSTSEC-2023-0071", # rsa: no patched release; transitive through russh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5941ce19..ad241bc3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,9 +37,7 @@ jobs: libxcb1-dev \ libxkbcommon-x11-dev \ libdbus-1-dev \ - libgtk-3-dev \ - libxdo-dev \ - libayatana-appindicator3-dev + libxdo-dev - name: Install Rust toolchain # Pinned to match rust-toolchain.toml — a floating nightly drops the @@ -71,7 +69,7 @@ jobs: - name: Test portable-pty Unix cwd authority regression shell: bash run: | - crate_dir="$(find "${CARGO_HOME:-$HOME/.cargo}/registry/src" -maxdepth 2 -type d -name 'portable-pty-0.8.1' | head -1)" + crate_dir="$(find "${CARGO_HOME:-$HOME/.cargo}/registry/src" -maxdepth 2 -type d -name 'portable-pty-0.9.0' | head -1)" test -n "$crate_dir" grep -F -q '// SDTEST-1745' "$crate_dir/src/cmdbuilder.rs" cargo update --manifest-path "$crate_dir/Cargo.toml" @@ -130,6 +128,12 @@ jobs: shell: bash run: ./scripts/apply-crate-patches.sh + # The changed AppKit/GPUI tray path lives outside shelldeck-core. Keep a + # native macOS compile gate for the actual application binary. + - name: Check native macOS tray integration + if: runner.os == 'macOS' + run: cargo check -p shelldeck --bin shelldeck + # ShellDeck's own PTY boundary must reject a vanished authorized cwd on # every supported OS before any native PTY or process side effect. - name: Test terminal cwd authority boundary @@ -139,7 +143,7 @@ jobs: if: runner.os != 'Windows' shell: bash run: | - crate_dir="$(find "${CARGO_HOME:-$HOME/.cargo}/registry/src" -maxdepth 2 -type d -name 'portable-pty-0.8.1' | head -1)" + crate_dir="$(find "${CARGO_HOME:-$HOME/.cargo}/registry/src" -maxdepth 2 -type d -name 'portable-pty-0.9.0' | head -1)" test -n "$crate_dir" grep -F -q '// SDTEST-1745' "$crate_dir/src/cmdbuilder.rs" cargo update --manifest-path "$crate_dir/Cargo.toml" @@ -155,12 +159,35 @@ jobs: if: runner.os == 'Windows' shell: bash run: | - crate_dir="$(find "${CARGO_HOME:-$HOME/.cargo}/registry/src" -maxdepth 2 -type d -name 'portable-pty-0.8.1' | head -1)" + crate_dir="$(find "${CARGO_HOME:-$HOME/.cargo}/registry/src" -maxdepth 2 -type d -name 'portable-pty-0.9.0' | head -1)" test -n "$crate_dir" grep -F -q '// SDTEST-1744' "$crate_dir/src/cmdbuilder.rs" cargo update --manifest-path "$crate_dir/Cargo.toml" cargo test --manifest-path "$crate_dir/Cargo.toml" explicit_missing_cwd_is_forwarded_instead_of_falling_back_home + # SDPATCH-120 owns the ICO selector, CreateIconFromResourceEx boundary, + # and fail-closed Explorer-restart custody. Run its vendored unit tests + # explicitly because dependency tests are not part of ShellDeck's suite. + - name: Test native Windows tray integration + if: runner.os == 'Windows' + shell: bash + run: | + # Path dependencies are intentionally outside the workspace, so copy + # the vendored crate into the disposable runner temp tree to execute + # its own unit tests without broadening `cargo test --workspace`. + gpui_test_root="${RUNNER_TEMP}/shelldeck-gpui-test" + mkdir -p "$gpui_test_root/patches" "$gpui_test_root/packaging/icons" + cp -R patches/adabraka-gpui "$gpui_test_root/patches/" + cp packaging/icons/shelldeck.ico "$gpui_test_root/packaging/icons/" + cp Cargo.lock "$gpui_test_root/patches/adabraka-gpui/Cargo.lock" + native_tray_tests="$(CARGO_TARGET_DIR="${GITHUB_WORKSPACE}/target" cargo test --manifest-path "$gpui_test_root/patches/adabraka-gpui/Cargo.toml" --lib -- --list | tr -d '\r')" + grep -Fqx 'platform::windows::platform::tests::sdtest_1813_taskbar_created_message_registration_is_live: test' <<<"$native_tray_tests" + grep -Fqx 'platform::windows::tray::tests::sdtest_1813_explorer_restart_readd_is_authoritative_and_retains_icon: test' <<<"$native_tray_tests" + grep -Fqx 'platform::windows::tray::tests::sdtest_1814_ico_resource_selects_closest_then_highest_depth: test' <<<"$native_tray_tests" + grep -Fqx 'platform::windows::tray::tests::sdtest_1815_shell_deck_ico_creates_a_native_hicon: test' <<<"$native_tray_tests" + CARGO_TARGET_DIR="${GITHUB_WORKSPACE}/target" \ + cargo test --manifest-path "$gpui_test_root/patches/adabraka-gpui/Cargo.toml" --lib sdtest_181 + - name: Test shelldeck-core run: cargo test -p shelldeck-core diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fc7f4d0f..17395982 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -108,9 +108,7 @@ jobs: libxcb1-dev \ libxkbcommon-x11-dev \ libdbus-1-dev \ - libgtk-3-dev \ libxdo-dev \ - libayatana-appindicator3-dev \ libfuse2 # macOS dependencies diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index acb436ce..429edef8 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -44,7 +44,7 @@ jobs: token: ${{ secrets.GITHUB_TOKEN }} # Keep this in sync with .cargo/audit.toml. audit-check otherwise opens # one scheduled issue per package occurrence, including duplicates. - ignore: "RUSTSEC-2023-0071,RUSTSEC-2024-0370,RUSTSEC-2024-0384,RUSTSEC-2024-0412,RUSTSEC-2024-0413,RUSTSEC-2024-0415,RUSTSEC-2024-0416,RUSTSEC-2024-0418,RUSTSEC-2024-0419,RUSTSEC-2024-0420,RUSTSEC-2024-0429,RUSTSEC-2024-0436,RUSTSEC-2025-0052,RUSTSEC-2025-0134,RUSTSEC-2026-0173,RUSTSEC-2026-0192" + ignore: "RUSTSEC-2023-0071,RUSTSEC-2024-0384,RUSTSEC-2024-0436,RUSTSEC-2025-0052,RUSTSEC-2025-0134,RUSTSEC-2026-0192" dependency-policy: name: Licenses, bans and sources diff --git a/Cargo.lock b/Cargo.lock index 8e6a3950..8d80dae5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -228,7 +228,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ffbfa3de35b2b2d300986e728d6f3d3e436d166453ebd6b8fe721712e563d7b0" dependencies = [ "adabraka_collections", - "adabraka_util_macros", "anyhow", "async-fs", "async_zip", @@ -237,7 +236,6 @@ dependencies = [ "dunce", "futures", "futures-lite 1.13.0", - "git2", "globset", "itertools 0.14.0", "libc", @@ -553,7 +551,7 @@ version = "0.38.0+1.3.281" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f" dependencies = [ - "libloading 0.8.9", + "libloading", ] [[package]] @@ -851,29 +849,6 @@ dependencies = [ "thiserror 1.0.69", ] -[[package]] -name = "atk" -version = "0.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b" -dependencies = [ - "atk-sys", - "glib", - "libc", -] - -[[package]] -name = "atk-sys" -version = "0.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086" -dependencies = [ - "glib-sys", - "gobject-sys", - "libc", - "system-deps", -] - [[package]] name = "atomic" version = "0.5.3" @@ -906,7 +881,7 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "automonique-platform-client" version = "0.1.0" -source = "git+https://github.com/bext-stack/automonique?rev=1dd5c13f7a53aac9e28d3cd004bf73789ca66bb7#1dd5c13f7a53aac9e28d3cd004bf73789ca66bb7" +source = "git+https://github.com/bext-stack/automonique?rev=524073c3a55ec73d57e4b05abf5ba3eb0abd4cb7#524073c3a55ec73d57e4b05abf5ba3eb0abd4cb7" dependencies = [ "automonique-protocol", "base64 0.22.1", @@ -917,7 +892,7 @@ dependencies = [ [[package]] name = "automonique-protocol" version = "0.1.0" -source = "git+https://github.com/bext-stack/automonique?rev=1dd5c13f7a53aac9e28d3cd004bf73789ca66bb7#1dd5c13f7a53aac9e28d3cd004bf73789ca66bb7" +source = "git+https://github.com/bext-stack/automonique?rev=524073c3a55ec73d57e4b05abf5ba3eb0abd4cb7#524073c3a55ec73d57e4b05abf5ba3eb0abd4cb7" [[package]] name = "av-scenechange" @@ -1094,9 +1069,6 @@ name = "bitflags" version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" -dependencies = [ - "serde_core", -] [[package]] name = "bitstream-io" @@ -1124,7 +1096,7 @@ dependencies = [ "hidden-trait", "js-sys", "khronos-egl", - "libloading 0.8.9", + "libloading", "log", "mint", "naga", @@ -1317,31 +1289,6 @@ version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" -[[package]] -name = "cairo-rs" -version = "0.18.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" -dependencies = [ - "bitflags 2.13.0", - "cairo-sys-rs", - "glib", - "libc", - "once_cell", - "thiserror 1.0.69", -] - -[[package]] -name = "cairo-sys-rs" -version = "0.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51" -dependencies = [ - "glib-sys", - "libc", - "system-deps", -] - [[package]] name = "calloop" version = "0.13.0" @@ -1440,22 +1387,18 @@ dependencies = [ "nom 7.1.3", ] -[[package]] -name = "cfg-expr" -version = "0.15.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" -dependencies = [ - "smallvec", - "target-lexicon", -] - [[package]] name = "cfg-if" 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" @@ -1564,7 +1507,7 @@ checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" dependencies = [ "glob", "libc", - "libloading 0.8.9", + "libloading", ] [[package]] @@ -2007,15 +1950,6 @@ dependencies = [ "itertools 0.10.5", ] -[[package]] -name = "crossbeam-channel" -version = "0.5.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e" -dependencies = [ - "crossbeam-utils", -] - [[package]] name = "crossbeam-deque" version = "0.8.7" @@ -2556,7 +2490,7 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" dependencies = [ - "libloading 0.8.9", + "libloading", ] [[package]] @@ -2565,12 +2499,6 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" -[[package]] -name = "dpi" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" - [[package]] name = "dtor" version = "0.0.6" @@ -2926,16 +2854,6 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" -[[package]] -name = "field-offset" -version = "0.3.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" -dependencies = [ - "memoffset 0.9.1", - "rustc_version", -] - [[package]] name = "filedescriptor" version = "0.8.3" @@ -3286,64 +3204,6 @@ dependencies = [ "slab", ] -[[package]] -name = "gdk" -version = "0.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9f245958c627ac99d8e529166f9823fb3b838d1d41fd2b297af3075093c2691" -dependencies = [ - "cairo-rs", - "gdk-pixbuf", - "gdk-sys", - "gio", - "glib", - "libc", - "pango", -] - -[[package]] -name = "gdk-pixbuf" -version = "0.18.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec" -dependencies = [ - "gdk-pixbuf-sys", - "gio", - "glib", - "libc", - "once_cell", -] - -[[package]] -name = "gdk-pixbuf-sys" -version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7" -dependencies = [ - "gio-sys", - "glib-sys", - "gobject-sys", - "libc", - "system-deps", -] - -[[package]] -name = "gdk-sys" -version = "0.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7" -dependencies = [ - "cairo-sys-rs", - "gdk-pixbuf-sys", - "gio-sys", - "glib-sys", - "gobject-sys", - "libc", - "pango-sys", - "pkg-config", - "system-deps", -] - [[package]] name = "generic-array" version = "0.14.7" @@ -3449,98 +3309,6 @@ version = "0.32.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" -[[package]] -name = "gio" -version = "0.18.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4fc8f532f87b79cbc51a79748f16a6828fb784be93145a322fa14d06d354c73" -dependencies = [ - "futures-channel", - "futures-core", - "futures-io", - "futures-util", - "gio-sys", - "glib", - "libc", - "once_cell", - "pin-project-lite", - "smallvec", - "thiserror 1.0.69", -] - -[[package]] -name = "gio-sys" -version = "0.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2" -dependencies = [ - "glib-sys", - "gobject-sys", - "libc", - "system-deps", - "winapi", -] - -[[package]] -name = "git2" -version = "0.20.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b88256088d75a56f8ecfa070513a775dd9107f6530ef14919dac831af9cfe2b" -dependencies = [ - "bitflags 2.13.0", - "libc", - "libgit2-sys", - "log", - "url", -] - -[[package]] -name = "glib" -version = "0.18.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" -dependencies = [ - "bitflags 2.13.0", - "futures-channel", - "futures-core", - "futures-executor", - "futures-task", - "futures-util", - "gio-sys", - "glib-macros", - "glib-sys", - "gobject-sys", - "libc", - "memchr", - "once_cell", - "smallvec", - "thiserror 1.0.69", -] - -[[package]] -name = "glib-macros" -version = "0.18.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc" -dependencies = [ - "heck 0.4.1", - "proc-macro-crate 2.0.0", - "proc-macro-error", - "proc-macro2", - "quote", - "syn 2.0.118", -] - -[[package]] -name = "glib-sys" -version = "0.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898" -dependencies = [ - "libc", - "system-deps", -] - [[package]] name = "glob" version = "0.3.3" @@ -3595,17 +3363,6 @@ dependencies = [ "web-sys", ] -[[package]] -name = "gobject-sys" -version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44" -dependencies = [ - "glib-sys", - "libc", - "system-deps", -] - [[package]] name = "gpu-alloc" version = "0.6.2" @@ -3642,58 +3399,6 @@ version = "0.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "12101ecc8225ea6d675bc70263074eab6169079621c2186fe0c66590b2df9681" -[[package]] -name = "gtk" -version = "0.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd56fb197bfc42bd5d2751f4f017d44ff59fbb58140c6b49f9b3b2bdab08506a" -dependencies = [ - "atk", - "cairo-rs", - "field-offset", - "futures-channel", - "gdk", - "gdk-pixbuf", - "gio", - "glib", - "gtk-sys", - "gtk3-macros", - "libc", - "pango", - "pkg-config", -] - -[[package]] -name = "gtk-sys" -version = "0.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f29a1c21c59553eb7dd40e918be54dccd60c52b049b75119d5d96ce6b624414" -dependencies = [ - "atk-sys", - "cairo-sys-rs", - "gdk-pixbuf-sys", - "gdk-sys", - "gio-sys", - "glib-sys", - "gobject-sys", - "libc", - "pango-sys", - "system-deps", -] - -[[package]] -name = "gtk3-macros" -version = "0.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52ff3c5b21f14f0736fed6dcfc0bfb4225ebf5725f3c0209edeec181e4d73e9d" -dependencies = [ - "proc-macro-crate 1.3.1", - "proc-macro-error", - "proc-macro2", - "quote", - "syn 2.0.118", -] - [[package]] name = "h2" version = "0.4.16" @@ -4155,7 +3860,7 @@ dependencies = [ "image-webp", "moxcms", "num-traits", - "png 0.18.1", + "png", "qoi", "ravif", "rayon", @@ -4212,11 +3917,11 @@ dependencies = [ [[package]] name = "inotify" -version = "0.10.2" +version = "0.11.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdd168d97690d0b8c412d6b6c10360277f4d7ee495c5d0d5d5fe0854923255cc" +checksum = "4cc00ea907cab49550b7da656f80ebb97be1b997d931fbcd28d39734e17ce592" dependencies = [ - "bitflags 1.3.2", + "bitflags 2.13.0", "inotify-sys", "libc", ] @@ -4332,15 +4037,6 @@ dependencies = [ "leaky-cow", ] -[[package]] -name = "ioctl-rs" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7970510895cee30b3e9128319f2cefd4bde883a39f38baa279567ba3a7eb97d" -dependencies = [ - "libc", -] - [[package]] name = "ipnet" version = "2.12.0" @@ -4539,17 +4235,6 @@ dependencies = [ "rand_core 0.10.1", ] -[[package]] -name = "keyboard-types" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" -dependencies = [ - "bitflags 2.13.0", - "serde", - "unicode-segmentation", -] - [[package]] name = "keyring" version = "3.6.3" @@ -4573,7 +4258,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76" dependencies = [ "libc", - "libloading 0.8.9", + "libloading", ] [[package]] @@ -4665,30 +4350,6 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" -[[package]] -name = "libappindicator" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03589b9607c868cc7ae54c0b2a22c8dc03dd41692d48f2d7df73615c6a95dc0a" -dependencies = [ - "glib", - "gtk", - "gtk-sys", - "libappindicator-sys", - "log", -] - -[[package]] -name = "libappindicator-sys" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf" -dependencies = [ - "gtk-sys", - "libloading 0.7.4", - "once_cell", -] - [[package]] name = "libc" version = "0.2.186" @@ -4714,28 +4375,6 @@ dependencies = [ "cc", ] -[[package]] -name = "libgit2-sys" -version = "0.18.8+1.9.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f7c568b25d7489bc3fb2988ed69ab111d2944d2f5fec3d5c987fe545ea97b50" -dependencies = [ - "cc", - "libc", - "libz-sys", - "pkg-config", -] - -[[package]] -name = "libloading" -version = "0.7.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" -dependencies = [ - "cfg-if", - "winapi", -] - [[package]] name = "libloading" version = "0.8.9" @@ -4771,25 +4410,6 @@ dependencies = [ "libc", ] -[[package]] -name = "libxdo" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00333b8756a3d28e78def82067a377de7fa61b24909000aeaa2b446a948d14db" -dependencies = [ - "libxdo-sys", -] - -[[package]] -name = "libxdo-sys" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db23b9e7e2b7831bbd8aac0bbeeeb7b68cbebc162b227e7052e8e55829a09212" -dependencies = [ - "libc", - "x11", -] - [[package]] name = "libz-sys" version = "1.1.29" @@ -5031,15 +4651,6 @@ dependencies = [ "libc", ] -[[package]] -name = "memoffset" -version = "0.6.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5aa361d4faea93603064a027415f07bd8e1d5c88c9fbf68bf56a285428fd79ce" -dependencies = [ - "autocfg", -] - [[package]] name = "memoffset" version = "0.9.1" @@ -5148,27 +4759,6 @@ dependencies = [ "pxfm", ] -[[package]] -name = "muda" -version = "0.17.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c9fec5a4e89860383d778d10563a605838f8f0b2f9303868937e5ff32e86177" -dependencies = [ - "crossbeam-channel", - "dpi", - "gtk", - "keyboard-types", - "libxdo", - "objc2", - "objc2-app-kit", - "objc2-core-foundation", - "objc2-foundation", - "once_cell", - "png 0.17.16", - "thiserror 2.0.18", - "windows-sys 0.60.2", -] - [[package]] name = "naga" version = "25.0.1" @@ -5178,7 +4768,7 @@ dependencies = [ "arrayvec", "bit-set", "bitflags 2.13.0", - "cfg_aliases", + "cfg_aliases 0.2.1", "codespan-reporting", "half", "hashbrown 0.15.5", @@ -5211,16 +4801,14 @@ checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" [[package]] name = "nix" -version = "0.25.1" +version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f346ff70e7dbfd675fe90590b92d59ef2de15a8779ae305ebcbfd3f0caf59be4" +checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4" dependencies = [ - "autocfg", - "bitflags 1.3.2", + "bitflags 2.13.0", "cfg-if", + "cfg_aliases 0.1.1", "libc", - "memoffset 0.6.5", - "pin-utils", ] [[package]] @@ -5231,9 +4819,9 @@ checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" dependencies = [ "bitflags 2.13.0", "cfg-if", - "cfg_aliases", + "cfg_aliases 0.2.1", "libc", - "memoffset 0.9.1", + "memoffset", ] [[package]] @@ -5244,7 +4832,7 @@ checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" dependencies = [ "bitflags 2.13.0", "cfg-if", - "cfg_aliases", + "cfg_aliases 0.2.1", "libc", ] @@ -5293,12 +4881,11 @@ dependencies = [ [[package]] name = "notify" -version = "7.0.0" +version = "8.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c533b4c39709f9ba5005d8002048266593c1cfaf3c5f0739d5b8ab0c6c504009" +checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" dependencies = [ "bitflags 2.13.0", - "filetime", "fsevent-sys", "inotify", "kqueue", @@ -5307,7 +4894,7 @@ dependencies = [ "mio", "notify-types", "walkdir", - "windows-sys 0.52.0", + "windows-sys 0.60.2", ] [[package]] @@ -5326,11 +4913,11 @@ dependencies = [ [[package]] name = "notify-types" -version = "1.0.1" +version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "585d3cb5e12e01aed9e8a1f70d5c6b5e86fe2a6e48fc8cd0b3e0b8df6f6eb174" +checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" dependencies = [ - "instant", + "bitflags 2.13.0", ] [[package]] @@ -5523,16 +5110,6 @@ dependencies = [ "objc2", ] -[[package]] -name = "objc2-core-graphics" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" -dependencies = [ - "bitflags 2.13.0", - "objc2-core-foundation", -] - [[package]] name = "objc2-encode" version = "4.1.0" @@ -5781,31 +5358,6 @@ dependencies = [ "windows-strings 0.5.1", ] -[[package]] -name = "pango" -version = "0.18.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4" -dependencies = [ - "gio", - "glib", - "libc", - "once_cell", - "pango-sys", -] - -[[package]] -name = "pango-sys" -version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5" -dependencies = [ - "glib-sys", - "gobject-sys", - "libc", - "system-deps", -] - [[package]] name = "parking" version = "2.2.1" @@ -6059,19 +5611,6 @@ dependencies = [ "plotters-backend", ] -[[package]] -name = "png" -version = "0.17.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" -dependencies = [ - "bitflags 1.3.2", - "crc32fast", - "fdeflate", - "flate2", - "miniz_oxide", -] - [[package]] name = "png" version = "0.18.1" @@ -6165,9 +5704,9 @@ dependencies = [ [[package]] name = "portable-pty" -version = "0.8.1" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "806ee80c2a03dbe1a9fb9534f8d19e4c0546b790cde8fd1fea9d6390644cb0be" +checksum = "b4a596a2b3d2752d94f51fac2d4a96737b8705dddd311a32b9af47211f08671e" dependencies = [ "anyhow", "bitflags 1.3.2", @@ -6176,8 +5715,8 @@ dependencies = [ "lazy_static", "libc", "log", - "nix 0.25.1", - "serial", + "nix 0.28.0", + "serial2", "shared_library", "shell-words", "winapi", @@ -6258,25 +5797,6 @@ dependencies = [ "elliptic-curve", ] -[[package]] -name = "proc-macro-crate" -version = "1.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" -dependencies = [ - "once_cell", - "toml_edit 0.19.15", -] - -[[package]] -name = "proc-macro-crate" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e8366a6159044a37876a2b9817124296703c586a5c92e2c53751fa06d8d43e8" -dependencies = [ - "toml_edit 0.20.7", -] - [[package]] name = "proc-macro-crate" version = "3.5.0" @@ -6286,52 +5806,6 @@ dependencies = [ "toml_edit 0.25.12+spec-1.1.0", ] -[[package]] -name = "proc-macro-error" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" -dependencies = [ - "proc-macro-error-attr", - "proc-macro2", - "quote", - "syn 1.0.109", - "version_check", -] - -[[package]] -name = "proc-macro-error-attr" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" -dependencies = [ - "proc-macro2", - "quote", - "version_check", -] - -[[package]] -name = "proc-macro-error-attr2" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" -dependencies = [ - "proc-macro2", - "quote", -] - -[[package]] -name = "proc-macro-error2" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" -dependencies = [ - "proc-macro-error-attr2", - "proc-macro2", - "quote", - "syn 2.0.118", -] - [[package]] name = "proc-macro2" version = "1.0.106" @@ -6450,7 +5924,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" dependencies = [ "bytes", - "cfg_aliases", + "cfg_aliases 0.2.1", "pin-project-lite", "quinn-proto", "quinn-udp", @@ -6491,7 +5965,7 @@ version = "0.5.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" dependencies = [ - "cfg_aliases", + "cfg_aliases 0.2.1", "libc", "once_cell", "socket2", @@ -7677,45 +7151,14 @@ dependencies = [ ] [[package]] -name = "serial" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1237a96570fc377c13baa1b88c7589ab66edced652e43ffb17088f003db3e86" -dependencies = [ - "serial-core", - "serial-unix", - "serial-windows", -] - -[[package]] -name = "serial-core" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f46209b345401737ae2125fe5b19a77acce90cd53e1658cda928e4fe9a64581" -dependencies = [ - "libc", -] - -[[package]] -name = "serial-unix" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f03fbca4c9d866e24a459cbca71283f545a37f8e3e002ad8c70593871453cab7" -dependencies = [ - "ioctl-rs", - "libc", - "serial-core", - "termios", -] - -[[package]] -name = "serial-windows" -version = "0.4.0" +name = "serial2" +version = "0.2.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15c6d3b776267a75d31bbdfd5d36c0ca051251caafc285827052bc53bcdc8162" +checksum = "b16809bc35793b19ce4e0c53924bc0dce3937f15487997cfdaed936004180730" dependencies = [ + "cfg-if", "libc", - "serial-core", + "windows-sys 0.61.2", ] [[package]] @@ -7805,13 +7248,12 @@ checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" [[package]] name = "shelldeck" -version = "0.9.4" +version = "0.9.6" dependencies = [ "adabraka-gpui", "adabraka-ui", "anyhow", "directories", - "gtk", "image", "notify-rust", "parking_lot", @@ -7822,14 +7264,13 @@ dependencies = [ "tokio", "tracing", "tracing-subscriber", - "tray-icon", "uuid", "winresource", ] [[package]] name = "shelldeck-core" -version = "0.9.4" +version = "0.9.6" dependencies = [ "agent-client-protocol", "anyhow", @@ -7859,7 +7300,7 @@ dependencies = [ [[package]] name = "shelldeck-ssh" -version = "0.9.4" +version = "0.9.6" dependencies = [ "anyhow", "async-trait", @@ -7879,7 +7320,7 @@ dependencies = [ [[package]] name = "shelldeck-terminal" -version = "0.9.4" +version = "0.9.6" dependencies = [ "anyhow", "chrono", @@ -7900,13 +7341,14 @@ dependencies = [ [[package]] name = "shelldeck-ui" -version = "0.9.4" +version = "0.9.6" dependencies = [ "adabraka-gpui", "adabraka-ui", "anyhow", "async-io", "async-process", + "automonique-protocol", "chrono", "futures-lite 2.6.1", "image", @@ -7952,7 +7394,7 @@ dependencies = [ [[package]] name = "shelldeck-update" -version = "0.9.4" +version = "0.9.6" dependencies = [ "adabraka-gpui", "anyhow", @@ -8237,9 +7679,9 @@ dependencies = [ [[package]] name = "stacksafe" -version = "0.1.4" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d9c1172965d317e87ddb6d364a040d958b40a1db82b6ef97da26253a8b3d090" +checksum = "95f9c34983ac74195c710c473db6fdf1085f64a47dbaa0090d1bea03be70da66" dependencies = [ "stacker", "stacksafe-macro", @@ -8247,13 +7689,13 @@ dependencies = [ [[package]] name = "stacksafe-macro" -version = "0.1.4" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "172175341049678163e979d9107ca3508046d4d2a7c6682bee46ac541b17db69" +checksum = "6feeae42a2d6b0dcb8aeb2f08d9e48cdac600239cf8a20fc59f9e252e86bdfe1" dependencies = [ - "proc-macro-error2", + "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.4", ] [[package]] @@ -8572,19 +8014,6 @@ dependencies = [ "libc", ] -[[package]] -name = "system-deps" -version = "6.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" -dependencies = [ - "cfg-expr", - "heck 0.5.0", - "pkg-config", - "toml 0.8.23", - "version-compare", -] - [[package]] name = "taffy" version = "0.9.0" @@ -8615,12 +8044,6 @@ dependencies = [ "objc", ] -[[package]] -name = "target-lexicon" -version = "0.12.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" - [[package]] name = "task-local" version = "0.1.1" @@ -8648,7 +8071,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand 2.4.1", - "getrandom 0.4.3", + "getrandom 0.3.4", "once_cell", "rustix 1.1.4", "windows-sys 0.61.2", @@ -8674,15 +8097,6 @@ dependencies = [ "winapi-util", ] -[[package]] -name = "termios" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d5d9cf598a6d7ce700a4e6a9199da127e6819a61e64b68609683cc9a01b5683a" -dependencies = [ - "libc", -] - [[package]] name = "thiserror" version = "1.0.69" @@ -8796,7 +8210,7 @@ dependencies = [ "bytemuck", "cfg-if", "log", - "png 0.18.1", + "png", "tiny-skia-path", ] @@ -8954,28 +8368,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "toml_edit" -version = "0.19.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" -dependencies = [ - "indexmap 2.14.0", - "toml_datetime 0.6.11", - "winnow 0.5.40", -] - -[[package]] -name = "toml_edit" -version = "0.20.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70f427fce4d84c72b5b732388bf4a9f4531b53f74e2887e3ecb2481f68f66d81" -dependencies = [ - "indexmap 2.14.0", - "toml_datetime 0.6.11", - "winnow 0.5.40", -] - [[package]] name = "toml_edit" version = "0.22.27" @@ -9140,27 +8532,6 @@ dependencies = [ "tracing-log", ] -[[package]] -name = "tray-icon" -version = "0.22.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76b42a907631429634f9f57ef72f9e7aec3e12e74050636138acb3752ecb8df3" -dependencies = [ - "crossbeam-channel", - "dirs 6.0.0", - "libappindicator", - "muda", - "objc2", - "objc2-app-kit", - "objc2-core-foundation", - "objc2-core-graphics", - "objc2-foundation", - "once_cell", - "png 0.18.1", - "thiserror 2.0.18", - "windows-sys 0.61.2", -] - [[package]] name = "tree-sitter" version = "0.25.10" @@ -9337,7 +8708,7 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ - "memoffset 0.9.1", + "memoffset", "tempfile", "windows-sys 0.61.2", ] @@ -9613,12 +8984,6 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" -[[package]] -name = "version-compare" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" - [[package]] name = "version_check" version = "0.9.5" @@ -10497,15 +9862,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" -[[package]] -name = "winnow" -version = "0.5.40" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" -dependencies = [ - "memchr", -] - [[package]] name = "winnow" version = "0.7.15" @@ -10818,7 +10174,7 @@ version = "4.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "267db9407081e90bbfa46d841d3cbc60f59c0351838c4bc65199ecd79ab1983e" dependencies = [ - "proc-macro-crate 3.5.0", + "proc-macro-crate", "proc-macro2", "quote", "syn 2.0.118", @@ -10831,7 +10187,7 @@ version = "5.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e05ad887425eecf5e8384dc2406a4a9313eb73468712fc1cdea362eb4fe0469" dependencies = [ - "proc-macro-crate 3.5.0", + "proc-macro-crate", "proc-macro2", "quote", "syn 2.0.118", @@ -11136,7 +10492,7 @@ version = "4.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73e2ba546bda683a90652bac4a279bc146adad1386f25379cf73200d2002c449" dependencies = [ - "proc-macro-crate 3.5.0", + "proc-macro-crate", "proc-macro2", "quote", "syn 2.0.118", @@ -11149,7 +10505,7 @@ version = "5.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8118ca6bda77bfc0ab51d660db0c955f2505eef854c9a449435bccb616933b31" dependencies = [ - "proc-macro-crate 3.5.0", + "proc-macro-crate", "proc-macro2", "quote", "syn 2.0.118", diff --git a/Cargo.toml b/Cargo.toml index ffb7433c..607c640d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,7 +10,7 @@ members = [ ] [workspace.package] -version = "0.9.4" +version = "0.9.6" edition = "2021" license = "MIT" authors = ["ShellDeck Contributors"] @@ -28,7 +28,7 @@ russh = { version = "0.60.3" } ssh2-config = "0.2" # Terminal -portable-pty = "0.8" +portable-pty = "0.9" vte = "0.13" # Async runtime @@ -49,14 +49,14 @@ serde_json = "1" toml = "0.8" # Utilities -uuid = { version = "1", features = ["v4", "serde"] } +uuid = { version = "1", features = ["v4", "v5", "serde"] } chrono = { version = "0.4", features = ["serde"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } anyhow = "1" thiserror = "2" directories = "6" -notify = "7" +notify = "8.2" # Platform credential stores are all behind feature flags in keyring 3: with # none enabled, `keyring::default` resolves to the in-process **mock** store on # every OS, so nothing is ever persisted. The deps are target-gated upstream, @@ -69,14 +69,8 @@ keyring = { version = "3", features = [ "crypto-rust", ] } auto-launch = "0.5" -tray-icon = "0.22" notify-rust = "4" image = { version = "0.25", default-features = false, features = ["png", "ico"] } -# GTK — Linux only; the tray-icon crate depends on libappindicator which -# itself sits on GTK. adabraka-gpui uses Wayland/X11 natively without -# initialising GTK, so we init + run gtk::main() on a dedicated thread -# (see crates/shelldeck/src/tray/mod.rs). -gtk = "0.18" parking_lot = "0.12" fs2 = "0.4" smallvec = "1.13" @@ -90,8 +84,8 @@ sha2 = "0.10" base64 = "0.22" ed25519-dalek = "2" criterion = "0.5" -automonique-platform-client = { git = "https://github.com/bext-stack/automonique", rev = "1dd5c13f7a53aac9e28d3cd004bf73789ca66bb7" } -automonique-protocol = { git = "https://github.com/bext-stack/automonique", rev = "1dd5c13f7a53aac9e28d3cd004bf73789ca66bb7" } +automonique-platform-client = { git = "https://github.com/bext-stack/automonique", rev = "524073c3a55ec73d57e4b05abf5ba3eb0abd4cb7" } +automonique-protocol = { git = "https://github.com/bext-stack/automonique", rev = "524073c3a55ec73d57e4b05abf5ba3eb0abd4cb7" } windows-sys = { version = "0.60", features = [ "Win32_Foundation", "Win32_Security", diff --git a/README.md b/README.md index 127e87f9..b003f144 100644 --- a/README.md +++ b/README.md @@ -86,8 +86,7 @@ permissions described in sudo apt install \ libssl-dev pkg-config libxkbcommon-dev libwayland-dev libvulkan-dev \ libfontconfig1-dev libxcb-shape0-dev libxcb-xfixes0-dev libxcb1-dev \ - libxkbcommon-x11-dev libdbus-1-dev libgtk-3-dev libxdo-dev \ - libayatana-appindicator3-dev + libxkbcommon-x11-dev libdbus-1-dev libxdo-dev ``` ## Build & Run diff --git a/cloudflare/update-worker/public/campaign/roles-v1/hero-desktop-poster.webp b/cloudflare/update-worker/public/campaign/roles-v1/hero-desktop-poster.webp deleted file mode 100644 index 3b4fa050..00000000 Binary files a/cloudflare/update-worker/public/campaign/roles-v1/hero-desktop-poster.webp and /dev/null differ diff --git a/cloudflare/update-worker/public/campaign/roles-v1/hero-desktop.mp4 b/cloudflare/update-worker/public/campaign/roles-v1/hero-desktop.mp4 deleted file mode 100644 index de07ad89..00000000 Binary files a/cloudflare/update-worker/public/campaign/roles-v1/hero-desktop.mp4 and /dev/null differ diff --git a/cloudflare/update-worker/public/campaign/roles-v1/hero-desktop.webm b/cloudflare/update-worker/public/campaign/roles-v1/hero-desktop.webm deleted file mode 100644 index 32de000c..00000000 Binary files a/cloudflare/update-worker/public/campaign/roles-v1/hero-desktop.webm and /dev/null differ diff --git a/cloudflare/update-worker/public/campaign/roles-v1/hero-mobile-poster.webp b/cloudflare/update-worker/public/campaign/roles-v1/hero-mobile-poster.webp deleted file mode 100644 index e7bf4dd1..00000000 Binary files a/cloudflare/update-worker/public/campaign/roles-v1/hero-mobile-poster.webp and /dev/null differ diff --git a/cloudflare/update-worker/public/campaign/roles-v1/hero-mobile.mp4 b/cloudflare/update-worker/public/campaign/roles-v1/hero-mobile.mp4 deleted file mode 100644 index 9c80f7ff..00000000 Binary files a/cloudflare/update-worker/public/campaign/roles-v1/hero-mobile.mp4 and /dev/null differ diff --git a/cloudflare/update-worker/public/campaign/roles-v1/hero-mobile.webm b/cloudflare/update-worker/public/campaign/roles-v1/hero-mobile.webm deleted file mode 100644 index d87520d2..00000000 Binary files a/cloudflare/update-worker/public/campaign/roles-v1/hero-mobile.webm and /dev/null differ diff --git a/cloudflare/update-worker/public/campaign/roles-v1/user-support-promo-desktop-poster.webp b/cloudflare/update-worker/public/campaign/roles-v1/user-support-promo-desktop-poster.webp new file mode 100644 index 00000000..5d4077c8 Binary files /dev/null and b/cloudflare/update-worker/public/campaign/roles-v1/user-support-promo-desktop-poster.webp differ diff --git a/cloudflare/update-worker/public/campaign/roles-v1/user-support-promo-desktop.mp4 b/cloudflare/update-worker/public/campaign/roles-v1/user-support-promo-desktop.mp4 new file mode 100644 index 00000000..d020554f Binary files /dev/null and b/cloudflare/update-worker/public/campaign/roles-v1/user-support-promo-desktop.mp4 differ diff --git a/cloudflare/update-worker/public/campaign/roles-v1/user-support-promo-desktop.webm b/cloudflare/update-worker/public/campaign/roles-v1/user-support-promo-desktop.webm new file mode 100644 index 00000000..add4fc80 Binary files /dev/null and b/cloudflare/update-worker/public/campaign/roles-v1/user-support-promo-desktop.webm differ diff --git a/cloudflare/update-worker/public/campaign/roles-v1/user-support-promo-mobile-poster.webp b/cloudflare/update-worker/public/campaign/roles-v1/user-support-promo-mobile-poster.webp new file mode 100644 index 00000000..a64010f2 Binary files /dev/null and b/cloudflare/update-worker/public/campaign/roles-v1/user-support-promo-mobile-poster.webp differ diff --git a/cloudflare/update-worker/public/campaign/roles-v1/user-support-promo-mobile.mp4 b/cloudflare/update-worker/public/campaign/roles-v1/user-support-promo-mobile.mp4 new file mode 100644 index 00000000..b819a7a6 Binary files /dev/null and b/cloudflare/update-worker/public/campaign/roles-v1/user-support-promo-mobile.mp4 differ diff --git a/cloudflare/update-worker/public/campaign/roles-v1/user-support-promo-mobile.webm b/cloudflare/update-worker/public/campaign/roles-v1/user-support-promo-mobile.webm new file mode 100644 index 00000000..473b9534 Binary files /dev/null and b/cloudflare/update-worker/public/campaign/roles-v1/user-support-promo-mobile.webm differ diff --git a/cloudflare/update-worker/src/landing.ts b/cloudflare/update-worker/src/landing.ts index 18ae0ecb..04292a5f 100644 --- a/cloudflare/update-worker/src/landing.ts +++ b/cloudflare/update-worker/src/landing.ts @@ -34,7 +34,7 @@ export function renderMarketingLandingPage(dl: LandingDownloadInfo): Response { - + @@ -216,8 +216,8 @@ export function renderMarketingLandingPage(dl: LandingDownloadInfo): Response {

Version ${v} · Gratuit et open source · Linux, macOS et Windows

-
- +
+
@@ -267,7 +267,7 @@ export function renderMarketingLandingPage(dl: LandingDownloadInfo): Response {

Relié à votre environnement

Le même fil, jusqu’à la résolution.

Sites, demandes et accès restent reliés à Inklura Manage. Automonique apporte l’exécution souveraine, Monique réalise le travail et ShellDeck garde la supervision au même endroit.

-
UtilisateurCrée et suit sa demande→
SupportQualifie, répond et coordonne→
DevDiagnostique et intervient✓
+
UtilisateurCrée et suit sa demande→
SupportQualifie, répond et coordonne→
DevDiagnostique et intervient✓
@@ -288,7 +288,7 @@ export function renderMarketingLandingPage(dl: LandingDownloadInfo): Response { if(!os&&/Android/.test(ua))os='linux';if(!os&&/iPhone|iPad/.test(ua))os='macos'; var card=os&&document.querySelector('[data-platform="'+os+'"]');if(card)card.classList.add('detected'); var reduced=window.matchMedia('(prefers-reduced-motion: reduce)');var compact=window.matchMedia('(max-width: 900px)');var heroVideo=document.querySelector('.hero-motion'); - function syncMotion(){if(!heroVideo)return;if(reduced.matches){heroVideo.pause();heroVideo.removeAttribute('src');while(heroVideo.firstChild)heroVideo.removeChild(heroVideo.firstChild);heroVideo.load();return}var variant=compact.matches?'mobile':'desktop';if(heroVideo.dataset.variant===variant){heroVideo.play().catch(function(){});return}heroVideo.dataset.variant=variant;heroVideo.poster='/campaign/roles-v1/hero-'+variant+'-poster.webp';while(heroVideo.firstChild)heroVideo.removeChild(heroVideo.firstChild);[['Webm','video/webm'],['Mp4','video/mp4']].forEach(function(item){var source=document.createElement('source');source.src=heroVideo.dataset[variant+item[0]];source.type=item[1];heroVideo.appendChild(source)});heroVideo.load();heroVideo.play().catch(function(){})} + function syncMotion(){if(!heroVideo)return;if(reduced.matches){heroVideo.pause();heroVideo.removeAttribute('src');while(heroVideo.firstChild)heroVideo.removeChild(heroVideo.firstChild);heroVideo.load();return}var variant=compact.matches?'mobile':'desktop';if(heroVideo.dataset.variant===variant){heroVideo.play().catch(function(){});return}heroVideo.dataset.variant=variant;heroVideo.poster='/campaign/roles-v1/user-support-promo-'+variant+'-poster.webp';while(heroVideo.firstChild)heroVideo.removeChild(heroVideo.firstChild);[['Webm','video/webm'],['Mp4','video/mp4']].forEach(function(item){var source=document.createElement('source');source.src=heroVideo.dataset[variant+item[0]];source.type=item[1];heroVideo.appendChild(source)});heroVideo.load();heroVideo.play().catch(function(){})} if(reduced.addEventListener)reduced.addEventListener('change',syncMotion);if(compact.addEventListener)compact.addEventListener('change',syncMotion);syncMotion(); var header=document.querySelector('.site-header');function syncHeader(){if(header)header.classList.toggle('scrolled',window.scrollY>16)}window.addEventListener('scroll',syncHeader,{passive:true});syncHeader(); if('IntersectionObserver' in window&&!reduced.matches){ diff --git a/crates/shelldeck-core/locales/en.toml b/crates/shelldeck-core/locales/en.toml index 2fdf4fc4..f6d9964f 100644 --- a/crates/shelldeck-core/locales/en.toml +++ b/crates/shelldeck-core/locales/en.toml @@ -238,6 +238,23 @@ notification.fleet.failed = "AI Operations job failed" notification.ai.summary = "ShellDeck — AI Assistant" notification.ai.success = "An AI task completed" notification.ai.failed = "An AI task failed" +notification.attention.summary = "ShellDeck — Attention" +notification.attention.open = "Open" +notification.attention.body = "%{reason} · workspace %{workspace}" +platform_attention.title = "Attention" +platform_attention.activity_title = "Chronological activity" +platform_attention.activity_workspace = "Workspace %{workspace}" +platform_attention.activity_more = "%{count} more in the chronology" +platform_attention.reason.review_requested = "Review requested" +platform_attention.reason.comment_reply = "Comment reply" +platform_attention.reason.approval_required = "Approval required" +platform_attention.reason.agent_working = "Agent working" +platform_attention.reason.check_running = "Check running" +platform_attention.reason.delivery_pending = "Delivery pending" +platform_attention.reason.complete = "Work completed" +platform_attention.reason.conflict = "Conflict needs attention" +platform_attention.reason.check_failed = "Check failed" +platform_attention.reason.external_blocker = "External blocker" settings.cloud_sync.section = "CLOUD SYNC" settings.cloud_sync.account.label = "Account" @@ -403,6 +420,20 @@ agents.status.preparing = "The agent is preparing its response" agents.status.exit = "Process exited with code %{code}" agents.status.stopped = "Run stopped" agents.activity.title = "Run activity" +agents.attention.acknowledge = "Acknowledge attention" +agents.inspector.files = "Files" +agents.inspector.changes = "Changes" +agents.inspector.local = "LOCAL" +agents.inspector.remote = "REMOTE" +agents.inspector.observed = "Activity" +agents.inspector.empty_files = "No files are visible in this working directory." +agents.inspector.empty_changes = "Changed files and diff previews will appear here as the agent works." +agents.inspector.focus_hint = "Choose a change to highlight its event in the agent timeline." +agents.inspector.remote_empty = "Browse the selected SSH target without pretending these are local files." +agents.inspector.remote_load = "Browse remote files" +agents.inspector.remote_parent = "Parent directory" +agents.inspector.remote_loading = "Loading the remote directory…" +agents.inspector.retry = "Retry" agents.error.target_missing = "The selected SSH connection no longer exists." agents.error.prompt_empty = "Describe the task before running it." agents.error.prompt_too_long = "The instruction is too long for safe local or SSH execution." @@ -412,8 +443,13 @@ agents.error.workdir_missing = "The “%{workdir}” directory does not exist or agents.error.model_too_long = "The model name is too long." agents.error.binary_missing = "The “%{binary}” command was not found on this machine. Install it and sign in to the provider before retrying." agents.error.remote_binary_missing = "The “%{binary}” command was not found on the SSH server. Install it and sign in to the provider on that target." +agents.error.automonique_ssh_boundary = "Automonique ACP runs only on this machine. To manage Automonique-owned remote work, map the workspace in AI Operations and open its Platform session; ShellDeck will not silently run it on the wrong host." +agents.error.automonique_model_override = "Automonique ACP chooses its model from the authenticated Automonique account. Clear the CLI model override for this session." agents.error.already_running = "An agent is already running." +agents.error.parallel_limit = "The %{count}-agent concurrency limit has been reached. Stop an agent before starting another." agents.error.runtime_lost = "The agent process ended without a result." +agents.error.session_missing = "The selected agent session no longer exists." +agents.error.session_checkout_changed = "This agent session is already bound to another worktree. Open a new session for this worktree." agents.toast.completed = "The agent completed its work." agents.toast.stopped = "The agent was stopped." agents.toast.exit = "The agent exited with code %{code}." @@ -1193,6 +1229,19 @@ fleet.review.resolution_keep_current = "keep current" fleet.review.resolution_keep_incoming = "keep incoming" fleet.review.files_title = "Files, hunks, and safe previews" fleet.review.comments_title = "Attributed comments" +fleet.review.notes_title = "Anchored notes" +fleet.review.note = "%{file} %{side}:%{line} · snapshot %{revision} · %{body}" +fleet.review.note_prepare = "Prepare the comment" +fleet.review.note_delete = "Delete" +fleet.review.note_stale = "Superseded snapshot" +fleet.review.note_failed = "The anchored note could not be stored." +fleet.review.save_note = "Save the note" +fleet.review.notes_selected = "%{count} note(s) selected and retained. A note is a local draft: prepare it as a comment before it can be delivered to the authorized session." +fleet.review.prepare_batch_send = "Send %{count} comment(s) to the session" +fleet.review.delivery_withheld_title = "Batch delivery unavailable" +fleet.review.delivery_withheld_no_server_capability = "No capability response is attributed to this project, workspace and snapshot revision: nothing was advertised, so no send control is offered." +fleet.review.delivery_withheld_no_deliverable_comment = "The server advertises no deliverable comment for this snapshot: no authorized session is bound to it, or no comment is in a sendable state. The advertisement is re-read on every refresh, including after a send." +fleet.review.delivery_withheld_no_custody_lane = "Durable custody is unavailable in this process: no batch send control is offered." fleet.review.attention_title = "Attention chronology" fleet.review.confirm_comment = "In project %{project}, workspace %{workspace}: add “%{body}” on %{file} · %{hunk} · %{side}:%{line}, from exact snapshot %{revision}." fleet.review.confirm_approval = "In project %{project}, workspace %{workspace}: approve review rev %{review_revision}, from exact snapshot %{revision}." @@ -1206,6 +1255,40 @@ fleet.review.state_ambiguous = "ambiguous" fleet.review.state_refused = "refused" fleet.review.pending_detail = "Action dispatched once; later attempts only look up its receipt." fleet.review.invalid_preview = "The action no longer matches the exact active review." +fleet.review.never_dispatched = "The preview prepared before restart was never sent." +fleet.review.capability_stale = "The rerun capability changed; refresh and confirm a new preview." +fleet.review.delivery_capability_stale = "The delivery advertisement changed; reload, then prepare a new selection." +fleet.review.custody_failed = "Durable custody refused dispatch; no provider call started." +fleet.review.receipt_actor = "%{detail} · actor %{actor}" +fleet.review.receipt_actor_short = "actor %{actor}" +fleet.review.lane_conflicted = "Unresolved conflicts" +fleet.review.lane_staged = "Staged" +fleet.review.lane_unstaged = "Unstaged" +fleet.review.lane_untracked = "Untracked" +fleet.review.lane_partial = "partially staged" +fleet.review.staging_proposed = "%{action} · proposal %{id}" +fleet.review.staging_blocked = "%{action} · blocked · proposal %{id}" +fleet.review.staging_withheld_title = "Staging is unavailable" +fleet.review.staging_withheld_no_server_capability = "No capability response is attributed to this project, workspace and snapshot revision: nothing has been advertised, so no staging control is offered." +fleet.review.staging_withheld_no_stageable_proposal = "The server advertises no staging and no conflict resolution for this snapshot: the registry binding does not grant it, the repository could not be read, or the worktree is in a state no write can fence exactly once. The advertisement is re-read on every refresh, including after a write." +fleet.review.staging_withheld_no_custody_lane = "Durable custody is unavailable in this process: no staging control is offered." +fleet.review.staging_observation = "Server fence, HEAD %{head} · index %{index}" +fleet.review.prepare_stage = "Stage" +fleet.review.prepare_unstage = "Unstage" +fleet.review.prepare_commit = "Commit" +fleet.review.resolve_keep_current = "Keep current" +fleet.review.resolve_keep_incoming = "Keep incoming" +fleet.review.preview_text = "Sanitized text preview" +fleet.review.preview_image = "Image %{media} · %{width}×%{height} px · %{bytes} bytes" +fleet.review.preview_image_box = "Bounded preview, never decoded" +fleet.review.preview_html = "Sanitized HTML %{media} · %{bytes} bytes · described only, never interpreted nor shown as source" +fleet.review.preview_truncated = "Preview truncated" +fleet.review.preview_withheld_no_content = "No preview declared" +fleet.review.preview_withheld_binary = "Opaque binary: no preview" +fleet.review.preview_withheld_unsanitized = "Unsanitized content: preview withheld" +fleet.review.preview_withheld_oversized = "Declared size over budget: preview withheld" +fleet.review.preview_withheld_oversized_raster = "Declared dimensions over budget: preview withheld" +fleet.review.preview_withheld_incoherent = "Declared kind and content disagree: preview withheld" fleet.sessions.search = "Search sessions" fleet.session.unread = "%{count} unread" fleet.session.control_lost = "control lost" @@ -2085,6 +2168,17 @@ workspaces.agent.running = "Agent running" workspaces.agent.waiting = "Agent waiting" workspaces.agent.failed = "Agent failed" workspaces.agent.completed = "Agent completed" +workspaces.attention.title = "Attention" +workspaces.attention.needs_you = "Needs you" +workspaces.attention.working = "Working" +workspaces.attention.blocked = "Blocked" +workspaces.attention.done = "Done" +workspaces.attention.idle = "Idle" +workspaces.attention.unread = "Unread" +workspaces.attention.read = "Read" +workspaces.attention.agent_root = "Root agent" +workspaces.attention.agent_path = "Agent · %{path}" +workspaces.attention.open_refused = "This attention item no longer matches an exact retained workspace surface." workspaces.freshness.fresh = "Fresh" workspaces.freshness.aging = "Aging" workspaces.freshness.stale = "Stale" diff --git a/crates/shelldeck-core/locales/fr.toml b/crates/shelldeck-core/locales/fr.toml index 7e76231a..686b7e86 100644 --- a/crates/shelldeck-core/locales/fr.toml +++ b/crates/shelldeck-core/locales/fr.toml @@ -238,6 +238,23 @@ notification.fleet.failed = "Job AI Operations échoué" notification.ai.summary = "ShellDeck – Assistant IA" notification.ai.success = "Une tâche IA est terminée" notification.ai.failed = "Une tâche IA a échoué" +notification.attention.summary = "ShellDeck – Attention" +notification.attention.open = "Ouvrir" +notification.attention.body = "%{reason} · espace %{workspace}" +platform_attention.title = "Attention" +platform_attention.activity_title = "Activité chronologique" +platform_attention.activity_workspace = "Espace %{workspace}" +platform_attention.activity_more = "%{count} de plus dans la chronologie" +platform_attention.reason.review_requested = "Revue demandée" +platform_attention.reason.comment_reply = "Réponse à un commentaire" +platform_attention.reason.approval_required = "Validation requise" +platform_attention.reason.agent_working = "Agent en cours" +platform_attention.reason.check_running = "Vérification en cours" +platform_attention.reason.delivery_pending = "Livraison en attente" +platform_attention.reason.complete = "Travail terminé" +platform_attention.reason.conflict = "Conflit à traiter" +platform_attention.reason.check_failed = "Vérification échouée" +platform_attention.reason.external_blocker = "Blocage externe" settings.cloud_sync.section = "SYNCHRONISATION CLOUD" settings.cloud_sync.account.label = "Compte" @@ -403,6 +420,20 @@ agents.status.preparing = "L’agent prépare sa réponse" agents.status.exit = "Processus terminé avec le code %{code}" agents.status.stopped = "Exécution arrêtée" agents.activity.title = "Journal d’exécution" +agents.attention.acknowledge = "Marquer l’alerte comme traitée" +agents.inspector.files = "Fichiers" +agents.inspector.changes = "Modifications" +agents.inspector.local = "LOCAL" +agents.inspector.remote = "DISTANT" +agents.inspector.observed = "Activité" +agents.inspector.empty_files = "Aucun fichier visible dans ce répertoire de travail." +agents.inspector.empty_changes = "Les fichiers modifiés et leurs aperçus de diff apparaîtront ici pendant le travail de l’agent." +agents.inspector.focus_hint = "Choisissez une modification pour surligner son événement dans la chronologie de l’agent." +agents.inspector.remote_empty = "Parcourez la cible SSH sélectionnée sans présenter ces fichiers comme locaux." +agents.inspector.remote_load = "Parcourir les fichiers distants" +agents.inspector.remote_parent = "Répertoire parent" +agents.inspector.remote_loading = "Chargement du répertoire distant…" +agents.inspector.retry = "Réessayer" agents.error.target_missing = "La connexion SSH sélectionnée n’existe plus." agents.error.prompt_empty = "Décris d’abord la tâche à exécuter." agents.error.prompt_too_long = "La consigne est trop longue pour une exécution locale ou SSH sûre." @@ -412,8 +443,13 @@ agents.error.workdir_missing = "Le répertoire « %{workdir} » n’existe pas o agents.error.model_too_long = "Le nom du modèle est trop long." agents.error.binary_missing = "La commande « %{binary} » est introuvable sur cette machine. Installe-la et connecte le fournisseur avant de relancer." agents.error.remote_binary_missing = "La commande « %{binary} » est introuvable sur le serveur SSH. Installe-la et connecte le fournisseur sur cette cible." +agents.error.automonique_ssh_boundary = "Automonique ACP s’exécute uniquement sur cette machine. Pour gérer un travail distant détenu par Automonique, associez l’espace dans AI Operations et ouvrez sa session Plateforme ; ShellDeck ne l’exécutera jamais silencieusement sur le mauvais hôte." +agents.error.automonique_model_override = "Automonique ACP choisit son modèle depuis le compte Automonique authentifié. Effacez la surcharge de modèle CLI pour cette session." agents.error.already_running = "Un agent est déjà en cours d’exécution." +agents.error.parallel_limit = "La limite de %{count} agents simultanés est atteinte. Arrête un agent avant d’en lancer un autre." agents.error.runtime_lost = "Le processus d’agent s’est interrompu sans résultat." +agents.error.session_missing = "La session d’agent sélectionnée n’existe plus." +agents.error.session_checkout_changed = "Cette session d’agent est déjà liée à un autre worktree. Ouvre une nouvelle session pour ce worktree." agents.toast.completed = "L’agent a terminé son travail." agents.toast.stopped = "L’agent a été arrêté." agents.toast.exit = "L’agent s’est terminé avec le code %{code}." @@ -1193,6 +1229,19 @@ fleet.review.resolution_keep_current = "conserver la version actuelle" fleet.review.resolution_keep_incoming = "conserver la version entrante" fleet.review.files_title = "Fichiers, sections et aperçus sûrs" fleet.review.comments_title = "Commentaires attribués" +fleet.review.notes_title = "Notes ancrées" +fleet.review.note = "%{file} %{side}:%{line} · instantané %{revision} · %{body}" +fleet.review.note_prepare = "Préparer le commentaire" +fleet.review.note_delete = "Supprimer" +fleet.review.note_stale = "Instantané dépassé" +fleet.review.note_failed = "La note ancrée n’a pas pu être enregistrée." +fleet.review.save_note = "Enregistrer la note" +fleet.review.notes_selected = "%{count} note(s) sélectionnée(s) et conservées. Une note est un brouillon local : préparez-la en commentaire pour qu’elle devienne livrable à la session autorisée." +fleet.review.prepare_batch_send = "Envoyer %{count} commentaire(s) à la session" +fleet.review.delivery_withheld_title = "Envoi groupé indisponible" +fleet.review.delivery_withheld_no_server_capability = "Aucune réponse de capacité n’est attribuée à ce projet, cet espace et cette révision d’instantané : rien n’a été annoncé, donc aucune commande d’envoi n’est proposée." +fleet.review.delivery_withheld_no_deliverable_comment = "Le serveur n’annonce aucun commentaire livrable pour cet instantané : aucune session autorisée n’y est reliée, ou aucun commentaire n’est dans un état envoyable. L’annonce est relue à chaque rafraîchissement, y compris après un envoi." +fleet.review.delivery_withheld_no_custody_lane = "La garde durable est indisponible dans ce processus : aucune commande d’envoi groupé n’est proposée." fleet.review.attention_title = "Chronologie d’attention" fleet.review.confirm_comment = "Dans le projet %{project}, espace %{workspace} : ajouter « %{body} » sur %{file} · %{hunk} · %{side}:%{line}, depuis l’instantané exact %{revision}." fleet.review.confirm_approval = "Dans le projet %{project}, espace %{workspace} : approuver la rév. de revue %{review_revision}, depuis l’instantané exact %{revision}." @@ -1206,6 +1255,40 @@ fleet.review.state_ambiguous = "ambigu" fleet.review.state_refused = "refusé" fleet.review.pending_detail = "Action envoyée une seule fois ; les tentatives suivantes consultent uniquement le reçu." fleet.review.invalid_preview = "L’action ne correspond plus exactement à la revue active." +fleet.review.never_dispatched = "L’aperçu préparé avant le redémarrage n’a jamais été envoyé." +fleet.review.capability_stale = "La capacité de relance a changé ; rechargez puis confirmez un nouvel aperçu." +fleet.review.delivery_capability_stale = "L’annonce de livraison a changé ; rechargez puis préparez une nouvelle sélection." +fleet.review.custody_failed = "La garde durable a refusé l’envoi ; aucun appel fournisseur n’a commencé." +fleet.review.receipt_actor = "%{detail} · acteur %{actor}" +fleet.review.receipt_actor_short = "acteur %{actor}" +fleet.review.lane_conflicted = "Conflits non résolus" +fleet.review.lane_staged = "Indexé" +fleet.review.lane_unstaged = "Non indexé" +fleet.review.lane_untracked = "Non suivi" +fleet.review.lane_partial = "partiellement indexé" +fleet.review.staging_proposed = "%{action} · proposition %{id}" +fleet.review.staging_blocked = "%{action} · bloqué · proposition %{id}" +fleet.review.staging_withheld_title = "Indexation indisponible" +fleet.review.staging_withheld_no_server_capability = "Aucune réponse de capacité n’est attribuée à ce projet, cet espace et cette révision d’instantané : rien n’a été annoncé, donc aucune commande d’indexation n’est proposée." +fleet.review.staging_withheld_no_stageable_proposal = "Le serveur n’annonce aucune indexation ni résolution de conflit pour cet instantané : la liaison du registre ne l’accorde pas, le dépôt n’a pas pu être lu, ou l’arbre de travail est dans un état qu’aucune écriture ne peut clôturer exactement une fois. L’annonce est relue à chaque rafraîchissement, y compris après une écriture." +fleet.review.staging_withheld_no_custody_lane = "La garde durable est indisponible dans ce processus : aucune commande d’indexation n’est proposée." +fleet.review.staging_observation = "Barrière serveur, HEAD %{head} · index %{index}" +fleet.review.prepare_stage = "Indexer" +fleet.review.prepare_unstage = "Désindexer" +fleet.review.prepare_commit = "Valider" +fleet.review.resolve_keep_current = "Garder la version actuelle" +fleet.review.resolve_keep_incoming = "Garder la version entrante" +fleet.review.preview_text = "Aperçu texte assaini" +fleet.review.preview_image = "Image %{media} · %{width}×%{height} px · %{bytes} octets" +fleet.review.preview_image_box = "Aperçu borné, jamais décodé" +fleet.review.preview_html = "HTML assaini %{media} · %{bytes} octets · décrit uniquement, jamais interprété ni affiché en source" +fleet.review.preview_truncated = "Aperçu tronqué" +fleet.review.preview_withheld_no_content = "Aucun aperçu déclaré" +fleet.review.preview_withheld_binary = "Binaire opaque : aucun aperçu" +fleet.review.preview_withheld_unsanitized = "Contenu non assaini : aperçu retenu" +fleet.review.preview_withheld_oversized = "Taille déclarée hors budget : aperçu retenu" +fleet.review.preview_withheld_oversized_raster = "Dimensions déclarées hors budget : aperçu retenu" +fleet.review.preview_withheld_incoherent = "Type et contenu déclarés incohérents : aperçu retenu" fleet.sessions.search = "Rechercher des sessions" fleet.session.unread = "%{count} non lus" fleet.session.control_lost = "contrôle perdu" @@ -2085,6 +2168,17 @@ workspaces.agent.running = "Agent actif" workspaces.agent.waiting = "Agent en attente" workspaces.agent.failed = "Agent en échec" workspaces.agent.completed = "Agent terminé" +workspaces.attention.title = "Attention" +workspaces.attention.needs_you = "Besoin de vous" +workspaces.attention.working = "En cours" +workspaces.attention.blocked = "Bloqué" +workspaces.attention.done = "Terminé" +workspaces.attention.idle = "Inactif" +workspaces.attention.unread = "Non lu" +workspaces.attention.read = "Lu" +workspaces.attention.agent_root = "Agent racine" +workspaces.attention.agent_path = "Agent · %{path}" +workspaces.attention.open_refused = "Cette attention ne correspond plus exactement à une surface de workspace retenue." workspaces.freshness.fresh = "À jour" workspaces.freshness.aging = "Vieillit" workspaces.freshness.stale = "Périmé" diff --git a/crates/shelldeck-core/src/acp.rs b/crates/shelldeck-core/src/acp.rs index 63291a5b..ae96271f 100644 --- a/crates/shelldeck-core/src/acp.rs +++ b/crates/shelldeck-core/src/acp.rs @@ -12,21 +12,29 @@ use std::path::PathBuf; use std::sync::Arc; use agent_client_protocol::schema::v1::{ - AgentCapabilities, AuthMethodId, AuthenticateRequest, ClientCapabilities, ContentBlock, - Implementation, InitializeRequest, LoadSessionRequest, McpServer, NewSessionRequest, - PermissionOptionId, PromptRequest, PromptResponse, RequestPermissionOutcome, + AgentCapabilities, AuthMethodId, AuthenticateRequest, CancelNotification, ClientCapabilities, + ContentBlock, Implementation, InitializeRequest, LoadSessionRequest, McpServer, + NewSessionRequest, PermissionOptionId, PromptRequest, PromptResponse, RequestPermissionOutcome, RequestPermissionRequest, RequestPermissionResponse, SelectedPermissionOutcome, SessionId, SessionNotification, }; use agent_client_protocol::schema::ProtocolVersion; use agent_client_protocol::{AcpAgent, AcpAgentConfig, Agent, ConnectionTo}; use parking_lot::Mutex; +use std::sync::atomic::{AtomicBool, Ordering}; use crate::error::{Result, ShellDeckError}; /// Stable SDK release used by both ShellDeck and Automonique. pub const ACP_SDK_VERSION: &str = "2.0.0"; +pub type AcpCancelSender = futures::channel::oneshot::Sender<()>; +pub type AcpCancelReceiver = futures::channel::oneshot::Receiver<()>; + +pub fn cancel_channel() -> (AcpCancelSender, AcpCancelReceiver) { + futures::channel::oneshot::channel() +} + /// Explicit child-process launch configuration. No shell is involved. #[derive(Clone, Debug, Eq, PartialEq)] pub struct AcpLaunch { @@ -76,6 +84,16 @@ impl AcpTurnRequest { } } + #[must_use] + pub fn text(cwd: impl Into, prompt: impl Into) -> Self { + Self::new( + cwd, + vec![ContentBlock::Text( + agent_client_protocol::schema::v1::TextContent::new(prompt), + )], + ) + } + #[must_use] pub fn resume(mut self, session_id: impl Into) -> Self { self.session_id = Some(session_id.into()); @@ -119,6 +137,7 @@ impl AcpPermissionBroker for CancelPermissions { pub struct AcpClient { launch: AcpLaunch, permissions: Arc, + updates: Arc, } impl AcpClient { @@ -127,6 +146,7 @@ impl AcpClient { Self { launch, permissions: Arc::new(CancelPermissions), + updates: Arc::new(|_| {}), } } @@ -136,16 +156,68 @@ impl AcpClient { self } + /// Observe each ordered ACP update as it arrives. The callback must remain + /// lightweight; provider I/O continues on the ACP connection task. + #[must_use] + pub fn with_updates( + mut self, + updates: Arc, + ) -> Self { + self.updates = updates; + self + } + + /// Route ACP updates directly into the provider-neutral coding-agent + /// timeline while preserving protocol order. + #[must_use] + pub fn with_agent_events( + self, + events: std::sync::mpsc::Sender, + ) -> Self { + let ready = Arc::new(AtomicBool::new(false)); + self.with_updates(Arc::new(move |notification| { + if !ready.swap(true, Ordering::AcqRel) { + let _ = events.send(crate::agent_runtime::AgentStreamEvent::Ready); + } + for event in crate::agent_runtime::acp_notification_events(notification) { + let _ = events.send(event); + } + })) + } + /// Run one complete turn. The process group is torn down by the SDK when /// the connection ends, including wrapper subprocesses on Unix. pub fn prompt(&self, request: AcpTurnRequest) -> Result { + self.prompt_inner(request, None)? + .ok_or_else(|| ShellDeckError::Connection("ACP turn was unexpectedly cancelled".into())) + } + + /// Run one turn with protocol-level cancellation. Dropping the connection + /// after `session/cancel` also activates the SDK's child process-group + /// guard, so Stop cannot leave an Automonique wrapper or tool orphaned. + pub fn prompt_cancellable( + &self, + request: AcpTurnRequest, + cancel: AcpCancelReceiver, + ) -> Result> { + self.prompt_inner(request, Some(cancel)) + } + + fn prompt_inner( + &self, + request: AcpTurnRequest, + mut cancel: Option, + ) -> Result> { validate_request(&request)?; let updates = Arc::new(Mutex::new(Vec::new())); let result = Arc::new(Mutex::new(None)); let updates_handler = Arc::clone(&updates); + let live_updates = Arc::clone(&self.updates); let result_handler = Arc::clone(&result); let permissions = Arc::clone(&self.permissions); + let cancelled = Arc::new(AtomicBool::new(false)); + let cancelled_handler = Arc::clone(&cancelled); let agent = AcpAgent::new( AcpAgentConfig::new(&self.launch.program).args(self.launch.arguments.clone()), ); @@ -156,6 +228,7 @@ impl AcpClient { .name("shelldeck") .on_receive_notification( async move |notification: SessionNotification, _connection| { + live_updates(¬ification); updates_handler.lock().push(notification); Ok(()) }, @@ -233,28 +306,48 @@ impl AcpClient { .await? .session_id }; - let response = connection + let prompt = connection .send_request(PromptRequest::new(session_id.clone(), request.prompt)) - .block_task() - .await?; + .block_task(); + let response = if let Some(cancel) = cancel.as_mut() { + use futures::future::{select, Either}; + use futures::FutureExt as _; + + let prompt = prompt.fuse(); + let stop = cancel.fuse(); + futures::pin_mut!(prompt, stop); + match select(prompt, stop).await { + Either::Left((response, _)) => response?, + Either::Right((_, _)) => { + cancelled_handler.store(true, Ordering::Release); + connection + .send_notification(CancelNotification::new(session_id))?; + return Ok(()); + } + } + } else { + prompt.await? + }; *result_handler.lock() = Some((initialized, session_id, response)); Ok(()) }), ) .map_err(acp_error)?; - let (initialized, session_id, response) = result - .lock() - .take() + let completed = result.lock().take(); + if completed.is_none() && cancelled.load(Ordering::Acquire) { + return Ok(None); + } + let (initialized, session_id, response) = completed .ok_or_else(|| ShellDeckError::Connection("ACP turn ended without a result".into()))?; let updates = std::mem::take(&mut *updates.lock()); - Ok(AcpTurnResult { + Ok(Some(AcpTurnResult { agent_info: initialized.agent_info, agent_capabilities: initialized.agent_capabilities, session_id, updates, response, - }) + })) } } diff --git a/crates/shelldeck-core/src/agent_runtime.rs b/crates/shelldeck-core/src/agent_runtime.rs index 53d4997d..c647bc3b 100644 --- a/crates/shelldeck-core/src/agent_runtime.rs +++ b/crates/shelldeck-core/src/agent_runtime.rs @@ -11,18 +11,119 @@ use std::path::{Path, PathBuf}; use std::process::{Child, Command, ExitStatus}; use uuid::Uuid; +use agent_client_protocol::schema::v1::{ + ContentBlock, PermissionOptionId, PermissionOptionKind, RequestPermissionRequest, + SessionNotification, SessionUpdate, ToolCallStatus, +}; + +use crate::agent_session::{AgentTraceKind, AgentTraceStatus, AgentTraceUpdate}; use crate::{Result, ShellDeckError}; pub const MAX_AGENT_PROMPT_BYTES: usize = 48 * 1024; pub const MAX_AGENT_MODEL_BYTES: usize = 256; pub const MAX_AGENT_WORKDIR_BYTES: usize = 4096; pub const MAX_AGENT_SESSION_BYTES: usize = 256; +pub const MAX_AGENT_STREAM_LINE_BYTES: usize = 256 * 1024; +pub const MAX_AGENT_STREAM_TEXT_BYTES: usize = 128 * 1024; +pub const MAX_AGENT_TRACE_FIELD_BYTES: usize = 8 * 1024; +pub const MAX_AGENT_ACTIVITY_BYTES: usize = 512; +pub const AGENT_STREAM_OVERSIZED_LABEL: &str = "Provider event omitted (too large)"; +/// Canonical workdir used when an SSH agent should start in the remote +/// account's home directory. It is expanded by [`AgentCommandSpec`] on the +/// remote shell; it is never passed through as a literal filesystem path. +pub const AGENT_REMOTE_HOME_WORKDIR: &str = "~"; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AgentStreamFrame { + Line(String), + Oversized, +} + +/// Incrementally frames an arbitrary provider byte stream without ever +/// retaining more than one admitted line. Once a line exceeds the parser's +/// byte bound, its remainder is discarded through the next newline and one +/// omission frame is emitted. +#[derive(Debug, Default)] +pub struct AgentStreamFramer { + pending: Vec, + discarding_oversized: bool, +} + +impl AgentStreamFramer { + pub fn push(&mut self, mut bytes: &[u8]) -> Vec { + let mut frames = Vec::new(); + while !bytes.is_empty() { + if self.discarding_oversized { + let Some(newline) = bytes.iter().position(|byte| *byte == b'\n') else { + break; + }; + self.discarding_oversized = false; + bytes = &bytes[newline + 1..]; + continue; + } + + if let Some(newline) = bytes.iter().position(|byte| *byte == b'\n') { + let segment = &bytes[..newline]; + if self.pending.len().saturating_add(segment.len()) > MAX_AGENT_STREAM_LINE_BYTES { + self.pending.clear(); + frames.push(AgentStreamFrame::Oversized); + } else { + self.pending.extend_from_slice(segment); + if self.pending.last() == Some(&b'\r') { + self.pending.pop(); + } + frames.push(AgentStreamFrame::Line( + String::from_utf8_lossy(&self.pending).into_owned(), + )); + self.pending.clear(); + } + bytes = &bytes[newline + 1..]; + continue; + } + + let remaining = MAX_AGENT_STREAM_LINE_BYTES.saturating_sub(self.pending.len()); + if bytes.len() > remaining { + self.pending.clear(); + self.discarding_oversized = true; + frames.push(AgentStreamFrame::Oversized); + } else { + self.pending.extend_from_slice(bytes); + } + break; + } + frames + } + + pub fn finish(&mut self) -> Vec { + if self.discarding_oversized { + self.discarding_oversized = false; + self.pending.clear(); + return Vec::new(); + } + if self.pending.is_empty() { + return Vec::new(); + } + let mut pending = std::mem::take(&mut self.pending); + if pending.last() == Some(&b'\r') { + pending.pop(); + } + vec![AgentStreamFrame::Line( + String::from_utf8_lossy(&pending).into_owned(), + )] + } + + pub fn buffered_bytes(&self) -> usize { + self.pending.len() + } +} #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum AgentProvider { Claude, Codex, + /// Automonique's stable ACP v1 agent surface. + Automonique, /// Jcode with the provider selected by its configuration on the target. Jcode, /// DeepSeek models are launched through the managed Jcode runner. Jcode @@ -35,6 +136,7 @@ impl AgentProvider { match self { Self::Claude => "Claude Code", Self::Codex => "Codex", + Self::Automonique => "Automonique (ACP)", Self::Jcode => "Jcode (auto)", Self::DeepSeek => "DeepSeek (Jcode)", } @@ -44,6 +146,7 @@ impl AgentProvider { match self { Self::Claude => "claude", Self::Codex => "codex", + Self::Automonique => "automonique", Self::Jcode | Self::DeepSeek => "jcode", } } @@ -137,7 +240,9 @@ impl AgentRunRequest { let absolute = match self.target { AgentTarget::Local => Path::new(workdir).is_absolute(), // Remote agent transport is POSIX shell based on every host OS. - AgentTarget::Ssh { .. } => workdir.starts_with('/'), + AgentTarget::Ssh { .. } => { + workdir == AGENT_REMOTE_HOME_WORKDIR || workdir.starts_with('/') + } }; if workdir.is_empty() || !absolute { return Err(ShellDeckError::Config( @@ -191,6 +296,11 @@ impl AgentCommandSpec { .filter(|value| !value.is_empty()); let (args, stdin, remove_env) = match request.provider { + AgentProvider::Automonique => { + return Err(ShellDeckError::Config( + "Automonique uses the ACP transport, not a command stream".to_string(), + )); + } AgentProvider::Claude => { let permission_mode = match request.access { AgentAccessMode::ReadOnly => "plan", @@ -251,6 +361,16 @@ impl AgentCommandSpec { (args, Some(request.prompt.as_bytes().to_vec()), Vec::new()) } AgentProvider::Jcode | AgentProvider::DeepSeek => { + // The SSH home sentinel is expanded by the outer `cd`. Keep + // Jcode rooted in that resulting directory rather than + // passing a quoted literal `~`, which no shell would expand. + let provider_workdir = if matches!(request.target, AgentTarget::Ssh { .. }) + && request.workdir.trim() == AGENT_REMOTE_HOME_WORKDIR + { + "." + } else { + request.workdir.trim() + }; let mut args = vec![ "run".to_string(), "--ndjson".to_string(), @@ -258,7 +378,7 @@ impl AgentCommandSpec { "--no-update".to_string(), "--no-selfdev".to_string(), "-C".to_string(), - request.workdir.trim().to_string(), + provider_workdir.to_string(), ]; if request.provider == AgentProvider::DeepSeek { args.push("--provider".to_string()); @@ -330,9 +450,16 @@ impl AgentCommandSpec { } None => command, }; + let workdir = self.cwd.to_string_lossy(); + let workdir = if workdir == AGENT_REMOTE_HOME_WORKDIR { + // This exact constant is the only unquoted workdir expression. + // Every user-controlled absolute path still crosses shell_quote. + "\"$HOME\"".to_string() + } else { + shell_quote(&workdir) + }; format!( - "export PATH=\"$HOME/.local/bin:$HOME/bin:/usr/local/bin:/opt/homebrew/bin:$PATH\"; cd -- {} && {command}", - shell_quote(&self.cwd.to_string_lossy()) + "export PATH=\"$HOME/.local/bin:$HOME/bin:/usr/local/bin:/opt/homebrew/bin:$PATH\"; cd -- {workdir} && {command}" ) } } @@ -584,6 +711,16 @@ pub enum AgentStreamEvent { /// Provider initialization completed. The UI localizes this state rather /// than baking a language into the provider-neutral parser. Ready, + /// Structured technical activity rendered independently from conversation + /// prose. All provider-derived strings are bounded and redacted. + Trace(AgentTraceUpdate), + /// Completion/status for a previously correlated technical activity. + TraceStatus { + correlation_id: String, + status: AgentTraceStatus, + summary: Option, + }, + /// Safe fallback for provider activity not recognized structurally. Activity(String), Error(String), } @@ -596,16 +733,130 @@ pub fn parse_stream_line(provider: AgentProvider, line: &str) -> Vec MAX_AGENT_STREAM_LINE_BYTES { + return vec![AgentStreamEvent::Activity( + AGENT_STREAM_OVERSIZED_LABEL.to_string(), + )]; + } let Ok(value) = serde_json::from_str::(trimmed) else { - return vec![AgentStreamEvent::Activity(trimmed.to_string())]; + let label = if trimmed.starts_with(['{', '[']) { + "Malformed provider event".to_string() + } else { + safe_provider_text(trimmed, MAX_AGENT_ACTIVITY_BYTES) + }; + return vec![AgentStreamEvent::Activity(label)]; }; match provider { + AgentProvider::Automonique => vec![AgentStreamEvent::Activity( + "Unexpected raw Automonique ACP record".to_string(), + )], AgentProvider::Claude => parse_claude_event(&value), AgentProvider::Codex => parse_codex_event(&value), AgentProvider::Jcode | AgentProvider::DeepSeek => parse_jcode_event(&value), } } +/// Map one typed ACP update into the same bounded timeline vocabulary used by +/// CLI providers. Raw ACP metadata, tool input and tool output never cross +/// into the transcript. +pub fn acp_notification_events(notification: &SessionNotification) -> Vec { + let mut events = vec![AgentStreamEvent::Session( + notification.session_id.0.to_string(), + )]; + match ¬ification.update { + SessionUpdate::AgentMessageChunk(chunk) => { + if let ContentBlock::Text(text) = &chunk.content { + events.push(AgentStreamEvent::TextDelta(safe_visible_text(&text.text))); + } + } + SessionUpdate::AgentThoughtChunk(chunk) => { + if let ContentBlock::Text(text) = &chunk.content { + events.push(AgentStreamEvent::Trace(AgentTraceUpdate { + correlation_id: None, + detail: AgentTraceKind::Activity { + label: safe_provider_text(&text.text, MAX_AGENT_ACTIVITY_BYTES), + }, + })); + } + } + SessionUpdate::ToolCall(call) => { + events.push(AgentStreamEvent::Trace(AgentTraceUpdate { + correlation_id: Some(call.tool_call_id.0.to_string()), + detail: AgentTraceKind::Tool { + name: safe_provider_text(&call.title, MAX_AGENT_TRACE_FIELD_BYTES), + status: acp_trace_status(call.status), + summary: None, + }, + })); + } + SessionUpdate::ToolCallUpdate(update) => { + if let Some(status) = update.fields.status { + events.push(AgentStreamEvent::TraceStatus { + correlation_id: update.tool_call_id.0.to_string(), + status: acp_trace_status(status), + summary: update + .fields + .title + .as_deref() + .map(|title| safe_provider_text(title, MAX_AGENT_TRACE_FIELD_BYTES)), + }); + } + } + SessionUpdate::Plan(plan) => { + events.push(AgentStreamEvent::Trace(AgentTraceUpdate { + correlation_id: None, + detail: AgentTraceKind::Activity { + label: format!("Plan updated ({} steps)", plan.entries.len()), + }, + })); + } + SessionUpdate::AvailableCommandsUpdate(_) + | SessionUpdate::CurrentModeUpdate(_) + | SessionUpdate::ConfigOptionUpdate(_) + | SessionUpdate::SessionInfoUpdate(_) + | SessionUpdate::UsageUpdate(_) => {} + SessionUpdate::UserMessageChunk(_) => {} + _ => {} + } + events +} + +fn acp_trace_status(status: ToolCallStatus) -> AgentTraceStatus { + match status { + ToolCallStatus::Pending | ToolCallStatus::InProgress => AgentTraceStatus::Running, + ToolCallStatus::Completed => AgentTraceStatus::Succeeded, + ToolCallStatus::Failed => AgentTraceStatus::Failed, + _ => AgentTraceStatus::Running, + } +} + +/// ACP permission policy derived from the run-level access confirmation. +/// Read-only never grants a tool request; mutating modes grant only the exact +/// one-shot option offered by Automonique and never persist an allow decision. +pub struct AcpAgentPermissionBroker { + access: AgentAccessMode, +} + +impl AcpAgentPermissionBroker { + #[must_use] + pub const fn new(access: AgentAccessMode) -> Self { + Self { access } + } +} + +impl crate::acp::AcpPermissionBroker for AcpAgentPermissionBroker { + fn select(&self, request: &RequestPermissionRequest) -> Option { + if self.access == AgentAccessMode::ReadOnly { + return None; + } + request + .options + .iter() + .find(|option| option.kind == PermissionOptionKind::AllowOnce) + .map(|option| option.option_id.clone()) + } +} + fn parse_claude_event(value: &Value) -> Vec { let mut events = session_events(value); match value.get("type").and_then(Value::as_str) { @@ -616,21 +867,25 @@ fn parse_claude_event(value: &Value) -> Vec { value .pointer("/message/content") .and_then(text_from_value) - .map(AgentStreamEvent::Error), + .map(|text| AgentStreamEvent::Error(safe_visible_text(&text))), ) } - Some("assistant") => events.extend( - value - .pointer("/message/content") - .and_then(text_from_value) - .map(AgentStreamEvent::Text), - ), + Some("assistant") => { + events.extend( + value + .pointer("/message/content") + .and_then(text_from_value) + .map(|text| AgentStreamEvent::Text(safe_visible_text(&text))), + ); + events.extend(claude_trace_events(value)); + } + Some("user") => events.extend(claude_tool_result_events(value)), Some("result") if value.get("is_error").and_then(Value::as_bool) == Some(true) => events .extend( value .get("result") .and_then(text_from_value) - .map(AgentStreamEvent::Error), + .map(|text| AgentStreamEvent::Error(safe_visible_text(&text))), ), Some("system") if value.get("subtype").and_then(Value::as_str) == Some("init") => { events.push(AgentStreamEvent::Ready) @@ -649,19 +904,24 @@ fn parse_codex_event(value: &Value) -> Vec { Some("agent_message") => events.extend( item.get("text") .and_then(text_from_value) - .map(AgentStreamEvent::Text), + .map(|text| AgentStreamEvent::Text(safe_visible_text(&text))), ), - Some(kind) => events.push(AgentStreamEvent::Activity(humanize_kind(kind))), + Some(_) => events.extend(codex_trace_events(item, AgentTraceStatus::Succeeded)), None => {} } } } + Some("item.started") => { + if let Some(item) = value.get("item") { + events.extend(codex_trace_events(item, AgentTraceStatus::Running)); + } + } Some("turn.failed") | Some("error") => events.extend( value .get("error") .or_else(|| value.get("message")) .and_then(text_from_value) - .map(AgentStreamEvent::Error), + .map(|text| AgentStreamEvent::Error(safe_visible_text(&text))), ), Some("thread.started") => events.push(AgentStreamEvent::Ready), _ => {} @@ -676,13 +936,13 @@ fn parse_jcode_event(value: &Value) -> Vec { .and_then(Value::as_str) .unwrap_or_default(); if let Some(delta) = value.get("delta").and_then(text_from_value) { - events.push(AgentStreamEvent::TextDelta(delta)); + events.push(AgentStreamEvent::TextDelta(safe_visible_text(&delta))); return events; } if matches!(kind, "result" | "assistant" | "message") { for key in ["result", "text", "content", "message", "response"] { if let Some(text) = value.get(key).and_then(text_from_value) { - events.push(AgentStreamEvent::Text(text)); + events.push(AgentStreamEvent::Text(safe_visible_text(&text))); return events; } } @@ -693,16 +953,343 @@ fn parse_jcode_event(value: &Value) -> Vec { .get("error") .or_else(|| value.get("message")) .and_then(text_from_value) - .map(AgentStreamEvent::Error), + .map(|text| AgentStreamEvent::Error(safe_visible_text(&text))), ); return events; } if !kind.is_empty() { - events.push(AgentStreamEvent::Activity(humanize_kind(kind))); + events.push(AgentStreamEvent::Activity(safe_provider_text( + &humanize_kind(kind), + MAX_AGENT_ACTIVITY_BYTES, + ))); } events } +fn claude_trace_events(value: &Value) -> Vec { + value + .pointer("/message/content") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter(|block| block.get("type").and_then(Value::as_str) == Some("tool_use")) + .map(|block| { + let name = block.get("name").and_then(Value::as_str).unwrap_or("tool"); + let input = block.get("input").unwrap_or(&Value::Null); + trace_update( + trace_for_tool(name, input, AgentTraceStatus::Running), + block.get("id").and_then(Value::as_str), + ) + }) + .collect() +} + +fn claude_tool_result_events(value: &Value) -> Vec { + value + .pointer("/message/content") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter(|block| block.get("type").and_then(Value::as_str) == Some("tool_result")) + .filter_map(|block| { + let correlation_id = block.get("tool_use_id").and_then(Value::as_str)?; + let status = if block.get("is_error").and_then(Value::as_bool) == Some(true) { + AgentTraceStatus::Failed + } else { + AgentTraceStatus::Succeeded + }; + let summary = block + .get("content") + .and_then(text_from_value) + .map(|text| safe_provider_text(&text, MAX_AGENT_TRACE_FIELD_BYTES)); + Some(AgentStreamEvent::TraceStatus { + correlation_id: safe_provider_text(correlation_id, MAX_AGENT_SESSION_BYTES), + status, + summary, + }) + }) + .collect() +} + +fn codex_trace_events(item: &Value, default_status: AgentTraceStatus) -> Vec { + let kind = item + .get("type") + .and_then(Value::as_str) + .unwrap_or("activity"); + let status = trace_status(item).unwrap_or(default_status); + let correlation_id = item.get("id").and_then(Value::as_str); + match kind { + "command_execution" | "command" => { + let command = item + .get("command") + .or_else(|| item.get("cmd")) + .and_then(text_from_value) + .unwrap_or_else(|| "command".to_string()); + let summary = item + .get("aggregated_output") + .or_else(|| item.get("output")) + .and_then(text_from_value) + .map(|text| safe_provider_text(&text, MAX_AGENT_TRACE_FIELD_BYTES)); + let exit_code = item + .get("exit_code") + .or_else(|| item.get("exitCode")) + .and_then(Value::as_i64) + .and_then(|code| i32::try_from(code).ok()); + let command = safe_provider_text(&command, MAX_AGENT_TRACE_FIELD_BYTES); + if looks_like_test(&command) { + vec![trace_update( + AgentTraceKind::Test { + name: command, + status, + summary, + }, + correlation_id, + )] + } else { + vec![trace_update( + AgentTraceKind::Command { + command, + status, + exit_code, + summary, + }, + correlation_id, + )] + } + } + "file_change" | "file_changes" => { + let changes = item + .get("changes") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + if changes.is_empty() { + return vec![trace_update( + AgentTraceKind::Tool { + name: humanize_kind(kind), + status, + summary: None, + }, + correlation_id, + )]; + } + changes + .iter() + .enumerate() + .map(|(index, change)| { + let path = value_string(change, &["path", "file_path", "filePath"]) + .unwrap_or_else(|| "file".to_string()); + let correlation = correlation_id.map(|id| format!("{id}:{index}")); + trace_update( + AgentTraceKind::Diff { + path: safe_provider_text(&path, MAX_AGENT_TRACE_FIELD_BYTES), + status, + additions: value_u32(change, &["additions", "added"]), + deletions: value_u32(change, &["deletions", "removed"]), + preview: value_string(change, &["diff", "patch"]) + .map(|text| safe_provider_text(&text, MAX_AGENT_TRACE_FIELD_BYTES)), + }, + correlation.as_deref(), + ) + }) + .collect() + } + "file_read" => { + let path = value_string(item, &["path", "file_path", "filePath"]) + .unwrap_or_else(|| "file".to_string()); + vec![trace_update( + AgentTraceKind::FileRead { + path: safe_provider_text(&path, MAX_AGENT_TRACE_FIELD_BYTES), + status, + line_start: value_optional_u32(item, &["line_start", "lineStart"]), + line_end: value_optional_u32(item, &["line_end", "lineEnd"]), + }, + correlation_id, + )] + } + _ => vec![trace_update( + AgentTraceKind::Tool { + name: safe_provider_text(&humanize_kind(kind), MAX_AGENT_ACTIVITY_BYTES), + status, + summary: None, + }, + correlation_id, + )], + } +} + +fn trace_for_tool(name: &str, input: &Value, status: AgentTraceStatus) -> AgentTraceKind { + match name.to_ascii_lowercase().as_str() { + "read" | "read_file" => { + let path = + value_string(input, &["file_path", "path"]).unwrap_or_else(|| "file".to_string()); + let start = value_optional_u32(input, &["offset", "line_start"]); + let count = value_optional_u32(input, &["limit"]); + AgentTraceKind::FileRead { + path: safe_provider_text(&path, MAX_AGENT_TRACE_FIELD_BYTES), + status, + line_start: start, + line_end: start + .zip(count) + .map(|(start, count)| start.saturating_add(count)), + } + } + "bash" | "shell" | "exec_command" => { + let command = + value_string(input, &["command", "cmd"]).unwrap_or_else(|| "command".to_string()); + let command = safe_provider_text(&command, MAX_AGENT_TRACE_FIELD_BYTES); + if looks_like_test(&command) { + AgentTraceKind::Test { + name: command, + status, + summary: None, + } + } else { + AgentTraceKind::Command { + command, + status, + exit_code: None, + summary: None, + } + } + } + "edit" | "write" | "apply_patch" => { + let path = + value_string(input, &["file_path", "path"]).unwrap_or_else(|| "file".to_string()); + AgentTraceKind::Diff { + path: safe_provider_text(&path, MAX_AGENT_TRACE_FIELD_BYTES), + status, + additions: 0, + deletions: 0, + preview: value_string(input, &["patch", "diff"]) + .map(|text| safe_provider_text(&text, MAX_AGENT_TRACE_FIELD_BYTES)), + } + } + _ => AgentTraceKind::Tool { + name: safe_provider_text(name, MAX_AGENT_ACTIVITY_BYTES), + status, + summary: None, + }, + } +} + +fn trace_update(detail: AgentTraceKind, correlation_id: Option<&str>) -> AgentStreamEvent { + AgentStreamEvent::Trace(AgentTraceUpdate { + correlation_id: correlation_id.map(|id| safe_provider_text(id, MAX_AGENT_SESSION_BYTES)), + detail, + }) +} + +fn trace_status(value: &Value) -> Option { + match value.get("status").and_then(Value::as_str)? { + "pending" => Some(AgentTraceStatus::Pending), + "in_progress" | "running" | "started" => Some(AgentTraceStatus::Running), + "completed" | "success" | "succeeded" => Some(AgentTraceStatus::Succeeded), + "failed" | "error" => Some(AgentTraceStatus::Failed), + "cancelled" | "canceled" => Some(AgentTraceStatus::Cancelled), + _ => Some(AgentTraceStatus::Unknown), + } +} + +fn value_string(value: &Value, keys: &[&str]) -> Option { + keys.iter() + .find_map(|key| value.get(*key).and_then(text_from_value)) +} + +fn value_optional_u32(value: &Value, keys: &[&str]) -> Option { + keys.iter().find_map(|key| { + value + .get(*key) + .and_then(Value::as_u64) + .and_then(|number| u32::try_from(number).ok()) + }) +} + +fn value_u32(value: &Value, keys: &[&str]) -> u32 { + value_optional_u32(value, keys).unwrap_or(0) +} + +fn looks_like_test(command: &str) -> bool { + let command = command.trim_start(); + [ + "cargo test", + "cargo nextest", + "npm test", + "npm run test", + "pnpm test", + "yarn test", + "pytest", + "go test", + ] + .iter() + .any(|prefix| command.starts_with(prefix)) +} + +fn safe_visible_text(text: &str) -> String { + safe_provider_text(text, MAX_AGENT_STREAM_TEXT_BYTES) +} + +fn safe_provider_text(text: &str, max_bytes: usize) -> String { + let redacted = redact_credentials(text); + truncate_utf8(&redacted, max_bytes) +} + +fn redact_credentials(text: &str) -> String { + let mut redact_next = false; + text.split_inclusive(char::is_whitespace) + .map(|part| { + let trimmed = part.trim_end_matches(char::is_whitespace); + let suffix = &part[trimmed.len()..]; + if redact_next && !trimmed.is_empty() { + redact_next = false; + return format!("[redacted]{suffix}"); + } + if trimmed.eq_ignore_ascii_case("bearer") { + redact_next = true; + return part.to_string(); + } + if ["sk-", "ghp_", "github_pat_", "xoxb-", "xoxp-"] + .iter() + .any(|prefix| trimmed.starts_with(prefix) && trimmed.len() > prefix.len() + 4) + { + return format!("[redacted]{suffix}"); + } + if let Some((key, _)) = trimmed.split_once('=') { + let key_upper = key.to_ascii_uppercase(); + if [ + "TOKEN", + "SECRET", + "PASSWORD", + "PASSWD", + "API_KEY", + "PRIVATE_KEY", + ] + .iter() + .any(|sensitive| key_upper.contains(sensitive)) + { + return format!("{key}=[redacted]{suffix}"); + } + } + part.to_string() + }) + .collect() +} + +fn truncate_utf8(text: &str, max_bytes: usize) -> String { + if text.len() <= max_bytes { + return text.to_string(); + } + let suffix = "…"; + if max_bytes < suffix.len() { + return String::new(); + } + let mut end = max_bytes.saturating_sub(suffix.len()).min(text.len()); + while !text.is_char_boundary(end) { + end = end.saturating_sub(1); + } + format!("{}{suffix}", &text[..end]) +} + fn session_events(value: &Value) -> Vec { ["session_id", "sessionId", "thread_id", "threadId"] .into_iter() @@ -746,6 +1333,83 @@ fn humanize_kind(kind: &str) -> String { #[cfg(test)] mod tests { use super::*; + use crate::acp::AcpPermissionBroker as _; + use agent_client_protocol::schema::v1::{ + ContentBlock, ContentChunk, PermissionOption, PermissionOptionKind, SessionNotification, + SessionUpdate, TextContent, ToolCall, ToolCallStatus, + }; + + // SDTEST-1906 — SDUC-475, SDUC-480 + #[test] + fn sdtest_1906_automonique_acp_updates_and_permissions_keep_typed_boundaries() { + let message = SessionNotification::new( + "acp-session-1", + SessionUpdate::AgentMessageChunk(ContentChunk::new(ContentBlock::Text( + TextContent::new("working"), + ))), + ); + assert_eq!( + acp_notification_events(&message), + vec![ + AgentStreamEvent::Session("acp-session-1".to_string()), + AgentStreamEvent::TextDelta("working".to_string()), + ] + ); + + let tool = SessionNotification::new( + "acp-session-1", + SessionUpdate::ToolCall( + ToolCall::new("tool-1", "Read workspace") + .status(ToolCallStatus::InProgress) + .raw_input(serde_json::json!({"token":"must-not-render"})), + ), + ); + let events = acp_notification_events(&tool); + assert!(matches!( + &events[1], + AgentStreamEvent::Trace(AgentTraceUpdate { + correlation_id: Some(id), + detail: AgentTraceKind::Tool { name, status: AgentTraceStatus::Running, .. }, + }) if id == "tool-1" && name == "Read workspace" + )); + assert!(!format!("{events:?}").contains("must-not-render")); + + let permission = RequestPermissionRequest::new( + "acp-session-1", + agent_client_protocol::schema::v1::ToolCallUpdate::new( + "tool-1", + agent_client_protocol::schema::v1::ToolCallUpdateFields::new(), + ), + vec![ + PermissionOption::new("reject", "Reject", PermissionOptionKind::RejectOnce), + PermissionOption::new("allow-once", "Allow once", PermissionOptionKind::AllowOnce), + PermissionOption::new("allow-always", "Always", PermissionOptionKind::AllowAlways), + ], + ); + assert!(AcpAgentPermissionBroker::new(AgentAccessMode::ReadOnly) + .select(&permission) + .is_none()); + assert_eq!( + AcpAgentPermissionBroker::new(AgentAccessMode::WorkspaceWrite) + .select(&permission) + .map(|id| id.0.to_string()), + Some("allow-once".to_string()) + ); + + let serialized = serde_json::to_string(&AgentProvider::Automonique).unwrap(); + assert_eq!(serialized, "\"automonique\""); + assert!(AgentCommandSpec::for_request(&AgentRunRequest::new( + AgentProvider::Automonique, + AgentTarget::Local, + AgentAccessMode::ReadOnly, + std::env::temp_dir().display().to_string(), + None, + "hello", + )) + .unwrap_err() + .to_string() + .contains("ACP transport")); + } #[cfg(not(windows))] const LOCAL_TEST_WORKDIR: &str = "/srv/project"; @@ -941,6 +1605,99 @@ mod tests { ); } + // SDTEST-1880 — SDUC-499 + #[test] + fn sdtest_1880_provider_tools_become_bounded_redacted_structured_trace() { + assert_eq!( + parse_stream_line( + AgentProvider::Claude, + r#"{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Read","input":{"file_path":"src/lib.rs","offset":10,"limit":20}},{"type":"tool_use","name":"Bash","input":{"command":"cargo test API_TOKEN=secret"}}]}}"#, + ), + vec![ + trace_update( + AgentTraceKind::FileRead { + path: "src/lib.rs".to_string(), + status: AgentTraceStatus::Running, + line_start: Some(10), + line_end: Some(30), + }, + None, + ), + trace_update( + AgentTraceKind::Test { + name: "cargo test API_TOKEN=[redacted]".to_string(), + status: AgentTraceStatus::Running, + summary: None, + }, + None, + ), + ] + ); + assert_eq!( + parse_stream_line( + AgentProvider::Codex, + r#"{"type":"item.completed","item":{"type":"command_execution","command":"cargo test","status":"failed","exit_code":101,"aggregated_output":"sk-secret-token"}}"#, + ), + vec![trace_update( + AgentTraceKind::Test { + name: "cargo test".to_string(), + status: AgentTraceStatus::Failed, + summary: Some("[redacted]".to_string()), + }, + None, + )] + ); + assert_eq!( + parse_stream_line(AgentProvider::Codex, r#"{"broken": "TOKEN=secret""#), + vec![AgentStreamEvent::Activity( + "Malformed provider event".to_string() + )] + ); + let oversized = "x".repeat(MAX_AGENT_STREAM_LINE_BYTES + 1); + assert_eq!( + parse_stream_line(AgentProvider::Jcode, &oversized), + vec![AgentStreamEvent::Activity( + "Provider event omitted (too large)".to_string() + )] + ); + } + + // SDTEST-1896 — SDUC-499 + #[test] + fn sdtest_1896_stream_framer_discards_one_oversized_line_and_recovers() { + let mut framer = AgentStreamFramer::default(); + let chunk = vec![b'x'; 64 * 1024]; + let mut frames = Vec::new(); + for _ in 0..6 { + frames.extend(framer.push(&chunk)); + assert!(framer.buffered_bytes() <= MAX_AGENT_STREAM_LINE_BYTES); + } + assert_eq!( + frames + .iter() + .filter(|frame| **frame == AgentStreamFrame::Oversized) + .count(), + 1 + ); + assert_eq!(framer.buffered_bytes(), 0); + + assert_eq!( + framer.push(b"discarded remainder\n{\"type\":\"content.delta\",\"delta\":\"ok\"}\n"), + vec![AgentStreamFrame::Line( + "{\"type\":\"content.delta\",\"delta\":\"ok\"}".to_string() + )] + ); + assert!(framer.finish().is_empty()); + + let exact = vec![b'y'; MAX_AGENT_STREAM_LINE_BYTES]; + assert!(framer.push(&exact).is_empty()); + assert_eq!(framer.buffered_bytes(), MAX_AGENT_STREAM_LINE_BYTES); + let admitted = framer.push(b"\n"); + assert!( + matches!(admitted.as_slice(), [AgentStreamFrame::Line(line)] if line.len() == MAX_AGENT_STREAM_LINE_BYTES) + ); + } + // SDTEST-1680 — SDUC-475 #[cfg(unix)] #[test] @@ -985,6 +1742,80 @@ mod tests { fs::remove_dir_all(&root).unwrap(); } + // SDTEST-1904 — SDUC-475 + #[cfg(unix)] + #[test] + fn sdtest_1904_ssh_home_workdir_expands_once_and_never_becomes_literal_tilde() { + use std::fs; + use std::os::unix::fs::PermissionsExt as _; + + let root = std::env::temp_dir().join(format!("shelldeck-agent-home-{}", Uuid::new_v4())); + let home = root.join("remote home"); + fs::create_dir_all(&home).unwrap(); + let capture = root.join("cwd.txt"); + let fake = root.join("fake-agent"); + fs::write( + &fake, + format!( + "#!/bin/sh\npwd > {}\n", + shell_quote(&capture.to_string_lossy()) + ), + ) + .unwrap(); + let mut permissions = fs::metadata(&fake).unwrap().permissions(); + permissions.set_mode(0o700); + fs::set_permissions(&fake, permissions).unwrap(); + + let target = AgentTarget::Ssh { + connection_id: Uuid::nil(), + connection_label: "remote".to_string(), + }; + let request = AgentRunRequest::new( + AgentProvider::Jcode, + target.clone(), + AgentAccessMode::ReadOnly, + AGENT_REMOTE_HOME_WORKDIR, + None, + "inspect", + ); + request.validate().unwrap(); + let jcode = AgentCommandSpec::for_request(&request).unwrap(); + assert!(jcode.args.windows(2).any(|pair| pair == ["-C", "."])); + + let invalid = AgentRunRequest::new( + AgentProvider::Claude, + target, + AgentAccessMode::ReadOnly, + "~/project", + None, + "inspect", + ); + assert!(invalid.validate().is_err()); + + let spec = AgentCommandSpec { + program: fake.display().to_string(), + args: Vec::new(), + cwd: PathBuf::from(AGENT_REMOTE_HOME_WORKDIR), + stdin: None, + remove_env: Vec::new(), + }; + let command = spec.remote_shell_command(); + assert!(command.contains("cd -- \"$HOME\"")); + assert!(!command.contains("cd -- '~'")); + let status = Command::new("sh") + .arg("-c") + .arg(command) + .env("HOME", &home) + .status() + .unwrap(); + assert!(status.success()); + assert_eq!( + fs::canonicalize(fs::read_to_string(&capture).unwrap().trim()).unwrap(), + fs::canonicalize(&home).unwrap() + ); + fs::remove_dir_all(&root).unwrap(); + } + // SDTEST-1771 — SDUC-491 #[cfg(windows)] #[test] diff --git a/crates/shelldeck-core/src/agent_session.rs b/crates/shelldeck-core/src/agent_session.rs new file mode 100644 index 00000000..71c4c520 --- /dev/null +++ b/crates/shelldeck-core/src/agent_session.rs @@ -0,0 +1,1161 @@ +//! Durable, provider-neutral state for the Dev Agents cockpit. +//! +//! Process handles remain in the UI/runtime layer. This module owns the +//! bounded state that may be rendered, retained while panes switch, and +//! serialized without persisting provider resume identifiers. + +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::agent_runtime::{ + AgentAccessMode, AgentProvider, AgentRunRequest, AgentStreamEvent, AgentTarget, +}; +use crate::{Result, ShellDeckError}; + +pub const DEFAULT_MAX_CONCURRENT_AGENT_SESSIONS: usize = 4; +pub const MAX_CONCURRENT_AGENT_SESSIONS: usize = 8; +pub const MAX_AGENT_SESSIONS: usize = 64; +pub const MAX_AGENT_SESSION_NAME_BYTES: usize = 160; +pub const MAX_AGENT_MESSAGES_PER_SESSION: usize = 256; +pub const MAX_AGENT_TRACE_EVENTS_PER_SESSION: usize = 512; +pub const MAX_AGENT_MESSAGE_BYTES: usize = 128 * 1024; +pub const MAX_AGENT_TRACE_STRING_BYTES: usize = 8 * 1024; +pub const MAX_AGENT_TRACE_CORRELATION_BYTES: usize = 256; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AgentExecutionContext { + pub provider: AgentProvider, + pub target: AgentTarget, + pub access: AgentAccessMode, + pub workdir: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model: Option, +} + +impl AgentExecutionContext { + pub fn from_request(request: &AgentRunRequest) -> Self { + Self { + provider: request.provider, + target: request.target.clone(), + access: request.access, + workdir: request.workdir.trim().to_string(), + model: request + .model + .as_deref() + .map(str::trim) + .filter(|model| !model.is_empty()) + .map(str::to_string), + } + } + + pub fn run_request(&self, prompt: impl Into) -> AgentRunRequest { + AgentRunRequest::new( + self.provider, + self.target.clone(), + self.access, + self.workdir.clone(), + self.model.clone(), + prompt, + ) + } + + fn normalized(mut self) -> Result { + self.workdir = self.workdir.trim().to_string(); + self.model = self + .model + .take() + .map(|model| model.trim().to_string()) + .filter(|model| !model.is_empty()); + self.run_request("validate execution context").validate()?; + Ok(self) + } +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AgentSessionStatus { + #[default] + Idle, + Starting, + Running, + Stopping, + Completed, + Failed, + Cancelled, +} + +impl AgentSessionStatus { + pub fn is_active(self) -> bool { + matches!(self, Self::Starting | Self::Running | Self::Stopping) + } + + pub fn is_terminal(self) -> bool { + matches!(self, Self::Completed | Self::Failed | Self::Cancelled) + } +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AgentSessionAttention { + #[default] + None, + Unread, + NeedsAttention, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AgentMessageRole { + User, + Agent, + Error, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AgentMessage { + pub id: Uuid, + #[serde(default)] + pub sequence: u64, + pub role: AgentMessageRole, + pub text: String, + pub at_ms: i64, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AgentTraceStatus { + Pending, + Running, + Succeeded, + Failed, + Cancelled, + #[default] + Unknown, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum AgentTraceKind { + Command { + command: String, + status: AgentTraceStatus, + #[serde(default, skip_serializing_if = "Option::is_none")] + exit_code: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + summary: Option, + }, + FileRead { + path: String, + #[serde(default)] + status: AgentTraceStatus, + #[serde(default, skip_serializing_if = "Option::is_none")] + line_start: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + line_end: Option, + }, + Diff { + path: String, + #[serde(default)] + status: AgentTraceStatus, + additions: u32, + deletions: u32, + #[serde(default, skip_serializing_if = "Option::is_none")] + preview: Option, + }, + Test { + name: String, + status: AgentTraceStatus, + #[serde(default, skip_serializing_if = "Option::is_none")] + summary: Option, + }, + Tool { + name: String, + status: AgentTraceStatus, + #[serde(default, skip_serializing_if = "Option::is_none")] + summary: Option, + }, + Activity { + label: String, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AgentTraceUpdate { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub correlation_id: Option, + pub detail: AgentTraceKind, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AgentTraceEvent { + pub id: Uuid, + #[serde(default)] + pub sequence: u64, + pub at_ms: i64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub correlation_id: Option, + pub detail: AgentTraceKind, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AgentSession { + pub id: Uuid, + pub name: String, + pub context: AgentExecutionContext, + pub status: AgentSessionStatus, + pub created_at_ms: i64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub started_at_ms: Option, + pub updated_at_ms: i64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub finished_at_ms: Option, + #[serde(default)] + pub unread_count: u32, + #[serde(default)] + pub attention: AgentSessionAttention, + #[serde(default)] + pub messages: Vec, + #[serde(default)] + pub trace: Vec, + #[serde(default = "initial_sequence")] + next_sequence: u64, + /// A provider conversation id is scoped to this live application process. + /// It is deliberately excluded from durable session state. + #[serde(skip)] + provider_session_id: Option, + #[serde(skip)] + provider_session_context: Option, +} + +impl AgentSession { + pub fn new( + name: impl Into, + context: AgentExecutionContext, + now_ms: i64, + ) -> Result { + let name = normalized_name(name.into())?; + let context = context.normalized()?; + Ok(Self { + id: Uuid::new_v4(), + name, + context, + status: AgentSessionStatus::Idle, + created_at_ms: now_ms, + started_at_ms: None, + updated_at_ms: now_ms, + finished_at_ms: None, + unread_count: 0, + attention: AgentSessionAttention::None, + messages: Vec::new(), + trace: Vec::new(), + next_sequence: initial_sequence(), + provider_session_id: None, + provider_session_context: None, + }) + } + + pub fn provider_session_id(&self) -> Option<&str> { + self.provider_session_id.as_deref() + } + + pub fn rename(&mut self, name: impl Into, now_ms: i64) -> Result<()> { + self.name = normalized_name(name.into())?; + self.updated_at_ms = now_ms; + Ok(()) + } + + /// Replace the execution context between runs. Resume authority is cleared + /// whenever any provider, target, access, workdir, or model field changes. + pub fn set_context(&mut self, context: AgentExecutionContext, now_ms: i64) -> Result { + if self.status.is_active() { + return Err(ShellDeckError::Config( + "stop the agent session before changing its execution context".to_string(), + )); + } + let context = context.normalized()?; + if context == self.context { + return Ok(false); + } + self.context = context; + self.provider_session_id = None; + self.provider_session_context = None; + self.updated_at_ms = now_ms; + Ok(true) + } + + pub fn run_request(&self, prompt: impl Into) -> AgentRunRequest { + let resume_session = self + .provider_session_context + .as_ref() + .filter(|context| *context == &self.context) + .and(self.provider_session_id.clone()); + self.context + .run_request(prompt) + .with_resume_session(resume_session) + } + + pub fn begin_run(&mut self, prompt: impl Into, now_ms: i64) -> Result { + if self.status.is_active() { + return Err(ShellDeckError::Config( + "agent session is already running".to_string(), + )); + } + let prompt = prompt.into(); + let request = self.run_request(prompt.clone()); + request.validate()?; + self.status = AgentSessionStatus::Starting; + self.started_at_ms = Some(now_ms); + self.updated_at_ms = now_ms; + self.finished_at_ms = None; + self.push_message(AgentMessageRole::User, prompt, now_ms); + Ok(request) + } + + pub fn request_stop(&mut self, now_ms: i64) -> bool { + if matches!( + self.status, + AgentSessionStatus::Starting | AgentSessionStatus::Running + ) { + self.status = AgentSessionStatus::Stopping; + self.updated_at_ms = now_ms; + true + } else { + false + } + } + + pub fn apply_stream_event(&mut self, event: AgentStreamEvent, now_ms: i64, visible: bool) { + let mut user_visible = true; + match event { + AgentStreamEvent::Text(text) => self.push_message( + AgentMessageRole::Agent, + sanitize_retained_output(&text, MAX_AGENT_MESSAGE_BYTES), + now_ms, + ), + AgentStreamEvent::TextDelta(delta) => self.push_text_delta( + sanitize_retained_output(&delta, MAX_AGENT_MESSAGE_BYTES), + now_ms, + ), + AgentStreamEvent::Session(session_id) => { + self.provider_session_id = Some(session_id); + self.provider_session_context = Some(self.context.clone()); + user_visible = false; + } + AgentStreamEvent::Ready => { + self.status = AgentSessionStatus::Running; + user_visible = false; + } + AgentStreamEvent::Trace(update) => { + user_visible = self.upsert_trace(update, now_ms); + } + AgentStreamEvent::TraceStatus { + correlation_id, + status, + summary, + } => { + user_visible = false; + self.update_trace_status(&correlation_id, status, summary, now_ms); + } + AgentStreamEvent::Activity(label) => { + self.upsert_trace( + AgentTraceUpdate { + correlation_id: None, + detail: AgentTraceKind::Activity { label }, + }, + now_ms, + ); + } + AgentStreamEvent::Error(error) => { + self.push_message( + AgentMessageRole::Error, + sanitize_retained_output(&error, MAX_AGENT_MESSAGE_BYTES), + now_ms, + ); + self.attention = AgentSessionAttention::NeedsAttention; + } + } + self.updated_at_ms = now_ms; + if user_visible && !visible { + self.unread_count = self.unread_count.saturating_add(1); + if self.attention == AgentSessionAttention::None { + self.attention = AgentSessionAttention::Unread; + } + } + } + + pub fn finish(&mut self, status: AgentSessionStatus, now_ms: i64, visible: bool) -> Result<()> { + if !matches!( + status, + AgentSessionStatus::Completed + | AgentSessionStatus::Failed + | AgentSessionStatus::Cancelled + ) { + return Err(ShellDeckError::Config( + "agent run must finish with a terminal status".to_string(), + )); + } + self.status = status; + self.updated_at_ms = now_ms; + self.finished_at_ms = Some(now_ms); + if !visible { + self.unread_count = self.unread_count.saturating_add(1); + if status == AgentSessionStatus::Failed { + self.attention = AgentSessionAttention::NeedsAttention; + } else if self.attention == AgentSessionAttention::None { + self.attention = AgentSessionAttention::Unread; + } + } else if status == AgentSessionStatus::Failed { + self.attention = AgentSessionAttention::NeedsAttention; + } + Ok(()) + } + + pub fn mark_read(&mut self) { + self.unread_count = 0; + if self.attention == AgentSessionAttention::Unread { + self.attention = AgentSessionAttention::None; + } + } + + pub fn acknowledge_attention(&mut self) { + self.unread_count = 0; + self.attention = AgentSessionAttention::None; + } + + fn push_message(&mut self, role: AgentMessageRole, text: String, now_ms: i64) { + let sequence = self.allocate_sequence(); + push_bounded( + &mut self.messages, + AgentMessage { + id: Uuid::new_v4(), + sequence, + role, + text: truncate_utf8(&text, MAX_AGENT_MESSAGE_BYTES), + at_ms: now_ms, + }, + MAX_AGENT_MESSAGES_PER_SESSION, + ); + } + + fn push_text_delta(&mut self, delta: String, now_ms: i64) { + if let Some(message) = self + .messages + .last_mut() + .filter(|message| message.role == AgentMessageRole::Agent) + { + let remaining = MAX_AGENT_MESSAGE_BYTES.saturating_sub(message.text.len()); + if remaining > 0 { + message.text.push_str(&truncate_utf8(&delta, remaining)); + } + } else { + self.push_message(AgentMessageRole::Agent, delta, now_ms); + } + } + + fn upsert_trace(&mut self, update: AgentTraceUpdate, now_ms: i64) -> bool { + let detail = bounded_trace(update.detail); + let correlation_id = update + .correlation_id + .map(|id| truncate_utf8(&id, MAX_AGENT_TRACE_CORRELATION_BYTES)); + if let Some(existing) = correlation_id.as_deref().and_then(|id| { + self.trace + .iter_mut() + .rev() + .find(|event| event.correlation_id.as_deref() == Some(id)) + }) { + existing.detail = detail; + return false; + } + if matches!(&detail, AgentTraceKind::Activity { label } if self.trace.last().is_some_and(|last| matches!(&last.detail, AgentTraceKind::Activity { label: previous } if previous == label))) + { + return false; + } + let sequence = self.allocate_sequence(); + push_bounded( + &mut self.trace, + AgentTraceEvent { + id: Uuid::new_v4(), + sequence, + at_ms: now_ms, + correlation_id, + detail, + }, + MAX_AGENT_TRACE_EVENTS_PER_SESSION, + ); + true + } + + fn update_trace_status( + &mut self, + correlation_id: &str, + status: AgentTraceStatus, + summary: Option, + _now_ms: i64, + ) { + let correlation_id = truncate_utf8(correlation_id, MAX_AGENT_TRACE_CORRELATION_BYTES); + let Some(event) = self + .trace + .iter_mut() + .rev() + .find(|event| event.correlation_id.as_deref() == Some(correlation_id.as_str())) + else { + return; + }; + set_trace_status(&mut event.detail, status); + if let Some(summary) = summary { + set_trace_summary( + &mut event.detail, + sanitize_retained_output(&summary, MAX_AGENT_TRACE_STRING_BYTES), + ); + } + } + + fn allocate_sequence(&mut self) -> u64 { + let retained_max = self + .messages + .last() + .map(|message| message.sequence) + .into_iter() + .chain(self.trace.last().map(|trace| trace.sequence)) + .max() + .unwrap_or(0); + let sequence = self.next_sequence.max(retained_max.saturating_add(1)); + self.next_sequence = sequence.saturating_add(1); + sequence + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AgentSessionCollection { + #[serde(default)] + sessions: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + selected_id: Option, + max_concurrent: usize, + #[serde(skip)] + surface_visible: bool, +} + +impl Default for AgentSessionCollection { + fn default() -> Self { + Self::new(DEFAULT_MAX_CONCURRENT_AGENT_SESSIONS) + .expect("the default agent concurrency limit is valid") + } +} + +impl AgentSessionCollection { + pub fn new(max_concurrent: usize) -> Result { + if !(1..=MAX_CONCURRENT_AGENT_SESSIONS).contains(&max_concurrent) { + return Err(ShellDeckError::Config(format!( + "agent concurrency limit must be between 1 and {MAX_CONCURRENT_AGENT_SESSIONS}" + ))); + } + Ok(Self { + sessions: Vec::new(), + selected_id: None, + max_concurrent, + surface_visible: false, + }) + } + + pub fn sessions(&self) -> &[AgentSession] { + &self.sessions + } + + pub fn selected_id(&self) -> Option { + self.selected_id + } + + pub fn selected(&self) -> Option<&AgentSession> { + let id = self.selected_id?; + self.get(id) + } + + pub fn get(&self, id: Uuid) -> Option<&AgentSession> { + self.sessions.iter().find(|session| session.id == id) + } + + pub fn get_mut(&mut self, id: Uuid) -> Option<&mut AgentSession> { + self.sessions.iter_mut().find(|session| session.id == id) + } + + pub fn active_count(&self) -> usize { + self.sessions + .iter() + .filter(|session| session.status.is_active()) + .count() + } + + pub fn has_capacity(&self) -> bool { + self.active_count() < self.max_concurrent + } + + pub fn create( + &mut self, + name: impl Into, + context: AgentExecutionContext, + now_ms: i64, + ) -> Result { + if self.sessions.len() >= MAX_AGENT_SESSIONS { + return Err(ShellDeckError::Config(format!( + "at most {MAX_AGENT_SESSIONS} agent sessions may be retained" + ))); + } + let session = AgentSession::new(name, context, now_ms)?; + let id = session.id; + self.sessions.push(session); + self.select(id)?; + Ok(id) + } + + pub fn select(&mut self, id: Uuid) -> Result<()> { + if self.get(id).is_none() { + return Err(ShellDeckError::Config( + "agent session no longer exists".to_string(), + )); + } + self.selected_id = Some(id); + if self.surface_visible { + self.get_mut(id) + .expect("session existence was checked") + .mark_read(); + } + Ok(()) + } + + /// Set whether the selected session is actually presented on screen. + /// Selection is navigation identity; only visibility consumes unread state. + pub fn set_surface_visible(&mut self, visible: bool) -> bool { + if self.surface_visible == visible { + return false; + } + self.surface_visible = visible; + if visible { + if let Some(id) = self.selected_id { + if let Some(session) = self.get_mut(id) { + session.mark_read(); + } + } + } + true + } + + pub fn surface_visible(&self) -> bool { + self.surface_visible + } + + pub fn begin_run( + &mut self, + id: Uuid, + prompt: impl Into, + now_ms: i64, + ) -> Result { + let already_active = self + .get(id) + .ok_or_else(|| ShellDeckError::Config("agent session no longer exists".to_string()))? + .status + .is_active(); + if !already_active && !self.has_capacity() { + return Err(ShellDeckError::Config(format!( + "at most {} agent sessions may run concurrently", + self.max_concurrent + ))); + } + self.get_mut(id) + .expect("session existence was checked") + .begin_run(prompt, now_ms) + } + + pub fn apply_stream_event( + &mut self, + id: Uuid, + event: AgentStreamEvent, + now_ms: i64, + ) -> Result<()> { + let visible = self.surface_visible && self.selected_id == Some(id); + self.get_mut(id) + .ok_or_else(|| ShellDeckError::Config("agent session no longer exists".to_string()))? + .apply_stream_event(event, now_ms, visible); + Ok(()) + } + + pub fn finish(&mut self, id: Uuid, status: AgentSessionStatus, now_ms: i64) -> Result<()> { + let visible = self.surface_visible && self.selected_id == Some(id); + self.get_mut(id) + .ok_or_else(|| ShellDeckError::Config("agent session no longer exists".to_string()))? + .finish(status, now_ms, visible) + } + + pub fn remove(&mut self, id: Uuid) -> Result { + let index = self + .sessions + .iter() + .position(|session| session.id == id) + .ok_or_else(|| ShellDeckError::Config("agent session no longer exists".to_string()))?; + if self.sessions[index].status.is_active() { + return Err(ShellDeckError::Config( + "stop the agent session before removing it".to_string(), + )); + } + let removed = self.sessions.remove(index); + if self.selected_id == Some(id) { + self.selected_id = self + .sessions + .get(index) + .or_else(|| self.sessions.last()) + .map(|s| s.id); + if self.surface_visible { + if let Some(selected_id) = self.selected_id { + if let Some(session) = self.get_mut(selected_id) { + session.mark_read(); + } + } + } + } + Ok(removed) + } + + /// An active process cannot survive application restart. This explicit + /// recovery step makes stale durable state presentable but never claims + /// that work is still running. + pub fn recover_interrupted(&mut self, now_ms: i64) { + for session in &mut self.sessions { + if session.status.is_active() { + session.status = AgentSessionStatus::Failed; + session.updated_at_ms = now_ms; + session.finished_at_ms = Some(now_ms); + session.attention = AgentSessionAttention::NeedsAttention; + session.provider_session_id = None; + session.provider_session_context = None; + } + } + } +} + +fn normalized_name(name: String) -> Result { + let name = name.trim(); + if name.is_empty() { + return Err(ShellDeckError::Config( + "agent session name must not be empty".to_string(), + )); + } + if name.len() > MAX_AGENT_SESSION_NAME_BYTES { + return Err(ShellDeckError::Config( + "agent session name is too long".to_string(), + )); + } + Ok(name.to_string()) +} + +const fn initial_sequence() -> u64 { + 1 +} + +fn push_bounded(items: &mut Vec, item: T, limit: usize) { + if items.len() == limit { + items.remove(0); + } + items.push(item); +} + +fn bounded_trace(detail: AgentTraceKind) -> AgentTraceKind { + match detail { + AgentTraceKind::Command { + command, + status, + exit_code, + summary, + } => AgentTraceKind::Command { + command: sanitize_retained_output(&command, MAX_AGENT_TRACE_STRING_BYTES), + status, + exit_code, + summary: summary + .map(|text| sanitize_retained_output(&text, MAX_AGENT_TRACE_STRING_BYTES)), + }, + AgentTraceKind::FileRead { + path, + status, + line_start, + line_end, + } => AgentTraceKind::FileRead { + path: sanitize_retained_output(&path, MAX_AGENT_TRACE_STRING_BYTES), + status, + line_start, + line_end, + }, + AgentTraceKind::Diff { + path, + status, + additions, + deletions, + preview, + } => AgentTraceKind::Diff { + path: sanitize_retained_output(&path, MAX_AGENT_TRACE_STRING_BYTES), + status, + additions, + deletions, + preview: preview + .map(|text| sanitize_retained_output(&text, MAX_AGENT_TRACE_STRING_BYTES)), + }, + AgentTraceKind::Test { + name, + status, + summary, + } => AgentTraceKind::Test { + name: sanitize_retained_output(&name, MAX_AGENT_TRACE_STRING_BYTES), + status, + summary: summary + .map(|text| sanitize_retained_output(&text, MAX_AGENT_TRACE_STRING_BYTES)), + }, + AgentTraceKind::Tool { + name, + status, + summary, + } => AgentTraceKind::Tool { + name: sanitize_retained_output(&name, MAX_AGENT_TRACE_STRING_BYTES), + status, + summary: summary + .map(|text| sanitize_retained_output(&text, MAX_AGENT_TRACE_STRING_BYTES)), + }, + AgentTraceKind::Activity { label } => AgentTraceKind::Activity { + label: sanitize_retained_output(&label, MAX_AGENT_TRACE_STRING_BYTES), + }, + } +} + +fn set_trace_status(detail: &mut AgentTraceKind, new_status: AgentTraceStatus) { + match detail { + AgentTraceKind::Command { status, .. } + | AgentTraceKind::FileRead { status, .. } + | AgentTraceKind::Diff { status, .. } + | AgentTraceKind::Test { status, .. } + | AgentTraceKind::Tool { status, .. } => *status = new_status, + AgentTraceKind::Activity { .. } => {} + } +} + +fn set_trace_summary(detail: &mut AgentTraceKind, new_summary: String) { + match detail { + AgentTraceKind::Command { summary, .. } + | AgentTraceKind::Test { summary, .. } + | AgentTraceKind::Tool { summary, .. } => *summary = Some(new_summary), + AgentTraceKind::FileRead { .. } + | AgentTraceKind::Diff { .. } + | AgentTraceKind::Activity { .. } => {} + } +} + +fn truncate_utf8(text: &str, max_bytes: usize) -> String { + if text.len() <= max_bytes { + return text.to_string(); + } + let suffix = "…"; + if max_bytes < suffix.len() { + return String::new(); + } + let mut end = max_bytes.saturating_sub(suffix.len()).min(text.len()); + while !text.is_char_boundary(end) { + end = end.saturating_sub(1); + } + format!("{}{suffix}", &text[..end]) +} + +fn sanitize_retained_output(text: &str, max_bytes: usize) -> String { + truncate_utf8(&redact_credentials(text), max_bytes) +} + +fn redact_credentials(text: &str) -> String { + let mut redact_next = false; + text.split_inclusive(char::is_whitespace) + .map(|part| { + let trimmed = part.trim_end_matches(char::is_whitespace); + let suffix = &part[trimmed.len()..]; + if redact_next && !trimmed.is_empty() { + redact_next = false; + return format!("[redacted]{suffix}"); + } + if trimmed.eq_ignore_ascii_case("bearer") { + redact_next = true; + return part.to_string(); + } + if ["sk-", "ghp_", "github_pat_", "xoxb-", "xoxp-"] + .iter() + .any(|prefix| trimmed.starts_with(prefix) && trimmed.len() > prefix.len() + 4) + { + return format!("[redacted]{suffix}"); + } + if let Some((key, _)) = trimmed.split_once('=') { + let key_upper = key.to_ascii_uppercase(); + if [ + "TOKEN", + "SECRET", + "PASSWORD", + "PASSWD", + "API_KEY", + "PRIVATE_KEY", + ] + .iter() + .any(|sensitive| key_upper.contains(sensitive)) + { + return format!("{key}=[redacted]{suffix}"); + } + } + part.to_string() + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[cfg(not(windows))] + const TEST_WORKDIR: &str = "/srv/project"; + #[cfg(windows)] + const TEST_WORKDIR: &str = r"C:\srv\project"; + + fn context(provider: AgentProvider) -> AgentExecutionContext { + AgentExecutionContext { + provider, + target: AgentTarget::Local, + access: AgentAccessMode::WorkspaceWrite, + workdir: TEST_WORKDIR.to_string(), + model: None, + } + } + + // SDTEST-1879 — SDUC-499 + #[test] + fn sdtest_1879_collection_runs_bounded_sessions_concurrently_and_tracks_attention() { + let mut sessions = AgentSessionCollection::new(2).unwrap(); + let first = sessions + .create("Fix parser", context(AgentProvider::Codex), 10) + .unwrap(); + let second = sessions + .create("Add tests", context(AgentProvider::Claude), 11) + .unwrap(); + let waiting = sessions + .create("Review", context(AgentProvider::Jcode), 12) + .unwrap(); + + sessions.begin_run(first, "fix it", 20).unwrap(); + sessions.begin_run(second, "test it", 21).unwrap(); + assert_eq!(sessions.active_count(), 2); + assert!(sessions.begin_run(waiting, "review it", 22).is_err()); + + sessions + .apply_stream_event(first, AgentStreamEvent::Ready, 23) + .unwrap(); + sessions + .apply_stream_event( + first, + AgentStreamEvent::Trace(AgentTraceUpdate { + correlation_id: Some("read-1".to_string()), + detail: AgentTraceKind::FileRead { + path: "src/lib.rs".to_string(), + status: AgentTraceStatus::Running, + line_start: Some(1), + line_end: Some(20), + }, + }), + 24, + ) + .unwrap(); + sessions + .apply_stream_event( + first, + AgentStreamEvent::TraceStatus { + correlation_id: "read-1".to_string(), + status: AgentTraceStatus::Succeeded, + summary: None, + }, + 25, + ) + .unwrap(); + sessions + .apply_stream_event(first, AgentStreamEvent::Text("Implemented".to_string()), 26) + .unwrap(); + let first_session = sessions.get(first).unwrap(); + assert_eq!(first_session.status, AgentSessionStatus::Running); + assert_eq!(first_session.unread_count, 2); + assert_eq!(first_session.attention, AgentSessionAttention::Unread); + assert_eq!(first_session.trace.len(), 1); + assert!(matches!( + first_session.trace[0].detail, + AgentTraceKind::FileRead { + status: AgentTraceStatus::Succeeded, + .. + } + )); + + sessions.set_surface_visible(true); + sessions.select(first).unwrap(); + assert_eq!(sessions.get(first).unwrap().unread_count, 0); + sessions + .finish(second, AgentSessionStatus::Failed, 30) + .unwrap(); + assert_eq!( + sessions.get(second).unwrap().attention, + AgentSessionAttention::NeedsAttention + ); + assert!(sessions.has_capacity()); + } + + // SDTEST-1881 — SDUC-499 + #[test] + fn sdtest_1881_durable_state_excludes_resume_ids_recovers_runs_and_bounds_streams() { + let mut sessions = AgentSessionCollection::default(); + let id = sessions + .create("Bound output", context(AgentProvider::Claude), 10) + .unwrap(); + sessions.begin_run(id, "inspect", 11).unwrap(); + sessions + .apply_stream_event( + id, + AgentStreamEvent::Session("provider-secret-resume-id".to_string()), + 12, + ) + .unwrap(); + { + let session = sessions.get_mut(id).unwrap(); + session.context.model = Some("changed-model".to_string()); + assert_eq!(session.run_request("continue").resume_session, None); + session.context.model = None; + assert_eq!( + session.run_request("continue").resume_session.as_deref(), + Some("provider-secret-resume-id") + ); + } + for _ in 0..3 { + sessions + .apply_stream_event( + id, + AgentStreamEvent::TextDelta("é".repeat(MAX_AGENT_MESSAGE_BYTES)), + 13, + ) + .unwrap(); + } + sessions + .apply_stream_event( + id, + AgentStreamEvent::Trace(AgentTraceUpdate { + correlation_id: Some("command-1".to_string()), + detail: AgentTraceKind::Command { + command: "é".repeat(MAX_AGENT_TRACE_STRING_BYTES), + status: AgentTraceStatus::Running, + exit_code: None, + summary: None, + }, + }), + 14, + ) + .unwrap(); + let session = sessions.get(id).unwrap(); + assert!(session.messages.last().unwrap().text.len() <= MAX_AGENT_MESSAGE_BYTES); + let AgentTraceKind::Command { command, .. } = &session.trace.last().unwrap().detail else { + panic!("expected command trace"); + }; + assert!(command.len() <= MAX_AGENT_TRACE_STRING_BYTES); + + let json = serde_json::to_string(&sessions).unwrap(); + assert!(!json.contains("provider-secret-resume-id")); + let mut restored: AgentSessionCollection = serde_json::from_str(&json).unwrap(); + assert_eq!(restored.get(id).unwrap().provider_session_id(), None); + restored.recover_interrupted(20); + assert_eq!(restored.get(id).unwrap().status, AgentSessionStatus::Failed); + assert_eq!( + restored.get(id).unwrap().attention, + AgentSessionAttention::NeedsAttention + ); + } + + // SDTEST-1895 — SDUC-499 + #[test] + fn sdtest_1895_equal_timestamps_and_incremental_updates_keep_one_monotonic_timeline() { + let mut session = AgentSession::new("Sequence", context(AgentProvider::Codex), 10).unwrap(); + session.begin_run("first", 20).unwrap(); + session.apply_stream_event( + AgentStreamEvent::Trace(AgentTraceUpdate { + correlation_id: Some("command-1".to_string()), + detail: AgentTraceKind::Command { + command: "cargo check".to_string(), + status: AgentTraceStatus::Running, + exit_code: None, + summary: None, + }, + }), + 20, + true, + ); + session.apply_stream_event(AgentStreamEvent::Text("working".to_string()), 20, true); + session.apply_stream_event(AgentStreamEvent::TextDelta("…done".to_string()), 30, true); + session.apply_stream_event( + AgentStreamEvent::Trace(AgentTraceUpdate { + correlation_id: Some("command-1".to_string()), + detail: AgentTraceKind::Command { + command: "cargo check".to_string(), + status: AgentTraceStatus::Succeeded, + exit_code: Some(0), + summary: None, + }, + }), + 40, + true, + ); + + let mut timeline = session + .messages + .iter() + .map(|message| (message.sequence, "message", message.at_ms)) + .chain( + session + .trace + .iter() + .map(|trace| (trace.sequence, "trace", trace.at_ms)), + ) + .collect::>(); + timeline.sort_by_key(|item| item.0); + assert_eq!( + timeline, + vec![(1, "message", 20), (2, "trace", 20), (3, "message", 20)] + ); + assert_eq!(session.messages[1].text, "working…done"); + assert!(matches!( + session.trace[0].detail, + AgentTraceKind::Command { + status: AgentTraceStatus::Succeeded, + exit_code: Some(0), + .. + } + )); + } + + // SDTEST-1898 — SDUC-499 + #[test] + fn sdtest_1898_selected_but_hidden_session_accumulates_unread_until_visible() { + let mut sessions = AgentSessionCollection::default(); + let id = sessions + .create("Visibility", context(AgentProvider::Claude), 10) + .unwrap(); + assert_eq!(sessions.selected_id(), Some(id)); + assert!(!sessions.surface_visible()); + + sessions + .apply_stream_event(id, AgentStreamEvent::Text("hidden output".to_string()), 20) + .unwrap(); + assert_eq!(sessions.get(id).unwrap().unread_count, 1); + + assert!(sessions.set_surface_visible(true)); + assert_eq!(sessions.get(id).unwrap().unread_count, 0); + sessions + .apply_stream_event(id, AgentStreamEvent::Text("visible output".to_string()), 30) + .unwrap(); + assert_eq!(sessions.get(id).unwrap().unread_count, 0); + + assert!(sessions.set_surface_visible(false)); + sessions + .apply_stream_event(id, AgentStreamEvent::Text("hidden again".to_string()), 40) + .unwrap(); + assert_eq!(sessions.get(id).unwrap().unread_count, 1); + } +} diff --git a/crates/shelldeck-core/src/config/mod.rs b/crates/shelldeck-core/src/config/mod.rs index 0a48ed51..61fa0fd4 100644 --- a/crates/shelldeck-core/src/config/mod.rs +++ b/crates/shelldeck-core/src/config/mod.rs @@ -13,6 +13,7 @@ pub mod manage_sites; pub mod manage_support; pub mod monique; pub mod platform; +pub mod platform_attention; pub mod platform_review; pub mod single_instance; pub mod ssh_config; diff --git a/crates/shelldeck-core/src/config/platform.rs b/crates/shelldeck-core/src/config/platform.rs index 1e5b9ea0..7ee09118 100644 --- a/crates/shelldeck-core/src/config/platform.rs +++ b/crates/shelldeck-core/src/config/platform.rs @@ -8,31 +8,41 @@ use std::collections::BTreeMap; use std::fmt; use std::time::{SystemTime, UNIX_EPOCH}; +pub use automonique_platform_client::platform_v2_client::AttentionReadResult; use automonique_platform_client::platform_v2_client::{ - NegotiationResult, PlatformV2Client, PlatformV2ClientError, ReviewReadResult, - ReviewReceiptResult, + NegotiationResult, PlatformV2Client, PlatformV2ClientError, ReviewCapabilitiesResult, + ReviewReadResult, ReviewReceiptResult, WorkContextQueryResult, }; -pub use automonique_platform_client::{ActionResult, ControlClaimResult}; +pub use automonique_platform_client::{ActionResult, ControlClaimResult, PlatformView}; use automonique_platform_client::{ - BearerToken, HttpsTransport, PlatformClient, PlatformView, SessionCommandStateResult, - SessionHistoryResult, SessionListResult, SubscriptionApply, SubscriptionResult, + BearerToken, HttpsTransport, PlatformClient, SessionCommandStateResult, SessionHistoryResult, + SessionListResult, SubscriptionApply, SubscriptionResult, }; pub use automonique_protocol::platform::{ ActionReceipt, Attachment, Capabilities, ClientId, ControlLease, ControlLeaseId, ExecuteRequest, FreshnessState, GetReceiptRequest, IdempotencyKey, PlatformAction, PlatformCursor, PlatformMethod, PlatformParameter, PlatformText, PlatformTransport, ReceiptId, - ReceiptOutcome, ReleaseControlRequest, ResourceAuthority, ResourceCoordinate, ResourceKind, - ResourceRecord, SessionCommandState, SessionFollowUpRequest, SessionHistoryEvent, + ReceiptOutcome, ReleaseControlRequest, ResourceAuthority, ResourceCoordinate, ResourceId, + ResourceKind, ResourceRecord, SessionCommandState, SessionFollowUpRequest, SessionHistoryEvent, SessionHistoryEvidence, SessionHistoryPage, SessionHistoryRole, SessionHistoryRunState, SessionHistoryToolState, SessionHistoryUnknownSource, SessionRecord, }; -use automonique_protocol::platform_v2::PlatformVersionOffer; +use automonique_protocol::platform_v2::{ + PlatformVersionOffer, WorkContextKind, WorkContextQuery, WorkContextRecord, + MAX_WORK_CONTEXT_PAGE_ITEMS, +}; +use automonique_protocol::platform_v2_attention::AttentionSource; pub use automonique_protocol::primitives::Revision; use url::Url; +use super::platform_attention::{ + AttentionSourceInventory, PlatformAttentionTarget, ReviewAttentionPresence, + MAX_ATTENTION_INVENTORY_RECORDS, +}; use super::platform_review::{ - PlatformReviewActionPreview, PlatformReviewLoad, PlatformReviewSemantic, PlatformReviewTarget, - PlatformReviewUnavailable, ReviewActionReceipt, ReviewReceiptOutcome, ReviewReconciliation, + PlatformReviewActionPreview, PlatformReviewCapabilitiesLoad, PlatformReviewLoad, + PlatformReviewSemantic, PlatformReviewTarget, PlatformReviewUnavailable, ReviewActionReceipt, + ReviewReceiptOutcome, ReviewReconciliation, }; use crate::error::{Result, ShellDeckError}; @@ -54,6 +64,12 @@ pub struct PlatformRefresh { pub resynchronized: bool, } +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PlatformAttentionLoad { + pub inventory: AttentionSourceInventory, + pub reads: Vec<(AttentionSource, AttentionReadResult)>, +} + const RETAINED_HISTORY_PAGE_LIMIT: u16 = 128; #[derive(Clone, Debug, Eq, PartialEq)] @@ -630,6 +646,43 @@ impl PlatformConnection { load_review(&mut client, target) } + /// Load the server's current, separately authorized review capabilities. + /// A review snapshot or provider session never substitutes for this lane. + pub fn review_capabilities( + &self, + target: &PlatformReviewTarget, + ) -> Result { + let mut client = self.review_client()?; + load_review_capabilities(&mut client, target) + } + + /// Load one complete, bounded attention inventory and every source in it + /// through the negotiated read-only v2 lane. + pub fn attention( + &self, + target: PlatformAttentionTarget, + review: ReviewAttentionPresence, + ) -> Result { + let mut client = self.review_client()?; + negotiate_review(&mut client)?; + let records = load_attention_inventory(&mut client, &target)?; + let inventory = + AttentionSourceInventory::from_authoritative_records(target.clone(), &records, review) + .map_err(|error| ShellDeckError::Connection(error.to_string()))?; + let mut reads = Vec::with_capacity(inventory.sources().len()); + for source in inventory.sources() { + let read = client + .get_attention_source_snapshot( + source.clone(), + target.project.clone(), + target.user_workspace.clone(), + ) + .map_err(platform_v2_error)?; + reads.push((source.clone(), read)); + } + Ok(PlatformAttentionLoad { inventory, reads }) + } + /// Dispatch one already-confirmed review action exactly once. /// /// Any uncertain transport outcome immediately switches to lookup by the @@ -1335,6 +1388,27 @@ fn load_review( } } +fn load_review_capabilities( + client: &mut PlatformV2Client, + target: &PlatformReviewTarget, +) -> Result { + negotiate_review(client)?; + match client + .get_review_capabilities(target.project.clone(), target.workspace.clone()) + .map_err(platform_v2_error)? + { + ReviewCapabilitiesResult::Capabilities(capabilities) => { + Ok(PlatformReviewCapabilitiesLoad::Available(capabilities)) + } + ReviewCapabilitiesResult::Refused(refusal) => Ok( + PlatformReviewCapabilitiesLoad::Unavailable(PlatformReviewUnavailable { + category: refusal.category().as_str().to_owned(), + explanation: refusal.explanation().as_str().to_owned(), + }), + ), + } +} + fn negotiate_review(client: &mut PlatformV2Client) -> Result<()> { let offer = PlatformVersionOffer::new(vec![2]).map_err(|_| { ShellDeckError::Connection("platform v2 negotiation offer is invalid".to_owned()) @@ -1351,19 +1425,84 @@ fn negotiate_review(client: &mut PlatformV2Client) -> Result<()> { } } +fn load_attention_inventory( + client: &mut PlatformV2Client, + target: &PlatformAttentionTarget, +) -> Result> { + let kinds = vec![ + WorkContextKind::Project, + WorkContextKind::UserWorkspace, + WorkContextKind::AttemptWorkspace, + WorkContextKind::Session, + ]; + let mut records = Vec::new(); + let mut after = None; + loop { + let query = WorkContextQuery::new( + kinds.clone(), + Vec::new(), + Some(target.project.clone()), + None, + after.clone(), + MAX_WORK_CONTEXT_PAGE_ITEMS as u16, + ) + .map_err(|_| ShellDeckError::Connection("attention inventory query is invalid".into()))?; + let page = match client + .query_work_contexts(query) + .map_err(platform_v2_error)? + { + WorkContextQueryResult::Page(page) => page, + WorkContextQueryResult::Resync(_) => { + return Err(ShellDeckError::Connection( + "attention inventory cursor expired".into(), + )); + } + WorkContextQueryResult::Refused(refusal) => { + return Err(ShellDeckError::Connection(format!( + "attention inventory refused: {}", + refusal.category().as_str() + ))); + } + }; + if records.len().saturating_add(page.items().len()) > MAX_ATTENTION_INVENTORY_RECORDS { + return Err(ShellDeckError::Connection( + "attention inventory exceeds its client bound".into(), + )); + } + let has_more = page.has_more(); + after = page.next_cursor().cloned(); + records.extend(page.into_items()); + if !has_more { + return Ok(records); + } + } +} + fn dispatch_review_action( client: &mut PlatformV2Client, preview: &PlatformReviewActionPreview, ) -> Result { negotiate_review(client)?; - client - .execute_review_action( - preview.target().workspace.clone(), - preview.expected_revision(), - preview.action().clone(), - preview.idempotency_key().clone(), - ) - .map_err(platform_v2_error) + if let Some(confirmation) = preview.confirmation() { + client + .execute_confirmed_review_action( + preview.target().workspace.clone(), + preview.expected_revision(), + preview.action().clone(), + preview.idempotency_key().clone(), + confirmation.clone(), + ) + .map_err(platform_v2_error) + } else { + client + .execute_review_action( + preview.target().workspace.clone(), + preview.expected_revision(), + preview.action().clone(), + preview.idempotency_key().clone(), + ) + .map_err(platform_v2_error) + } } fn lookup_review_action( @@ -1371,13 +1510,24 @@ fn lookup_review_action( preview: &PlatformReviewActionPreview, ) -> Result { negotiate_review(client)?; - client - .get_review_receipt( - preview.target().project.clone(), - preview.target().workspace.clone(), - preview.idempotency_key().clone(), - ) - .map_err(platform_v2_error) + if let Some(confirmation) = preview.confirmation() { + client + .get_correlated_review_receipt( + preview.target().project.clone(), + preview.target().workspace.clone(), + preview.idempotency_key().clone(), + confirmation.receipt_correlation_digest().clone(), + ) + .map_err(platform_v2_error) + } else { + client + .get_review_receipt( + preview.target().project.clone(), + preview.target().workspace.clone(), + preview.idempotency_key().clone(), + ) + .map_err(platform_v2_error) + } } #[cfg(test)] @@ -1518,6 +1668,134 @@ mod tests { )); } + // SDTEST-1827 — capability loading is its own authenticated request, and + // a confirmed rerun carries every exact fence on dispatch and uses only + // the correlated receipt lookup during recovery. + #[test] + fn confirmed_rerun_uses_authoritative_capability_and_correlated_lookup_only() { + use automonique_platform_client::platform_v2_client::testing::{ + DeterministicPlatformV2Step, DeterministicPlatformV2Transport, + }; + use automonique_protocol::platform_v2::{ + NegotiatedPlatform, PlatformVersion, WorkContextAvailability, + }; + use automonique_protocol::platform_v2_review::{ReviewAuthority, ReviewAuthorityId}; + use automonique_protocol::platform_v2_review_api::decode_review_snapshot; + use automonique_protocol::platform_v2_transport::{ + PlatformNegotiationResponse, PlatformV2Refusal, PlatformV2Request, PlatformV2Response, + ReviewCapabilities, ReviewCheckRerunCapability, ReviewConfirmationDigest, + ReviewGitStagingCapabilities, ReviewPullRequestCapabilities, + ReviewReceiptCorrelationDigest, + }; + + let snapshot = decode_review_snapshot(include_bytes!( + "../../tests/fixtures/platform-v2-review-v2.json" + )) + .unwrap(); + let semantic = PlatformReviewSemantic::from(&snapshot); + let target = PlatformReviewTarget { + project: automonique_protocol::platform_v2::ProjectId::new("project-1").unwrap(), + workspace: snapshot.workspace().clone(), + }; + let check = &semantic.checks[0]; + let confirmation = ReviewConfirmationDigest::new("a".repeat(64)).unwrap(); + let correlation = ReviewReceiptCorrelationDigest::new("b".repeat(64)).unwrap(); + let workspace_revision = Revision::new(92).unwrap(); + let capabilities = ReviewCapabilities::new( + target.project.clone(), + target.workspace.clone(), + semantic.revision, + workspace_revision, + vec![ReviewCheckRerunCapability::new( + crate::config::platform_review::ReviewCheckId::new(check.id.clone()).unwrap(), + check.freshness.observed_revision, + ReviewAuthority::new( + check.authority.kind, + ReviewAuthorityId::new(check.authority.id.clone()).unwrap(), + ), + confirmation.clone(), + correlation.clone(), + ) + .unwrap()], + Vec::new(), + ReviewPullRequestCapabilities::default(), + ReviewGitStagingCapabilities::default(), + ) + .unwrap(); + let negotiated = || { + NegotiatedPlatform::new( + PlatformVersion::V2, + PlatformVersion::V2.schema(), + WorkContextAvailability::V2Structured, + ) + .unwrap() + }; + + let capability_transport = DeterministicPlatformV2Transport::new([ + DeterministicPlatformV2Step::Negotiation(PlatformNegotiationResponse::Negotiated( + negotiated(), + )), + DeterministicPlatformV2Step::V2(Box::new(PlatformV2Response::ReviewCapabilities( + capabilities.clone(), + ))), + ]); + let mut capability_client = PlatformV2Client::new_testing(capability_transport); + assert_eq!( + load_review_capabilities(&mut capability_client, &target).unwrap(), + PlatformReviewCapabilitiesLoad::Available(Box::new(capabilities.clone())) + ); + assert!(matches!( + capability_client.transport().requests()[0].request(), + PlatformV2Request::GetReviewCapabilities(request) + if request.project() == &target.project && request.workspace() == &target.workspace + )); + + let preview = PlatformReviewActionPreview::rerun_check( + target.clone(), + &semantic, + &capabilities, + &check.id, + ) + .unwrap(); + let refusal = || PlatformV2Refusal::new("fixture_refusal", "fixture refusal").unwrap(); + let dispatch_transport = DeterministicPlatformV2Transport::new([ + DeterministicPlatformV2Step::Negotiation(PlatformNegotiationResponse::Negotiated( + negotiated(), + )), + DeterministicPlatformV2Step::V2(Box::new(PlatformV2Response::Refused(refusal()))), + ]); + let mut dispatch_client = PlatformV2Client::new_testing(dispatch_transport); + assert!(matches!( + dispatch_review_action(&mut dispatch_client, &preview).unwrap(), + ReviewReceiptResult::Refused(_) + )); + assert!(matches!( + dispatch_client.transport().requests()[0].request(), + PlatformV2Request::ExecuteReviewAction(request) + if request.confirmation_digest() == Some(&confirmation) + && request.expected_workspace_revision() == Some(workspace_revision) + && request.receipt_correlation_digest() == Some(&correlation) + )); + + let lookup_transport = DeterministicPlatformV2Transport::new([ + DeterministicPlatformV2Step::Negotiation(PlatformNegotiationResponse::Negotiated( + negotiated(), + )), + DeterministicPlatformV2Step::V2(Box::new(PlatformV2Response::Refused(refusal()))), + ]); + let mut lookup_client = PlatformV2Client::new_testing(lookup_transport); + assert!(matches!( + lookup_review_action(&mut lookup_client, &preview).unwrap(), + ReviewReceiptResult::Refused(_) + )); + assert!(matches!( + lookup_client.transport().requests()[0].request(), + PlatformV2Request::GetReviewReceipt(lookup) + if lookup.receipt_correlation_digest() == Some(&correlation) + && lookup.idempotency_key() == preview.idempotency_key() + )); + } + #[test] fn endpoint_is_canonical_and_https_only() { assert_eq!( diff --git a/crates/shelldeck-core/src/config/platform_attention.rs b/crates/shelldeck-core/src/config/platform_attention.rs new file mode 100644 index 00000000..fb7a3f5d --- /dev/null +++ b/crates/shelldeck-core/src/config/platform_attention.rs @@ -0,0 +1,3183 @@ +//! Source-atomic native consumption of Platform v2 attention. +//! +//! It retains the complete authoritative source, project, user-workspace, item, +//! revision, and authority-qualified Platform session coordinates. Native +//! activation may resolve those coordinates through current client-local +//! catalogues, but never manufactures a pane, tab, terminal, or path. + +use std::collections::{BTreeMap, BTreeSet}; +use std::path::{Path, PathBuf}; + +use automonique_platform_client::platform_v2_client::AttentionReadResult; +use automonique_protocol::platform_v2::{ + ProjectId, UserWorkspaceId, WorkContextIdentity, WorkContextKind, WorkContextRecord, + WorkContextRelationKind, +}; +pub use automonique_protocol::platform_v2_attention::{ + AttentionItem, AttentionItemId, AttentionItemReason, AttentionItemState, AttentionSource, + AttentionSourceId, AttentionSourceKind, AttentionSourceSnapshot, MAX_ATTENTION_ITEMS, +}; +use automonique_protocol::platform_v2_transport::PlatformV2Refusal; +use automonique_protocol::primitives::Revision; +use serde::{Deserialize, Serialize}; +use thiserror::Error; +use uuid::Uuid; + +use super::app_config::AppConfig; +use super::platform::{ + FreshnessState, ResourceAuthority, ResourceCoordinate, ResourceKind, SessionRecord, +}; +use super::workspace_catalog::PlatformV2Mapping; +use super::workspace_catalog::{CatalogWorkspaceId, ProjectCatalog}; +use crate::workspace_navigation::{ + PaneNode, WorkspaceFocus, WorkspaceNavigationState, WorkspaceTabContent, +}; +use crate::workspace_review::storage::{ + bounded_descriptor_read, ensure_private_directory_io, lock_path, open_lock_file, + secure_atomic_write, +}; + +/// Same per-workspace ceiling as the authoritative hosted cockpit. +pub const MAX_ATTENTION_SOURCES_PER_WORKSPACE: usize = 64; +/// A client inventory is accepted only as one bounded, duplicate-free value. +pub const MAX_ATTENTION_INVENTORY_RECORDS: usize = 512; +/// Maximum locally retained read/notification tuples. +pub const MAX_ATTENTION_LOCAL_ENTRIES: usize = 4_096; +const MAX_ATTENTION_LOCAL_FILE_BYTES: u64 = 1024 * 1024; +const ATTENTION_LOCAL_SCHEMA: u16 = 2; +static ATTENTION_LOCAL_STORE_LOCK: parking_lot::Mutex<()> = parking_lot::Mutex::new(()); + +/// Namespace reserved for ShellDeck's presentation-only attention UUIDs. +/// +/// The UUID is never sent to Automonique and is never an authority coordinate. +/// Its name bytes are the length-delimited raw `(source kind, source id, item +/// id)` tuple, so delimiter-bearing opaque identifiers cannot alias each other. +const ATTENTION_UI_NAMESPACE: Uuid = Uuid::from_bytes([ + 0xa6, 0x61, 0x14, 0x3f, 0x23, 0x88, 0x5a, 0x25, 0xa4, 0xca, 0x53, 0x42, 0x6e, 0x92, 0x7d, 0x31, +]); + +#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct PlatformAttentionTarget { + pub project: ProjectId, + pub user_workspace: UserWorkspaceId, +} + +impl PlatformAttentionTarget { + /// Admit attention only from a completely reconciled catalog mapping. + pub fn from_exact_mapping(mapping: &PlatformV2Mapping) -> Result { + if !mapping.is_exact() { + return Err(AttentionError::MappingNotExact); + } + Ok(Self { + project: ProjectId::new(mapping.project.id.clone()) + .map_err(|_| AttentionError::MappingInvalid)?, + user_workspace: UserWorkspaceId::new(mapping.user_workspace.id.clone()) + .map_err(|_| AttentionError::MappingInvalid)?, + }) + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ReviewAttentionPresence { + /// A typed `GetReview` returned the exact requested review. + Present, + /// A typed `GetReview` returned the exact not-found refusal. + Absent, +} + +/// Complete bounded source inventory for one exact project/user-workspace. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AttentionSourceInventory { + target: PlatformAttentionTarget, + sources: Vec, + /// Exact Platform session coordinate authoritatively related to each + /// provider WorkContext source. This binding must survive inventory + /// projection so an item cannot redirect its source to another otherwise + /// valid current session. + provider_sessions: BTreeMap, +} + +impl AttentionSourceInventory { + pub fn from_authoritative_records( + target: PlatformAttentionTarget, + records: &[WorkContextRecord], + review: ReviewAttentionPresence, + ) -> Result { + if records.len() > MAX_ATTENTION_INVENTORY_RECORDS { + return Err(AttentionError::InventoryTooLarge); + } + let mut identities = BTreeSet::new(); + if records + .iter() + .any(|record| !identities.insert(record.identity().clone())) + { + return Err(AttentionError::InventoryDuplicate); + } + + let workspace_identity = WorkContextIdentity::UserWorkspace(target.user_workspace.clone()); + let workspace = records + .iter() + .filter(|record| record.identity() == &workspace_identity) + .collect::>(); + if workspace.len() != 1 { + return Err(AttentionError::WorkspaceMissingOrAmbiguous); + } + let project_identity = WorkContextIdentity::Project(target.project.clone()); + if relation(workspace[0], WorkContextRelationKind::UserWorkspaceProject) + != Some(&project_identity) + { + return Err(AttentionError::WorkspaceProjectMismatch); + } + + let workspace_source = AttentionSourceId::new(target.user_workspace.as_str().to_owned()) + .map_err(|_| AttentionError::SourceInvalid)?; + let mut sources = BTreeSet::new(); + let mut provider_sessions = BTreeMap::new(); + if review == ReviewAttentionPresence::Present { + sources.insert(AttentionSource::new( + AttentionSourceKind::Review, + workspace_source.clone(), + )); + } + sources.insert(AttentionSource::new( + AttentionSourceKind::Orchestration, + workspace_source, + )); + + let attempts = records + .iter() + .filter(|record| record.kind() == WorkContextKind::AttemptWorkspace) + .filter(|record| { + relation(record, WorkContextRelationKind::AttemptUserWorkspace) + == Some(&workspace_identity) + }) + .map(|record| record.identity().clone()) + .collect::>(); + for session in records + .iter() + .filter(|record| record.kind() == WorkContextKind::Session) + { + if relation(session, WorkContextRelationKind::SessionAttemptWorkspace) + .is_some_and(|attempt| attempts.contains(attempt)) + { + let id = AttentionSourceId::new(session.identity().id().to_owned()) + .map_err(|_| AttentionError::SourceInvalid)?; + let source = AttentionSource::new(AttentionSourceKind::ProviderSession, id); + let coordinate = + match relation(session, WorkContextRelationKind::SessionPlatformSession) { + Some(WorkContextIdentity::PlatformSession(session)) + if session.coordinate().authority == ResourceAuthority::Automonique + && session.coordinate().kind == ResourceKind::Session => + { + session.coordinate().clone() + } + _ => return Err(AttentionError::ProviderSessionRelationInvalid), + }; + if !sources.insert(source.clone()) + || provider_sessions.insert(source, coordinate).is_some() + { + return Err(AttentionError::SourceDuplicate); + } + } + } + if sources.len() > MAX_ATTENTION_SOURCES_PER_WORKSPACE { + return Err(AttentionError::SourceInventoryTooLarge); + } + Ok(Self { + target, + sources: sources.into_iter().collect(), + provider_sessions, + }) + } + + #[must_use] + pub const fn target(&self) -> &PlatformAttentionTarget { + &self.target + } + + #[must_use] + pub fn sources(&self) -> &[AttentionSource] { + &self.sources + } + + #[must_use] + pub fn contains(&self, source: &AttentionSource) -> bool { + self.sources.binary_search(source).is_ok() + } + + #[must_use] + pub fn provider_session(&self, source: &AttentionSource) -> Option<&ResourceCoordinate> { + self.provider_sessions.get(source) + } +} + +fn relation( + record: &WorkContextRecord, + kind: WorkContextRelationKind, +) -> Option<&WorkContextIdentity> { + record + .relations() + .iter() + .find(|relation| relation.kind() == kind) + .map(|relation| relation.target()) +} + +/// Full raw authority key. The item id has no meaning without its source. +#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct AttentionItemKey { + source: AttentionSource, + item: AttentionItemId, +} + +impl AttentionItemKey { + #[must_use] + pub const fn new(source: AttentionSource, item: AttentionItemId) -> Self { + Self { source, item } + } + + #[must_use] + pub const fn source(&self) -> &AttentionSource { + &self.source + } + + #[must_use] + pub const fn item(&self) -> &AttentionItemId { + &self.item + } +} + +/// Presentation-only stable identifier derived from [`AttentionItemKey`]. +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct AttentionUiItemId(Uuid); + +impl AttentionUiItemId { + #[must_use] + pub fn from_authoritative_key(key: &AttentionItemKey) -> Self { + let mut name = Vec::with_capacity( + key.source.kind().as_str().len() + + key.source.id().as_str().len() + + key.item.as_str().len() + + 12, + ); + append_component(&mut name, key.source.kind().as_str()); + append_component(&mut name, key.source.id().as_str()); + append_component(&mut name, key.item.as_str()); + Self(Uuid::new_v5(&ATTENTION_UI_NAMESPACE, &name)) + } + + #[must_use] + pub const fn uuid(self) -> Uuid { + self.0 + } +} + +fn append_component(output: &mut Vec, value: &str) { + let length = u32::try_from(value.len()).expect("protocol field bound fits u32"); + output.extend_from_slice(&length.to_be_bytes()); + output.extend_from_slice(value.as_bytes()); +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AuthoritativeAttentionItem { + key: AttentionItemKey, + ui_id: AttentionUiItemId, + value: AttentionItem, +} + +impl AuthoritativeAttentionItem { + #[must_use] + pub const fn key(&self) -> &AttentionItemKey { + &self.key + } + + #[must_use] + pub const fn ui_id(&self) -> AttentionUiItemId { + self.ui_id + } + + #[must_use] + pub const fn value(&self) -> &AttentionItem { + &self.value + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum AttentionUnavailableReason { + NotObserved, + Transport, + Protocol, + InventoryIncomplete, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum AttentionSourceStatus { + Available, + Refused { category: String }, + Unavailable(AttentionUnavailableReason), +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct AttentionSourceProjection { + snapshot: AttentionSourceSnapshot, + items: BTreeMap, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct AttentionSourceSlot { + status: AttentionSourceStatus, + /// Retained while unavailable/refused so a later read must still be the + /// exact successor rather than silently resetting the revision chain. + projection: Option, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum AttentionApplyOutcome { + Inserted, + Replaced, + ExactReplay, + AvailabilityRestored, + Refused, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum AttentionReplacementMode { + Continuous, + AuthenticatedCompleteBaseline, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PlatformAttentionBoard { + inventory: AttentionSourceInventory, + slots: BTreeMap, + ui_index: BTreeMap, +} + +/// Stable same-process activation request. It deliberately carries the +/// authoritative presentation identity and revision, not a previously +/// resolved pane/session. Every click is resolved again against the current +/// authorized catalogues. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct PlatformAttentionActivation { + pub workspace: CatalogWorkspaceId, + pub item: AttentionUiItemId, + pub item_revision: Revision, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum PlatformAttentionDestination { + WorkspaceAttention { + workspace: CatalogWorkspaceId, + }, + FleetSession { + workspace: CatalogWorkspaceId, + session: ResourceCoordinate, + }, + RetainedProviderPane { + workspace: CatalogWorkspaceId, + session: ResourceCoordinate, + focus: WorkspaceFocus, + }, +} + +/// Resolve an activation through the complete current catalogue. +/// +/// Provider coordinates must be exact authorized Automonique sessions. A +/// retained provider pane, when present, must also be unique. Review and +/// orchestration items intentionally resolve only to the mapped workspace's +/// attention surface and cannot acquire provider authority. +pub fn resolve_platform_attention_activation( + activation: PlatformAttentionActivation, + board: &PlatformAttentionBoard, + catalog: &ProjectCatalog, + navigation: &WorkspaceNavigationState, + sessions: &[SessionRecord], +) -> Result { + let item = board + .item_by_ui_id(activation.item) + .ok_or(AttentionActivationError::ItemMissing)?; + if item.value().revision() != activation.item_revision { + return Err(AttentionActivationError::ItemStale); + } + + let matches = catalog + .workspaces() + .filter(|workspace| { + workspace.platform_mapping().is_some_and(|mapping| { + PlatformAttentionTarget::from_exact_mapping(mapping) + .is_ok_and(|target| &target == board.inventory().target()) + }) + }) + .map(|workspace| workspace.id()) + .collect::>(); + if matches.as_slice() != [activation.workspace] { + return Err(AttentionActivationError::WorkspaceMissingOrAmbiguous); + } + let retained = navigation + .workspace(activation.workspace) + .ok_or(AttentionActivationError::WorkspaceMissingOrAmbiguous)?; + + let source_kind = item.key().source().kind(); + if source_kind != AttentionSourceKind::ProviderSession { + if item.value().platform_session().is_some() { + return Err(AttentionActivationError::CoordinateInvalid); + } + return Ok(PlatformAttentionDestination::WorkspaceAttention { + workspace: activation.workspace, + }); + } + + let coordinate = item + .value() + .platform_session() + .map(|session| session.coordinate()) + .ok_or(AttentionActivationError::CoordinateInvalid)?; + if coordinate.authority != ResourceAuthority::Automonique + || coordinate.kind != ResourceKind::Session + || board.inventory().provider_session(item.key().source()) != Some(coordinate) + { + return Err(AttentionActivationError::CoordinateInvalid); + } + let matching_sessions = sessions + .iter() + .filter(|record| &record.session.resource == coordinate) + .collect::>(); + let [session] = matching_sessions.as_slice() else { + return Err(AttentionActivationError::SessionMissingOrAmbiguous); + }; + if session.session.freshness.state != FreshnessState::Fresh { + return Err(AttentionActivationError::SessionNotFresh); + } + + let mapping = catalog + .workspace(activation.workspace) + .ok() + .and_then(|workspace| workspace.platform_mapping()) + .filter(|mapping| mapping.is_exact()) + .ok_or(AttentionActivationError::WorkspaceMissingOrAmbiguous)?; + let mut pane_matches = Vec::new(); + collect_provider_panes( + retained.surface.root.as_ref(), + &mapping.user_workspace.id, + coordinate, + &mut pane_matches, + ); + match pane_matches.as_slice() { + [] => Ok(PlatformAttentionDestination::FleetSession { + workspace: activation.workspace, + session: coordinate.clone(), + }), + [focus] => Ok(PlatformAttentionDestination::RetainedProviderPane { + workspace: activation.workspace, + session: coordinate.clone(), + focus: *focus, + }), + _ => Err(AttentionActivationError::PaneAmbiguous), + } +} + +fn collect_provider_panes( + node: Option<&PaneNode>, + user_workspace: &str, + coordinate: &ResourceCoordinate, + output: &mut Vec, +) { + let Some(node) = node else { return }; + match node { + PaneNode::Leaf(leaf) => { + for tab in &leaf.tabs { + if matches!( + &tab.content, + WorkspaceTabContent::ProviderSession(binding) + if binding.platform_user_workspace_id == user_workspace + && binding.session_id == coordinate.id.as_str() + ) { + output.push(WorkspaceFocus { + pane_id: leaf.id, + tab_id: tab.id, + }); + } + } + } + PaneNode::Split { first, second, .. } => { + collect_provider_panes(Some(first), user_workspace, coordinate, output); + collect_provider_panes(Some(second), user_workspace, coordinate, output); + } + } +} + +impl PlatformAttentionBoard { + #[must_use] + pub fn new(inventory: AttentionSourceInventory) -> Self { + let slots = inventory + .sources() + .iter() + .cloned() + .map(|source| { + ( + source, + AttentionSourceSlot { + status: AttentionSourceStatus::Unavailable( + AttentionUnavailableReason::NotObserved, + ), + projection: None, + }, + ) + }) + .collect(); + Self { + inventory, + slots, + ui_index: BTreeMap::new(), + } + } + + #[must_use] + pub const fn inventory(&self) -> &AttentionSourceInventory { + &self.inventory + } + + /// Replace the complete source inventory. Removed sources and all their UI + /// projections disappear together; retained sources keep revision custody. + pub fn replace_inventory( + &mut self, + inventory: AttentionSourceInventory, + ) -> Result<(), AttentionError> { + if inventory.target() != self.inventory.target() { + return Err(AttentionError::TargetMismatch); + } + let mut slots = BTreeMap::new(); + for source in inventory.sources() { + slots.insert( + source.clone(), + self.slots + .get(source) + .cloned() + .unwrap_or(AttentionSourceSlot { + status: AttentionSourceStatus::Unavailable( + AttentionUnavailableReason::NotObserved, + ), + projection: None, + }), + ); + } + let ui_index = build_ui_index(&slots)?; + self.inventory = inventory; + self.slots = slots; + self.ui_index = ui_index; + Ok(()) + } + + pub fn apply_read( + &mut self, + requested_source: &AttentionSource, + result: AttentionReadResult, + ) -> Result { + match result { + AttentionReadResult::Snapshot(snapshot) => { + if snapshot.source() != requested_source { + return Err(AttentionError::SourceMismatch); + } + self.replace_source(*snapshot) + } + AttentionReadResult::Refused(refusal) => { + self.mark_refused(requested_source, &refusal)?; + Ok(AttentionApplyOutcome::Refused) + } + } + } + + /// Install a complete snapshot returned directly by an authenticated + /// Platform attention read as a fresh baseline or an explicit gap resync. + /// + /// Unlike [`Self::apply_read`], this operation may begin above revision one + /// and may bridge a missed predecessor. It still rejects rollback, + /// conflicting replay, regressing observation/item revisions, wrong + /// source/target, and presentation identity collisions. Callers must not + /// pass cached, synthesized, or locally assembled snapshots here. + pub fn apply_authenticated_baseline_read( + &mut self, + requested_source: &AttentionSource, + result: AttentionReadResult, + ) -> Result { + match result { + AttentionReadResult::Snapshot(snapshot) => { + if snapshot.source() != requested_source { + return Err(AttentionError::SourceMismatch); + } + self.replace_source_with_mode( + *snapshot, + AttentionUiItemId::from_authoritative_key, + AttentionReplacementMode::AuthenticatedCompleteBaseline, + ) + } + AttentionReadResult::Refused(refusal) => { + self.mark_refused(requested_source, &refusal)?; + Ok(AttentionApplyOutcome::Refused) + } + } + } + + pub fn replace_source( + &mut self, + snapshot: AttentionSourceSnapshot, + ) -> Result { + self.replace_source_with_mode( + snapshot, + AttentionUiItemId::from_authoritative_key, + AttentionReplacementMode::Continuous, + ) + } + + #[cfg(test)] + fn replace_source_with( + &mut self, + snapshot: AttentionSourceSnapshot, + projector: impl Fn(&AttentionItemKey) -> AttentionUiItemId, + ) -> Result { + self.replace_source_with_mode(snapshot, projector, AttentionReplacementMode::Continuous) + } + + fn replace_source_with_mode( + &mut self, + snapshot: AttentionSourceSnapshot, + projector: impl Fn(&AttentionItemKey) -> AttentionUiItemId, + mode: AttentionReplacementMode, + ) -> Result { + let source = snapshot.source().clone(); + let current_slot = self + .slots + .get(&source) + .ok_or(AttentionError::SourceNotInventoried)?; + if snapshot.project() != &self.inventory.target.project + || snapshot.user_workspace() != &self.inventory.target.user_workspace + { + return Err(AttentionError::TargetMismatch); + } + + let previous_status = current_slot.status.clone(); + if let Some(current) = ¤t_slot.projection { + if snapshot.revision() == current.snapshot.revision() { + if snapshot != current.snapshot { + return Err(AttentionError::ConflictingReplay); + } + if previous_status == AttentionSourceStatus::Available { + return Ok(AttentionApplyOutcome::ExactReplay); + } + let mut slots = self.slots.clone(); + slots + .get_mut(&source) + .expect("source admission checked above") + .status = AttentionSourceStatus::Available; + let ui_index = build_ui_index(&slots)?; + self.slots = slots; + self.ui_index = ui_index; + return Ok(AttentionApplyOutcome::AvailabilityRestored); + } + match mode { + AttentionReplacementMode::Continuous => current + .snapshot + .validate_successor(&snapshot) + .map_err(|_| AttentionError::InvalidSuccessor)?, + AttentionReplacementMode::AuthenticatedCompleteBaseline => { + validate_baseline_advance(¤t.snapshot, &snapshot)?; + } + } + } else if mode == AttentionReplacementMode::Continuous + && snapshot.revision() != Revision::FIRST + { + return Err(AttentionError::InitialRevisionRequired); + } + + let mut projected = BTreeMap::new(); + for item in snapshot.items() { + let key = AttentionItemKey::new(source.clone(), item.id().clone()); + let ui_id = projector(&key); + projected.insert( + key.clone(), + AuthoritativeAttentionItem { + key, + ui_id, + value: item.clone(), + }, + ); + } + + // Build the entire candidate board and collision index before making + // any visible mutation. One malformed/colliding item changes nothing. + let mut slots = self.slots.clone(); + let was_present = current_slot.projection.is_some(); + slots.insert( + source, + AttentionSourceSlot { + status: AttentionSourceStatus::Available, + projection: Some(AttentionSourceProjection { + snapshot, + items: projected, + }), + }, + ); + let ui_index = build_ui_index(&slots)?; + self.slots = slots; + self.ui_index = ui_index; + Ok(if was_present { + AttentionApplyOutcome::Replaced + } else { + AttentionApplyOutcome::Inserted + }) + } + + /// A refusal hides the old projection but retains its revision chain. + pub fn mark_refused( + &mut self, + source: &AttentionSource, + refusal: &PlatformV2Refusal, + ) -> Result<(), AttentionError> { + let slot = self + .slots + .get_mut(source) + .ok_or(AttentionError::SourceNotInventoried)?; + slot.status = AttentionSourceStatus::Refused { + category: refusal.category().as_str().to_owned(), + }; + self.rebuild_visible_index()?; + Ok(()) + } + + /// A local transport/protocol failure is not an authoritative empty source. + pub fn mark_unavailable( + &mut self, + source: &AttentionSource, + reason: AttentionUnavailableReason, + ) -> Result<(), AttentionError> { + let slot = self + .slots + .get_mut(source) + .ok_or(AttentionError::SourceNotInventoried)?; + slot.status = AttentionSourceStatus::Unavailable(reason); + self.rebuild_visible_index()?; + Ok(()) + } + + fn rebuild_visible_index(&mut self) -> Result<(), AttentionError> { + let index = build_ui_index(&self.slots)?; + self.ui_index = index; + Ok(()) + } + + #[must_use] + pub fn status(&self, source: &AttentionSource) -> Option<&AttentionSourceStatus> { + self.slots.get(source).map(|slot| &slot.status) + } + + #[must_use] + pub fn retained_snapshot(&self, source: &AttentionSource) -> Option<&AttentionSourceSnapshot> { + self.slots + .get(source) + .and_then(|slot| slot.projection.as_ref()) + .map(|projection| &projection.snapshot) + } + + pub fn visible_items(&self) -> impl Iterator { + self.slots.values().flat_map(|slot| { + (slot.status == AttentionSourceStatus::Available) + .then_some(slot.projection.as_ref()) + .flatten() + .into_iter() + .flat_map(|projection| projection.items.values()) + }) + } + + /// Every visible item, newest observation first. + /// + /// The authoritative `observed_at_ms` is the only chronology ShellDeck + /// has: order is never inferred from poll arrival, source order, or when + /// a row reached this process. Equal observations fall back to the item + /// revision and then to the authoritative `(source, item)` key, so the + /// order is total, deterministic, and identical in every process. + #[must_use] + pub fn chronology(&self) -> Vec<&AuthoritativeAttentionItem> { + let mut items = self.visible_items().collect::>(); + items.sort_by(|left, right| { + right + .value() + .observed_at_ms() + .cmp(&left.value().observed_at_ms()) + .then_with(|| right.value().revision().cmp(&left.value().revision())) + .then_with(|| left.key().cmp(right.key())) + }); + items + } + + #[must_use] + pub fn item_by_ui_id(&self, id: AttentionUiItemId) -> Option<&AuthoritativeAttentionItem> { + let key = self.ui_index.get(&id)?; + self.slots + .get(key.source())? + .projection + .as_ref()? + .items + .get(key) + } +} + +fn validate_baseline_advance( + current: &AttentionSourceSnapshot, + next: &AttentionSourceSnapshot, +) -> Result<(), AttentionError> { + if next.source() != current.source() + || next.project() != current.project() + || next.user_workspace() != current.user_workspace() + || next.revision() <= current.revision() + || next + .previous_revision() + .is_none_or(|previous| previous < current.revision()) + || next.observed_at_ms() < current.observed_at_ms() + { + return Err(AttentionError::InvalidBaseline); + } + for next_item in next.items() { + let current_item = current + .items() + .iter() + .find(|item| item.id() == next_item.id()); + if current_item.is_some_and(|current_item| { + next_item.revision() < current_item.revision() + || next_item.observed_at_ms() < current_item.observed_at_ms() + || (next_item.revision() == current_item.revision() && next_item != current_item) + }) { + return Err(AttentionError::InvalidBaseline); + } + } + Ok(()) +} + +fn build_ui_index( + slots: &BTreeMap, +) -> Result, AttentionError> { + let mut index = BTreeMap::new(); + for slot in slots.values() { + if slot.status != AttentionSourceStatus::Available { + continue; + } + if let Some(projection) = &slot.projection { + for item in projection.items.values() { + if index + .insert(item.ui_id(), item.key().clone()) + .is_some_and(|existing| existing != *item.key()) + { + return Err(AttentionError::UiIdentityCollision); + } + } + } + } + Ok(index) +} + +/// Durable local acknowledgement key. Server `unread` remains untouched. +#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct AttentionLocalKey { + target: PlatformAttentionTarget, + item: AttentionItemKey, + item_revision: Revision, +} + +/// Durable fence for local overlay custody belonging to a retired Platform +/// source incarnation. The target is part of the key so a remap can never +/// make an old target's cleanup look like custody for the replacement. +#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct AttentionRetirement { + target: PlatformAttentionTarget, + source: AttentionSource, +} + +impl AttentionRetirement { + #[must_use] + pub const fn new(target: PlatformAttentionTarget, source: AttentionSource) -> Self { + Self { target, source } + } + + #[must_use] + pub const fn target(&self) -> &PlatformAttentionTarget { + &self.target + } + + #[must_use] + pub const fn source(&self) -> &AttentionSource { + &self.source + } +} + +impl AttentionLocalKey { + #[must_use] + pub const fn new( + target: PlatformAttentionTarget, + item: AttentionItemKey, + item_revision: Revision, + ) -> Self { + Self { + target, + item, + item_revision, + } + } + + #[must_use] + pub const fn target(&self) -> &PlatformAttentionTarget { + &self.target + } + + #[must_use] + pub const fn item(&self) -> &AttentionItemKey { + &self.item + } + + #[must_use] + pub const fn item_revision(&self) -> Revision { + self.item_revision + } +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +struct AttentionLocalFlags { + read: bool, + notified: bool, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AttentionLocalState { + capacity: usize, + entries: BTreeMap, + retirements: BTreeSet, +} + +impl AttentionLocalState { + pub fn with_capacity(capacity: usize) -> Result { + if capacity == 0 || capacity > MAX_ATTENTION_LOCAL_ENTRIES { + return Err(AttentionLocalStateError::CapacityInvalid); + } + Ok(Self { + capacity, + entries: BTreeMap::new(), + retirements: BTreeSet::new(), + }) + } + + #[must_use] + pub fn is_read(&self, key: &AttentionLocalKey) -> bool { + self.entries.get(key).is_some_and(|flags| flags.read) + } + + #[must_use] + pub fn is_notified(&self, key: &AttentionLocalKey) -> bool { + self.entries.get(key).is_some_and(|flags| flags.notified) + } + + fn mark_read(&mut self, key: AttentionLocalKey) -> Result { + if self.retirements.iter().any(|retirement| { + retirement.target() == key.target() && retirement.source() == key.item().source() + }) { + return Err(AttentionLocalStateError::RetirementPending); + } + self.update(key, |flags| &mut flags.read) + } + + fn mark_notified(&mut self, key: AttentionLocalKey) -> Result { + if self.retirements.iter().any(|retirement| { + retirement.target() == key.target() && retirement.source() == key.item().source() + }) { + return Err(AttentionLocalStateError::RetirementPending); + } + self.update(key, |flags| &mut flags.notified) + } + + fn update( + &mut self, + key: AttentionLocalKey, + select: impl Fn(&mut AttentionLocalFlags) -> &mut bool, + ) -> Result { + if !self.entries.contains_key(&key) && self.entries.len() == self.capacity { + return Err(AttentionLocalStateError::CapacityExceeded); + } + let value = select(self.entries.entry(key).or_default()); + if *value { + return Ok(false); + } + *value = true; + Ok(true) + } + + fn retain_source_keys( + &mut self, + target: &PlatformAttentionTarget, + source: &AttentionSource, + current: &BTreeSet, + ) -> bool { + let before = self.entries.len(); + self.entries.retain(|key, _| { + key.target() != target || key.item().source() != source || current.contains(key) + }); + before != self.entries.len() + } + + fn begin_retirement( + &mut self, + retirement: AttentionRetirement, + ) -> Result { + if !self.retirements.contains(&retirement) && self.retirements.len() == self.capacity { + return Err(AttentionLocalStateError::CapacityExceeded); + } + Ok(self.retirements.insert(retirement)) + } + + fn finish_retirement(&mut self, retirement: &AttentionRetirement) -> bool { + let removed_entries = + self.retain_source_keys(retirement.target(), retirement.source(), &BTreeSet::new()); + self.retirements.remove(retirement) || removed_entries + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum NotificationReservation { + Ineligible, + AlreadyReserved, + Reserved, +} + +/// Atomic store for local read and at-most-once notification custody. +/// +/// Every mutation first writes a complete candidate document and changes the +/// in-memory value only after atomic replacement succeeds. Load, persistence, +/// and capacity errors are returned to the caller; callers must suppress a +/// notification on error instead of evicting an older tuple and risking spam. +#[derive(Clone, Debug)] +pub struct AttentionLocalStateStore { + path: PathBuf, + state: AttentionLocalState, +} + +impl AttentionLocalStateStore { + pub fn open_default() -> Result { + Self::open( + AppConfig::config_dir() + .join("platform-attention") + .join("state") + .join("local-v1.json"), + MAX_ATTENTION_LOCAL_ENTRIES, + ) + } + + pub fn open(path: PathBuf, capacity: usize) -> Result { + AttentionLocalState::with_capacity(capacity)?; + let _process_guard = ATTENTION_LOCAL_STORE_LOCK.lock(); + prepare_local_storage(&path)?; + let lock = open_lock_file(&lock_path(&path))?; + fs2::FileExt::lock_exclusive(&lock)?; + let state = load_local_state(&path, capacity)?; + Ok(Self { path, state }) + } + + #[must_use] + pub const fn state(&self) -> &AttentionLocalState { + &self.state + } + + pub fn record_read( + &mut self, + key: AttentionLocalKey, + ) -> Result { + self.transact(|state| state.mark_read(key)) + } + + pub fn reserve_notification( + &mut self, + key: AttentionLocalKey, + authoritative_unread: bool, + ) -> Result { + if !authoritative_unread { + return Ok(NotificationReservation::Ineligible); + } + self.transact(|state| { + if state.retirements.iter().any(|retirement| { + retirement.target() == key.target() && retirement.source() == key.item().source() + }) { + return Err(AttentionLocalStateError::RetirementPending); + } + if state.is_notified(&key) { + return Ok(NotificationReservation::AlreadyReserved); + } + state.mark_notified(key)?; + Ok(NotificationReservation::Reserved) + }) + } + + /// Prune superseded/removed revisions only after accepting a complete + /// authoritative source replacement. Refusal/unavailability must not call + /// this method because neither means an empty source. + pub fn reconcile_source( + &mut self, + target: &PlatformAttentionTarget, + source: &AttentionSource, + current: BTreeSet, + ) -> Result { + if current.len() > MAX_ATTENTION_ITEMS + || current + .iter() + .any(|key| key.target() != target || key.item().source() != source) + { + return Err(AttentionLocalStateError::ReconciliationInvalid); + } + self.transact(|state| { + if state + .retirements + .iter() + .any(|retirement| retirement.target() == target && retirement.source() == source) + { + return Err(AttentionLocalStateError::RetirementPending); + } + Ok(state.retain_source_keys(target, source, ¤t)) + }) + } + + pub fn remove_source( + &mut self, + target: &PlatformAttentionTarget, + source: &AttentionSource, + ) -> Result { + self.reconcile_source(target, source, BTreeSet::new()) + } + + /// Persist the retirement fence before the corresponding in-memory board + /// is removed. A crash after this succeeds leaves enough custody for the + /// next process to finish cleanup before admitting a replacement. + pub fn begin_retirement( + &mut self, + retirement: AttentionRetirement, + ) -> Result { + self.transact(|state| state.begin_retirement(retirement)) + } + + /// Atomically remove every overlay tuple for the retired source and its + /// durable fence. Failure leaves both intact and therefore fail-closed. + pub fn finish_retirement( + &mut self, + retirement: &AttentionRetirement, + ) -> Result { + self.transact(|state| Ok(state.finish_retirement(retirement))) + } + + #[must_use] + pub fn pending_retirements(&self) -> &BTreeSet { + &self.state.retirements + } + + fn transact( + &mut self, + update: impl FnOnce(&mut AttentionLocalState) -> Result, + ) -> Result { + let _process_guard = ATTENTION_LOCAL_STORE_LOCK.lock(); + prepare_local_storage(&self.path)?; + let lock = open_lock_file(&lock_path(&self.path))?; + fs2::FileExt::lock_exclusive(&lock)?; + let disk = load_local_state(&self.path, self.state.capacity)?; + let mut candidate = disk.clone(); + let outcome = update(&mut candidate)?; + if candidate != disk { + persist_local_state(&self.path, &candidate)?; + } + self.state = candidate; + Ok(outcome) + } +} + +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct AttentionLocalDocument { + schema: u16, + entries: Vec, + retirements: Vec, +} + +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct AttentionLocalEntry { + project_id: String, + workspace_id: String, + source_kind: String, + source_id: String, + item_id: String, + item_revision: u64, + read: bool, + notified: bool, +} + +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct AttentionLocalRetirement { + project_id: String, + workspace_id: String, + source_kind: String, + source_id: String, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct AttentionLocalDocumentV1 { + schema: u16, + entries: Vec, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +#[allow(dead_code)] // Parsed only to validate and boundedly discard unscoped v1 custody. +struct AttentionLocalEntryV1 { + source_kind: String, + source_id: String, + item_id: String, + item_revision: u64, + read: bool, + notified: bool, +} + +fn load_local_state( + path: &Path, + capacity: usize, +) -> Result { + let mut state = AttentionLocalState::with_capacity(capacity)?; + let Some(bytes) = bounded_descriptor_read(path, MAX_ATTENTION_LOCAL_FILE_BYTES)? else { + return Ok(state); + }; + if bytes.len() as u64 > MAX_ATTENTION_LOCAL_FILE_BYTES { + return Err(AttentionLocalStateError::DocumentInvalid); + } + let schema = serde_json::from_slice::(&bytes)? + .get("schema") + .and_then(serde_json::Value::as_u64) + .ok_or(AttentionLocalStateError::DocumentInvalid)?; + if schema == 1 { + let document: AttentionLocalDocumentV1 = serde_json::from_slice(&bytes)?; + if document.schema != 1 || document.entries.len() > capacity { + return Err(AttentionLocalStateError::DocumentInvalid); + } + // V1 tuples were not target-qualified. They cannot be safely + // attributed after a remap, so migration deliberately discards them + // rather than inheriting read/notification custody. + return Ok(state); + } + if schema != u64::from(ATTENTION_LOCAL_SCHEMA) { + return Err(AttentionLocalStateError::DocumentInvalid); + } + let document: AttentionLocalDocument = serde_json::from_slice(&bytes)?; + let (entries, retirements) = (document.entries, document.retirements); + if entries.len() > capacity || retirements.len() > capacity { + return Err(AttentionLocalStateError::DocumentInvalid); + } + for entry in entries { + if !entry.read && !entry.notified { + return Err(AttentionLocalStateError::DocumentInvalid); + } + let source = AttentionSource::new( + AttentionSourceKind::parse(&entry.source_kind) + .map_err(|_| AttentionLocalStateError::DocumentInvalid)?, + AttentionSourceId::new(entry.source_id) + .map_err(|_| AttentionLocalStateError::DocumentInvalid)?, + ); + let key = AttentionLocalKey::new( + PlatformAttentionTarget { + project: ProjectId::new(entry.project_id) + .map_err(|_| AttentionLocalStateError::DocumentInvalid)?, + user_workspace: UserWorkspaceId::new(entry.workspace_id) + .map_err(|_| AttentionLocalStateError::DocumentInvalid)?, + }, + AttentionItemKey::new( + source, + AttentionItemId::new(entry.item_id) + .map_err(|_| AttentionLocalStateError::DocumentInvalid)?, + ), + Revision::new(entry.item_revision) + .map_err(|_| AttentionLocalStateError::DocumentInvalid)?, + ); + if state + .entries + .insert( + key, + AttentionLocalFlags { + read: entry.read, + notified: entry.notified, + }, + ) + .is_some() + { + return Err(AttentionLocalStateError::DocumentInvalid); + } + } + for retirement in retirements { + let retirement = AttentionRetirement::new( + PlatformAttentionTarget { + project: ProjectId::new(retirement.project_id) + .map_err(|_| AttentionLocalStateError::DocumentInvalid)?, + user_workspace: UserWorkspaceId::new(retirement.workspace_id) + .map_err(|_| AttentionLocalStateError::DocumentInvalid)?, + }, + AttentionSource::new( + AttentionSourceKind::parse(&retirement.source_kind) + .map_err(|_| AttentionLocalStateError::DocumentInvalid)?, + AttentionSourceId::new(retirement.source_id) + .map_err(|_| AttentionLocalStateError::DocumentInvalid)?, + ), + ); + if !state.retirements.insert(retirement) { + return Err(AttentionLocalStateError::DocumentInvalid); + } + } + Ok(state) +} + +fn persist_local_state( + path: &Path, + state: &AttentionLocalState, +) -> Result<(), AttentionLocalStateError> { + let bytes = encode_local_state(state)?; + secure_atomic_write(path, &bytes)?; + Ok(()) +} + +fn encode_local_state(state: &AttentionLocalState) -> Result, AttentionLocalStateError> { + let entries = state + .entries + .iter() + .map(|(key, flags)| AttentionLocalEntry { + project_id: key.target().project.as_str().to_owned(), + workspace_id: key.target().user_workspace.as_str().to_owned(), + source_kind: key.item().source().kind().as_str().to_owned(), + source_id: key.item().source().id().as_str().to_owned(), + item_id: key.item().item().as_str().to_owned(), + item_revision: key.item_revision().get(), + read: flags.read, + notified: flags.notified, + }) + .collect(); + let document = AttentionLocalDocument { + schema: ATTENTION_LOCAL_SCHEMA, + entries, + retirements: state + .retirements + .iter() + .map(|retirement| AttentionLocalRetirement { + project_id: retirement.target().project.as_str().to_owned(), + workspace_id: retirement.target().user_workspace.as_str().to_owned(), + source_kind: retirement.source().kind().as_str().to_owned(), + source_id: retirement.source().id().as_str().to_owned(), + }) + .collect(), + }; + let bytes = serde_json::to_vec_pretty(&document)?; + if bytes.len() as u64 > MAX_ATTENTION_LOCAL_FILE_BYTES { + return Err(AttentionLocalStateError::DocumentInvalid); + } + Ok(bytes) +} + +fn prepare_local_storage(path: &Path) -> Result<(), AttentionLocalStateError> { + let parent = path.parent().ok_or_else(|| { + AttentionLocalStateError::Io(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "attention local-state path has no parent", + )) + })?; + ensure_private_directory_io(parent)?; + Ok(()) +} + +#[derive(Debug, Error)] +pub enum AttentionError { + #[error("platform mapping is not exact")] + MappingNotExact, + #[error("platform mapping contains an invalid protocol identity")] + MappingInvalid, + #[error("attention inventory exceeds its client bound")] + InventoryTooLarge, + #[error("attention inventory repeats a work-context identity")] + InventoryDuplicate, + #[error("attention workspace is missing or ambiguous")] + WorkspaceMissingOrAmbiguous, + #[error("attention workspace does not belong to the exact project")] + WorkspaceProjectMismatch, + #[error("attention source identity is invalid")] + SourceInvalid, + #[error("attention provider source lacks its exact Platform session relation")] + ProviderSessionRelationInvalid, + #[error("attention source is duplicated")] + SourceDuplicate, + #[error("attention source inventory exceeds its per-workspace bound")] + SourceInventoryTooLarge, + #[error("attention source is not in the authoritative inventory")] + SourceNotInventoried, + #[error("attention source does not match the requested source")] + SourceMismatch, + #[error("attention snapshot target does not match the board")] + TargetMismatch, + #[error("attention source must begin at revision one")] + InitialRevisionRequired, + #[error("attention source successor is stale or discontinuous")] + InvalidSuccessor, + #[error("attention complete baseline rolls back retained source or item custody")] + InvalidBaseline, + #[error("same attention revision has different content")] + ConflictingReplay, + #[error("attention UI identity collides with another raw source/item tuple")] + UiIdentityCollision, +} + +#[derive(Debug, Error, Eq, PartialEq)] +pub enum AttentionActivationError { + #[error("attention item is no longer present")] + ItemMissing, + #[error("attention item revision is stale")] + ItemStale, + #[error("attention workspace is missing or ambiguous in the current catalog")] + WorkspaceMissingOrAmbiguous, + #[error("attention provider coordinate is invalid")] + CoordinateInvalid, + #[error("attention provider session is missing or ambiguous")] + SessionMissingOrAmbiguous, + #[error("attention provider session is not fresh")] + SessionNotFresh, + #[error("attention provider session is retained by multiple panes")] + PaneAmbiguous, +} + +#[derive(Debug, Error)] +pub enum AttentionLocalStateError { + #[error("attention local-state capacity is invalid")] + CapacityInvalid, + #[error("attention local-state capacity is exhausted")] + CapacityExceeded, + #[error("attention local-state document is invalid")] + DocumentInvalid, + #[error("attention local-state reconciliation is not source-exact")] + ReconciliationInvalid, + #[error("attention local-state source retirement is still pending")] + RetirementPending, + #[error("attention local-state I/O failed: {0}")] + Io(#[from] std::io::Error), + #[error("attention local-state serialization failed: {0}")] + Serialization(#[from] serde_json::Error), +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::workspace_catalog::{ + CatalogCheckoutId, CatalogProjectId, CheckoutHost, PlatformContextRef, + PlatformMappingReconciliation, ProjectCheckout, ProjectRecord, RepositoryIdentity, + WorkspaceLaunchIntake, WorkspaceLaunchRequest, + }; + use crate::workspace_navigation::{ + PaneId, PaneLeaf, ProviderSessionBinding, WorkspaceCardState, WorkspaceNavigationAction, + WorkspaceSurfaceState, WorkspaceTab, WorkspaceTabContent, WorkspaceTabId, + }; + use automonique_protocol::platform::{ + Freshness, FreshnessState, ResourceAuthority, ResourceCoordinate, ResourceId, ResourceKind, + ResourceRecord, + }; + use automonique_protocol::platform_v2::{ + AttemptWorkspaceId, CheckoutId, V1SessionRef, WorkContextAttributes, WorkContextLabel, + WorkContextLifecycle, WorkContextRelation, WorkSessionId, + }; + use automonique_protocol::primitives::EpochMillis; + use std::sync::atomic::{AtomicU64, Ordering}; + + static TEMP_NONCE: AtomicU64 = AtomicU64::new(0); + + fn target() -> PlatformAttentionTarget { + PlatformAttentionTarget { + project: ProjectId::new("project-1").unwrap(), + user_workspace: UserWorkspaceId::new("workspace-1").unwrap(), + } + } + + fn source(kind: AttentionSourceKind, id: &str) -> AttentionSource { + AttentionSource::new(kind, AttentionSourceId::new(id.to_owned()).unwrap()) + } + + fn item(id: &str, revision: u64, unread: bool) -> AttentionItem { + AttentionItem::new( + AttentionItemId::new(id.to_owned()).unwrap(), + Revision::new(revision).unwrap(), + revision * 100, + AttentionItemState::Blocked, + AttentionItemReason::ExternalBlocker, + unread, + Vec::new(), + None, + ) + .unwrap() + } + + fn provider_item_for(id: &str, revision: u64, session_id: &str) -> AttentionItem { + AttentionItem::new( + AttentionItemId::new(id).unwrap(), + Revision::new(revision).unwrap(), + revision * 100, + AttentionItemState::Done, + AttentionItemReason::Complete, + true, + Vec::new(), + Some( + V1SessionRef::new(ResourceCoordinate::new( + ResourceAuthority::Automonique, + ResourceKind::Session, + ResourceId::new(session_id).unwrap(), + )) + .unwrap(), + ), + ) + .unwrap() + } + + fn provider_item(id: &str, revision: u64) -> AttentionItem { + provider_item_for(id, revision, "platform-session-1") + } + + fn snapshot( + source: AttentionSource, + revision: u64, + previous: Option, + items: Vec, + ) -> AttentionSourceSnapshot { + AttentionSourceSnapshot::new( + source, + target().project, + target().user_workspace, + Revision::new(revision).unwrap(), + previous.map(|value| Revision::new(value).unwrap()), + revision * 100, + items, + ) + .unwrap() + } + + fn relation(kind: WorkContextRelationKind, target: WorkContextIdentity) -> WorkContextRelation { + WorkContextRelation::new(kind, target).unwrap() + } + + fn record( + identity: WorkContextIdentity, + lifecycle: WorkContextLifecycle, + relations: Vec, + ) -> WorkContextRecord { + WorkContextRecord::new( + identity, + Revision::FIRST, + lifecycle, + WorkContextLabel::new("record").unwrap(), + WorkContextAttributes::EMPTY, + relations, + ) + .unwrap() + } + + fn workspace_record(project: &str, workspace: &str) -> WorkContextRecord { + record( + WorkContextIdentity::UserWorkspace(UserWorkspaceId::new(workspace).unwrap()), + WorkContextLifecycle::Active, + vec![ + relation( + WorkContextRelationKind::UserWorkspaceProject, + WorkContextIdentity::Project(ProjectId::new(project).unwrap()), + ), + relation( + WorkContextRelationKind::UserWorkspaceCheckout, + WorkContextIdentity::Checkout(CheckoutId::new("checkout-1").unwrap()), + ), + ], + ) + } + + fn attempt_record(id: &str) -> WorkContextRecord { + record( + WorkContextIdentity::AttemptWorkspace(AttemptWorkspaceId::new(id).unwrap()), + WorkContextLifecycle::Running, + vec![relation( + WorkContextRelationKind::AttemptUserWorkspace, + WorkContextIdentity::UserWorkspace(UserWorkspaceId::new("workspace-1").unwrap()), + )], + ) + } + + fn session_record(id: &str, attempt: &str) -> WorkContextRecord { + let platform_session = V1SessionRef::new(ResourceCoordinate::new( + ResourceAuthority::Automonique, + ResourceKind::Session, + ResourceId::new(format!("platform-{id}")).unwrap(), + )) + .unwrap(); + record( + WorkContextIdentity::Session(WorkSessionId::new(id).unwrap()), + WorkContextLifecycle::Active, + vec![ + relation( + WorkContextRelationKind::SessionAttemptWorkspace, + WorkContextIdentity::AttemptWorkspace( + AttemptWorkspaceId::new(attempt).unwrap(), + ), + ), + relation( + WorkContextRelationKind::SessionPlatformSession, + WorkContextIdentity::PlatformSession(platform_session), + ), + ], + ) + } + + fn inventory(review: ReviewAttentionPresence) -> AttentionSourceInventory { + AttentionSourceInventory::from_authoritative_records( + target(), + &[ + workspace_record("project-1", "workspace-1"), + attempt_record("attempt-1"), + session_record("session-1", "attempt-1"), + ], + review, + ) + .unwrap() + } + + fn local_key(source_id: &str, item_id: &str, revision: u64) -> AttentionLocalKey { + AttentionLocalKey::new( + target(), + AttentionItemKey::new( + source(AttentionSourceKind::Orchestration, source_id), + AttentionItemId::new(item_id.to_owned()).unwrap(), + ), + Revision::new(revision).unwrap(), + ) + } + + fn temp_path(name: &str) -> PathBuf { + let base = std::env::temp_dir().join(format!( + "shelldeck-platform-attention-{}-{}", + std::process::id(), + TEMP_NONCE.fetch_add(1, Ordering::Relaxed) + )); + base.join("private").join("state").join(name) + } + + // SDTEST-1816 — SDUC-493 + #[test] + fn sdtest_1816_inventory_is_exact_bounded_and_authority_derived() { + let source_inventory = inventory(ReviewAttentionPresence::Present); + assert_eq!(source_inventory.sources().len(), 3); + assert!(source_inventory.contains(&source(AttentionSourceKind::Review, "workspace-1"))); + assert!( + source_inventory.contains(&source(AttentionSourceKind::Orchestration, "workspace-1")) + ); + assert!( + source_inventory.contains(&source(AttentionSourceKind::ProviderSession, "session-1")) + ); + + let without_review = inventory(ReviewAttentionPresence::Absent); + assert_eq!(without_review.sources().len(), 2); + assert!(!without_review.contains(&source(AttentionSourceKind::Review, "workspace-1"))); + + let wrong_project = AttentionSourceInventory::from_authoritative_records( + target(), + &[workspace_record("project-elsewhere", "workspace-1")], + ReviewAttentionPresence::Absent, + ); + assert!(matches!( + wrong_project, + Err(AttentionError::WorkspaceProjectMismatch) + )); + + let duplicate = workspace_record("project-1", "workspace-1"); + let ambiguous = AttentionSourceInventory::from_authoritative_records( + target(), + &[duplicate.clone(), duplicate], + ReviewAttentionPresence::Absent, + ); + assert!(matches!(ambiguous, Err(AttentionError::InventoryDuplicate))); + + let mut records = vec![workspace_record("project-1", "workspace-1")]; + for index in 0..63 { + let attempt = format!("attempt-{index}"); + records.push(attempt_record(&attempt)); + records.push(session_record(&format!("session-{index}"), &attempt)); + } + let overflow = AttentionSourceInventory::from_authoritative_records( + target(), + &records, + ReviewAttentionPresence::Present, + ); + assert!(matches!( + overflow, + Err(AttentionError::SourceInventoryTooLarge) + )); + } + + // SDTEST-1817 — SDUC-493 + #[test] + fn sdtest_1817_snapshot_replacement_is_atomic_and_retains_revision_custody() { + let orchestration = source(AttentionSourceKind::Orchestration, "workspace-1"); + let current_after_restart = snapshot( + orchestration.clone(), + 7, + Some(6), + vec![item("restart-baseline", 7, true)], + ); + let mut fresh = PlatformAttentionBoard::new(inventory(ReviewAttentionPresence::Present)); + assert!(matches!( + fresh.apply_read( + &orchestration, + AttentionReadResult::Snapshot(Box::new(current_after_restart.clone())), + ), + Err(AttentionError::InitialRevisionRequired) + )); + assert_eq!( + fresh + .apply_authenticated_baseline_read( + &orchestration, + AttentionReadResult::Snapshot(Box::new(current_after_restart.clone())), + ) + .unwrap(), + AttentionApplyOutcome::Inserted + ); + let mut restarted = + PlatformAttentionBoard::new(inventory(ReviewAttentionPresence::Present)); + restarted + .apply_authenticated_baseline_read( + &orchestration, + AttentionReadResult::Snapshot(Box::new(current_after_restart)), + ) + .unwrap(); + assert_eq!( + restarted + .retained_snapshot(&orchestration) + .unwrap() + .revision(), + Revision::new(7).unwrap() + ); + + let mut board = PlatformAttentionBoard::new(inventory(ReviewAttentionPresence::Present)); + assert_eq!( + board + .replace_source(snapshot( + orchestration.clone(), + 1, + None, + vec![item("old", 1, true)], + )) + .unwrap(), + AttentionApplyOutcome::Inserted + ); + let before = board.clone(); + let discontinuous = snapshot( + orchestration.clone(), + 3, + Some(2), + vec![item("old", 2, true)], + ); + assert!(matches!( + board.replace_source(discontinuous.clone()), + Err(AttentionError::InvalidSuccessor) + )); + assert_eq!(board, before, "rejected replacement changes nothing"); + assert_eq!( + board + .apply_authenticated_baseline_read( + &orchestration, + AttentionReadResult::Snapshot(Box::new(discontinuous)), + ) + .unwrap(), + AttentionApplyOutcome::Replaced + ); + assert_eq!( + board.retained_snapshot(&orchestration).unwrap().revision(), + Revision::new(3).unwrap() + ); + let before_regression = board.clone(); + let regressing_baseline = snapshot( + orchestration.clone(), + 4, + Some(3), + vec![item("old", 1, true)], + ); + assert!(matches!( + board.apply_authenticated_baseline_read( + &orchestration, + AttentionReadResult::Snapshot(Box::new(regressing_baseline)), + ), + Err(AttentionError::InvalidBaseline) + )); + assert_eq!(board, before_regression); + let forked_baseline = snapshot( + orchestration.clone(), + 6, + Some(1), + vec![item("forked", 6, true)], + ); + assert!(matches!( + board.apply_authenticated_baseline_read( + &orchestration, + AttentionReadResult::Snapshot(Box::new(forked_baseline)), + ), + Err(AttentionError::InvalidBaseline) + )); + assert_eq!(board, before_regression); + + let first = board.retained_snapshot(&orchestration).unwrap().clone(); + assert_eq!( + board.replace_source(first).unwrap(), + AttentionApplyOutcome::ExactReplay + ); + board + .mark_unavailable(&orchestration, AttentionUnavailableReason::Transport) + .unwrap(); + assert_eq!(board.visible_items().count(), 0); + assert_eq!( + board.retained_snapshot(&orchestration).unwrap().revision(), + Revision::new(3).unwrap() + ); + + let empty = snapshot(orchestration.clone(), 4, Some(3), Vec::new()); + assert_eq!( + board.replace_source(empty).unwrap(), + AttentionApplyOutcome::Replaced + ); + assert_eq!(board.visible_items().count(), 0); + + let new_incarnation = snapshot( + orchestration.clone(), + 5, + Some(4), + vec![item("new-incarnation", 5, true)], + ); + board.replace_source(new_incarnation).unwrap(); + let visible = board.visible_items().next().unwrap(); + assert_eq!(visible.key().item().as_str(), "new-incarnation"); + + let review = source(AttentionSourceKind::Review, "workspace-1"); + let before_mismatched_read = board.clone(); + assert!(matches!( + board.apply_read( + &review, + AttentionReadResult::Snapshot(Box::new(snapshot( + orchestration.clone(), + 6, + Some(5), + Vec::new(), + ))), + ), + Err(AttentionError::SourceMismatch) + )); + assert_eq!(board, before_mismatched_read); + + let refusal = PlatformV2Refusal::new("not_authorized", "refused").unwrap(); + assert_eq!( + board + .apply_read(&orchestration, AttentionReadResult::Refused(refusal)) + .unwrap(), + AttentionApplyOutcome::Refused + ); + assert_eq!(board.visible_items().count(), 0); + assert_eq!( + board.retained_snapshot(&orchestration).unwrap().revision(), + Revision::new(5).unwrap() + ); + let replay = board.retained_snapshot(&orchestration).unwrap().clone(); + assert_eq!( + board.replace_source(replay).unwrap(), + AttentionApplyOutcome::AvailabilityRestored + ); + + let provider = source(AttentionSourceKind::ProviderSession, "session-1"); + board + .replace_source(snapshot( + provider.clone(), + 1, + None, + vec![provider_item("provider-item", 1)], + )) + .unwrap(); + let provider_ui_id = board + .visible_items() + .find(|item| item.key().source() == &provider) + .unwrap() + .ui_id(); + let new_inventory = AttentionSourceInventory::from_authoritative_records( + target(), + &[workspace_record("project-1", "workspace-1")], + ReviewAttentionPresence::Absent, + ) + .unwrap(); + board.replace_inventory(new_inventory).unwrap(); + assert!(board.retained_snapshot(&provider).is_none()); + assert!(board.item_by_ui_id(provider_ui_id).is_none()); + + board + .replace_inventory(inventory(ReviewAttentionPresence::Present)) + .unwrap(); + assert!(matches!( + board.replace_source(snapshot( + provider.clone(), + 5, + Some(4), + vec![provider_item("provider-current", 5)], + )), + Err(AttentionError::InitialRevisionRequired) + )); + board + .apply_authenticated_baseline_read( + &provider, + AttentionReadResult::Snapshot(Box::new(snapshot( + provider.clone(), + 5, + Some(4), + vec![provider_item("provider-current", 5)], + ))), + ) + .unwrap(); + assert_eq!( + board.retained_snapshot(&provider).unwrap().revision(), + Revision::new(5).unwrap() + ); + } + + // SDTEST-1818 — SDUC-493 + #[test] + fn sdtest_1818_ui_identity_is_source_scoped_and_collisions_fail_atomically() { + let left = AttentionItemKey::new( + source(AttentionSourceKind::Orchestration, "a:b"), + AttentionItemId::new("c").unwrap(), + ); + let right = AttentionItemKey::new( + source(AttentionSourceKind::Orchestration, "a"), + AttentionItemId::new("b:c").unwrap(), + ); + assert_ne!( + AttentionUiItemId::from_authoritative_key(&left), + AttentionUiItemId::from_authoritative_key(&right), + "length prefixes prevent delimiter aliasing" + ); + assert_eq!( + AttentionUiItemId::from_authoritative_key(&left), + AttentionUiItemId::from_authoritative_key(&left), + "the presentation UUID is deterministic" + ); + assert_eq!( + AttentionUiItemId::from_authoritative_key(&left).uuid(), + Uuid::parse_str("4b8762bc-3aa9-5973-815c-d0e44ba41aa9").unwrap(), + "the documented v1 namespace and tuple encoding are release-stable" + ); + + let review = source(AttentionSourceKind::Review, "workspace-1"); + let orchestration = source(AttentionSourceKind::Orchestration, "workspace-1"); + let mut board = PlatformAttentionBoard::new(inventory(ReviewAttentionPresence::Present)); + let forced = AttentionUiItemId(Uuid::nil()); + board + .replace_source_with( + snapshot(review.clone(), 1, None, vec![item("review-item", 1, true)]), + |_| forced, + ) + .unwrap(); + let before = board.clone(); + assert!(matches!( + board.replace_source_with( + snapshot( + orchestration.clone(), + 1, + None, + vec![item("orchestration-item", 1, true)], + ), + |_| forced, + ), + Err(AttentionError::UiIdentityCollision) + )); + assert_eq!(board, before); + assert!(board.retained_snapshot(&orchestration).is_none()); + assert_eq!(board.item_by_ui_id(forced).unwrap().key().source(), &review); + } + + // SDTEST-1819 — SDUC-493 + #[test] + fn sdtest_1819_local_overlay_is_revision_bound_durable_and_fail_closed() { + let path = temp_path("attention.json"); + let first = local_key("workspace-1", "item-1", 1); + let next_revision = local_key("workspace-1", "item-1", 2); + let second = local_key("workspace-1", "item-2", 1); + let mut store = AttentionLocalStateStore::open(path.clone(), 2).unwrap(); + assert_eq!( + store.reserve_notification(first.clone(), false).unwrap(), + NotificationReservation::Ineligible + ); + assert!(!path.exists(), "ineligible state does not create custody"); + assert!(store.record_read(first.clone()).unwrap()); + assert_eq!( + store.reserve_notification(first.clone(), true).unwrap(), + NotificationReservation::Reserved + ); + assert_eq!( + store.reserve_notification(first.clone(), true).unwrap(), + NotificationReservation::AlreadyReserved + ); + + let mut restarted = AttentionLocalStateStore::open(path.clone(), 2).unwrap(); + assert!(restarted.state().is_read(&first)); + assert!(restarted.state().is_notified(&first)); + assert!(!restarted.state().is_read(&next_revision)); + assert!(!restarted.state().is_notified(&next_revision)); + restarted + .reserve_notification(next_revision.clone(), true) + .unwrap(); + let durable_before = std::fs::read(&path).unwrap(); + assert!(matches!( + restarted.record_read(second), + Err(AttentionLocalStateError::CapacityExceeded) + )); + assert_eq!(std::fs::read(&path).unwrap(), durable_before); + + let current = BTreeSet::from([next_revision.clone()]); + restarted + .reconcile_source( + next_revision.target(), + next_revision.item().source(), + current, + ) + .unwrap(); + assert!(!restarted.state().is_read(&first)); + assert!(restarted.state().is_notified(&next_revision)); + let before_wrong_source = std::fs::read(&path).unwrap(); + assert!(matches!( + restarted.reconcile_source( + next_revision.target(), + &source(AttentionSourceKind::Review, "workspace-1"), + BTreeSet::from([next_revision.clone()]), + ), + Err(AttentionLocalStateError::ReconciliationInvalid) + )); + assert_eq!(std::fs::read(&path).unwrap(), before_wrong_source); + + let failing_path = temp_path("will-be-directory"); + let mut failing = AttentionLocalStateStore::open(failing_path.clone(), 2).unwrap(); + std::fs::create_dir(&failing_path).unwrap(); + assert!(matches!( + failing.record_read(first.clone()), + Err(AttentionLocalStateError::Io(_)) + )); + assert!(!failing.state().is_read(&first)); + + let invalid_path = temp_path("invalid.json"); + prepare_local_storage(&invalid_path).unwrap(); + std::fs::write( + &invalid_path, + br#"{"schema":1,"entries":[],"invented":true}"#, + ) + .unwrap(); + assert!(matches!( + AttentionLocalStateStore::open(invalid_path, 2), + Err(AttentionLocalStateError::Serialization(_)) + )); + + let stale_handle_path = temp_path("stale-handles.json"); + let stale_key = local_key("workspace-1", "stale-handle-item", 1); + let mut first_handle = + AttentionLocalStateStore::open(stale_handle_path.clone(), 2).unwrap(); + let mut second_handle = + AttentionLocalStateStore::open(stale_handle_path.clone(), 2).unwrap(); + assert_eq!( + first_handle + .reserve_notification(stale_key.clone(), true) + .unwrap(), + NotificationReservation::Reserved + ); + assert_eq!( + second_handle + .reserve_notification(stale_key.clone(), true) + .unwrap(), + NotificationReservation::AlreadyReserved + ); + assert!(second_handle.state().is_notified(&stale_key)); + + let oversized_path = temp_path("oversized.json"); + prepare_local_storage(&oversized_path).unwrap(); + let oversized_source = source( + AttentionSourceKind::Orchestration, + &"s".repeat(automonique_protocol::platform_v2_attention::MAX_ATTENTION_FIELD_BYTES), + ); + let large_state = |count: usize| { + let mut state = + AttentionLocalState::with_capacity(MAX_ATTENTION_LOCAL_ENTRIES).unwrap(); + for index in 0..count { + let item_id = format!("{index:04}{}", "i".repeat(252)); + state.entries.insert( + AttentionLocalKey::new( + target(), + AttentionItemKey::new( + oversized_source.clone(), + AttentionItemId::new(item_id).unwrap(), + ), + Revision::FIRST, + ), + AttentionLocalFlags { + read: true, + notified: false, + }, + ); + } + state + }; + let mut fits = 0; + let mut exceeds = MAX_ATTENTION_LOCAL_ENTRIES; + assert!(encode_local_state(&large_state(exceeds)).is_err()); + while fits + 1 < exceeds { + let candidate = fits + (exceeds - fits) / 2; + if encode_local_state(&large_state(candidate)).is_ok() { + fits = candidate; + } else { + exceeds = candidate; + } + } + let accepted_large = large_state(fits); + persist_local_state(&oversized_path, &accepted_large).unwrap(); + let durable_large = std::fs::read(&oversized_path).unwrap(); + let mut size_fenced = + AttentionLocalStateStore::open(oversized_path.clone(), MAX_ATTENTION_LOCAL_ENTRIES) + .unwrap(); + let overflow_key = AttentionLocalKey::new( + target(), + AttentionItemKey::new( + oversized_source, + AttentionItemId::new(format!("{fits:04}{}", "i".repeat(252))).unwrap(), + ), + Revision::FIRST, + ); + assert!(matches!( + size_fenced.record_read(overflow_key.clone()), + Err(AttentionLocalStateError::DocumentInvalid) + )); + assert!(!size_fenced.state().is_read(&overflow_key)); + assert_eq!(std::fs::read(&oversized_path).unwrap(), durable_large); + + std::fs::remove_dir_all(path.parent().unwrap()).ok(); + std::fs::remove_dir_all(failing_path.parent().unwrap()).ok(); + } + + // SDTEST-1837 — SDUC-493 + #[test] + fn sdtest_1837_retirement_fence_survives_failed_cleanup_and_restart() { + let path = temp_path("retirement-restart.json"); + let key = local_key("workspace-1", "retired-item", 1); + let retirement = AttentionRetirement::new( + target(), + source(AttentionSourceKind::Orchestration, "workspace-1"), + ); + let mut store = AttentionLocalStateStore::open(path.clone(), 4).unwrap(); + assert!(store.record_read(key.clone()).unwrap()); + assert!(store.begin_retirement(retirement.clone()).unwrap()); + assert!(store.pending_retirements().contains(&retirement)); + assert!(matches!( + store.record_read(key.clone()), + Err(AttentionLocalStateError::RetirementPending) + )); + assert!(matches!( + store.reserve_notification(key.clone(), true), + Err(AttentionLocalStateError::RetirementPending) + )); + + let durable_fenced = std::fs::read(&path).unwrap(); + let displaced = path.with_extension("fenced-backup"); + std::fs::rename(&path, &displaced).unwrap(); + std::fs::create_dir(&path).unwrap(); + assert!(matches!( + store.finish_retirement(&retirement), + Err(AttentionLocalStateError::Io(_)) + )); + assert!(store.state().is_read(&key)); + assert!(store.pending_retirements().contains(&retirement)); + + std::fs::remove_dir(&path).unwrap(); + std::fs::rename(&displaced, &path).unwrap(); + assert_eq!(std::fs::read(&path).unwrap(), durable_fenced); + let mut restarted = AttentionLocalStateStore::open(path.clone(), 4).unwrap(); + assert!(restarted.state().is_read(&key)); + assert!(restarted.pending_retirements().contains(&retirement)); + assert!(restarted.finish_retirement(&retirement).unwrap()); + assert!(!restarted.state().is_read(&key)); + assert!(restarted.pending_retirements().is_empty()); + + let final_restart = AttentionLocalStateStore::open(path.clone(), 4).unwrap(); + assert!(!final_restart.state().is_read(&key)); + assert!(final_restart.pending_retirements().is_empty()); + + let legacy_path = temp_path("unscoped-v1.json"); + prepare_local_storage(&legacy_path).unwrap(); + std::fs::write( + &legacy_path, + br#"{ + "schema": 1, + "entries": [{ + "source_kind": "orchestration", + "source_id": "workspace-1", + "item_id": "retired-item", + "item_revision": 1, + "read": true, + "notified": true + }] + }"#, + ) + .unwrap(); + let mut migrated = AttentionLocalStateStore::open(legacy_path.clone(), 4).unwrap(); + assert!( + !migrated.state().is_read(&key), + "unscoped v1 custody is discarded" + ); + migrated.record_read(key.clone()).unwrap(); + let migrated_document = std::fs::read_to_string(&legacy_path).unwrap(); + assert!(migrated_document.contains("\"schema\": 2")); + assert!(migrated_document.contains("\"project_id\": \"project-1\"")); + assert!(migrated_document.contains("\"workspace_id\": \"workspace-1\"")); + std::fs::remove_dir_all(path.parent().unwrap()).ok(); + std::fs::remove_dir_all(legacy_path.parent().unwrap()).ok(); + } + + // SDTEST-1820 — SDUC-493 + #[test] + fn sdtest_1820_notification_reservation_is_locked_across_processes() { + const CHILD_PATH: &str = "SHELLDECK_ATTENTION_CHILD_PATH"; + const CHILD_READY: &str = "SHELLDECK_ATTENTION_CHILD_READY"; + const CHILD_START: &str = "SHELLDECK_ATTENTION_CHILD_START"; + const CHILD_RESULT: &str = "SHELLDECK_ATTENTION_CHILD_RESULT"; + const TEST_NAME: &str = "config::platform_attention::tests::sdtest_1820_notification_reservation_is_locked_across_processes"; + + fn wait_for(path: &Path) { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + while !path.exists() { + assert!( + std::time::Instant::now() < deadline, + "timed out waiting for {}", + path.display() + ); + std::thread::sleep(std::time::Duration::from_millis(2)); + } + } + + if let Some(path) = std::env::var_os(CHILD_PATH) { + let path = PathBuf::from(path); + let ready = PathBuf::from(std::env::var_os(CHILD_READY).unwrap()); + let start = PathBuf::from(std::env::var_os(CHILD_START).unwrap()); + let result = PathBuf::from(std::env::var_os(CHILD_RESULT).unwrap()); + let mut store = AttentionLocalStateStore::open(path, 2).unwrap(); + std::fs::write(&ready, b"ready").unwrap(); + wait_for(&start); + let outcome = store + .reserve_notification(local_key("workspace-1", "process-race", 1), true) + .unwrap(); + let value = match outcome { + NotificationReservation::Reserved => "reserved", + NotificationReservation::AlreadyReserved => "already", + NotificationReservation::Ineligible => panic!("unread item became ineligible"), + }; + std::fs::write(result, value).unwrap(); + return; + } + + let path = temp_path("process-race.json"); + let base = path + .parent() + .and_then(Path::parent) + .and_then(Path::parent) + .unwrap() + .to_path_buf(); + std::fs::create_dir_all(&base).unwrap(); + let start = base.join("start"); + let executable = std::env::current_exe().unwrap(); + let mut children = Vec::new(); + for index in 0..2 { + let ready = base.join(format!("ready-{index}")); + let result = base.join(format!("result-{index}")); + let child = std::process::Command::new(&executable) + .arg("--exact") + .arg(TEST_NAME) + .arg("--nocapture") + .env(CHILD_PATH, &path) + .env(CHILD_READY, &ready) + .env(CHILD_START, &start) + .env(CHILD_RESULT, &result) + .spawn() + .unwrap(); + children.push((child, ready, result)); + } + for (_, ready, _) in &children { + wait_for(ready); + } + std::fs::write(&start, b"start").unwrap(); + + let mut outcomes = Vec::new(); + for (mut child, _, result) in children { + assert!(child.wait().unwrap().success()); + outcomes.push(std::fs::read_to_string(result).unwrap()); + } + outcomes.sort(); + assert_eq!(outcomes, vec!["already".to_owned(), "reserved".to_owned()]); + + let restarted = AttentionLocalStateStore::open(path, 2).unwrap(); + assert!(restarted + .state() + .is_notified(&local_key("workspace-1", "process-race", 1))); + std::fs::remove_dir_all(base).ok(); + } + + // SDTEST-1821 — SDUC-493 + #[cfg(unix)] + #[test] + fn sdtest_1821_attention_storage_refuses_links_and_reads_one_descriptor() { + use std::os::unix::fs::symlink; + + let linked_path = temp_path("linked.json"); + prepare_local_storage(&linked_path).unwrap(); + let base = linked_path + .parent() + .and_then(Path::parent) + .and_then(Path::parent) + .unwrap() + .to_path_buf(); + let outside = base.join("outside.json"); + std::fs::write(&outside, br#"{"not":"attention custody"}"#).unwrap(); + + symlink(&outside, &linked_path).unwrap(); + assert!(AttentionLocalStateStore::open(linked_path.clone(), 2).is_err()); + std::fs::remove_file(&linked_path).unwrap(); + symlink(base.join("missing.json"), &linked_path).unwrap(); + assert!(AttentionLocalStateStore::open(linked_path.clone(), 2).is_err()); + std::fs::remove_file(&linked_path).unwrap(); + + let lock_attack_path = temp_path("linked-lock.json"); + let lock_attack_base = lock_attack_path + .parent() + .and_then(Path::parent) + .and_then(Path::parent) + .unwrap() + .to_path_buf(); + prepare_local_storage(&lock_attack_path).unwrap(); + symlink(&outside, lock_path(&lock_attack_path)).unwrap(); + assert!(AttentionLocalStateStore::open(lock_attack_path.clone(), 2).is_err()); + std::fs::remove_file(lock_path(&lock_attack_path)).unwrap(); + + let key = local_key("workspace-1", "descriptor", 1); + let mut store = AttentionLocalStateStore::open(linked_path.clone(), 2).unwrap(); + store.record_read(key).unwrap(); + let accepted = std::fs::read(&linked_path).unwrap(); + let moved = base.join("accepted.json"); + let bytes = crate::workspace_review::storage::bounded_descriptor_read_after_open( + &linked_path, + MAX_ATTENTION_LOCAL_FILE_BYTES, + || { + std::fs::rename(&linked_path, &moved).unwrap(); + symlink(&outside, &linked_path).unwrap(); + }, + ) + .unwrap() + .unwrap(); + assert_eq!(bytes, accepted); + std::fs::remove_file(&linked_path).unwrap(); + std::fs::rename(&moved, &linked_path).unwrap(); + + let linked_parent_path = temp_path("parent-link.json"); + let private_root = linked_parent_path + .parent() + .unwrap() + .parent() + .unwrap() + .to_path_buf(); + let parent_base = private_root.parent().unwrap().to_path_buf(); + std::fs::create_dir_all(&parent_base).unwrap(); + let outside_root = parent_base.join("outside-root"); + std::fs::create_dir_all(outside_root.join("state")).unwrap(); + symlink(&outside_root, &private_root).unwrap(); + assert!(AttentionLocalStateStore::open(linked_parent_path, 2).is_err()); + + std::fs::remove_dir_all(base).ok(); + std::fs::remove_dir_all(lock_attack_base).ok(); + std::fs::remove_file(&private_root).ok(); + std::fs::remove_dir_all(&parent_base).ok(); + } + + // Windows release runners exercise the same no-follow path using native + // reparse-point metadata and FILE_FLAG_OPEN_REPARSE_POINT. + #[cfg(windows)] + #[test] + fn sdtest_1821_attention_storage_refuses_windows_reparse_points() { + let linked_path = temp_path("parent-reparse.json"); + let private_root = linked_path + .parent() + .and_then(Path::parent) + .unwrap() + .to_path_buf(); + let base = private_root.parent().unwrap().to_path_buf(); + let outside = base.join("outside-root"); + std::fs::create_dir_all(outside.join("state")).unwrap(); + let status = std::process::Command::new("cmd") + .arg("/C") + .arg("mklink") + .arg("/J") + .arg(&private_root) + .arg(&outside) + .status() + .unwrap(); + assert!(status.success(), "failed to create test junction"); + assert!(AttentionLocalStateStore::open(linked_path, 2).is_err()); + std::fs::remove_dir(private_root).ok(); + std::fs::remove_dir_all(base).ok(); + } + + fn activation_catalog() -> (ProjectCatalog, CatalogWorkspaceId, CatalogCheckoutId) { + let project = CatalogProjectId::from_uuid(Uuid::from_u128(900)); + let checkout = CatalogCheckoutId::from_uuid(Uuid::from_u128(901)); + let workspace = CatalogWorkspaceId::from_uuid(Uuid::from_u128(902)); + let mut project_record = ProjectRecord::new(project, "Attention project"); + project_record.add_checkout(ProjectCheckout::new( + checkout, + "main", + CheckoutHost::Local { + device_label: "local".into(), + root: std::env::temp_dir().join("shelldeck-attention-activation"), + }, + RepositoryIdentity { + slug: "bext/shelldeck".into(), + canonical_url: None, + }, + )); + let mut catalog = ProjectCatalog::default(); + catalog.insert_project(project_record).unwrap(); + catalog + .create_workspace(WorkspaceLaunchRequest { + id: workspace, + project_id: project, + checkout_id: checkout, + name: "Attention".into(), + intake: WorkspaceLaunchIntake::Manual, + }) + .unwrap(); + catalog + .set_platform_mapping( + workspace, + None, + PlatformV2Mapping { + reconciliation_revision: 1, + project: PlatformContextRef { + id: "project-1".into(), + revision: 1, + }, + checkout: PlatformContextRef { + id: "checkout-1".into(), + revision: 1, + }, + user_workspace: PlatformContextRef { + id: "workspace-1".into(), + revision: 1, + }, + reconciliation: PlatformMappingReconciliation::Exact { + reconciled_at_millis: 1, + }, + }, + ) + .unwrap(); + (catalog, workspace, checkout) + } + + fn provider_surface(count: usize) -> WorkspaceSurfaceState { + let tabs = (0..count) + .map(|index| WorkspaceTab { + id: WorkspaceTabId::from_uuid(Uuid::from_u128(920 + index as u128)), + title: "Provider".into(), + content: WorkspaceTabContent::ProviderSession(ProviderSessionBinding { + platform_user_workspace_id: "workspace-1".into(), + session_id: "platform-session-1".into(), + run_id: None, + }), + }) + .collect::>(); + let active_tab = tabs.first().map(|tab| tab.id); + WorkspaceSurfaceState { + root: Some(PaneNode::Leaf(PaneLeaf { + id: PaneId::from_uuid(Uuid::from_u128(919)), + tabs, + active_tab, + })), + focus: active_tab.map(|tab_id| WorkspaceFocus { + pane_id: PaneId::from_uuid(Uuid::from_u128(919)), + tab_id, + }), + } + } + + fn authorized_session_with_freshness(state: FreshnessState) -> SessionRecord { + SessionRecord { + session: ResourceRecord { + resource: ResourceCoordinate::new( + ResourceAuthority::Automonique, + ResourceKind::Session, + ResourceId::new("platform-session-1").unwrap(), + ), + freshness: Freshness { + state, + observed_at: EpochMillis::from_millis(1), + revision: Revision::FIRST, + }, + summary: automonique_protocol::platform::PlatformText::new("session").unwrap(), + }, + run: None, + attachable: true, + controllable: false, + } + } + + fn authorized_session() -> SessionRecord { + authorized_session_with_freshness(FreshnessState::Fresh) + } + + // SDTEST-1829 + #[test] + fn sdtest_1829_activation_re_resolves_exact_current_catalogues_and_refuses_ambiguity() { + let (catalog, workspace, _) = activation_catalog(); + let mut navigation = WorkspaceNavigationState::default(); + navigation + .reduce( + &catalog, + WorkspaceNavigationAction::Retain { + id: workspace, + surface: provider_surface(0), + card: WorkspaceCardState::default(), + }, + ) + .unwrap(); + let provider = source(AttentionSourceKind::ProviderSession, "session-1"); + let mut board = PlatformAttentionBoard::new(inventory(ReviewAttentionPresence::Present)); + board + .apply_authenticated_baseline_read( + &provider, + AttentionReadResult::Snapshot(Box::new(snapshot( + provider.clone(), + 1, + None, + vec![provider_item("provider", 1)], + ))), + ) + .unwrap(); + let visible = board.visible_items().next().unwrap(); + let activation = PlatformAttentionActivation { + workspace, + item: visible.ui_id(), + item_revision: visible.value().revision(), + }; + let sessions = vec![authorized_session()]; + assert!(matches!( + resolve_platform_attention_activation( + activation, + &board, + &catalog, + &navigation, + &sessions, + ), + Ok(PlatformAttentionDestination::FleetSession { .. }) + )); + assert_eq!( + resolve_platform_attention_activation(activation, &board, &catalog, &navigation, &[],), + Err(AttentionActivationError::SessionMissingOrAmbiguous) + ); + let duplicate = vec![authorized_session(), authorized_session()]; + assert_eq!( + resolve_platform_attention_activation( + activation, + &board, + &catalog, + &navigation, + &duplicate, + ), + Err(AttentionActivationError::SessionMissingOrAmbiguous) + ); + for state in [FreshnessState::Stale, FreshnessState::Unknown] { + assert_eq!( + resolve_platform_attention_activation( + activation, + &board, + &catalog, + &navigation, + &[authorized_session_with_freshness(state)], + ), + Err(AttentionActivationError::SessionNotFresh) + ); + } + + let mut redirected = + PlatformAttentionBoard::new(inventory(ReviewAttentionPresence::Present)); + redirected + .apply_authenticated_baseline_read( + &provider, + AttentionReadResult::Snapshot(Box::new(snapshot( + provider.clone(), + 1, + None, + vec![provider_item_for("provider", 1, "platform-session-foreign")], + ))), + ) + .unwrap(); + let redirected_item = redirected.visible_items().next().unwrap(); + let redirected_activation = PlatformAttentionActivation { + workspace, + item: redirected_item.ui_id(), + item_revision: redirected_item.value().revision(), + }; + let mut foreign = authorized_session(); + foreign.session.resource.id = ResourceId::new("platform-session-foreign").unwrap(); + assert_eq!( + resolve_platform_attention_activation( + redirected_activation, + &redirected, + &catalog, + &navigation, + &[foreign], + ), + Err(AttentionActivationError::CoordinateInvalid), + "an item cannot redirect its provider source to another current session" + ); + + navigation + .reduce( + &catalog, + WorkspaceNavigationAction::UpdateSurface { + id: workspace, + surface: provider_surface(1), + }, + ) + .unwrap(); + assert!(matches!( + resolve_platform_attention_activation( + activation, + &board, + &catalog, + &navigation, + &sessions, + ), + Ok(PlatformAttentionDestination::RetainedProviderPane { .. }) + )); + navigation + .reduce( + &catalog, + WorkspaceNavigationAction::UpdateSurface { + id: workspace, + surface: provider_surface(2), + }, + ) + .unwrap(); + assert_eq!( + resolve_platform_attention_activation( + activation, + &board, + &catalog, + &navigation, + &sessions, + ), + Err(AttentionActivationError::PaneAmbiguous) + ); + + board + .apply_read( + &provider, + AttentionReadResult::Snapshot(Box::new(snapshot( + provider.clone(), + 2, + Some(1), + vec![provider_item("provider", 2)], + ))), + ) + .unwrap(); + assert_eq!( + resolve_platform_attention_activation( + activation, + &board, + &catalog, + &navigation, + &sessions, + ), + Err(AttentionActivationError::ItemStale) + ); + } + + // --- Shared cross-client attention succession corpus ------------------- + + const ATTENTION_CORPUS: &[u8] = + include_bytes!("../../tests/fixtures/platform-v2-attention-conformance-v1.json"); + + #[derive(serde::Deserialize)] + struct CorpusFile { + schema: String, + version: String, + target: CorpusTarget, + cases: Vec, + } + + #[derive(serde::Deserialize)] + struct CorpusTarget { + project: String, + user_workspace: String, + } + + #[derive(serde::Deserialize)] + struct CorpusCase { + id: String, + source: CorpusSource, + reads: Vec, + expected: CorpusExpected, + } + + #[derive(serde::Deserialize)] + struct CorpusSource { + kind: String, + id: String, + } + + #[derive(serde::Deserialize)] + #[serde(tag = "kind", rename_all = "snake_case")] + enum CorpusRead { + Snapshot { + mode: String, + outcome: String, + snapshot: CorpusSnapshot, + }, + Refusal { + category: String, + }, + Unavailable { + reason: String, + }, + } + + #[derive(serde::Deserialize)] + struct CorpusSnapshot { + source: CorpusSource, + project: String, + user_workspace: String, + revision: String, + previous_revision: Option, + observed_at_ms: String, + items: Vec, + } + + #[derive(serde::Deserialize)] + struct CorpusItem { + id: String, + revision: String, + observed_at_ms: String, + state: String, + reason: String, + unread: bool, + nested_agent_path: Vec, + platform_session: Option, + } + + #[derive(serde::Deserialize)] + struct CorpusSession { + authority: String, + kind: String, + id: String, + } + + #[derive(serde::Deserialize)] + struct CorpusExpected { + available: bool, + visible_items: Vec, + } + + fn corpus_decimal(value: &str) -> u64 { + assert!( + !value.is_empty() + && (value == "0" + || (!value.starts_with('0') && value.bytes().all(|b| b.is_ascii_digit()))), + "corpus revision is not a canonical decimal: {value}" + ); + value + .parse() + .expect("corpus revision fits the client fence") + } + + fn corpus_target() -> PlatformAttentionTarget { + PlatformAttentionTarget { + project: ProjectId::new("project-conformance").unwrap(), + user_workspace: UserWorkspaceId::new("workspace-conformance").unwrap(), + } + } + + /// The graph the corpus target implies: one workspace in its project, one + /// attempt under it, and one session bound to an exact Platform session. + fn corpus_inventory() -> AttentionSourceInventory { + let workspace = record( + WorkContextIdentity::UserWorkspace( + UserWorkspaceId::new("workspace-conformance").unwrap(), + ), + WorkContextLifecycle::Active, + vec![ + relation( + WorkContextRelationKind::UserWorkspaceProject, + WorkContextIdentity::Project(ProjectId::new("project-conformance").unwrap()), + ), + relation( + WorkContextRelationKind::UserWorkspaceCheckout, + WorkContextIdentity::Checkout(CheckoutId::new("checkout-conformance").unwrap()), + ), + ], + ); + let attempt = record( + WorkContextIdentity::AttemptWorkspace( + AttemptWorkspaceId::new("attempt-conformance").unwrap(), + ), + WorkContextLifecycle::Running, + vec![relation( + WorkContextRelationKind::AttemptUserWorkspace, + WorkContextIdentity::UserWorkspace( + UserWorkspaceId::new("workspace-conformance").unwrap(), + ), + )], + ); + let platform_session = V1SessionRef::new(ResourceCoordinate::new( + ResourceAuthority::Automonique, + ResourceKind::Session, + ResourceId::new("platform-session-conformance").unwrap(), + )) + .unwrap(); + let session = record( + WorkContextIdentity::Session(WorkSessionId::new("session-conformance").unwrap()), + WorkContextLifecycle::Active, + vec![ + relation( + WorkContextRelationKind::SessionAttemptWorkspace, + WorkContextIdentity::AttemptWorkspace( + AttemptWorkspaceId::new("attempt-conformance").unwrap(), + ), + ), + relation( + WorkContextRelationKind::SessionPlatformSession, + WorkContextIdentity::PlatformSession(platform_session), + ), + ], + ); + AttentionSourceInventory::from_authoritative_records( + corpus_target(), + &[workspace, attempt, session], + ReviewAttentionPresence::Present, + ) + .unwrap() + } + + fn corpus_source(value: &CorpusSource) -> AttentionSource { + let kind = match value.kind.as_str() { + "review" => AttentionSourceKind::Review, + "orchestration" => AttentionSourceKind::Orchestration, + "provider_session" => AttentionSourceKind::ProviderSession, + other => panic!("corpus source kind {other} is unknown"), + }; + AttentionSource::new(kind, AttentionSourceId::new(value.id.clone()).unwrap()) + } + + fn corpus_state(value: &str) -> AttentionItemState { + match value { + "needs_you" => AttentionItemState::NeedsYou, + "working" => AttentionItemState::Working, + "blocked" => AttentionItemState::Blocked, + "done" => AttentionItemState::Done, + other => panic!("corpus item state {other} is unknown"), + } + } + + fn corpus_reason(value: &str) -> AttentionItemReason { + match value { + "review_requested" => AttentionItemReason::ReviewRequested, + "comment_reply" => AttentionItemReason::CommentReply, + "approval_required" => AttentionItemReason::ApprovalRequired, + "agent_working" => AttentionItemReason::AgentWorking, + "check_running" => AttentionItemReason::CheckRunning, + "delivery_pending" => AttentionItemReason::DeliveryPending, + "complete" => AttentionItemReason::Complete, + "conflict" => AttentionItemReason::Conflict, + "check_failed" => AttentionItemReason::CheckFailed, + "external_blocker" => AttentionItemReason::ExternalBlocker, + other => panic!("corpus item reason {other} is unknown"), + } + } + + fn corpus_snapshot(value: &CorpusSnapshot) -> AttentionSourceSnapshot { + let items = value + .items + .iter() + .map(|item| { + let session = item.platform_session.as_ref().map(|coordinate| { + assert_eq!(coordinate.authority, "automonique"); + assert_eq!(coordinate.kind, "session"); + V1SessionRef::new(ResourceCoordinate::new( + ResourceAuthority::Automonique, + ResourceKind::Session, + ResourceId::new(coordinate.id.clone()).unwrap(), + )) + .unwrap() + }); + AttentionItem::new( + AttentionItemId::new(item.id.clone()).unwrap(), + Revision::new(corpus_decimal(&item.revision)).unwrap(), + corpus_decimal(&item.observed_at_ms), + corpus_state(&item.state), + corpus_reason(&item.reason), + item.unread, + item.nested_agent_path + .iter() + .map(|agent| { + automonique_protocol::platform_v2_attention::AttentionAgentId::new( + agent.clone(), + ) + .unwrap() + }) + .collect(), + session, + ) + .unwrap() + }) + .collect(); + AttentionSourceSnapshot::new( + corpus_source(&value.source), + ProjectId::new(value.project.clone()).unwrap(), + UserWorkspaceId::new(value.user_workspace.clone()).unwrap(), + Revision::new(corpus_decimal(&value.revision)).unwrap(), + value + .previous_revision + .as_ref() + .map(|previous| Revision::new(corpus_decimal(previous)).unwrap()), + corpus_decimal(&value.observed_at_ms), + items, + ) + .unwrap() + } + + fn corpus_unavailable(reason: &str) -> AttentionUnavailableReason { + match reason { + "transport" => AttentionUnavailableReason::Transport, + "inventory_incomplete" => AttentionUnavailableReason::InventoryIncomplete, + other => panic!("corpus unavailable reason {other} is unknown"), + } + } + + /// Assert this board reached exactly what the corpus records for the read. + fn assert_corpus_outcome( + case: &str, + outcome: &str, + actual: &Result, + ) { + let matched = match outcome { + "inserted" => matches!(actual, Ok(AttentionApplyOutcome::Inserted)), + "replaced" => matches!(actual, Ok(AttentionApplyOutcome::Replaced)), + "exact_replay" => matches!(actual, Ok(AttentionApplyOutcome::ExactReplay)), + "availability_restored" => { + matches!(actual, Ok(AttentionApplyOutcome::AvailabilityRestored)) + } + "initial_revision_required" => { + matches!(actual, Err(AttentionError::InitialRevisionRequired)) + } + "invalid_successor" => matches!(actual, Err(AttentionError::InvalidSuccessor)), + "conflicting_replay" => matches!(actual, Err(AttentionError::ConflictingReplay)), + "baseline_invalid" => matches!(actual, Err(AttentionError::InvalidBaseline)), + other => panic!("corpus outcome {other} is unknown"), + }; + assert!( + matched, + "case {case} expected {outcome}, board reached {actual:?}" + ); + } + + // SDTEST-1856 — SDUC-493 + #[test] + fn sdtest_1856_chronology_follows_authoritative_observation_not_source_order() { + fn observed(id: &str, revision: u64, observed_at_ms: u64) -> AttentionItem { + AttentionItem::new( + AttentionItemId::new(id.to_owned()).unwrap(), + Revision::new(revision).unwrap(), + observed_at_ms, + AttentionItemState::Blocked, + AttentionItemReason::ExternalBlocker, + true, + Vec::new(), + None, + ) + .unwrap() + } + + let review = source(AttentionSourceKind::Review, "workspace-1"); + let orchestration = source(AttentionSourceKind::Orchestration, "workspace-1"); + let mut board = PlatformAttentionBoard::new(inventory(ReviewAttentionPresence::Present)); + board + .replace_source( + AttentionSourceSnapshot::new( + review.clone(), + target().project, + target().user_workspace, + Revision::FIRST, + None, + 900, + vec![observed("review-old", 1, 100)], + ) + .unwrap(), + ) + .unwrap(); + board + .apply_authenticated_baseline_read( + &orchestration, + AttentionReadResult::Snapshot(Box::new( + AttentionSourceSnapshot::new( + orchestration.clone(), + target().project, + target().user_workspace, + Revision::new(3).unwrap(), + Some(Revision::new(2).unwrap()), + 900, + vec![ + observed("orchestration-newest", 3, 800), + observed("orchestration-tie-high", 3, 400), + observed("orchestration-tie-low", 2, 400), + ], + ) + .unwrap(), + )), + ) + .unwrap(); + + // Key order alone would put the review source first: it is the lowest + // source kind. Chronology must contradict that. + assert_eq!( + board + .visible_items() + .map(|item| item.key().item().as_str().to_owned()) + .collect::>(), + vec![ + "review-old", + "orchestration-newest", + "orchestration-tie-high", + "orchestration-tie-low", + ] + ); + assert_eq!( + board + .chronology() + .iter() + .map(|item| item.key().item().as_str().to_owned()) + .collect::>(), + vec![ + "orchestration-newest", + // Equal observation falls back to the higher item revision. + "orchestration-tie-high", + "orchestration-tie-low", + "review-old", + ] + ); + + // A hidden source contributes no chronology, and hiding it never + // reorders what survives. + board + .mark_unavailable(&orchestration, AttentionUnavailableReason::Transport) + .unwrap(); + assert_eq!( + board + .chronology() + .iter() + .map(|item| item.key().item().as_str().to_owned()) + .collect::>(), + vec!["review-old"] + ); + } + + // SDTEST-1842 — SDUC-493 + #[test] + fn sdtest_1842_shared_attention_corpus_replays_to_the_recorded_outcomes() { + let corpus: CorpusFile = serde_json::from_slice(ATTENTION_CORPUS).unwrap(); + assert_eq!(corpus.schema, "automonique.attention-conformance/v1"); + assert_eq!(corpus.version, "1"); + assert_eq!(corpus.target.project, "project-conformance"); + assert_eq!(corpus.target.user_workspace, "workspace-conformance"); + assert!(!corpus.cases.is_empty()); + + for case in &corpus.cases { + let source = corpus_source(&case.source); + let mut board = PlatformAttentionBoard::new(corpus_inventory()); + assert!( + board.inventory().contains(&source), + "case {} names a source the corpus target does not inventory", + case.id + ); + + for read in &case.reads { + match read { + CorpusRead::Snapshot { + mode, + outcome, + snapshot, + } => { + let value = corpus_snapshot(snapshot); + let result = match mode.as_str() { + "continuous" => board.apply_read( + &source, + AttentionReadResult::Snapshot(Box::new(value)), + ), + "baseline" => board.apply_authenticated_baseline_read( + &source, + AttentionReadResult::Snapshot(Box::new(value)), + ), + other => panic!("corpus read mode {other} is unknown"), + }; + assert_corpus_outcome(&case.id, outcome, &result); + } + CorpusRead::Refusal { category } => { + let refusal = PlatformV2Refusal::new(category.clone(), "corpus").unwrap(); + board.mark_refused(&source, &refusal).unwrap(); + } + CorpusRead::Unavailable { reason } => { + board + .mark_unavailable(&source, corpus_unavailable(reason)) + .unwrap(); + } + } + } + + let available = matches!( + board.status(&source), + Some(AttentionSourceStatus::Available) + ); + assert_eq!( + available, case.expected.available, + "case {} reached the wrong availability", + case.id + ); + let visible = board + .visible_items() + .filter(|item| item.key().source() == &source) + .map(|item| item.value().id().as_str().to_owned()) + .collect::>(); + assert_eq!( + visible, case.expected.visible_items, + "case {} rendered the wrong items", + case.id + ); + // Hiding a source must not discard its revision chain: a client + // that forgot where it was could only resynchronize by trusting + // whatever the next read claims. + let ever_accepted = case.reads.iter().any(|read| { + matches!( + read, + CorpusRead::Snapshot { outcome, .. } + if outcome == "inserted" || outcome == "replaced" + ) + }); + assert_eq!( + board.retained_snapshot(&source).is_some(), + ever_accepted, + "case {} disagrees about whether the revision chain survived", + case.id + ); + } + } +} diff --git a/crates/shelldeck-core/src/config/platform_review.rs b/crates/shelldeck-core/src/config/platform_review.rs index 0c058ac7..f8bef462 100644 --- a/crates/shelldeck-core/src/config/platform_review.rs +++ b/crates/shelldeck-core/src/config/platform_review.rs @@ -5,6 +5,7 @@ //! observations. Mutation previews retain their exact snapshot coordinates; //! only the server can resolve them against separately configured authority. +pub use automonique_platform_client::platform_v2_client::ReviewActionConfirmation; use automonique_protocol::platform::IdempotencyKey; use automonique_protocol::platform_v2::{ProjectId, WorkContextIdentity, WorkContextTargetKind}; pub use automonique_protocol::platform_v2_review::{ @@ -16,6 +17,12 @@ pub use automonique_protocol::platform_v2_review::{ ReviewProposalId, ReviewProposalKind, ReviewReceiptOutcome, ReviewReconciliation, ReviewSchemaVersion, ReviewSnapshot, ReviewText, WorktreeFileState, }; +pub use automonique_protocol::platform_v2_transport::{ + ReviewAgentDeliveryCapability, ReviewCapabilities, ReviewCheckRerunCapability, + ReviewConfirmationDigest, ReviewConflictResolutionCapability, ReviewGitStagingCapabilities, + ReviewIndexDigest, ReviewPullRequestCapabilities, ReviewPullRequestMergeCapability, + ReviewReceiptCorrelationDigest, ReviewStagingCapability, +}; use automonique_protocol::primitives::Revision; use std::collections::BTreeSet; use std::sync::atomic::{AtomicU64, Ordering}; @@ -23,6 +30,31 @@ use std::time::{SystemTime, UNIX_EPOCH}; use super::workspace_catalog::PlatformV2Mapping; +mod custody; +mod delivery; +mod notes; +pub use custody::{ + PlatformReviewCustodyStore, ReviewCustodyError, ReviewCustodyPresentation, + ReviewCustodyRecovery, +}; +pub use delivery::{ + advertised_agent_deliveries, review_delivery_control, AdvertisedReviewDelivery, + ReviewAgentDeliveryProjection, ReviewDeliveryWithheld, +}; +pub use notes::{PlatformReviewNote, PlatformReviewNoteStore, ReviewNoteError}; + +mod worktree; +pub use worktree::{ + advertised_conflict_resolutions, advertised_staging_proposals, review_safe_preview, + review_safe_text, review_staging_control, AdvertisedReviewConflictResolution, + AdvertisedReviewStaging, ReviewPreviewWithheld, ReviewSafeHtml, ReviewSafeImage, + ReviewSafePreview, ReviewSafeText, ReviewStagingProposal, ReviewStagingWithheld, + ReviewWorktreeFile, ReviewWorktreeHunk, ReviewWorktreeLane, ReviewWorktreeLaneGroup, + ReviewWorktreeObservation, ReviewWorktreeProjection, MAX_SAFE_PREVIEW_BYTES, + MAX_SAFE_PREVIEW_EDGE, MAX_SAFE_PREVIEW_LINES, MAX_SAFE_PREVIEW_LINE_CHARS, + MAX_SAFE_PREVIEW_PIXELS, SAFE_PREVIEW_BOX_EDGE, +}; + #[derive(Clone, Debug, Eq, PartialEq)] pub struct PlatformReviewTarget { pub project: ProjectId, @@ -95,6 +127,7 @@ pub struct PlatformReviewActionPreview { expected_revision: Revision, action: ReviewAction, idempotency_key: IdempotencyKey, + confirmation: Option, } impl PlatformReviewActionPreview { @@ -122,7 +155,7 @@ impl PlatformReviewActionPreview { body: ReviewText::new(body.trim().to_owned()) .map_err(|_| "review comment is invalid")?, }; - Self::review_action(target, review.revision, action, &nonce) + Self::review_action(target, review.revision, action, &nonce, None) } pub fn approve( @@ -141,76 +174,194 @@ impl PlatformReviewActionPreview { expected_review_revision: review.review.freshness.observed_revision, }, &nonce, + None, ) } + /// Prepare the unconfirmed batch delivery of advertised review comments. + /// + /// Every coordinate that crosses the network is taken verbatim from the + /// capability: the comment ids and their `expected_comment_revision` come + /// from `agent_deliverable_comments`, and the request is pinned to the + /// capability's own `snapshot_revision`. The snapshot is consulted only to + /// prove the two reads agree; it never supplies a value. + /// + /// This is the unconfirmed lane. It deliberately carries no confirmation + /// digest and no receipt correlation — see [`delivery`] for why both would + /// be wrong here, and why minting the second would actively break receipt + /// recovery. The advertisement is the fence, which is why an unadvertised + /// selection refuses here instead of being posted and refused remotely. pub fn batch_send_comments( target: PlatformReviewTarget, review: &PlatformReviewSemantic, + capabilities: &ReviewCapabilities, comment_ids: &[String], ) -> Result { validate_review_target(&target, review)?; - if review.review.freshness.state != ReviewFreshnessState::Fresh || comment_ids.is_empty() { - return Err("review comments are not actionable"); + if comment_ids.is_empty() { + return Err("review comment selection is empty"); } let selected = comment_ids.iter().collect::>(); if selected.len() != comment_ids.len() { return Err("review comment selection is duplicated"); } - let comments = comment_ids + let advertised = advertised_agent_deliveries(Some(capabilities), &target, review); + if advertised.is_empty() { + return Err("review agent delivery is not advertised"); + } + // Walk the advertisement rather than the selection. The server sorts + // the advertised list by comment id and the protocol requires a batch + // to be strictly ordered by that same id, so filtering here yields + // wire order for free; walking the selection would not. + let comments = advertised .iter() - .map(|id| { - let comment = review - .comments - .iter() - .find(|comment| &comment.id == id) - .filter(|comment| review.comment_is_batch_actionable(&comment.id)) - .ok_or("review comment is not actionable")?; + .filter(|entry| selected.contains(&entry.comment_id)) + .map(|entry| { Ok(ReviewCommentTarget::new( - ReviewCommentId::new(comment.id.clone()) + ReviewCommentId::new(entry.comment_id.clone()) .map_err(|_| "review comment identity is invalid")?, - comment.revision, + entry.expected_comment_revision, )) }) .collect::, &'static str>>()?; + if comments.len() != selected.len() { + return Err("review comment is not advertised as deliverable"); + } let nonce = review_nonce(); Self::review_action( target, - review.revision, + capabilities.snapshot_revision(), ReviewAction::BatchSendCommentsToAgent { comments }, &nonce, + None, ) } - pub fn apply_proposal( + /// Prepare one confirmed index-level staging transition. + /// + /// The *kind* is taken from the capability, never from the snapshot's + /// proposal. Both agree on a coherent read — [`advertised_staging_proposals`] + /// checks that — but the value the confirmation digest was minted over is + /// this one, so this is the one that crosses the wire. + /// + /// This is the confirmed lane. `Stage`, `Unstage` and `Commit` now require + /// a server-minted confirmation, and their unconfirmed spelling cannot be + /// encoded at all, so an unadvertised proposal refuses here rather than + /// being posted and refused remotely. A deployment withholding the commit + /// grant advertises no `Commit` entry, so this refuses for it while + /// admitting its `Stage` and `Unstage` siblings — the withholding is the + /// absence, and nothing here can invent the missing entry. + pub fn stage_proposal( target: PlatformReviewTarget, review: &PlatformReviewSemantic, + capabilities: &ReviewCapabilities, proposal_id: &str, ) -> Result { validate_review_target(&target, review)?; - let proposal = review - .proposals + let advertised = advertised_staging_proposals(Some(capabilities), &target, review); + if !advertised .iter() - .find(|proposal| proposal.id == proposal_id) - .filter(|proposal| review.proposal_is_actionable(&proposal.id)) - .ok_or("review proposal is not actionable")?; - let proposal_id = ReviewProposalId::new(proposal.id.clone()) + .any(|entry| entry.proposal_id == proposal_id) + { + return Err("review staging proposal is not advertised"); + } + let capability = capabilities + .staging() + .iter() + .find(|capability| capability.proposal_id().as_str() == proposal_id) + .ok_or("review staging capability is unavailable or stale")?; + let id = ReviewProposalId::new(proposal_id.to_owned()) .map_err(|_| "review proposal identity is invalid")?; - let action = match proposal.kind { - ReviewProposalKind::Stage => ReviewAction::Stage { proposal_id }, - ReviewProposalKind::Unstage => ReviewAction::Unstage { proposal_id }, - ReviewProposalKind::Commit => ReviewAction::Commit { proposal_id }, + let action = match capability.kind() { + ReviewProposalKind::Stage => ReviewAction::Stage { proposal_id: id }, + ReviewProposalKind::Unstage => ReviewAction::Unstage { proposal_id: id }, + ReviewProposalKind::Commit => ReviewAction::Commit { proposal_id: id }, + // Unreachable: `ReviewStagingCapability::new` refuses this kind, + // because collapsing a conflict writes worktree bytes rather than + // index entries and carries its own capability. ReviewProposalKind::ResolveConflict => { return Err("conflict resolution requires an explicit resolution") } }; + let confirmation = ReviewActionConfirmation::new( + capability.confirmation_digest().clone(), + capabilities.workspace_revision(), + capability.receipt_correlation_digest().clone(), + ); + let nonce = review_nonce(); + Self::review_action( + target, + capabilities.snapshot_revision(), + action, + &nonce, + Some(confirmation), + ) + } + + /// Prepare one confirmed collapse of a conflicted file to a recorded side. + /// + /// `resolution` is not a free choice: the server advertises one entry per + /// side git actually recorded, and this refuses any other. That is why the + /// side has to arrive as an argument rather than be defaulted — the + /// confirmation digest commits to it, so a control that guessed would + /// carry a digest for the other blob. + /// + /// No content crosses this wire in either direction. What lands is exactly + /// the blob git is already holding as stage 2 (`keep_current`) or stage 3 + /// (`keep_incoming`) for the one path the action names. + pub fn resolve_conflict( + target: PlatformReviewTarget, + review: &PlatformReviewSemantic, + capabilities: &ReviewCapabilities, + proposal_id: &str, + file_id: &str, + resolution: ConflictResolution, + ) -> Result { + validate_review_target(&target, review)?; + let advertised = advertised_conflict_resolutions(Some(capabilities), &target, review); + if !advertised.iter().any(|entry| { + entry.proposal_id == proposal_id + && entry.file_id == file_id + && entry.resolution == resolution + }) { + return Err("review conflict resolution is not advertised"); + } + let capability = capabilities + .conflict_resolutions() + .iter() + .find(|capability| { + capability.proposal_id().as_str() == proposal_id + && capability.file_id().as_str() == file_id + && capability.resolution() == resolution + }) + .ok_or("review conflict resolution capability is unavailable or stale")?; + let confirmation = ReviewActionConfirmation::new( + capability.confirmation_digest().clone(), + capabilities.workspace_revision(), + capability.receipt_correlation_digest().clone(), + ); let nonce = review_nonce(); - Self::review_action(target, review.revision, action, &nonce) + Self::review_action( + target, + capabilities.snapshot_revision(), + ReviewAction::ResolveConflict { + proposal_id: ReviewProposalId::new(proposal_id.to_owned()) + .map_err(|_| "review proposal identity is invalid")?, + file_id: automonique_protocol::platform_v2_review::ReviewFileId::new( + file_id.to_owned(), + ) + .map_err(|_| "review file identity is invalid")?, + resolution, + }, + &nonce, + Some(confirmation), + ) } pub fn rerun_check( target: PlatformReviewTarget, review: &PlatformReviewSemantic, + capabilities: &ReviewCapabilities, check_id: &str, ) -> Result { validate_review_target(&target, review)?; @@ -220,6 +371,27 @@ impl PlatformReviewActionPreview { .find(|check| check.id == check_id) .filter(|check| review.check_is_rerunnable(&check.id)) .ok_or("review check is not actionable")?; + if capabilities.project() != &target.project + || capabilities.workspace() != &target.workspace + || capabilities.snapshot_revision() != review.revision + { + return Err("review capabilities are stale or belong to another workspace"); + } + let capability = capabilities + .rerunnable_checks() + .iter() + .find(|capability| capability.check_id().as_str() == check.id) + .filter(|capability| { + capability.expected_check_revision() == check.freshness.observed_revision + && capability.authority().kind() == check.authority.kind + && capability.authority().id().as_str() == check.authority.id + }) + .ok_or("review check capability is unavailable or stale")?; + let confirmation = ReviewActionConfirmation::new( + capability.confirmation_digest().clone(), + capabilities.workspace_revision(), + capability.receipt_correlation_digest().clone(), + ); let nonce = review_nonce(); Self::review_action( target, @@ -230,38 +402,67 @@ impl PlatformReviewActionPreview { expected_check_revision: check.freshness.observed_revision, }, &nonce, + Some(confirmation), ) } + /// Prepare the confirmed merge of the advertised pull request. + /// + /// This became a confirmed lane with the same protocol revision that + /// earned the staging capability: `MergePullRequest` is now inside + /// `requires_confirmation`, so its unconfirmed spelling no longer encodes + /// and the previous constructor minted a preview the transport could not + /// have sent. Every coordinate is therefore taken from the capability the + /// server minted, and the snapshot is consulted only to prove the two + /// reads agree. + /// + /// The server ships this slot empty for every workspace today — no + /// provider adapter can preflight a pull-request write — so this refuses + /// in production, which is the honest answer rather than a silent one. pub fn merge_pull_request( target: PlatformReviewTarget, review: &PlatformReviewSemantic, + capabilities: &ReviewCapabilities, ) -> Result { validate_review_target(&target, review)?; - let pull = &review.pull_request; if !review.pull_request_is_mergeable() { return Err("pull request is not mergeable"); } - let id = pull - .id - .as_ref() - .ok_or("pull request identity is unavailable")?; - let head = pull - .head_revision - .as_ref() - .ok_or("pull request head revision is unavailable")?; + if capabilities.project() != &target.project + || capabilities.workspace() != &target.workspace + || capabilities.snapshot_revision() != review.revision + { + return Err("review capabilities are stale or belong to another workspace"); + } + let pull = &review.pull_request; + let capability = capabilities + .merge_pull_request() + .filter(|capability| { + Some(capability.pull_request_id().as_str()) == pull.id.as_deref() + && capability.expected_pull_request_revision() + == pull.freshness.observed_revision + && Some(capability.expected_head_revision().as_str()) + == pull.head_revision.as_deref() + && capability.authority().kind() == pull.authority.kind + && capability.authority().id().as_str() == pull.authority.id + }) + .ok_or("pull request merge capability is unavailable or stale")?; + let confirmation = ReviewActionConfirmation::new( + capability.confirmation_digest().clone(), + capabilities.workspace_revision(), + capability.receipt_correlation_digest().clone(), + ); let nonce = review_nonce(); Self::review_action( target, - review.revision, + capabilities.snapshot_revision(), ReviewAction::MergePullRequest { - pull_request_id: PullRequestId::new(id.clone()) - .map_err(|_| "pull request identity is invalid")?, - expected_pull_request_revision: pull.freshness.observed_revision, - expected_head_revision: ReviewField::new(head.clone()) - .map_err(|_| "pull request head revision is invalid")?, + pull_request_id: capability.pull_request_id().clone(), + expected_pull_request_revision: capability.expected_pull_request_revision(), + expected_head_revision: capability.expected_head_revision().clone(), }, &nonce, + Some(confirmation), ) } @@ -270,10 +471,19 @@ impl PlatformReviewActionPreview { expected_revision: Revision, action: ReviewAction, nonce: &str, + confirmation: Option, ) -> Result { action .validate_client_shape() .map_err(|_| "review action is invalid")?; + // Ask the contract rather than restate it. The confirmed set grew from + // the check rerun alone to the three pull-request writes and the four + // staging writes, and a local `matches!` would have kept accepting the + // unconfirmed spelling of each new member long after the transport + // stopped encoding it. + if action.requires_confirmation() != confirmation.is_some() { + return Err("review action confirmation does not match its action"); + } let idempotency_key = IdempotencyKey::new(format!("shelldeck-review-{nonce}")) .map_err(|_| "review idempotency key is invalid")?; Ok(Self { @@ -281,6 +491,7 @@ impl PlatformReviewActionPreview { expected_revision, action, idempotency_key, + confirmation, }) } @@ -304,6 +515,168 @@ impl PlatformReviewActionPreview { &self.idempotency_key } + #[must_use] + pub const fn confirmation(&self) -> Option<&ReviewActionConfirmation> { + self.confirmation.as_ref() + } + + /// Whether this preview must be revalidated against the latest + /// authoritative capability generation before it crosses the durable + /// dispatch fence. + /// + /// A confirmed rerun must, because its digest names a plan the server + /// preflighted. A batch delivery must for the opposite reason: it carries + /// no digest at all, so the advertisement *is* its fence, and a list that + /// has since been retracted has to refuse locally rather than be posted. + #[must_use] + pub const fn requires_capability_revalidation(&self) -> bool { + self.confirmation.is_some() + || matches!(self.action, ReviewAction::BatchSendCommentsToAgent { .. }) + } + + /// Revalidate a confirmed rerun or an unconfirmed batch delivery against + /// the latest authoritative capability generation before crossing the + /// durable dispatch fence. + #[must_use] + pub fn matches_capabilities(&self, capabilities: &ReviewCapabilities) -> bool { + match (&self.action, &self.confirmation) { + ( + ReviewAction::RerunCheck { + check_id, + expected_check_revision, + }, + Some(confirmation), + ) => { + self.capability_coordinates_match(capabilities) + && capabilities.workspace_revision() + == confirmation.expected_workspace_revision() + && capabilities.rerunnable_checks().iter().any(|capability| { + capability.check_id() == check_id + && capability.expected_check_revision() == *expected_check_revision + && capability.confirmation_digest() + == confirmation.confirmation_digest() + && capability.receipt_correlation_digest() + == confirmation.receipt_correlation_digest() + }) + } + // The unconfirmed delivery lane has no digest to compare, so the + // comparison is the advertisement itself: every comment must still + // be listed, at exactly the revision this preview committed to. + // + // That is what makes a reused list refuse here instead of reaching + // the server. Settling a delivery moves the comment out of + // `not_sent`/`refused` and bumps both the snapshot revision and the + // comment's own revision, so a re-read after settlement cannot + // still advertise it — whatever fresh idempotency key a caller + // attaches. + (ReviewAction::BatchSendCommentsToAgent { comments }, None) => { + self.capability_coordinates_match(capabilities) + && comments.iter().all(|comment| { + capabilities + .agent_deliverable_comments() + .iter() + .any(|capability| { + capability.comment_id() == comment.comment_id() + && capability.expected_comment_revision() + == comment.expected_revision() + }) + }) + } + // The staging lane is fenced by its digest, and the digest is what + // makes a moved worktree refuse *here* rather than at the daemon. + // The server mints it over the commit `HEAD` resolved to, the + // whole index, and every named path's objects and stat identity, + // so any of those moving between two capability reads yields a + // different digest and no match. Comparing the exposed + // `expected_head_revision` and `expected_index_digest` separately + // would restate a subset of the same commitment. + // + // Only a fresh capability document can supply that comparison, + // which is why every staging preview reports + // `requires_capability_revalidation`. + ( + ReviewAction::Stage { proposal_id } + | ReviewAction::Unstage { proposal_id } + | ReviewAction::Commit { proposal_id }, + Some(confirmation), + ) => { + let kind = match &self.action { + ReviewAction::Stage { .. } => ReviewProposalKind::Stage, + ReviewAction::Unstage { .. } => ReviewProposalKind::Unstage, + _ => ReviewProposalKind::Commit, + }; + self.capability_coordinates_match(capabilities) + && capabilities.workspace_revision() + == confirmation.expected_workspace_revision() + && capabilities.staging().iter().any(|capability| { + capability.proposal_id() == proposal_id + && capability.kind() == kind + && capability.confirmation_digest() + == confirmation.confirmation_digest() + && capability.receipt_correlation_digest() + == confirmation.receipt_correlation_digest() + }) + } + // The side is part of the identity, not a parameter of it: two + // entries for one file differ only by `resolution`, and each + // carries the digest minted over the blob that side would write. + ( + ReviewAction::ResolveConflict { + proposal_id, + file_id, + resolution, + }, + Some(confirmation), + ) => { + self.capability_coordinates_match(capabilities) + && capabilities.workspace_revision() + == confirmation.expected_workspace_revision() + && capabilities + .conflict_resolutions() + .iter() + .any(|capability| { + capability.proposal_id() == proposal_id + && capability.file_id() == file_id + && capability.resolution() == *resolution + && capability.confirmation_digest() + == confirmation.confirmation_digest() + && capability.receipt_correlation_digest() + == confirmation.receipt_correlation_digest() + }) + } + ( + ReviewAction::MergePullRequest { + pull_request_id, + expected_pull_request_revision, + expected_head_revision, + }, + Some(confirmation), + ) => { + self.capability_coordinates_match(capabilities) + && capabilities.workspace_revision() + == confirmation.expected_workspace_revision() + && capabilities.merge_pull_request().is_some_and(|capability| { + capability.pull_request_id() == pull_request_id + && capability.expected_pull_request_revision() + == *expected_pull_request_revision + && capability.expected_head_revision() == expected_head_revision + && capability.confirmation_digest() + == confirmation.confirmation_digest() + && capability.receipt_correlation_digest() + == confirmation.receipt_correlation_digest() + }) + } + (_, None) => true, + _ => false, + } + } + + fn capability_coordinates_match(&self, capabilities: &ReviewCapabilities) -> bool { + capabilities.project() == &self.target.project + && capabilities.workspace() == &self.target.workspace + && capabilities.snapshot_revision() == self.expected_revision + } + #[must_use] pub const fn kind(&self) -> &'static str { match self.action { @@ -458,6 +831,28 @@ pub enum PlatformReviewLoad { Unavailable(PlatformReviewUnavailable), } +/// Authenticated server-advertised review mutation capabilities for one exact +/// project/workspace snapshot. An unavailable capability load grants nothing. +/// +/// The available payload is boxed because `ReviewCapabilities` now carries the +/// agent-delivery list and three pull-request slots, which makes it far larger +/// than a refusal's two strings. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum PlatformReviewCapabilitiesLoad { + Available(Box), + Unavailable(PlatformReviewUnavailable), +} + +impl PlatformReviewCapabilitiesLoad { + #[must_use] + pub fn available(&self) -> Option<&ReviewCapabilities> { + match self { + Self::Available(capabilities) => Some(capabilities), + Self::Unavailable(_) => None, + } + } +} + impl PlatformReviewLoad { #[must_use] pub fn needs_user_action(&self) -> bool { @@ -538,6 +933,37 @@ impl PlatformReviewSemantic { }) } + /// Whether one conflicted file inside one proposal may be collapsed to a + /// side git recorded. + /// + /// Deliberately not folded into [`Self::proposal_is_actionable`], which + /// answers a different question: staging moves index entries for a whole + /// proposal and is refused outright while any of its files is unmerged, + /// whereas this is admissible *only* while the one named file is unmerged. + /// The two predicates are near-inverses, so sharing one would make each + /// caller re-derive which half it meant. + /// + /// Which side may be written is never decided here: the server advertises + /// one entry per side it actually recorded, and the client may name no + /// other. + #[must_use] + pub fn conflict_resolution_is_actionable(&self, proposal_id: &str, file_id: &str) -> bool { + self.proposals + .iter() + .find(|proposal| proposal.id == proposal_id) + .is_some_and(|proposal| { + proposal.kind == ReviewProposalKind::ResolveConflict + && proposal + .authority + .as_ref() + .is_some_and(|authority| authority.kind == ReviewAuthorityKind::Git) + && proposal.files.iter().any(|id| id == file_id) + && self.files.iter().any(|file| { + file.id == file_id && file.conflict == ConflictState::Unresolved + }) + }) + } + #[must_use] pub fn check_is_rerunnable(&self, check_id: &str) -> bool { self.checks.iter().any(|check| { @@ -976,6 +1402,221 @@ mod tests { const RENDER_CORPUS: &[u8] = include_bytes!("../../tests/fixtures/platform-v2-render-conformance-v1.json"); + /// Mint the advertisement a coherent server would return for `comment_ids` + /// at this review's exact snapshot revision. + /// + /// Every value comes from the review itself, so the helper cannot advertise + /// a comment revision the snapshot disagrees with — the tests that need + /// that divergence build it explicitly. + fn delivery_capabilities( + target: &PlatformReviewTarget, + review: &PlatformReviewSemantic, + comment_ids: &[&str], + ) -> ReviewCapabilities { + let authority = ReviewAuthority::new( + ReviewAuthorityKind::Review, + automonique_protocol::platform_v2_review::ReviewAuthorityId::new( + review.review.authority.id.clone(), + ) + .unwrap(), + ); + let advertised = comment_ids + .iter() + .map(|id| { + let comment = review.comments.iter().find(|c| c.id == *id).unwrap(); + ReviewAgentDeliveryCapability::new( + ReviewCommentId::new(comment.id.clone()).unwrap(), + comment.revision, + authority.clone(), + ) + .unwrap() + }) + .collect(); + ReviewCapabilities::new( + target.project.clone(), + target.workspace.clone(), + review.revision, + Revision::new(91).unwrap(), + Vec::new(), + advertised, + ReviewPullRequestCapabilities::default(), + ReviewGitStagingCapabilities::default(), + ) + .unwrap() + } + + /// Mirror the server's own eligibility rule for `agent_deliverable_comments`. + /// + /// The daemon advertises a comment only when the caller holds the review + /// authority the snapshot names, the review is fresh, and the comment is + /// still in `not_sent` or `refused`. Reproducing that rule here — rather + /// than hand-writing each expected list — is what lets the delivery tests + /// *show* an advertisement retracting instead of asserting that it does. + fn advertise( + target: &PlatformReviewTarget, + review: &PlatformReviewSemantic, + ) -> ReviewCapabilities { + let eligible = if review.review.authority.kind == ReviewAuthorityKind::Review + && review.review.freshness.state == ReviewFreshnessState::Fresh + { + review + .comments + .iter() + .filter(|comment| { + matches!( + comment.agent_state, + CommentAgentState::NotSent | CommentAgentState::Refused + ) + }) + .map(|comment| comment.id.as_str()) + .collect::>() + } else { + Vec::new() + }; + delivery_capabilities(target, review, &eligible) + } + + /// Mirror the settlement the daemon performs when a delivery completes: + /// the delivered comment leaves the sendable states and bumps its own + /// revision, and the snapshot revision bumps with it. + fn settle(review: &PlatformReviewSemantic, delivered: &[&str]) -> PlatformReviewSemantic { + let mut settled = review.clone(); + settled.revision = Revision::new(review.revision.get() + 1).unwrap(); + for comment in &mut settled.comments { + if delivered.contains(&comment.id.as_str()) { + comment.agent_state = CommentAgentState::Sent; + comment.revision = Revision::new(comment.revision.get() + 1).unwrap(); + } + } + settled + } + + /// The canonical fixture carries one already-sent comment. Batch delivery + /// needs at least two sendable ones, and needs their snapshot order to + /// differ from their sorted order, so the wire ordering requirement is + /// exercised rather than satisfied by accident. + fn deliverable_review() -> PlatformReviewSemantic { + let snapshot = decode_review_snapshot(CANONICAL_FIXTURE).unwrap(); + let mut review = PlatformReviewSemantic::from(&snapshot); + review.comments[0].agent_state = CommentAgentState::NotSent; + let mut second = review.comments[0].clone(); + second.id = "comment-0".to_owned(); + second.revision = Revision::new(review.comments[0].revision.get() + 3).unwrap(); + second.agent_state = CommentAgentState::Refused; + review.comments.push(second); + review + } + + fn rerun_capabilities( + target: &PlatformReviewTarget, + review: &PlatformReviewSemantic, + ) -> ReviewCapabilities { + let check = &review.checks[0]; + ReviewCapabilities::new( + target.project.clone(), + target.workspace.clone(), + review.revision, + Revision::new(91).unwrap(), + vec![ReviewCheckRerunCapability::new( + ReviewCheckId::new(check.id.clone()).unwrap(), + check.freshness.observed_revision, + ReviewAuthority::new( + check.authority.kind, + automonique_protocol::platform_v2_review::ReviewAuthorityId::new( + check.authority.id.clone(), + ) + .unwrap(), + ), + ReviewConfirmationDigest::new("a".repeat(64)).unwrap(), + ReviewReceiptCorrelationDigest::new("b".repeat(64)).unwrap(), + ) + .unwrap()], + Vec::new(), + ReviewPullRequestCapabilities::default(), + ReviewGitStagingCapabilities::default(), + ) + .unwrap() + } + + /// The advertisement a coherent server would mint for this snapshot's + /// first proposal, at the `HEAD` and index its preflight read. + fn staging_capabilities( + target: &PlatformReviewTarget, + review: &PlatformReviewSemantic, + ) -> ReviewCapabilities { + let proposal = &review.proposals[0]; + ReviewCapabilities::new( + target.project.clone(), + target.workspace.clone(), + review.revision, + Revision::new(91).unwrap(), + Vec::new(), + Vec::new(), + ReviewPullRequestCapabilities::default(), + ReviewGitStagingCapabilities { + staging: vec![ReviewStagingCapability::new( + ReviewProposalId::new(proposal.id.clone()).unwrap(), + proposal.kind, + ReviewField::new("4".repeat(40)).unwrap(), + ReviewIndexDigest::new("a".repeat(64)).unwrap(), + ReviewAuthority::new( + ReviewAuthorityKind::Git, + automonique_protocol::platform_v2_review::ReviewAuthorityId::new( + proposal.authority.as_ref().unwrap().id.clone(), + ) + .unwrap(), + ), + ReviewConfirmationDigest::new("c".repeat(64)).unwrap(), + ReviewReceiptCorrelationDigest::new("d".repeat(64)).unwrap(), + ) + .unwrap()], + conflict_resolutions: Vec::new(), + }, + ) + .unwrap() + } + + /// The merge slot the server ships empty in production. It exists here + /// because `MergePullRequest` is a confirmed lane now, so the unconfirmed + /// spelling cannot be constructed at all. + fn merge_capabilities( + target: &PlatformReviewTarget, + review: &PlatformReviewSemantic, + ) -> ReviewCapabilities { + let pull = &review.pull_request; + ReviewCapabilities::new( + target.project.clone(), + target.workspace.clone(), + review.revision, + Revision::new(91).unwrap(), + Vec::new(), + Vec::new(), + ReviewPullRequestCapabilities { + merge: Some( + ReviewPullRequestMergeCapability::new( + PullRequestId::new(pull.id.clone().unwrap()).unwrap(), + pull.freshness.observed_revision, + ReviewField::new(pull.head_revision.clone().unwrap()).unwrap(), + pull.readiness, + ReviewAuthority::new( + pull.authority.kind, + automonique_protocol::platform_v2_review::ReviewAuthorityId::new( + pull.authority.id.clone(), + ) + .unwrap(), + ), + ReviewConfirmationDigest::new("e".repeat(64)).unwrap(), + ReviewReceiptCorrelationDigest::new("f".repeat(64)).unwrap(), + ) + .unwrap(), + ), + ..ReviewPullRequestCapabilities::default() + }, + ReviewGitStagingCapabilities::default(), + ) + .unwrap() + } + #[derive(Deserialize)] struct RenderCorpus { schema: String, @@ -1338,6 +1979,7 @@ mod tests { let batch = PlatformReviewActionPreview::batch_send_comments( target.clone(), &semantic, + &delivery_capabilities(&target, &semantic, &[&semantic.comments[0].id]), &[semantic.comments[0].id.clone()], ) .unwrap(); @@ -1366,9 +2008,10 @@ mod tests { }) ); - let proposal = PlatformReviewActionPreview::apply_proposal( + let proposal = PlatformReviewActionPreview::stage_proposal( target.clone(), &semantic, + &staging_capabilities(&target, &semantic), &semantic.proposals[0].id, ) .unwrap(); @@ -1389,10 +2032,21 @@ mod tests { proposal_id: semantic.proposals[0].id.clone(), }) ); + // Staging is a confirmed lane: the digests are the server's, verbatim. + let staging_confirmation = proposal.confirmation().unwrap(); + assert_eq!( + staging_confirmation.confirmation_digest().as_str(), + "c".repeat(64) + ); + assert_eq!( + staging_confirmation.receipt_correlation_digest().as_str(), + "d".repeat(64) + ); let rerun = PlatformReviewActionPreview::rerun_check( target.clone(), &semantic, + &rerun_capabilities(&target, &semantic), &semantic.checks[0].id, ) .unwrap(); @@ -1419,8 +2073,12 @@ mod tests { }) ); - let merge = - PlatformReviewActionPreview::merge_pull_request(target.clone(), &semantic).unwrap(); + let merge = PlatformReviewActionPreview::merge_pull_request( + target.clone(), + &semantic, + &merge_capabilities(&target, &semantic), + ) + .unwrap(); assert_eq!(merge.target(), &target); assert_eq!(merge.expected_revision(), semantic.revision); assert_eq!( @@ -1456,14 +2114,30 @@ mod tests { }) ); + let merge_confirmation = merge.confirmation().unwrap(); + assert_eq!( + merge_confirmation.confirmation_digest().as_str(), + "e".repeat(64) + ); + assert_eq!( + merge_confirmation.receipt_correlation_digest().as_str(), + "f".repeat(64) + ); + let mut stale = semantic.clone(); stale.pull_request.freshness.state = ReviewFreshnessState::Stale; - assert!(PlatformReviewActionPreview::merge_pull_request(target.clone(), &stale).is_err()); + assert!(PlatformReviewActionPreview::merge_pull_request( + target.clone(), + &stale, + &merge_capabilities(&target, &semantic), + ) + .is_err()); stale = semantic.clone(); stale.checks[0].freshness.state = ReviewFreshnessState::Stale; assert!(PlatformReviewActionPreview::rerun_check( target.clone(), &stale, + &rerun_capabilities(&target, &semantic), &stale.checks[0].id, ) .is_err()); @@ -1475,9 +2149,10 @@ mod tests { .find(|file| file.id == proposal_file) .unwrap() .conflict = ConflictState::Unresolved; - assert!(PlatformReviewActionPreview::apply_proposal( + assert!(PlatformReviewActionPreview::stage_proposal( target.clone(), &conflicted, + &staging_capabilities(&target, &semantic), &conflicted.proposals[0].id, ) .is_err()); @@ -1491,28 +2166,36 @@ mod tests { wrong_authority.checks[0].authority.kind = ReviewAuthorityKind::Review; wrong_authority.pull_request.authority.kind = ReviewAuthorityKind::Review; assert!(!wrong_authority.approval_is_actionable()); + // The advertisement is minted from the review the snapshot names, so a + // Git review authority cannot produce one at all: the capability's own + // constructor refuses a non-review authority. assert!(PlatformReviewActionPreview::batch_send_comments( target.clone(), &wrong_authority, + &delivery_capabilities(&target, &semantic, &[&semantic.comments[0].id]), &[wrong_authority.comments[0].id.clone()], ) .is_err()); - assert!(PlatformReviewActionPreview::apply_proposal( + assert!(PlatformReviewActionPreview::stage_proposal( target.clone(), &wrong_authority, + &staging_capabilities(&target, &semantic), &wrong_authority.proposals[0].id, ) .is_err()); assert!(PlatformReviewActionPreview::rerun_check( target.clone(), &wrong_authority, + &rerun_capabilities(&target, &semantic), &wrong_authority.checks[0].id, ) .is_err()); - assert!( - PlatformReviewActionPreview::merge_pull_request(target.clone(), &wrong_authority,) - .is_err() - ); + assert!(PlatformReviewActionPreview::merge_pull_request( + target.clone(), + &wrong_authority, + &merge_capabilities(&target, &semantic), + ) + .is_err()); let foreign_target = PlatformReviewTarget { project: target.project.clone(), workspace: WorkContextIdentity::parse_local( @@ -1521,24 +2204,30 @@ mod tests { ) .unwrap(), }; - assert!( - PlatformReviewActionPreview::merge_pull_request(foreign_target, &semantic).is_err() - ); - assert!(PlatformReviewActionPreview::apply_proposal( + assert!(PlatformReviewActionPreview::merge_pull_request( + foreign_target, + &semantic, + &merge_capabilities(&target, &semantic), + ) + .is_err()); + assert!(PlatformReviewActionPreview::stage_proposal( target.clone(), &semantic, + &staging_capabilities(&target, &semantic), "proposal-missing", ) .is_err()); assert!(PlatformReviewActionPreview::rerun_check( target.clone(), &semantic, + &rerun_capabilities(&target, &semantic), "check-missing", ) .is_err()); assert!(PlatformReviewActionPreview::batch_send_comments( - target, + target.clone(), &semantic, + &delivery_capabilities(&target, &semantic, &[&semantic.comments[0].id]), &[ semantic.comments[0].id.clone(), semantic.comments[0].id.clone() @@ -1547,6 +2236,158 @@ mod tests { .is_err()); } + // SDTEST-1863 + #[test] + fn advertised_delivery_is_sent_verbatim_and_a_reused_advertisement_is_refused() { + let review = deliverable_review(); + let snapshot = decode_review_snapshot(CANONICAL_FIXTURE).unwrap(); + let target = PlatformReviewTarget { + project: ProjectId::new("project-1").unwrap(), + workspace: snapshot.workspace().clone(), + }; + + // The server advertises both sendable comments, sorted by id, which is + // the opposite of their order inside the snapshot. + let advertised = advertise(&target, &review); + let ids = advertised + .agent_deliverable_comments() + .iter() + .map(|capability| capability.comment_id().as_str()) + .collect::>(); + assert_eq!(ids, vec!["comment-0", "comment-1"]); + assert_ne!( + ids, + review + .comments + .iter() + .map(|comment| comment.id.as_str()) + .collect::>() + ); + + let preview = PlatformReviewActionPreview::batch_send_comments( + target.clone(), + &review, + &advertised, + &["comment-1".to_owned(), "comment-0".to_owned()], + ) + .unwrap(); + // Every coordinate is the capability's, and the request is pinned to + // the capability's snapshot revision. + assert_eq!(preview.expected_revision(), advertised.snapshot_revision()); + assert!(preview.confirmation().is_none()); + let ReviewAction::BatchSendCommentsToAgent { comments } = preview.action() else { + panic!("batch preview changed action family"); + }; + assert_eq!( + comments + .iter() + .map(|comment| (comment.comment_id().as_str(), comment.expected_revision())) + .collect::>(), + advertised + .agent_deliverable_comments() + .iter() + .map(|capability| ( + capability.comment_id().as_str(), + capability.expected_comment_revision() + )) + .collect::>() + ); + assert!(preview.requires_capability_revalidation()); + assert!(preview.matches_capabilities(&advertised)); + + // A capability response minted against another snapshot revision must + // construct nothing, or the preview would pin itself to a revision the + // active review never observed. + let superseded = advertise(&target, &settle(&review, &[])); + assert_ne!(superseded.snapshot_revision(), review.revision); + assert!(PlatformReviewActionPreview::batch_send_comments( + target.clone(), + &review, + &superseded, + &["comment-1".to_owned()], + ) + .is_err()); + // A comment that exists and is sendable, but was not advertised, is + // refused locally rather than posted for the server to refuse. + let partial = delivery_capabilities(&target, &review, &["comment-0"]); + assert!(PlatformReviewActionPreview::batch_send_comments( + target.clone(), + &review, + &partial, + &["comment-0".to_owned(), "comment-1".to_owned()], + ) + .is_err()); + + // One delivery settles. Re-read the advertisement rather than reusing + // the list: the delivered comment left `refused`, its own revision + // bumped, and the snapshot revision bumped with it. + let settled = settle(&review, &["comment-0"]); + let readvertised = advertise(&target, &settled); + assert_eq!( + readvertised + .agent_deliverable_comments() + .iter() + .map(|capability| capability.comment_id().as_str()) + .collect::>(), + vec!["comment-1"], + "settling one delivery must retract only that comment" + ); + assert_eq!(readvertised.snapshot_revision(), settled.revision); + + // The preview built from the retracted list is now refused locally, so + // it never reaches the server. + assert!(!preview.matches_capabilities(&readvertised)); + + // And a fresh preview built from that same stale list — the exact + // request a duplicate-delivery bug would have to make: same batch, + // same advertised revisions, a brand new idempotency key — is refused + // for the same reason, which is why no digest is needed here. + let replay = PlatformReviewActionPreview::batch_send_comments( + target.clone(), + &review, + &advertised, + &["comment-1".to_owned(), "comment-0".to_owned()], + ) + .unwrap(); + assert_ne!(replay.idempotency_key(), preview.idempotency_key()); + assert!(!replay.matches_capabilities(&readvertised)); + + // Preparing against the settled snapshot cannot reconstruct it either: + // the delivered comment is no longer advertised at all. + assert!(PlatformReviewActionPreview::batch_send_comments( + target.clone(), + &settled, + &readvertised, + &["comment-0".to_owned()], + ) + .is_err()); + + // The survivor is still deliverable, at the new snapshot revision. + let next = PlatformReviewActionPreview::batch_send_comments( + target.clone(), + &settled, + &readvertised, + &["comment-1".to_owned()], + ) + .unwrap(); + assert_eq!(next.expected_revision(), settled.revision); + assert!(next.matches_capabilities(&readvertised)); + + // Settling the last one empties the advertisement entirely, and an + // empty advertisement can construct nothing. + let drained = settle(&settled, &["comment-1"]); + let empty = advertise(&target, &drained); + assert!(empty.agent_deliverable_comments().is_empty()); + assert!(!next.matches_capabilities(&empty)); + assert!(PlatformReviewActionPreview::batch_send_comments( + target, + &drained, + &empty, + &["comment-1".to_owned()], + ) + .is_err()); + } + // SDTEST-1778 #[test] fn canonical_render_corpus_projects_every_cross_client_semantic_key() { diff --git a/crates/shelldeck-core/src/config/platform_review/custody.rs b/crates/shelldeck-core/src/config/platform_review/custody.rs new file mode 100644 index 00000000..7199cb90 --- /dev/null +++ b/crates/shelldeck-core/src/config/platform_review/custody.rs @@ -0,0 +1,1376 @@ +//! Durable at-most-once custody for native Platform v2 review mutations. +//! +//! A prepared preview is inert. Before any network dispatch it is advanced to +//! `dispatched` under the cross-process lock. Every restart treats dispatched +//! and accepted effects as lookup-only; ShellDeck never reconstructs a POST +//! decision from a provider session, chronology, or other observation. + +use std::path::{Path, PathBuf}; + +use automonique_protocol::platform::ReceiptId; +use automonique_protocol::platform_v2_review::{ + ReviewActionId, ReviewActorId, ReviewFileId, ReviewHunkId, MAX_REVIEW_COMMENTS, +}; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use super::*; +use crate::config::app_config::AppConfig; +use crate::workspace_review::storage::{ + bounded_descriptor_read, ensure_private_directory_io, lock_path, open_lock_file, + secure_atomic_write, +}; + +const REVIEW_CUSTODY_SCHEMA: u16 = 1; +const MAX_REVIEW_CUSTODY_RECORDS: usize = 64; +const MAX_REVIEW_CUSTODY_FILE_BYTES: u64 = 1024 * 1024; +const MAX_REFUSAL_BYTES: usize = 16 * 1024; +static REVIEW_CUSTODY_PROCESS_LOCK: parking_lot::Mutex<()> = parking_lot::Mutex::new(()); + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ReviewCustodyPresentation { + pub outcome: String, + pub detail: String, + pub actor: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum ReviewCustodyRecovery { + NeverStarted(PlatformReviewActionPreview), + LookupOnly(PlatformReviewActionPreview), + Terminal(ReviewCustodyPresentation), +} + +#[derive(Clone, Debug)] +pub struct PlatformReviewCustodyStore { + path: PathBuf, +} + +impl PlatformReviewCustodyStore { + pub fn open_default() -> Result { + Self::open( + AppConfig::config_dir() + .join("platform-review") + .join("custody") + .join("effects-v1.json"), + ) + } + + pub fn open(path: PathBuf) -> Result { + let store = Self { path }; + let _guard = REVIEW_CUSTODY_PROCESS_LOCK.lock(); + store.prepare_storage()?; + let lock = open_lock_file(&lock_path(&store.path))?; + fs2::FileExt::lock_exclusive(&lock)?; + let _ = load_document(&store.path)?; + Ok(store) + } + + /// Persist one inert confirmation preview. A target may own only one + /// non-terminal effect at a time; terminal presentation is replaced by a + /// later explicit preview for that same exact target. + pub fn prepare(&self, preview: &PlatformReviewActionPreview) -> Result<(), ReviewCustodyError> { + self.transact(|document| { + let key = preview.idempotency_key().as_str(); + if document.records.iter().any(|record| record.key == key) { + return Err(ReviewCustodyError::DuplicateEffect); + } + if document.records.iter().any(|record| { + record.matches_target(preview.target()) && !record.state.is_terminal() + }) { + return Err(ReviewCustodyError::EffectAlreadyPending); + } + document.records.retain(|record| { + !record.matches_target(preview.target()) || !record.state.is_terminal() + }); + if document.records.len() >= MAX_REVIEW_CUSTODY_RECORDS { + return Err(ReviewCustodyError::CapacityExceeded); + } + document.records.push(ReviewCustodyRecord::from_preview( + preview, + ReviewCustodyState::Prepared, + )?); + Ok(()) + }) + } + + /// Establish the at-most-once boundary before the provider call begins. + pub fn mark_dispatched( + &self, + preview: &PlatformReviewActionPreview, + ) -> Result<(), ReviewCustodyError> { + self.transact(|document| { + let record = exact_record_mut(document, preview)?; + match record.state { + ReviewCustodyState::Prepared => { + record.state = ReviewCustodyState::Dispatched; + Ok(()) + } + ReviewCustodyState::Dispatched | ReviewCustodyState::Receipt { .. } => Ok(()), + ReviewCustodyState::Refused { .. } => Err(ReviewCustodyError::EffectTerminal), + } + }) + } + + pub fn record_receipt( + &self, + preview: &PlatformReviewActionPreview, + receipt: &ReviewActionReceipt, + ) -> Result<(), ReviewCustodyError> { + if receipt.idempotency_key() != preview.idempotency_key() { + return Err(ReviewCustodyError::ReceiptMismatch); + } + self.transact(|document| { + let record = exact_record_mut(document, preview)?; + if matches!(record.state, ReviewCustodyState::Prepared) { + return Err(ReviewCustodyError::EffectNeverDispatched); + } + record.state = ReviewCustodyState::Receipt { + receipt: ReviewReceiptDisk::from_receipt(receipt), + }; + Ok(()) + }) + } + + pub fn record_refusal( + &self, + preview: &PlatformReviewActionPreview, + category: &str, + explanation: &str, + ) -> Result<(), ReviewCustodyError> { + if category.is_empty() + || category.len() > MAX_REFUSAL_BYTES + || explanation.len() > MAX_REFUSAL_BYTES + { + return Err(ReviewCustodyError::DocumentInvalid); + } + self.transact(|document| { + let record = exact_record_mut(document, preview)?; + if matches!(record.state, ReviewCustodyState::Prepared) { + return Err(ReviewCustodyError::EffectNeverDispatched); + } + record.state = ReviewCustodyState::Refused { + category: category.to_owned(), + explanation: explanation.to_owned(), + }; + Ok(()) + }) + } + + /// Cancel only a preview that provably never crossed the dispatch fence. + pub fn cancel_prepared( + &self, + preview: &PlatformReviewActionPreview, + ) -> Result<(), ReviewCustodyError> { + self.transact(|document| { + let index = exact_record_index(document, preview)?; + if !matches!(document.records[index].state, ReviewCustodyState::Prepared) { + return Err(ReviewCustodyError::EffectAlreadyPending); + } + document.records.remove(index); + Ok(()) + }) + } + + pub fn recovery( + &self, + target: &PlatformReviewTarget, + ) -> Result, ReviewCustodyError> { + let _guard = REVIEW_CUSTODY_PROCESS_LOCK.lock(); + self.prepare_storage()?; + let lock = open_lock_file(&lock_path(&self.path))?; + fs2::FileExt::lock_exclusive(&lock)?; + let document = load_document(&self.path)?; + let mut matching = document + .records + .iter() + .filter(|record| record.matches_target(target)); + let Some(record) = matching.next() else { + return Ok(None); + }; + if matching.next().is_some() { + return Err(ReviewCustodyError::DocumentInvalid); + } + let preview = record.to_preview()?; + Ok(Some(match &record.state { + ReviewCustodyState::Prepared => ReviewCustodyRecovery::NeverStarted(preview), + ReviewCustodyState::Dispatched => ReviewCustodyRecovery::LookupOnly(preview), + ReviewCustodyState::Receipt { receipt } if receipt.requires_lookup()? => { + ReviewCustodyRecovery::LookupOnly(preview) + } + ReviewCustodyState::Receipt { receipt } => { + ReviewCustodyRecovery::Terminal(receipt.presentation()?) + } + ReviewCustodyState::Refused { + category, + explanation, + } => ReviewCustodyRecovery::Terminal(ReviewCustodyPresentation { + outcome: category.clone(), + detail: explanation.clone(), + actor: None, + }), + })) + } + + fn transact( + &self, + update: impl FnOnce(&mut ReviewCustodyDocument) -> Result, + ) -> Result { + let _guard = REVIEW_CUSTODY_PROCESS_LOCK.lock(); + self.prepare_storage()?; + let lock = open_lock_file(&lock_path(&self.path))?; + fs2::FileExt::lock_exclusive(&lock)?; + let mut document = load_document(&self.path)?; + let outcome = update(&mut document)?; + document.revision = document + .revision + .checked_add(1) + .ok_or(ReviewCustodyError::DocumentInvalid)?; + persist_document(&self.path, &document)?; + Ok(outcome) + } + + fn prepare_storage(&self) -> Result<(), ReviewCustodyError> { + let parent = self.path.parent().ok_or(ReviewCustodyError::PathInvalid)?; + ensure_private_directory_io(parent)?; + Ok(()) + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct ReviewCustodyDocument { + schema: u16, + revision: u64, + records: Vec, +} + +impl Default for ReviewCustodyDocument { + fn default() -> Self { + Self { + schema: REVIEW_CUSTODY_SCHEMA, + revision: 0, + records: Vec::new(), + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct ReviewCustodyRecord { + project: String, + workspace_kind: String, + workspace: String, + expected_revision: u64, + key: String, + action: ReviewActionDisk, + confirmation: Option, + state: ReviewCustodyState, +} + +impl ReviewCustodyRecord { + fn from_preview( + preview: &PlatformReviewActionPreview, + state: ReviewCustodyState, + ) -> Result { + Ok(Self { + project: preview.target.project.as_str().to_owned(), + workspace_kind: preview.target.workspace.kind().as_str().to_owned(), + workspace: preview.target.workspace.id().to_owned(), + expected_revision: preview.expected_revision.get(), + key: preview.idempotency_key.as_str().to_owned(), + action: ReviewActionDisk::from_action(&preview.action)?, + confirmation: preview + .confirmation + .as_ref() + .map(ReviewConfirmationDisk::from_confirmation), + state, + }) + } + + fn matches_target(&self, target: &PlatformReviewTarget) -> bool { + self.project == target.project.as_str() + && self.workspace_kind == target.workspace.kind().as_str() + && self.workspace == target.workspace.id() + } + + fn to_preview(&self) -> Result { + let project = ProjectId::new(self.project.clone()) + .map_err(|_| ReviewCustodyError::DocumentInvalid)?; + let kind = WorkContextTargetKind::parse(&self.workspace_kind) + .map_err(|_| ReviewCustodyError::DocumentInvalid)?; + let workspace = WorkContextIdentity::parse_local(kind, &self.workspace) + .map_err(|_| ReviewCustodyError::DocumentInvalid)?; + if kind != WorkContextTargetKind::UserWorkspace { + return Err(ReviewCustodyError::DocumentInvalid); + } + let expected_revision = Revision::new(self.expected_revision) + .map_err(|_| ReviewCustodyError::DocumentInvalid)?; + let action = self.action.to_action()?; + let idempotency_key = IdempotencyKey::new(self.key.clone()) + .map_err(|_| ReviewCustodyError::DocumentInvalid)?; + let confirmation = self + .confirmation + .as_ref() + .map(ReviewConfirmationDisk::to_confirmation) + .transpose()?; + // Same contract predicate the preview constructor uses, for the same + // reason: a record whose confirmation no longer matches its family is + // a document that cannot be replayed onto the wire, so it is rejected + // as invalid rather than recovered into an unsendable preview. + if action.requires_confirmation() != confirmation.is_some() { + return Err(ReviewCustodyError::DocumentInvalid); + } + action + .validate_client_shape() + .map_err(|_| ReviewCustodyError::DocumentInvalid)?; + Ok(PlatformReviewActionPreview { + target: PlatformReviewTarget { project, workspace }, + expected_revision, + action, + idempotency_key, + confirmation, + }) + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +enum ReviewActionDisk { + AddComment { + comment_id: String, + file_id: String, + hunk_id: String, + side: String, + line: u32, + body: String, + }, + ApproveReview { + expected_review_revision: u64, + }, + RerunCheck { + check_id: String, + expected_check_revision: u64, + }, + /// The unconfirmed batch delivery lane. + /// + /// It carries no `confirmation` — the server advertises no digest for it — + /// but it is still an exposed mutation, so it takes the same durable + /// at-most-once record as every other one. Without it a restart between + /// the POST and the receipt would leave a delivered batch indistinguishable + /// from one that never left, and re-preparing would mint a fresh + /// idempotency key. + BatchSendCommentsToAgent { + comments: Vec, + }, + /// The three confirmed index-level staging lanes. + /// + /// Each is a separate variant rather than one variant carrying a `kind` + /// string, because the three are separately granted: an operator withholds + /// committing independently of index writes, and a recovered plan that + /// could be re-read as another kind would let the narrower grant replay as + /// the wider one. + /// + /// The disk format did not understand these before this slice, exactly as + /// it did not understand the batch delivery before PR #161: preparation + /// would have returned "unsupported action" and the dispatch would have + /// died at the custody fence. + Stage { + proposal_id: String, + }, + Unstage { + proposal_id: String, + }, + Commit { + proposal_id: String, + }, + /// Collapsing one conflicted path to one side git already recorded. + /// + /// The side is persisted because it is part of the effect's identity: it + /// decides which blob lands, and the confirmation digest was minted over + /// it. A record that lost it could be replayed as the other side. + ResolveConflict { + proposal_id: String, + file_id: String, + resolution: String, + }, + /// The confirmed pull-request merge. + /// + /// Unreachable in production — the server advertises no merge capability — + /// but the record has to exist for the same reason as the others: the + /// action is now confirmed, so it crosses the durable fence, and a family + /// the format cannot spell fails at `prepare` rather than at the wire. + MergePullRequest { + pull_request_id: String, + expected_pull_request_revision: u64, + expected_head_revision: String, + }, +} + +/// One comment target inside a persisted batch delivery. +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct ReviewCommentTargetDisk { + comment_id: String, + expected_revision: u64, +} + +impl ReviewActionDisk { + fn from_action(action: &ReviewAction) -> Result { + Ok(match action { + ReviewAction::AddComment { + comment_id, + anchor, + body, + } => Self::AddComment { + comment_id: comment_id.as_str().to_owned(), + file_id: anchor.file_id().as_str().to_owned(), + hunk_id: anchor.hunk_id().as_str().to_owned(), + side: anchor.side().as_str().to_owned(), + line: anchor.line(), + body: body.as_str().to_owned(), + }, + ReviewAction::ApproveReview { + expected_review_revision, + } => Self::ApproveReview { + expected_review_revision: expected_review_revision.get(), + }, + ReviewAction::RerunCheck { + check_id, + expected_check_revision, + } => Self::RerunCheck { + check_id: check_id.as_str().to_owned(), + expected_check_revision: expected_check_revision.get(), + }, + ReviewAction::BatchSendCommentsToAgent { comments } => Self::BatchSendCommentsToAgent { + comments: comments + .iter() + .map(|comment| ReviewCommentTargetDisk { + comment_id: comment.comment_id().as_str().to_owned(), + expected_revision: comment.expected_revision().get(), + }) + .collect(), + }, + ReviewAction::Stage { proposal_id } => Self::Stage { + proposal_id: proposal_id.as_str().to_owned(), + }, + ReviewAction::Unstage { proposal_id } => Self::Unstage { + proposal_id: proposal_id.as_str().to_owned(), + }, + ReviewAction::Commit { proposal_id } => Self::Commit { + proposal_id: proposal_id.as_str().to_owned(), + }, + ReviewAction::ResolveConflict { + proposal_id, + file_id, + resolution, + } => Self::ResolveConflict { + proposal_id: proposal_id.as_str().to_owned(), + file_id: file_id.as_str().to_owned(), + resolution: resolution.as_str().to_owned(), + }, + ReviewAction::MergePullRequest { + pull_request_id, + expected_pull_request_revision, + expected_head_revision, + } => Self::MergePullRequest { + pull_request_id: pull_request_id.as_str().to_owned(), + expected_pull_request_revision: expected_pull_request_revision.get(), + expected_head_revision: expected_head_revision.as_str().to_owned(), + }, + _ => return Err(ReviewCustodyError::UnsupportedAction), + }) + } + + fn to_action(&self) -> Result { + Ok(match self { + Self::AddComment { + comment_id, + file_id, + hunk_id, + side, + line, + body, + } => ReviewAction::AddComment { + comment_id: ReviewCommentId::new(comment_id.clone()) + .map_err(|_| ReviewCustodyError::DocumentInvalid)?, + anchor: ReviewAnchor::new( + ReviewFileId::new(file_id.clone()) + .map_err(|_| ReviewCustodyError::DocumentInvalid)?, + ReviewHunkId::new(hunk_id.clone()) + .map_err(|_| ReviewCustodyError::DocumentInvalid)?, + DiffSide::parse(side).map_err(|_| ReviewCustodyError::DocumentInvalid)?, + *line, + ) + .map_err(|_| ReviewCustodyError::DocumentInvalid)?, + body: ReviewText::new(body.clone()) + .map_err(|_| ReviewCustodyError::DocumentInvalid)?, + }, + Self::ApproveReview { + expected_review_revision, + } => ReviewAction::ApproveReview { + expected_review_revision: Revision::new(*expected_review_revision) + .map_err(|_| ReviewCustodyError::DocumentInvalid)?, + }, + Self::RerunCheck { + check_id, + expected_check_revision, + } => ReviewAction::RerunCheck { + check_id: ReviewCheckId::new(check_id.clone()) + .map_err(|_| ReviewCustodyError::DocumentInvalid)?, + expected_check_revision: Revision::new(*expected_check_revision) + .map_err(|_| ReviewCustodyError::DocumentInvalid)?, + }, + Self::BatchSendCommentsToAgent { comments } => { + // Bound before allocating. `validate_client_shape` would also + // reject an oversized batch, but only after building the whole + // vector out of a document this process has not yet trusted. + if comments.len() > MAX_REVIEW_COMMENTS { + return Err(ReviewCustodyError::DocumentInvalid); + } + ReviewAction::BatchSendCommentsToAgent { + comments: comments + .iter() + .map(|comment| { + Ok(ReviewCommentTarget::new( + ReviewCommentId::new(comment.comment_id.clone()) + .map_err(|_| ReviewCustodyError::DocumentInvalid)?, + Revision::new(comment.expected_revision) + .map_err(|_| ReviewCustodyError::DocumentInvalid)?, + )) + }) + .collect::, ReviewCustodyError>>()?, + } + } + Self::Stage { proposal_id } => ReviewAction::Stage { + proposal_id: disk_proposal_id(proposal_id)?, + }, + Self::Unstage { proposal_id } => ReviewAction::Unstage { + proposal_id: disk_proposal_id(proposal_id)?, + }, + Self::Commit { proposal_id } => ReviewAction::Commit { + proposal_id: disk_proposal_id(proposal_id)?, + }, + Self::ResolveConflict { + proposal_id, + file_id, + resolution, + } => ReviewAction::ResolveConflict { + proposal_id: disk_proposal_id(proposal_id)?, + file_id: ReviewFileId::new(file_id.clone()) + .map_err(|_| ReviewCustodyError::DocumentInvalid)?, + resolution: ConflictResolution::parse(resolution) + .map_err(|_| ReviewCustodyError::DocumentInvalid)?, + }, + Self::MergePullRequest { + pull_request_id, + expected_pull_request_revision, + expected_head_revision, + } => ReviewAction::MergePullRequest { + pull_request_id: PullRequestId::new(pull_request_id.clone()) + .map_err(|_| ReviewCustodyError::DocumentInvalid)?, + expected_pull_request_revision: Revision::new(*expected_pull_request_revision) + .map_err(|_| ReviewCustodyError::DocumentInvalid)?, + expected_head_revision: ReviewField::new(expected_head_revision.clone()) + .map_err(|_| ReviewCustodyError::DocumentInvalid)?, + }, + }) + } +} + +/// Re-parse a persisted proposal id through the protocol's own grammar. +/// +/// The looser of two spellings is the one a recovered plan replays through, so +/// a document this process has not yet trusted is never allowed to widen what +/// a proposal id may be. +fn disk_proposal_id(value: &str) -> Result { + ReviewProposalId::new(value.to_owned()).map_err(|_| ReviewCustodyError::DocumentInvalid) +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct ReviewConfirmationDisk { + confirmation_digest: String, + expected_workspace_revision: u64, + receipt_correlation_digest: String, +} + +impl ReviewConfirmationDisk { + fn from_confirmation(confirmation: &ReviewActionConfirmation) -> Self { + Self { + confirmation_digest: confirmation.confirmation_digest().as_str().to_owned(), + expected_workspace_revision: confirmation.expected_workspace_revision().get(), + receipt_correlation_digest: confirmation + .receipt_correlation_digest() + .as_str() + .to_owned(), + } + } + + fn to_confirmation(&self) -> Result { + Ok(ReviewActionConfirmation::new( + ReviewConfirmationDigest::new(self.confirmation_digest.clone()) + .map_err(|_| ReviewCustodyError::DocumentInvalid)?, + Revision::new(self.expected_workspace_revision) + .map_err(|_| ReviewCustodyError::DocumentInvalid)?, + ReviewReceiptCorrelationDigest::new(self.receipt_correlation_digest.clone()) + .map_err(|_| ReviewCustodyError::DocumentInvalid)?, + )) + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(tag = "state", rename_all = "snake_case", deny_unknown_fields)] +enum ReviewCustodyState { + Prepared, + Dispatched, + Receipt { + receipt: ReviewReceiptDisk, + }, + Refused { + category: String, + explanation: String, + }, +} + +impl ReviewCustodyState { + fn is_terminal(&self) -> bool { + match self { + Self::Prepared | Self::Dispatched => false, + Self::Receipt { receipt } => receipt.requires_lookup().map_or(true, |value| !value), + Self::Refused { .. } => true, + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct ReviewReceiptDisk { + receipt_id: String, + key: String, + action_id: String, + actor: String, + outcome: String, + revision: Option, + current_revision: Option, + reconciliation: String, +} + +impl ReviewReceiptDisk { + fn from_receipt(receipt: &ReviewActionReceipt) -> Self { + Self { + receipt_id: receipt.receipt_id().as_str().to_owned(), + key: receipt.idempotency_key().as_str().to_owned(), + action_id: receipt.action_id().as_str().to_owned(), + actor: receipt.actor().as_str().to_owned(), + outcome: receipt.outcome().as_str().to_owned(), + revision: receipt.revision().map(Revision::get), + current_revision: receipt.current_revision().map(Revision::get), + reconciliation: receipt.reconciliation().as_str().to_owned(), + } + } + + fn to_receipt(&self) -> Result { + ReviewActionReceipt::new( + ReceiptId::new(self.receipt_id.clone()) + .map_err(|_| ReviewCustodyError::DocumentInvalid)?, + IdempotencyKey::new(self.key.clone()) + .map_err(|_| ReviewCustodyError::DocumentInvalid)?, + ReviewActionId::new(self.action_id.clone()) + .map_err(|_| ReviewCustodyError::DocumentInvalid)?, + ReviewActorId::new(self.actor.clone()) + .map_err(|_| ReviewCustodyError::DocumentInvalid)?, + ReviewReceiptOutcome::parse(&self.outcome) + .map_err(|_| ReviewCustodyError::DocumentInvalid)?, + self.revision + .map(Revision::new) + .transpose() + .map_err(|_| ReviewCustodyError::DocumentInvalid)?, + self.current_revision + .map(Revision::new) + .transpose() + .map_err(|_| ReviewCustodyError::DocumentInvalid)?, + ReviewReconciliation::parse(&self.reconciliation) + .map_err(|_| ReviewCustodyError::DocumentInvalid)?, + ) + .map_err(|_| ReviewCustodyError::DocumentInvalid) + } + + fn requires_lookup(&self) -> Result { + let receipt = self.to_receipt()?; + Ok( + receipt.reconciliation() == ReviewReconciliation::PollReceipt + || matches!( + receipt.outcome(), + ReviewReceiptOutcome::Accepted | ReviewReceiptOutcome::Unknown + ), + ) + } + + fn presentation(&self) -> Result { + let receipt = self.to_receipt()?; + Ok(ReviewCustodyPresentation { + outcome: receipt.outcome().as_str().to_owned(), + detail: format!( + "{} · {}", + receipt.action_id().as_str(), + receipt.reconciliation().as_str() + ), + actor: Some(receipt.actor().as_str().to_owned()), + }) + } +} + +fn exact_record_index( + document: &ReviewCustodyDocument, + preview: &PlatformReviewActionPreview, +) -> Result { + let mut matching = document.records.iter().enumerate().filter(|(_, record)| { + record.key == preview.idempotency_key().as_str() && record.matches_target(preview.target()) + }); + let Some((index, record)) = matching.next() else { + return Err(ReviewCustodyError::UnknownEffect); + }; + if matching.next().is_some() || record.to_preview()? != *preview { + return Err(ReviewCustodyError::DocumentInvalid); + } + Ok(index) +} + +fn exact_record_mut<'a>( + document: &'a mut ReviewCustodyDocument, + preview: &PlatformReviewActionPreview, +) -> Result<&'a mut ReviewCustodyRecord, ReviewCustodyError> { + let index = exact_record_index(document, preview)?; + Ok(&mut document.records[index]) +} + +fn load_document(path: &Path) -> Result { + let Some(bytes) = bounded_descriptor_read(path, MAX_REVIEW_CUSTODY_FILE_BYTES)? else { + return Ok(ReviewCustodyDocument::default()); + }; + if bytes.len() as u64 > MAX_REVIEW_CUSTODY_FILE_BYTES { + return Err(ReviewCustodyError::DocumentInvalid); + } + let document: ReviewCustodyDocument = serde_json::from_slice(&bytes)?; + validate_document(&document)?; + Ok(document) +} + +fn validate_document(document: &ReviewCustodyDocument) -> Result<(), ReviewCustodyError> { + if document.schema != REVIEW_CUSTODY_SCHEMA + || document.records.len() > MAX_REVIEW_CUSTODY_RECORDS + { + return Err(ReviewCustodyError::DocumentInvalid); + } + let mut keys = std::collections::BTreeSet::new(); + let mut active_targets = std::collections::BTreeSet::new(); + for record in &document.records { + let preview = record.to_preview()?; + if !keys.insert(record.key.as_str()) { + return Err(ReviewCustodyError::DocumentInvalid); + } + if !record.state.is_terminal() + && !active_targets.insert(( + preview.target.project.as_str().to_owned(), + preview.target.workspace.kind().as_str().to_owned(), + preview.target.workspace.id().to_owned(), + )) + { + return Err(ReviewCustodyError::DocumentInvalid); + } + if let ReviewCustodyState::Receipt { receipt } = &record.state { + let value = receipt.to_receipt()?; + if value.idempotency_key() != preview.idempotency_key() { + return Err(ReviewCustodyError::DocumentInvalid); + } + } + if let ReviewCustodyState::Refused { + category, + explanation, + } = &record.state + { + if category.is_empty() + || category.len() > MAX_REFUSAL_BYTES + || explanation.len() > MAX_REFUSAL_BYTES + { + return Err(ReviewCustodyError::DocumentInvalid); + } + } + } + Ok(()) +} + +fn persist_document( + path: &Path, + document: &ReviewCustodyDocument, +) -> Result<(), ReviewCustodyError> { + validate_document(document)?; + let bytes = serde_json::to_vec_pretty(document)?; + if bytes.len() as u64 > MAX_REVIEW_CUSTODY_FILE_BYTES { + return Err(ReviewCustodyError::DocumentInvalid); + } + secure_atomic_write(path, &bytes)?; + Ok(()) +} + +#[derive(Debug, Error)] +pub enum ReviewCustodyError { + #[error("review custody storage failed: {0}")] + Io(#[from] std::io::Error), + #[error("review custody document is not valid JSON: {0}")] + Json(#[from] serde_json::Error), + #[error("review custody path is invalid")] + PathInvalid, + #[error("review custody document is invalid or exceeds its bound")] + DocumentInvalid, + #[error("review custody has reached its bounded capacity")] + CapacityExceeded, + #[error("review custody already contains this effect")] + DuplicateEffect, + #[error("another review effect is already pending for this workspace")] + EffectAlreadyPending, + #[error("review effect is not in custody")] + UnknownEffect, + #[error("review effect is already terminal")] + EffectTerminal, + #[error("review effect was never dispatched")] + EffectNeverDispatched, + #[error("review receipt does not match the exact effect")] + ReceiptMismatch, + #[error("review action is outside this custody slice")] + UnsupportedAction, +} + +#[cfg(test)] +mod tests { + use std::sync::{Arc, Barrier}; + + use super::*; + use automonique_protocol::platform_v2_review_api::decode_review_snapshot; + + const CANONICAL_FIXTURE: &[u8] = + include_bytes!("../../../tests/fixtures/platform-v2-review-v2.json"); + + fn semantic() -> PlatformReviewSemantic { + PlatformReviewSemantic::from(&decode_review_snapshot(CANONICAL_FIXTURE).unwrap()) + } + + fn target(review: &PlatformReviewSemantic) -> PlatformReviewTarget { + PlatformReviewTarget { + project: ProjectId::new("project-1").unwrap(), + workspace: WorkContextIdentity::parse_local( + WorkContextTargetKind::UserWorkspace, + &review.workspace_id, + ) + .unwrap(), + } + } + + fn approval() -> PlatformReviewActionPreview { + let review = semantic(); + PlatformReviewActionPreview::approve(target(&review), &review).unwrap() + } + + fn comment(body: &str) -> PlatformReviewActionPreview { + let review = semantic(); + let file = review + .files + .iter() + .find(|file| !file.hunks.is_empty()) + .unwrap(); + let hunk = &file.hunks[0]; + let (side, line) = if hunk.new_lines > 0 { + (DiffSide::New, hunk.new_start) + } else { + (DiffSide::Old, hunk.old_start) + }; + let anchor = ReviewAnchorSemantic { + file_id: file.id.clone(), + hunk_id: hunk.id.clone(), + side, + line, + }; + PlatformReviewActionPreview::add_comment(target(&review), &review, &anchor, body).unwrap() + } + + struct TempRoot(PathBuf); + + impl TempRoot { + fn new() -> Self { + let path = std::env::temp_dir().join(format!( + "shelldeck-review-custody-{}-{}", + std::process::id(), + uuid::Uuid::new_v4() + )); + std::fs::create_dir_all(&path).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o700)).unwrap(); + } + Self(path) + } + + fn path(&self) -> &Path { + &self.0 + } + } + + impl Drop for TempRoot { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + fn store_path(directory: &TempRoot) -> PathBuf { + directory + .path() + .join("platform-review") + .join("custody") + .join("effects-v1.json") + } + + fn receipt( + preview: &PlatformReviewActionPreview, + outcome: ReviewReceiptOutcome, + actor: &str, + ) -> ReviewActionReceipt { + let (revision, reconciliation) = match outcome { + ReviewReceiptOutcome::Accepted | ReviewReceiptOutcome::Unknown => { + (None, ReviewReconciliation::PollReceipt) + } + ReviewReceiptOutcome::Completed => ( + Some(Revision::new(43).unwrap()), + ReviewReconciliation::Final, + ), + ReviewReceiptOutcome::Refused | ReviewReceiptOutcome::Conflict => { + (None, ReviewReconciliation::Final) + } + }; + ReviewActionReceipt::new( + ReceiptId::new("receipt-1").unwrap(), + preview.idempotency_key().clone(), + ReviewActionId::new("action-1").unwrap(), + ReviewActorId::new(actor).unwrap(), + outcome, + revision, + (outcome == ReviewReceiptOutcome::Conflict).then(|| Revision::new(44).unwrap()), + reconciliation, + ) + .unwrap() + } + + // SDTEST-1822 — an inert preview survives a process restart without ever + // becoming provider work; only the durable dispatch fence enables lookup. + #[test] + fn prepared_restart_is_never_started_and_dispatched_restart_is_lookup_only() { + let directory = TempRoot::new(); + let preview = approval(); + let store = PlatformReviewCustodyStore::open(store_path(&directory)).unwrap(); + store.prepare(&preview).unwrap(); + + let restarted = PlatformReviewCustodyStore::open(store_path(&directory)).unwrap(); + assert_eq!( + restarted.recovery(preview.target()).unwrap(), + Some(ReviewCustodyRecovery::NeverStarted(preview.clone())) + ); + + restarted.mark_dispatched(&preview).unwrap(); + let restarted = PlatformReviewCustodyStore::open(store_path(&directory)).unwrap(); + assert_eq!( + restarted.recovery(preview.target()).unwrap(), + Some(ReviewCustodyRecovery::LookupOnly(preview)) + ); + } + + // SDTEST-1823 — Accepted remains durable across restart, retains its actor, + // and advances to a terminal presentation only after the correlated poll. + #[test] + fn accepted_restart_then_completed_retains_actor_and_never_reopens_dispatch() { + let directory = TempRoot::new(); + let preview = approval(); + let store = PlatformReviewCustodyStore::open(store_path(&directory)).unwrap(); + store.prepare(&preview).unwrap(); + store.mark_dispatched(&preview).unwrap(); + store + .record_receipt( + &preview, + &receipt(&preview, ReviewReceiptOutcome::Accepted, "actor-1"), + ) + .unwrap(); + + let restarted = PlatformReviewCustodyStore::open(store_path(&directory)).unwrap(); + assert!(matches!( + restarted.recovery(preview.target()).unwrap(), + Some(ReviewCustodyRecovery::LookupOnly(value)) if value == preview + )); + restarted + .record_receipt( + &preview, + &receipt(&preview, ReviewReceiptOutcome::Completed, "actor-1"), + ) + .unwrap(); + assert_eq!( + restarted.recovery(preview.target()).unwrap(), + Some(ReviewCustodyRecovery::Terminal(ReviewCustodyPresentation { + outcome: "completed".to_owned(), + detail: "action-1 · final".to_owned(), + actor: Some("actor-1".to_owned()), + })) + ); + } + + // SDTEST-1824 — separate store instances serialize through the OS lock, so + // two processes cannot reserve two effects for the same workspace. + #[test] + fn concurrent_stores_reserve_only_one_effect_for_an_exact_target() { + let directory = TempRoot::new(); + let path = store_path(&directory); + PlatformReviewCustodyStore::open(path.clone()).unwrap(); + let first = approval(); + let second = approval(); + let barrier = Arc::new(Barrier::new(3)); + let handles = [first, second] + .into_iter() + .map(|preview| { + let path = path.clone(); + let barrier = barrier.clone(); + std::thread::spawn(move || { + let store = PlatformReviewCustodyStore::open(path).unwrap(); + barrier.wait(); + store.prepare(&preview) + }) + }) + .collect::>(); + barrier.wait(); + let results = handles + .into_iter() + .map(|handle| handle.join().unwrap()) + .collect::>(); + assert_eq!(results.iter().filter(|result| result.is_ok()).count(), 1); + assert_eq!( + results + .iter() + .filter(|result| matches!(result, Err(ReviewCustodyError::EffectAlreadyPending))) + .count(), + 1 + ); + } + + // SDTEST-1825 — invalid and oversized documents fail closed before any + // candidate state is admitted or rewritten. + #[test] + fn oversized_document_is_rejected_without_replacement() { + let directory = TempRoot::new(); + let path = store_path(&directory); + let store = PlatformReviewCustodyStore::open(path.clone()).unwrap(); + store.prepare(&approval()).unwrap(); + let oversized = vec![b'x'; MAX_REVIEW_CUSTODY_FILE_BYTES as usize + 1]; + std::fs::write(&path, &oversized).unwrap(); + assert!(matches!( + PlatformReviewCustodyStore::open(path.clone()), + Err(ReviewCustodyError::DocumentInvalid) + )); + assert_eq!(std::fs::read(path).unwrap(), oversized); + } + + // SDTEST-1858 — the unconfirmed comment family crosses the same durable + // fence as a confirmed rerun: restart never reposts it, its receipt keeps + // the server actor, and a second comment cannot open a parallel effect. + #[test] + fn unconfirmed_comment_is_fenced_restart_safe_and_actor_attributed() { + let directory = TempRoot::new(); + let preview = comment("borne exacte"); + assert!(preview.confirmation().is_none()); + let store = PlatformReviewCustodyStore::open(store_path(&directory)).unwrap(); + store.prepare(&preview).unwrap(); + + // A second comment for the same workspace cannot reserve its own + // effect while the first is still non-terminal. + assert!(matches!( + store.prepare(&comment("seconde borne")), + Err(ReviewCustodyError::EffectAlreadyPending) + )); + + store.mark_dispatched(&preview).unwrap(); + let restarted = PlatformReviewCustodyStore::open(store_path(&directory)).unwrap(); + assert_eq!( + restarted.recovery(preview.target()).unwrap(), + Some(ReviewCustodyRecovery::LookupOnly(preview.clone())) + ); + restarted + .record_receipt( + &preview, + &receipt(&preview, ReviewReceiptOutcome::Completed, "reviewer-7"), + ) + .unwrap(); + assert_eq!( + PlatformReviewCustodyStore::open(store_path(&directory)) + .unwrap() + .recovery(preview.target()) + .unwrap(), + Some(ReviewCustodyRecovery::Terminal(ReviewCustodyPresentation { + outcome: "completed".to_owned(), + detail: "action-1 · final".to_owned(), + actor: Some("reviewer-7".to_owned()), + })) + ); + } + + /// The advertisement a coherent server would mint for this snapshot's one + /// sendable comment, and the preview built verbatim from it. + fn batch_delivery() -> PlatformReviewActionPreview { + let mut review = semantic(); + review.comments[0].agent_state = CommentAgentState::NotSent; + let target = target(&review); + let capabilities = ReviewCapabilities::new( + target.project.clone(), + target.workspace.clone(), + review.revision, + Revision::new(91).unwrap(), + Vec::new(), + vec![ReviewAgentDeliveryCapability::new( + ReviewCommentId::new(review.comments[0].id.clone()).unwrap(), + review.comments[0].revision, + ReviewAuthority::new( + ReviewAuthorityKind::Review, + automonique_protocol::platform_v2_review::ReviewAuthorityId::new( + review.review.authority.id.clone(), + ) + .unwrap(), + ), + ) + .unwrap()], + ReviewPullRequestCapabilities::default(), + ReviewGitStagingCapabilities::default(), + ) + .unwrap(); + PlatformReviewActionPreview::batch_send_comments( + target, + &review, + &capabilities, + &[review.comments[0].id.clone()], + ) + .unwrap() + } + + // SDTEST-1865 — the unconfirmed batch delivery takes the same durable + // at-most-once lane as every other exposed mutation, and survives the + // round trip through disk with its exact comment set intact. + #[test] + fn unconfirmed_batch_delivery_is_fenced_and_survives_the_disk_round_trip() { + let directory = TempRoot::new(); + let preview = batch_delivery(); + // No confirmation digest: this lane is fenced by the advertisement and + // the domain state machine, never by a digest. + assert!(preview.confirmation().is_none()); + // It must still be revalidated before dispatch, exactly like a + // confirmed rerun and unlike a comment or an approval. + assert!(preview.requires_capability_revalidation()); + + // Before this slice the disk format knew only AddComment, + // ApproveReview and RerunCheck, so a batch delivery could not be + // recorded at all and would have refused at the fence. + let store = PlatformReviewCustodyStore::open(store_path(&directory)).unwrap(); + store.prepare(&preview).unwrap(); + assert!(matches!( + store.prepare(&batch_delivery()), + Err(ReviewCustodyError::EffectAlreadyPending) + )); + + // A restart before dispatch reports the preview as never started, and + // the exact action survives serialization: same comments, same + // revisions, same order. + let restarted = PlatformReviewCustodyStore::open(store_path(&directory)).unwrap(); + assert_eq!( + restarted.recovery(preview.target()).unwrap(), + Some(ReviewCustodyRecovery::NeverStarted(preview.clone())) + ); + + restarted.mark_dispatched(&preview).unwrap(); + assert_eq!( + PlatformReviewCustodyStore::open(store_path(&directory)) + .unwrap() + .recovery(preview.target()) + .unwrap(), + Some(ReviewCustodyRecovery::LookupOnly(preview.clone())), + "a restart mid-delivery must look the receipt up, never re-post" + ); + + let reopened = PlatformReviewCustodyStore::open(store_path(&directory)).unwrap(); + reopened + .record_receipt( + &preview, + &receipt(&preview, ReviewReceiptOutcome::Completed, "reviewer-9"), + ) + .unwrap(); + assert_eq!( + PlatformReviewCustodyStore::open(store_path(&directory)) + .unwrap() + .recovery(preview.target()) + .unwrap(), + Some(ReviewCustodyRecovery::Terminal(ReviewCustodyPresentation { + outcome: "completed".to_owned(), + detail: "action-1 · final".to_owned(), + actor: Some("reviewer-9".to_owned()), + })) + ); + } + + /// One confirmed staging preview, built from the advertisement a coherent + /// server would mint for this snapshot's `Commit` proposal. + fn staging_preview() -> PlatformReviewActionPreview { + let review = semantic(); + let target = target(&review); + let proposal = &review.proposals[0]; + let capabilities = ReviewCapabilities::new( + target.project.clone(), + target.workspace.clone(), + review.revision, + Revision::new(91).unwrap(), + Vec::new(), + Vec::new(), + ReviewPullRequestCapabilities::default(), + ReviewGitStagingCapabilities { + staging: vec![ReviewStagingCapability::new( + ReviewProposalId::new(proposal.id.clone()).unwrap(), + proposal.kind, + ReviewField::new("7".repeat(40)).unwrap(), + ReviewIndexDigest::new("a".repeat(64)).unwrap(), + ReviewAuthority::new( + ReviewAuthorityKind::Git, + automonique_protocol::platform_v2_review::ReviewAuthorityId::new( + proposal.authority.as_ref().unwrap().id.clone(), + ) + .unwrap(), + ), + ReviewConfirmationDigest::new("c".repeat(64)).unwrap(), + ReviewReceiptCorrelationDigest::new("d".repeat(64)).unwrap(), + ) + .unwrap()], + conflict_resolutions: Vec::new(), + }, + ) + .unwrap(); + PlatformReviewActionPreview::stage_proposal(target, &review, &capabilities, &proposal.id) + .unwrap() + } + + // SDTEST-1870 — the confirmed staging families take the same durable + // at-most-once lane as every other exposed mutation, and survive the round + // trip through disk with their exact coordinates and digests intact. + // + // The disk format did not know these actions before this slice, exactly as + // it did not know the batch delivery before PR #161: `prepare` would have + // returned `UnsupportedAction` and the dispatch would have died at the + // fence. That assumption was wrong once already, which is why this test + // drives the whole store rather than only the enum. + #[test] + fn confirmed_staging_is_fenced_and_survives_the_disk_round_trip() { + let directory = TempRoot::new(); + let preview = staging_preview(); + // Staging is a confirmed lane now: the unconfirmed spelling cannot be + // constructed at all, so a record without a confirmation would be an + // unsendable document. + let confirmation = preview.confirmation().expect("staging carries a digest"); + assert_eq!(confirmation.confirmation_digest().as_str(), "c".repeat(64)); + assert_eq!( + confirmation.receipt_correlation_digest().as_str(), + "d".repeat(64) + ); + assert!(preview.requires_capability_revalidation()); + + let store = PlatformReviewCustodyStore::open(store_path(&directory)).unwrap(); + store.prepare(&preview).unwrap(); + assert!(matches!( + store.prepare(&staging_preview()), + Err(ReviewCustodyError::EffectAlreadyPending) + )); + + // A restart before dispatch reports it as never started, and the exact + // action plus both digests survive serialization. + let restarted = PlatformReviewCustodyStore::open(store_path(&directory)).unwrap(); + assert_eq!( + restarted.recovery(preview.target()).unwrap(), + Some(ReviewCustodyRecovery::NeverStarted(preview.clone())) + ); + + restarted.mark_dispatched(&preview).unwrap(); + assert_eq!( + PlatformReviewCustodyStore::open(store_path(&directory)) + .unwrap() + .recovery(preview.target()) + .unwrap(), + Some(ReviewCustodyRecovery::LookupOnly(preview.clone())), + "a restart mid-write must look the receipt up through its correlation, never re-post" + ); + } + + // SDTEST-1871 — every confirmed staging family round-trips through the + // disk enum, and the side of a conflict resolution is part of the persisted + // identity rather than a value recomputed on recovery. + #[test] + fn every_staging_family_round_trips_and_a_resolution_keeps_its_side() { + for action in [ + ReviewAction::Stage { + proposal_id: ReviewProposalId::new("proposal-1".to_owned()).unwrap(), + }, + ReviewAction::Unstage { + proposal_id: ReviewProposalId::new("proposal-1".to_owned()).unwrap(), + }, + ReviewAction::Commit { + proposal_id: ReviewProposalId::new("proposal-1".to_owned()).unwrap(), + }, + ReviewAction::ResolveConflict { + proposal_id: ReviewProposalId::new("proposal-1".to_owned()).unwrap(), + file_id: ReviewFileId::new("file-1".to_owned()).unwrap(), + resolution: ConflictResolution::KeepCurrent, + }, + ReviewAction::ResolveConflict { + proposal_id: ReviewProposalId::new("proposal-1".to_owned()).unwrap(), + file_id: ReviewFileId::new("file-1".to_owned()).unwrap(), + resolution: ConflictResolution::KeepIncoming, + }, + ] { + let disk = ReviewActionDisk::from_action(&action).expect("family is spelled on disk"); + let json = serde_json::to_string(&disk).expect("serializable"); + let decoded: ReviewActionDisk = serde_json::from_str(&json).expect("decodable"); + assert_eq!( + decoded.to_action().expect("re-parses through the grammar"), + action, + "a persisted staging action must replay as exactly itself" + ); + } + + // A document whose confirmation no longer matches its family cannot be + // replayed onto the wire, so it is rejected as invalid rather than + // recovered into an unsendable preview. + let mut record = + ReviewCustodyRecord::from_preview(&staging_preview(), ReviewCustodyState::Prepared) + .unwrap(); + record.confirmation = None; + assert!(matches!( + record.to_preview(), + Err(ReviewCustodyError::DocumentInvalid) + )); + } + + #[cfg(unix)] + // SDTEST-1826 — linked/replaced custody paths and lock files are never + // followed outside ShellDeck's private directory boundary. + #[test] + fn linked_custody_path_is_rejected() { + use std::os::unix::fs::symlink; + + let directory = TempRoot::new(); + let path = store_path(&directory); + let parent = path.parent().unwrap(); + std::fs::create_dir_all(parent).unwrap(); + let outside = directory.path().join("outside.json"); + std::fs::write(&outside, b"outside").unwrap(); + symlink(&outside, &path).unwrap(); + assert!(PlatformReviewCustodyStore::open(path).is_err()); + assert_eq!(std::fs::read(outside).unwrap(), b"outside"); + } +} diff --git a/crates/shelldeck-core/src/config/platform_review/delivery.rs b/crates/shelldeck-core/src/config/platform_review/delivery.rs new file mode 100644 index 00000000..bf93c297 --- /dev/null +++ b/crates/shelldeck-core/src/config/platform_review/delivery.rs @@ -0,0 +1,506 @@ +//! Server-advertised batch delivery of review comments to the authorized session. +//! +//! This module is a read-side projection, exactly like [`super::worktree`]. It +//! grants nothing: it reports which comments the server has *proven* it can +//! deliver to the retained agent session bound to this review coordinate, and +//! otherwise reports which fence is missing so the surface can say so instead +//! of offering a control that would refuse. +//! +//! # Why this lane carries no confirmation digest +//! +//! A confirmed check rerun needs one because the client names the target and +//! the effect fires in a system the daemon does not own, so only a digest can +//! bind the executed write to the preflighted plan. Delivery to a retained +//! session differs on every axis that motivated the digest: +//! +//! * The target is never on the wire. Provider and session identity come from +//! the operator-owned registry, keyed by the review coordinate, so a client +//! can neither name nor redirect the session it reaches. +//! * The snapshot revision is fenced twice server-side, by the store's +//! current-revision check and again by `resolve_action`. +//! * The comment set is fenced by the batch arm of `resolve_action`, which +//! requires every comment to exist at exactly the advertised revision and to +//! still be in `not_sent` or `refused`. +//! +//! Exactly-once therefore falls out of the domain state machine rather than a +//! digest: settling a delivery moves the comment out of those two states and +//! bumps both the snapshot revision and the comment's own revision, so the +//! advertisement that authorized a send cannot construct a second one. +//! +//! Minting a receipt correlation here would be worse than redundant: the +//! host's receipt lookup *skips* a retained action whenever the request +//! carries a correlation digest, so advertising one would hand this client a +//! token that makes its own receipt unfindable. A fence that breaks recovery +//! is not a fence. +//! +//! The client consequence is the one rule this module exists to enforce: the +//! advertisement is the fence, so it must be re-read after every settlement +//! rather than reused. See [`super::PlatformReviewActionPreview::matches_capabilities`]. + +use super::{ + PlatformReviewSemantic, PlatformReviewTarget, ReviewAuthorityKind, ReviewCapabilities, +}; +use automonique_protocol::primitives::Revision; + +/// Why the projection offers no batch send-to-agent control. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ReviewDeliveryWithheld { + /// No capability response is attributed to this exact project, workspace + /// and snapshot revision, so nothing has been advertised at all. + NoServerCapability, + /// The capability response is exact and its advertised list is empty. + /// That is the server's honest fail-closed answer — no registry binding, + /// no reachable delivery adapter, a stale review, or no comment currently + /// in a sendable state — and it must produce no control. + NoDeliverableComment, + /// No durable at-most-once custody store is available in this process. + NoCustodyLane, +} + +impl ReviewDeliveryWithheld { + /// Every reason a surface must be able to explain. + pub const ALL: [Self; 3] = [ + Self::NoServerCapability, + Self::NoDeliverableComment, + Self::NoCustodyLane, + ]; + + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::NoServerCapability => "no_server_capability", + Self::NoDeliverableComment => "no_deliverable_comment", + Self::NoCustodyLane => "no_custody_lane", + } + } + + /// Canonical cross-client presentation key, carrying no localized copy. + #[must_use] + pub fn semantic_key(self) -> String { + format!("delivery_withheld.{}", self.as_str()) + } +} + +/// One comment the server advertised as deliverable, carried verbatim. +/// +/// `expected_comment_revision` is the server's value, never the snapshot's. +/// The two agree on a coherent read — that is what [`advertised_agent_deliveries`] +/// checks — but the value that crosses the network is this one, because it is +/// the one the server committed to accepting. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AdvertisedReviewDelivery { + pub comment_id: String, + pub expected_comment_revision: Revision, + pub authority_id: String, +} + +/// The exact advertised delivery set for one active snapshot. +/// +/// Every entry survives three checks: the capability response is attributed to +/// this exact project, workspace and snapshot revision; its authority is the +/// review authority the snapshot itself names; and the comment is present in +/// that same snapshot, at the advertised revision, in a sendable state. +/// +/// The third check is a torn-read guard, not a second source of authority. The +/// capability list and the snapshot are two separate reads, and a coherent +/// server mints them from one projection, so a disagreement means they +/// straddled a change. Refusing the entry is the fail-closed answer; it can +/// never *add* an entry the server did not advertise. +#[must_use] +pub fn advertised_agent_deliveries( + capabilities: Option<&ReviewCapabilities>, + target: &PlatformReviewTarget, + review: &PlatformReviewSemantic, +) -> Vec { + let Some(capabilities) = capabilities else { + return Vec::new(); + }; + if capabilities.project() != &target.project + || capabilities.workspace() != &target.workspace + || capabilities.snapshot_revision() != review.revision + || review.workspace_kind != target.workspace.kind() + || review.workspace_id != target.workspace.id() + { + return Vec::new(); + } + capabilities + .agent_deliverable_comments() + .iter() + .filter(|capability| { + capability.authority().kind() == ReviewAuthorityKind::Review + && capability.authority().id().as_str() == review.review.authority.id + && review.review.authority.kind == ReviewAuthorityKind::Review + }) + .filter(|capability| { + review.comments.iter().any(|comment| { + comment.id == capability.comment_id().as_str() + && comment.revision == capability.expected_comment_revision() + }) && review.comment_is_batch_actionable(capability.comment_id().as_str()) + }) + .map(|capability| AdvertisedReviewDelivery { + comment_id: capability.comment_id().as_str().to_owned(), + expected_comment_revision: capability.expected_comment_revision(), + authority_id: capability.authority().id().as_str().to_owned(), + }) + .collect() +} + +/// Decide whether a batch send-to-agent control may be rendered at all. +/// +/// Both fences must hold, exactly like the confirmed check rerun: the server +/// must have advertised at least one deliverable comment for this exact +/// snapshot, and the durable custody store must be able to record the +/// at-most-once boundary before dispatch. This lane needs no *confirmation +/// digest*, but it still needs the durable record every other exposed mutation +/// gets: a restart between the POST and the receipt must not make a delivered +/// batch indistinguishable from one that never left. +/// +/// A missing fence makes the control absent, never optimistically disabled. +/// +/// # Errors +/// +/// Returns the first unmet fence. +pub const fn review_delivery_control( + exact_capability: bool, + advertised: bool, + custody_available: bool, +) -> Result<(), ReviewDeliveryWithheld> { + if !exact_capability { + Err(ReviewDeliveryWithheld::NoServerCapability) + } else if !advertised { + Err(ReviewDeliveryWithheld::NoDeliverableComment) + } else if !custody_available { + Err(ReviewDeliveryWithheld::NoCustodyLane) + } else { + Ok(()) + } +} + +/// Everything a surface needs to render the delivery lane for one snapshot. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ReviewAgentDeliveryProjection { + advertised: Vec, + /// Why no control may be offered, when none may. + pub control: Result<(), ReviewDeliveryWithheld>, +} + +impl ReviewAgentDeliveryProjection { + #[must_use] + pub fn new( + review: &PlatformReviewSemantic, + target: &PlatformReviewTarget, + capabilities: Option<&ReviewCapabilities>, + custody_available: bool, + ) -> Self { + // "Exact" is about attribution, not content: a capability response for + // this project, workspace and snapshot revision was received. An empty + // advertised list inside it is a different, more specific answer, and + // the surface must be able to say which one it got. + let exact_capability = capabilities.is_some_and(|capabilities| { + capabilities.project() == &target.project + && capabilities.workspace() == &target.workspace + && capabilities.snapshot_revision() == review.revision + }); + let advertised = advertised_agent_deliveries(capabilities, target, review); + let control = + review_delivery_control(exact_capability, !advertised.is_empty(), custody_available); + Self { + advertised, + control, + } + } + + /// The projection for a surface that has no exactly attributed target. + /// + /// Nothing was advertised because nothing could have been, so this is the + /// same fail-closed answer as an absent capability response. + #[must_use] + pub const fn unavailable() -> Self { + Self { + advertised: Vec::new(), + control: Err(ReviewDeliveryWithheld::NoServerCapability), + } + } + + /// The advertised set, in the capability's own order. + /// + /// The server sorts it by comment id, and the protocol requires a batch to + /// be strictly ordered by that same id, so a selection filtered out of + /// this slice is already in wire order. + #[must_use] + pub fn advertised(&self) -> &[AdvertisedReviewDelivery] { + &self.advertised + } + + /// Whether this exact comment may carry a send control. + #[must_use] + pub fn advertises(&self, comment_id: &str) -> bool { + self.advertised + .iter() + .any(|entry| entry.comment_id == comment_id) + } +} + +#[cfg(test)] +mod tests { + use super::super::{ + CommentAgentState, PlatformReviewSemantic, ReviewAgentDeliveryCapability, ReviewAuthority, + ReviewCommentId, ReviewFreshnessState, ReviewGitStagingCapabilities, + ReviewPullRequestCapabilities, + }; + use super::*; + use automonique_protocol::platform_v2::{ProjectId, WorkContextIdentity}; + use automonique_protocol::platform_v2_review::ReviewAuthorityId; + use automonique_protocol::platform_v2_review_api::decode_review_snapshot; + + const CANONICAL_FIXTURE: &[u8] = + include_bytes!("../../../tests/fixtures/platform-v2-review-v2.json"); + + fn fixture() -> (PlatformReviewTarget, PlatformReviewSemantic) { + let snapshot = decode_review_snapshot(CANONICAL_FIXTURE).unwrap(); + let mut review = PlatformReviewSemantic::from(&snapshot); + review.comments[0].agent_state = CommentAgentState::NotSent; + let target = PlatformReviewTarget { + project: ProjectId::new("project-1").unwrap(), + workspace: snapshot.workspace().clone(), + }; + (target, review) + } + + /// Build a capability response with arbitrary coordinates, so each fence + /// can be broken one at a time. + fn capabilities( + project: &ProjectId, + workspace: &WorkContextIdentity, + snapshot_revision: Revision, + entries: Vec<(&str, Revision, &str)>, + ) -> ReviewCapabilities { + let advertised = entries + .into_iter() + .map(|(id, revision, authority)| { + ReviewAgentDeliveryCapability::new( + ReviewCommentId::new(id.to_owned()).unwrap(), + revision, + ReviewAuthority::new( + ReviewAuthorityKind::Review, + ReviewAuthorityId::new(authority.to_owned()).unwrap(), + ), + ) + .unwrap() + }) + .collect(); + ReviewCapabilities::new( + project.clone(), + workspace.clone(), + snapshot_revision, + Revision::new(91).unwrap(), + Vec::new(), + advertised, + ReviewPullRequestCapabilities::default(), + ReviewGitStagingCapabilities::default(), + ) + .unwrap() + } + + fn exact(target: &PlatformReviewTarget, review: &PlatformReviewSemantic) -> ReviewCapabilities { + let comment = &review.comments[0]; + capabilities( + &target.project, + &target.workspace, + review.revision, + vec![( + comment.id.as_str(), + comment.revision, + review.review.authority.id.as_str(), + )], + ) + } + + // SDTEST-1864 + #[test] + fn every_delivery_fence_fails_closed_with_its_own_distinct_reason() { + let (target, review) = fixture(); + let exact_capabilities = exact(&target, &review); + + let granted = + ReviewAgentDeliveryProjection::new(&review, &target, Some(&exact_capabilities), true); + assert_eq!(granted.control, Ok(())); + assert!(granted.advertises(&review.comments[0].id)); + assert_eq!( + granted.advertised(), + &[AdvertisedReviewDelivery { + comment_id: review.comments[0].id.clone(), + expected_comment_revision: review.comments[0].revision, + authority_id: review.review.authority.id.clone(), + }] + ); + + // No capability response at all. + assert_eq!( + ReviewAgentDeliveryProjection::new(&review, &target, None, true).control, + Err(ReviewDeliveryWithheld::NoServerCapability) + ); + assert_eq!( + ReviewAgentDeliveryProjection::unavailable().control, + Err(ReviewDeliveryWithheld::NoServerCapability) + ); + + // A response for another project, another workspace, or another + // snapshot revision is not this snapshot's answer. + let foreign_project = capabilities( + &ProjectId::new("project-2").unwrap(), + &target.workspace, + review.revision, + vec![( + review.comments[0].id.as_str(), + review.comments[0].revision, + review.review.authority.id.as_str(), + )], + ); + assert_eq!( + ReviewAgentDeliveryProjection::new(&review, &target, Some(&foreign_project), true) + .control, + Err(ReviewDeliveryWithheld::NoServerCapability) + ); + let stale_snapshot = capabilities( + &target.project, + &target.workspace, + Revision::new(review.revision.get() + 1).unwrap(), + vec![( + review.comments[0].id.as_str(), + review.comments[0].revision, + review.review.authority.id.as_str(), + )], + ); + assert_eq!( + ReviewAgentDeliveryProjection::new(&review, &target, Some(&stale_snapshot), true) + .control, + Err(ReviewDeliveryWithheld::NoServerCapability) + ); + + // `advertised_agent_deliveries` is asserted directly, not only through + // the projection: the action preview calls it on its own, so a fence + // that lived only in `ReviewAgentDeliveryProjection::new` would let a + // preview pin itself to a foreign or superseded snapshot revision. + assert!( + advertised_agent_deliveries(Some(&exact_capabilities), &target, &review).len() == 1 + ); + for hole in [&foreign_project, &stale_snapshot] { + assert!( + advertised_agent_deliveries(Some(hole), &target, &review).is_empty(), + "a capability response for another coordinate advertises nothing" + ); + } + assert!(advertised_agent_deliveries(None, &target, &review).is_empty()); + let foreign_workspace = PlatformReviewTarget { + project: target.project.clone(), + workspace: WorkContextIdentity::parse_local(target.workspace.kind(), "wc_user_foreign") + .unwrap(), + }; + assert!(advertised_agent_deliveries( + Some(&exact_capabilities), + &foreign_workspace, + &review + ) + .is_empty()); + + // An exact response advertising nothing is the server's honest + // fail-closed answer, and must read differently from an absent one. + let empty = capabilities( + &target.project, + &target.workspace, + review.revision, + Vec::new(), + ); + assert_eq!( + ReviewAgentDeliveryProjection::new(&review, &target, Some(&empty), true).control, + Err(ReviewDeliveryWithheld::NoDeliverableComment) + ); + + // Custody is still required. This lane needs no confirmation digest, + // but it is an exposed mutation and takes the same durable record. + assert_eq!( + ReviewAgentDeliveryProjection::new(&review, &target, Some(&exact_capabilities), false) + .control, + Err(ReviewDeliveryWithheld::NoCustodyLane) + ); + + // Torn reads. The capability list and the snapshot are two separate + // reads; a coherent server mints them from one projection, so any + // disagreement means they straddled a change and the entry is dropped. + let wrong_comment_revision = capabilities( + &target.project, + &target.workspace, + review.revision, + vec![( + review.comments[0].id.as_str(), + Revision::new(review.comments[0].revision.get() + 1).unwrap(), + review.review.authority.id.as_str(), + )], + ); + assert_eq!( + ReviewAgentDeliveryProjection::new( + &review, + &target, + Some(&wrong_comment_revision), + true + ) + .control, + Err(ReviewDeliveryWithheld::NoDeliverableComment) + ); + let unknown_comment = capabilities( + &target.project, + &target.workspace, + review.revision, + vec![( + "comment-absent", + review.comments[0].revision, + review.review.authority.id.as_str(), + )], + ); + assert_eq!( + ReviewAgentDeliveryProjection::new(&review, &target, Some(&unknown_comment), true) + .control, + Err(ReviewDeliveryWithheld::NoDeliverableComment) + ); + let foreign_authority = capabilities( + &target.project, + &target.workspace, + review.revision, + vec![( + review.comments[0].id.as_str(), + review.comments[0].revision, + "authority-other", + )], + ); + assert_eq!( + ReviewAgentDeliveryProjection::new(&review, &target, Some(&foreign_authority), true) + .control, + Err(ReviewDeliveryWithheld::NoDeliverableComment) + ); + + // A comment already delivered, and a review that is no longer fresh, + // are both unsendable however the advertisement reads. + let mut sent = review.clone(); + sent.comments[0].agent_state = CommentAgentState::Sent; + assert_eq!( + ReviewAgentDeliveryProjection::new(&sent, &target, Some(&exact_capabilities), true) + .control, + Err(ReviewDeliveryWithheld::NoDeliverableComment) + ); + let mut stale = review.clone(); + stale.review.freshness.state = ReviewFreshnessState::Stale; + assert_eq!( + ReviewAgentDeliveryProjection::new(&stale, &target, Some(&exact_capabilities), true) + .control, + Err(ReviewDeliveryWithheld::NoDeliverableComment) + ); + + // Every reason a surface must explain carries its own key. + let keys = ReviewDeliveryWithheld::ALL + .iter() + .map(|reason| reason.semantic_key()) + .collect::>(); + assert_eq!(keys.len(), ReviewDeliveryWithheld::ALL.len()); + } +} diff --git a/crates/shelldeck-core/src/config/platform_review/notes.rs b/crates/shelldeck-core/src/config/platform_review/notes.rs new file mode 100644 index 00000000..f9036f5c --- /dev/null +++ b/crates/shelldeck-core/src/config/platform_review/notes.rs @@ -0,0 +1,591 @@ +//! Durable line-anchored review notes for the native Platform v2 lane. +//! +//! A note is a local draft, never an observation and never authority. It +//! retains the exact snapshot coordinates it was written against — file, +//! hunk, side, line and the captured snapshot revision — so a later snapshot +//! can never silently re-anchor it. Selection is persisted next to the note +//! so a batch delivery decision survives restart, but the store itself +//! dispatches nothing: only a typed preview may cross the network. + +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use super::*; +use crate::config::app_config::AppConfig; +use crate::workspace_review::storage::{ + bounded_descriptor_read, ensure_private_directory_io, lock_path, open_lock_file, + secure_atomic_write, +}; + +const REVIEW_NOTE_SCHEMA: u16 = 1; +const MAX_REVIEW_NOTE_TARGETS: usize = 16; +const MAX_REVIEW_NOTES_PER_TARGET: usize = 64; +const MAX_REVIEW_NOTE_BODY_BYTES: usize = 4 * 1024; +const MAX_REVIEW_NOTE_FILE_BYTES: u64 = 1024 * 1024; +static REVIEW_NOTE_PROCESS_LOCK: parking_lot::Mutex<()> = parking_lot::Mutex::new(()); + +/// One persisted line-anchored draft, bound to the snapshot it was written on. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PlatformReviewNote { + pub id: String, + pub anchor: ReviewAnchorSemantic, + pub body: String, + pub captured_revision: Revision, + pub selected: bool, + pub created_at_ms: u64, +} + +impl PlatformReviewNote { + /// A note may be prepared or delivered only while the snapshot it was + /// anchored against is still the exact active one and its coordinates + /// still resolve inside that snapshot. + #[must_use] + pub fn is_actionable(&self, review: &PlatformReviewSemantic) -> bool { + self.captured_revision == review.revision && validate_anchor(review, &self.anchor).is_ok() + } +} + +#[derive(Clone, Debug)] +pub struct PlatformReviewNoteStore { + path: PathBuf, +} + +impl PlatformReviewNoteStore { + pub fn open_default() -> Result { + Self::open( + AppConfig::config_dir() + .join("platform-review") + .join("notes") + .join("notes-v1.json"), + ) + } + + pub fn open(path: PathBuf) -> Result { + let store = Self { path }; + let _guard = REVIEW_NOTE_PROCESS_LOCK.lock(); + store.prepare_storage()?; + let lock = open_lock_file(&lock_path(&store.path))?; + fs2::FileExt::lock_exclusive(&lock)?; + let _ = load_document(&store.path)?; + Ok(store) + } + + /// Persist one note anchored inside the exact current snapshot. + pub fn add( + &self, + target: &PlatformReviewTarget, + review: &PlatformReviewSemantic, + anchor: &ReviewAnchorSemantic, + body: &str, + ) -> Result { + validate_review_target(target, review).map_err(|_| ReviewNoteError::ForeignSnapshot)?; + validate_anchor(review, anchor).map_err(|_| ReviewNoteError::AnchorInvalid)?; + let body = body.trim().to_owned(); + if body.is_empty() || body.len() > MAX_REVIEW_NOTE_BODY_BYTES { + return Err(ReviewNoteError::BodyInvalid); + } + let note = PlatformReviewNote { + id: format!("shelldeck-note-{}", review_nonce()), + anchor: anchor.clone(), + body, + captured_revision: review.revision, + selected: false, + created_at_ms: now_ms(), + }; + let stored = note.clone(); + self.transact(|document| { + let record = document.target_mut(target)?; + if record.notes.len() >= MAX_REVIEW_NOTES_PER_TARGET { + return Err(ReviewNoteError::CapacityExceeded); + } + if record.notes.iter().any(|value| value.id == stored.id) { + return Err(ReviewNoteError::DuplicateNote); + } + record.notes.push(ReviewNoteDisk::from_note(&stored)); + Ok(()) + })?; + Ok(note) + } + + /// Mark or unmark one note for batch delivery. + pub fn set_selected( + &self, + target: &PlatformReviewTarget, + id: &str, + selected: bool, + ) -> Result<(), ReviewNoteError> { + self.transact(|document| { + let record = document.target_mut(target)?; + let note = record + .notes + .iter_mut() + .find(|note| note.id == id) + .ok_or(ReviewNoteError::UnknownNote)?; + note.selected = selected; + Ok(()) + }) + } + + pub fn remove(&self, target: &PlatformReviewTarget, id: &str) -> Result<(), ReviewNoteError> { + self.transact(|document| { + let record = document.target_mut(target)?; + let before = record.notes.len(); + record.notes.retain(|note| note.id != id); + if record.notes.len() == before { + return Err(ReviewNoteError::UnknownNote); + } + document.records.retain(|record| !record.notes.is_empty()); + Ok(()) + }) + } + + /// Every retained note for this exact target, oldest first. + pub fn notes( + &self, + target: &PlatformReviewTarget, + ) -> Result, ReviewNoteError> { + let document = self.read()?; + let Some(record) = document + .records + .iter() + .find(|record| record.matches_target(target)) + else { + return Ok(Vec::new()); + }; + let mut notes = record + .notes + .iter() + .map(ReviewNoteDisk::to_note) + .collect::, _>>()?; + notes.sort_by(|left, right| { + left.created_at_ms + .cmp(&right.created_at_ms) + .then_with(|| left.id.cmp(&right.id)) + }); + Ok(notes) + } + + /// Selected notes that the exact active snapshot still supports. A note + /// captured against a superseded revision is retained but never returned + /// here: it must be re-anchored explicitly, never silently. + pub fn selected_actionable( + &self, + target: &PlatformReviewTarget, + review: &PlatformReviewSemantic, + ) -> Result, ReviewNoteError> { + validate_review_target(target, review).map_err(|_| ReviewNoteError::ForeignSnapshot)?; + Ok(self + .notes(target)? + .into_iter() + .filter(|note| note.selected && note.is_actionable(review)) + .collect()) + } + + fn read(&self) -> Result { + let _guard = REVIEW_NOTE_PROCESS_LOCK.lock(); + self.prepare_storage()?; + let lock = open_lock_file(&lock_path(&self.path))?; + fs2::FileExt::lock_exclusive(&lock)?; + load_document(&self.path) + } + + fn transact( + &self, + update: impl FnOnce(&mut ReviewNoteDocument) -> Result, + ) -> Result { + let _guard = REVIEW_NOTE_PROCESS_LOCK.lock(); + self.prepare_storage()?; + let lock = open_lock_file(&lock_path(&self.path))?; + fs2::FileExt::lock_exclusive(&lock)?; + let mut document = load_document(&self.path)?; + let outcome = update(&mut document)?; + document.revision = document + .revision + .checked_add(1) + .ok_or(ReviewNoteError::DocumentInvalid)?; + persist_document(&self.path, &document)?; + Ok(outcome) + } + + fn prepare_storage(&self) -> Result<(), ReviewNoteError> { + let parent = self.path.parent().ok_or(ReviewNoteError::PathInvalid)?; + ensure_private_directory_io(parent)?; + Ok(()) + } +} + +fn now_ms() -> u64 { + u64::try_from( + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis(), + ) + .unwrap_or(u64::MAX) +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct ReviewNoteDocument { + schema: u16, + revision: u64, + records: Vec, +} + +impl Default for ReviewNoteDocument { + fn default() -> Self { + Self { + schema: REVIEW_NOTE_SCHEMA, + revision: 0, + records: Vec::new(), + } + } +} + +impl ReviewNoteDocument { + fn target_mut( + &mut self, + target: &PlatformReviewTarget, + ) -> Result<&mut ReviewNoteTargetDisk, ReviewNoteError> { + if let Some(index) = self + .records + .iter() + .position(|record| record.matches_target(target)) + { + return Ok(&mut self.records[index]); + } + if self.records.len() >= MAX_REVIEW_NOTE_TARGETS { + return Err(ReviewNoteError::CapacityExceeded); + } + self.records.push(ReviewNoteTargetDisk { + project: target.project.as_str().to_owned(), + workspace_kind: target.workspace.kind().as_str().to_owned(), + workspace: target.workspace.id().to_owned(), + notes: Vec::new(), + }); + self.records + .last_mut() + .ok_or(ReviewNoteError::DocumentInvalid) + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct ReviewNoteTargetDisk { + project: String, + workspace_kind: String, + workspace: String, + notes: Vec, +} + +impl ReviewNoteTargetDisk { + fn matches_target(&self, target: &PlatformReviewTarget) -> bool { + self.project == target.project.as_str() + && self.workspace_kind == target.workspace.kind().as_str() + && self.workspace == target.workspace.id() + } + + fn validate_identity(&self) -> Result<(), ReviewNoteError> { + ProjectId::new(self.project.clone()).map_err(|_| ReviewNoteError::DocumentInvalid)?; + let kind = WorkContextTargetKind::parse(&self.workspace_kind) + .map_err(|_| ReviewNoteError::DocumentInvalid)?; + if kind != WorkContextTargetKind::UserWorkspace { + return Err(ReviewNoteError::DocumentInvalid); + } + WorkContextIdentity::parse_local(kind, &self.workspace) + .map_err(|_| ReviewNoteError::DocumentInvalid)?; + Ok(()) + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct ReviewNoteDisk { + id: String, + file_id: String, + hunk_id: String, + side: String, + line: u32, + body: String, + captured_revision: u64, + selected: bool, + created_at_ms: u64, +} + +impl ReviewNoteDisk { + fn from_note(note: &PlatformReviewNote) -> Self { + Self { + id: note.id.clone(), + file_id: note.anchor.file_id.clone(), + hunk_id: note.anchor.hunk_id.clone(), + side: note.anchor.side.as_str().to_owned(), + line: note.anchor.line, + body: note.body.clone(), + captured_revision: note.captured_revision.get(), + selected: note.selected, + created_at_ms: note.created_at_ms, + } + } + + fn to_note(&self) -> Result { + if self.id.is_empty() + || self.file_id.is_empty() + || self.hunk_id.is_empty() + || self.body.is_empty() + || self.body.len() > MAX_REVIEW_NOTE_BODY_BYTES + { + return Err(ReviewNoteError::DocumentInvalid); + } + Ok(PlatformReviewNote { + id: self.id.clone(), + anchor: ReviewAnchorSemantic { + file_id: self.file_id.clone(), + hunk_id: self.hunk_id.clone(), + side: DiffSide::parse(&self.side).map_err(|_| ReviewNoteError::DocumentInvalid)?, + line: self.line, + }, + body: self.body.clone(), + captured_revision: Revision::new(self.captured_revision) + .map_err(|_| ReviewNoteError::DocumentInvalid)?, + selected: self.selected, + created_at_ms: self.created_at_ms, + }) + } +} + +fn load_document(path: &Path) -> Result { + let Some(bytes) = bounded_descriptor_read(path, MAX_REVIEW_NOTE_FILE_BYTES)? else { + return Ok(ReviewNoteDocument::default()); + }; + if bytes.len() as u64 > MAX_REVIEW_NOTE_FILE_BYTES { + return Err(ReviewNoteError::DocumentInvalid); + } + let document: ReviewNoteDocument = serde_json::from_slice(&bytes)?; + validate_document(&document)?; + Ok(document) +} + +fn validate_document(document: &ReviewNoteDocument) -> Result<(), ReviewNoteError> { + if document.schema != REVIEW_NOTE_SCHEMA || document.records.len() > MAX_REVIEW_NOTE_TARGETS { + return Err(ReviewNoteError::DocumentInvalid); + } + let mut targets = std::collections::BTreeSet::new(); + for record in &document.records { + record.validate_identity()?; + if !targets.insert(( + record.project.as_str(), + record.workspace_kind.as_str(), + record.workspace.as_str(), + )) { + return Err(ReviewNoteError::DocumentInvalid); + } + if record.notes.len() > MAX_REVIEW_NOTES_PER_TARGET { + return Err(ReviewNoteError::DocumentInvalid); + } + let mut ids = std::collections::BTreeSet::new(); + for note in &record.notes { + let _ = note.to_note()?; + if !ids.insert(note.id.as_str()) { + return Err(ReviewNoteError::DocumentInvalid); + } + } + } + Ok(()) +} + +fn persist_document(path: &Path, document: &ReviewNoteDocument) -> Result<(), ReviewNoteError> { + validate_document(document)?; + let bytes = serde_json::to_vec_pretty(document)?; + if bytes.len() as u64 > MAX_REVIEW_NOTE_FILE_BYTES { + return Err(ReviewNoteError::DocumentInvalid); + } + secure_atomic_write(path, &bytes)?; + Ok(()) +} + +#[derive(Debug, Error)] +pub enum ReviewNoteError { + #[error("review note storage failed: {0}")] + Io(#[from] std::io::Error), + #[error("review note document is not valid JSON: {0}")] + Json(#[from] serde_json::Error), + #[error("review note path is invalid")] + PathInvalid, + #[error("review note document is invalid or exceeds its bound")] + DocumentInvalid, + #[error("review notes have reached their bounded capacity")] + CapacityExceeded, + #[error("review note already exists")] + DuplicateNote, + #[error("review note is not retained for this target")] + UnknownNote, + #[error("review note body is empty or exceeds its bound")] + BodyInvalid, + #[error("review note anchor is not in the exact snapshot")] + AnchorInvalid, + #[error("review snapshot belongs to another workspace")] + ForeignSnapshot, +} + +#[cfg(test)] +mod tests { + use super::*; + use automonique_protocol::platform_v2_review_api::decode_review_snapshot; + + const CANONICAL_FIXTURE: &[u8] = + include_bytes!("../../../tests/fixtures/platform-v2-review-v2.json"); + + fn semantic() -> PlatformReviewSemantic { + PlatformReviewSemantic::from(&decode_review_snapshot(CANONICAL_FIXTURE).unwrap()) + } + + fn target(review: &PlatformReviewSemantic) -> PlatformReviewTarget { + PlatformReviewTarget { + project: ProjectId::new("project-1").unwrap(), + workspace: WorkContextIdentity::parse_local( + WorkContextTargetKind::UserWorkspace, + &review.workspace_id, + ) + .unwrap(), + } + } + + fn first_anchor(review: &PlatformReviewSemantic) -> ReviewAnchorSemantic { + let file = review + .files + .iter() + .find(|file| !file.hunks.is_empty()) + .unwrap(); + let hunk = &file.hunks[0]; + let (side, start) = if hunk.new_lines > 0 { + (DiffSide::New, hunk.new_start) + } else { + (DiffSide::Old, hunk.old_start) + }; + ReviewAnchorSemantic { + file_id: file.id.clone(), + hunk_id: hunk.id.clone(), + side, + line: start, + } + } + + struct TempRoot(PathBuf); + + impl TempRoot { + fn new() -> Self { + let path = std::env::temp_dir().join(format!( + "shelldeck-review-notes-{}-{}", + std::process::id(), + uuid::Uuid::new_v4() + )); + std::fs::create_dir_all(&path).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o700)).unwrap(); + } + Self(path) + } + + fn store_path(&self) -> PathBuf { + self.0 + .join("platform-review") + .join("notes") + .join("notes-v1.json") + } + } + + impl Drop for TempRoot { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + // SDTEST-1853 — a line-anchored note survives restart with its exact + // captured coordinates and its selection decision. + #[test] + fn note_and_selection_survive_restart_with_exact_anchor() { + let directory = TempRoot::new(); + let review = semantic(); + let target = target(&review); + let anchor = first_anchor(&review); + let store = PlatformReviewNoteStore::open(directory.store_path()).unwrap(); + let note = store + .add(&target, &review, &anchor, " revoir cette borne ") + .unwrap(); + store.set_selected(&target, ¬e.id, true).unwrap(); + + let restarted = PlatformReviewNoteStore::open(directory.store_path()).unwrap(); + let notes = restarted.notes(&target).unwrap(); + assert_eq!(notes.len(), 1); + assert_eq!(notes[0].anchor, anchor); + assert_eq!(notes[0].body, "revoir cette borne"); + assert_eq!(notes[0].captured_revision, review.revision); + assert!(notes[0].selected); + assert_eq!( + restarted.selected_actionable(&target, &review).unwrap(), + notes + ); + } + + // SDTEST-1854 — a note is never re-anchored onto a newer snapshot and a + // coordinate outside the exact snapshot is refused before persistence. + #[test] + fn superseded_revision_and_foreign_anchor_are_never_actionable() { + let directory = TempRoot::new(); + let review = semantic(); + let target = target(&review); + let anchor = first_anchor(&review); + let store = PlatformReviewNoteStore::open(directory.store_path()).unwrap(); + let note = store.add(&target, &review, &anchor, "borne").unwrap(); + store.set_selected(&target, ¬e.id, true).unwrap(); + + let mut advanced = review.clone(); + advanced.revision = Revision::new(review.revision.get() + 1).unwrap(); + assert!(!note.is_actionable(&advanced)); + assert!(store + .selected_actionable(&target, &advanced) + .unwrap() + .is_empty()); + // The note itself is retained, not silently dropped or moved. + assert_eq!(store.notes(&target).unwrap().len(), 1); + + let outside = ReviewAnchorSemantic { + line: anchor.line.saturating_add(9_999), + ..anchor + }; + assert!(matches!( + store.add(&target, &review, &outside, "hors borne"), + Err(ReviewNoteError::AnchorInvalid) + )); + assert!(matches!( + store.add(&target, &review, &first_anchor(&review), " "), + Err(ReviewNoteError::BodyInvalid) + )); + assert_eq!(store.notes(&target).unwrap().len(), 1); + } + + // SDTEST-1855 — an invalid or oversized note document fails closed and + // never replaces what is already on disk. + #[test] + fn oversized_note_document_is_rejected_without_replacement() { + let directory = TempRoot::new(); + let review = semantic(); + let target = target(&review); + let path = directory.store_path(); + let store = PlatformReviewNoteStore::open(path.clone()).unwrap(); + store + .add(&target, &review, &first_anchor(&review), "borne") + .unwrap(); + let oversized = vec![b'x'; MAX_REVIEW_NOTE_FILE_BYTES as usize + 1]; + std::fs::write(&path, &oversized).unwrap(); + assert!(matches!( + PlatformReviewNoteStore::open(path.clone()), + Err(ReviewNoteError::DocumentInvalid) + )); + assert_eq!(std::fs::read(path).unwrap(), oversized); + } +} diff --git a/crates/shelldeck-core/src/config/platform_review/worktree.rs b/crates/shelldeck-core/src/config/platform_review/worktree.rs new file mode 100644 index 00000000..79ba257e --- /dev/null +++ b/crates/shelldeck-core/src/config/platform_review/worktree.rs @@ -0,0 +1,1878 @@ +//! Combined worktree lanes and bounded safe previews for one review snapshot. +//! +//! This module is a pure read-side projection of [`PlatformReviewSemantic`]. +//! It answers two questions and nothing else: which combined +//! staged/unstaged/untracked/conflicted lane every observed file belongs to, +//! and what — if anything — may be painted for that file's preview. +//! +//! It grants no authority of its own. Staging is a Git mutation, so a control +//! exists only where the server advertised a slot for that exact proposal, and +//! the projection otherwise reports which fence is missing so the surface can +//! say so instead of offering a control that would refuse. +//! +//! # What the server has to prove before a control exists +//! +//! A worktree is shared substrate: any other process running as the daemon uid +//! can move `HEAD` and rewrite the index between an advertisement and the +//! action arriving, and the snapshot revision does not help — it tracks what +//! the projection observed, not what the repository now is. So every entry in +//! `staging` and `conflict_resolutions` names both mutable things the write +//! depends on, and its confirmation digest commits to them. This module +//! carries that observation verbatim ([`ReviewWorktreeObservation`]) so a +//! reader can see what the server read rather than having to trust that it +//! read anything. +//! +//! # Absence is the withholding +//! +//! The three staging kinds share one capability type but not one grant: an +//! operator withholds committing separately from index writes, and conflict +//! resolution separately again. A deployment that grants stage and unstage but +//! not commit therefore produces a `staging` list with `Stage` and `Unstage` +//! entries and no `Commit` entry — and the commit control must simply not +//! exist. A disabled button with a tooltip would claim the grant is present +//! and merely unavailable, which is the opposite of what the list says. +//! +//! # No hunk granularity +//! +//! [`super::ReviewProposalId`] names a proposal and a proposal lists file ids; +//! `ReviewAnchor`'s hunk id exists for comments only. File-level staging is +//! all the contract can express, so it is all this module offers. A hunk +//! control here would be advertising something no action can name. + +use super::{ + ConflictResolution, ConflictState, DiffChangeKind, DiffSide, PlatformReviewSemantic, + PlatformReviewTarget, PreviewKind, ReviewAnchorSemantic, ReviewAuthorityKind, + ReviewCapabilities, ReviewFileSemantic, ReviewProposalKind, WorktreeFileState, +}; +use automonique_protocol::primitives::Revision; + +/// Largest declared preview payload ShellDeck will describe at all. +/// +/// The declared size is the server's claim, not a measurement: refusing above +/// this ceiling keeps an inflated claim from becoming a rendering budget. +pub const MAX_SAFE_PREVIEW_BYTES: u64 = 512 * 1024; + +/// Largest declared raster area ShellDeck will describe. +pub const MAX_SAFE_PREVIEW_PIXELS: u64 = 16 * 1024 * 1024; + +/// Largest declared raster edge ShellDeck will describe. +pub const MAX_SAFE_PREVIEW_EDGE: u32 = 16_384; + +/// Longest edge, in logical pixels, of the bounded image placeholder box. +pub const SAFE_PREVIEW_BOX_EDGE: u32 = 200; + +/// Most preview lines retained for one file or one hunk. +pub const MAX_SAFE_PREVIEW_LINES: usize = 12; + +/// Most characters retained per preview line. +pub const MAX_SAFE_PREVIEW_LINE_CHARS: usize = 160; + +/// Invisible formatting scalars that can reorder or hide neighbouring text. +/// +/// `char::is_control` — the protocol's own bound — does not cover these, so a +/// bidirectional override smuggled into a path or hunk preview would still +/// render as a convincingly different line (the "trojan source" class). +const INVISIBLE_FORMATTING: [char; 13] = [ + '\u{061c}', // ARABIC LETTER MARK + '\u{200b}', // ZERO WIDTH SPACE + '\u{200c}', // ZERO WIDTH NON-JOINER + '\u{200d}', // ZERO WIDTH JOINER + '\u{200e}', // LEFT-TO-RIGHT MARK + '\u{200f}', // RIGHT-TO-LEFT MARK + '\u{202a}', // LEFT-TO-RIGHT EMBEDDING + '\u{202b}', // RIGHT-TO-LEFT EMBEDDING + '\u{202d}', // LEFT-TO-RIGHT OVERRIDE + '\u{202e}', // RIGHT-TO-LEFT OVERRIDE + '\u{2066}', // LEFT-TO-RIGHT ISOLATE + '\u{2067}', // RIGHT-TO-LEFT ISOLATE + '\u{2068}', // FIRST STRONG ISOLATE +]; + +/// One combined lane of the review's worktree state. +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub enum ReviewWorktreeLane { + /// Unresolved merge conflicts. Listed first: nothing else can proceed. + Conflicted, + Staged, + Unstaged, + Untracked, +} + +impl ReviewWorktreeLane { + /// Presentation order, most blocking first. + pub const ALL: [Self; 4] = [ + Self::Conflicted, + Self::Staged, + Self::Unstaged, + Self::Untracked, + ]; + + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Conflicted => "conflicted", + Self::Staged => "staged", + Self::Unstaged => "unstaged", + Self::Untracked => "untracked", + } + } + + /// Canonical cross-client presentation key, carrying no localized copy. + #[must_use] + pub fn semantic_key(self) -> String { + format!("worktree_lane.{}", self.as_str()) + } +} + +/// Why the projection offers no Git staging control. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ReviewStagingWithheld { + /// No capability response is attributed to this exact project, workspace + /// and snapshot revision, so nothing has been advertised at all. + NoServerCapability, + /// The capability response is exact and both its git lists are empty. + /// That is the server's honest fail-closed answer — no registry binding + /// granting index writes, a repository the preflight could not read, an + /// unborn `HEAD`, or a worktree in a state no staging write can be fenced + /// against — and it must produce no control. + NoStageableProposal, + /// No durable at-most-once custody store is available in this process. + NoCustodyLane, +} + +impl ReviewStagingWithheld { + /// Every reason a surface must be able to explain. + pub const ALL: [Self; 3] = [ + Self::NoServerCapability, + Self::NoStageableProposal, + Self::NoCustodyLane, + ]; + + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::NoServerCapability => "no_server_capability", + Self::NoStageableProposal => "no_stageable_proposal", + Self::NoCustodyLane => "no_custody_lane", + } + } + + #[must_use] + pub fn semantic_key(self) -> String { + format!("staging_withheld.{}", self.as_str()) + } +} + +/// The one repository read every git capability in a response was minted from. +/// +/// `ReviewCapabilities::new` refuses a response whose git entries disagree on +/// either field, so this is a property of the whole document rather than of an +/// entry. It is carried here for the reason the server carries it at all: a +/// client must be able to see the `HEAD` and index the preflight observed, and +/// decline to offer a control once it holds a document that read a different +/// worktree. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ReviewWorktreeObservation { + /// The commit `HEAD` resolved to when the server read the repository. + pub head_revision: String, + /// A digest over the whole index as the preflight read it. + pub index_digest: String, +} + +/// One staging transition the server proved it can perform, carried verbatim. +/// +/// The two confirmation digests are deliberately absent. They are minted per +/// entry and must cross the wire exactly as the server spelled them, so +/// [`super::PlatformReviewActionPreview`] reads them straight off the +/// capability rather than through a render-side copy that could drift. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AdvertisedReviewStaging { + pub proposal_id: String, + pub kind: ReviewProposalKind, + pub expected_head_revision: String, + pub expected_index_digest: String, + pub authority_id: String, +} + +/// One conflicted file the server proved it can collapse to one recorded side. +/// +/// The server lists **one entry per admissible side**, not one per file with +/// the side left to the client: the side decides which bytes land, so it is +/// inside the confirmation digest, and a digest cannot commit to a choice not +/// yet made. A file with both stage 2 and stage 3 recorded therefore yields +/// two entries here, and the surface renders two controls. A delete/modify +/// conflict has one side recorded and yields one. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AdvertisedReviewConflictResolution { + pub proposal_id: String, + pub file_id: String, + pub resolution: ConflictResolution, + pub expected_head_revision: String, + pub expected_index_digest: String, + pub authority_id: String, +} + +/// Whether a capability response is attributed to this exact review read. +fn exact_capability_attribution( + capabilities: &ReviewCapabilities, + target: &PlatformReviewTarget, + review: &PlatformReviewSemantic, +) -> bool { + capabilities.project() == &target.project + && capabilities.workspace() == &target.workspace + && capabilities.snapshot_revision() == review.revision + && review.workspace_kind == target.workspace.kind() + && review.workspace_id == target.workspace.id() +} + +/// The exact advertised staging set for one active snapshot. +/// +/// Every entry survives three checks, exactly like the delivery lane: the +/// capability response is attributed to this project, workspace and snapshot +/// revision; its authority is a Git authority the proposal itself names; and +/// the proposal is present in that same snapshot, at the same kind, and +/// locally actionable. +/// +/// The third check is a torn-read guard, not a second source of authority. The +/// capability list and the snapshot are two separate reads; a disagreement +/// means they straddled a change, and refusing the entry is the fail-closed +/// answer. It can never *add* an entry the server did not advertise, which is +/// what makes a withheld `commit` grant render as absence. +#[must_use] +pub fn advertised_staging_proposals( + capabilities: Option<&ReviewCapabilities>, + target: &PlatformReviewTarget, + review: &PlatformReviewSemantic, +) -> Vec { + let Some(capabilities) = capabilities else { + return Vec::new(); + }; + if !exact_capability_attribution(capabilities, target, review) { + return Vec::new(); + } + capabilities + .staging() + .iter() + .filter(|capability| capability.authority().kind() == ReviewAuthorityKind::Git) + .filter(|capability| { + review.proposals.iter().any(|proposal| { + proposal.id == capability.proposal_id().as_str() + && proposal.kind == capability.kind() + && proposal.authority.as_ref().is_some_and(|authority| { + authority.kind == ReviewAuthorityKind::Git + && authority.id == capability.authority().id().as_str() + }) + }) && review.proposal_is_actionable(capability.proposal_id().as_str()) + }) + .map(|capability| AdvertisedReviewStaging { + proposal_id: capability.proposal_id().as_str().to_owned(), + kind: capability.kind(), + expected_head_revision: capability.expected_head_revision().as_str().to_owned(), + expected_index_digest: capability.expected_index_digest().as_str().to_owned(), + authority_id: capability.authority().id().as_str().to_owned(), + }) + .collect() +} + +/// The exact advertised conflict-resolution set for one active snapshot. +/// +/// The snapshot-side guard differs from the staging one because the domain +/// does: the proposal must be a `ResolveConflict` on a Git authority, the file +/// must be one the proposal names, and that file must still be unmerged in +/// this snapshot. A file git no longer reports as conflicted cannot be +/// collapsed to a side, whatever an older capability read said. +#[must_use] +pub fn advertised_conflict_resolutions( + capabilities: Option<&ReviewCapabilities>, + target: &PlatformReviewTarget, + review: &PlatformReviewSemantic, +) -> Vec { + let Some(capabilities) = capabilities else { + return Vec::new(); + }; + if !exact_capability_attribution(capabilities, target, review) { + return Vec::new(); + } + capabilities + .conflict_resolutions() + .iter() + .filter(|capability| capability.authority().kind() == ReviewAuthorityKind::Git) + .filter(|capability| { + review.conflict_resolution_is_actionable( + capability.proposal_id().as_str(), + capability.file_id().as_str(), + ) && review.proposals.iter().any(|proposal| { + proposal.id == capability.proposal_id().as_str() + && proposal.authority.as_ref().is_some_and(|authority| { + authority.id == capability.authority().id().as_str() + }) + }) + }) + .map(|capability| AdvertisedReviewConflictResolution { + proposal_id: capability.proposal_id().as_str().to_owned(), + file_id: capability.file_id().as_str().to_owned(), + resolution: capability.resolution(), + expected_head_revision: capability.expected_head_revision().as_str().to_owned(), + expected_index_digest: capability.expected_index_digest().as_str().to_owned(), + authority_id: capability.authority().id().as_str().to_owned(), + }) + .collect() +} + +/// Decide whether any Git staging control may be rendered at all. +/// +/// Three fences must hold, exactly like the batch delivery lane: a capability +/// response attributed to this exact snapshot, at least one entry inside it, +/// and a durable custody store able to record the at-most-once boundary before +/// dispatch. A missing fence makes the control absent, never optimistically +/// disabled. +/// +/// This is the lane-level answer. It says a control *may* exist, never that +/// any particular one does: each proposal still needs its own advertised slot, +/// which is how a deployment withholding `commit` while granting `stage` and +/// `unstage` produces two controls and not three. +/// +/// # Errors +/// +/// Returns the first unmet fence. +pub const fn review_staging_control( + exact_capability: bool, + advertised: bool, + custody_available: bool, +) -> Result<(), ReviewStagingWithheld> { + if !exact_capability { + Err(ReviewStagingWithheld::NoServerCapability) + } else if !advertised { + Err(ReviewStagingWithheld::NoStageableProposal) + } else if !custody_available { + Err(ReviewStagingWithheld::NoCustodyLane) + } else { + Ok(()) + } +} + +/// Bounded, control-free text safe to paint as plain characters. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct ReviewSafeText { + pub lines: Vec, + /// At least one line or one character was dropped by the bounds. + pub truncated: bool, +} + +impl ReviewSafeText { + #[must_use] + pub fn is_empty(&self) -> bool { + self.lines.is_empty() + } +} + +/// Server-sanitized raster metadata plus the bounded box that may be painted. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ReviewSafeImage { + pub media_type: String, + pub width: u32, + pub height: u32, + pub byte_size: u64, + /// Aspect-preserving placeholder width, never above [`SAFE_PREVIEW_BOX_EDGE`]. + pub box_width: u32, + /// Aspect-preserving placeholder height, never above [`SAFE_PREVIEW_BOX_EDGE`]. + pub box_height: u32, +} + +/// Server-sanitized HTML, described only. +/// +/// This deliberately carries no markup and no source text: the review contract +/// ships no HTML body, and ShellDeck would neither interpret one as markup nor +/// re-emit one as source. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ReviewSafeHtml { + pub media_type: String, + pub byte_size: u64, +} + +/// Why a file's preview is not painted. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ReviewPreviewWithheld { + /// The snapshot declares no preview, or the declared text carries none. + NoContent, + /// Opaque binary content is never previewed. + Binary, + /// The server did not mark the payload sanitized. + Unsanitized, + /// The declared payload is missing or above the render budget. + Oversized, + /// The declared raster is degenerate or above the decode budget. + OversizedRaster, + /// The declared kind, media type, and payload disagree. + Incoherent, +} + +impl ReviewPreviewWithheld { + /// Every reason a surface must be able to explain. + pub const ALL: [Self; 6] = [ + Self::NoContent, + Self::Binary, + Self::Unsanitized, + Self::Oversized, + Self::OversizedRaster, + Self::Incoherent, + ]; + + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::NoContent => "no_content", + Self::Binary => "binary", + Self::Unsanitized => "unsanitized", + Self::Oversized => "oversized", + Self::OversizedRaster => "oversized_raster", + Self::Incoherent => "incoherent", + } + } + + #[must_use] + pub fn semantic_key(self) -> String { + format!("preview_withheld.{}", self.as_str()) + } +} + +/// What may be painted for one reviewed file. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum ReviewSafePreview { + Text(ReviewSafeText), + Image(ReviewSafeImage), + Html(ReviewSafeHtml), + Withheld(ReviewPreviewWithheld), +} + +impl ReviewSafePreview { + #[must_use] + pub const fn withheld(&self) -> Option { + match self { + Self::Withheld(reason) => Some(*reason), + _ => None, + } + } +} + +/// One server-proposed staging transition covering an exact file. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ReviewStagingProposal { + pub proposal_id: String, + pub kind: ReviewProposalKind, + /// The snapshot's own admissibility: a Git authority, a supported kind, + /// and no unresolved conflict among the proposal's files. + pub admissible: bool, +} + +/// One reviewable hunk with its exact anchor and bounded text. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ReviewWorktreeHunk { + pub id: String, + pub anchor: ReviewAnchorSemantic, + pub text: ReviewSafeText, +} + +/// One reviewed file as it appears inside a lane. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ReviewWorktreeFile { + pub id: String, + pub path: ReviewSafeText, + pub change: DiffChangeKind, + pub worktree: WorktreeFileState, + pub conflict: ConflictState, + /// The file is listed in more than one lane because only part of its + /// content is staged. + pub partial: bool, + pub preview: ReviewSafePreview, + pub hunks: Vec, + pub staging: Vec, +} + +/// One lane of the combined worktree review. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ReviewWorktreeLaneGroup { + pub lane: ReviewWorktreeLane, + pub semantic_key: String, + pub files: Vec, +} + +/// The combined staged/unstaged/untracked/conflicted review projection. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ReviewWorktreeProjection { + pub source_revision: Revision, + pub lanes: Vec, + /// `Ok` only when an exact server capability, at least one advertised + /// entry, and a durable custody lane all back a staging mutation. + /// Otherwise it names the exact missing fence instead of a control. + pub staging: Result<(), ReviewStagingWithheld>, + advertised_staging: Vec, + advertised_conflict_resolutions: Vec, + observation: Option, +} + +impl ReviewWorktreeProjection { + /// Project one review snapshot into its combined lanes. + /// + /// `custody_available` is the caller's durable at-most-once store; it is + /// never inferred from the snapshot. + /// `target` is optional because the lanes are not: a surface with no + /// exactly reconciled target still renders the combined read, it simply + /// has nothing a capability response could be attributed to, so every + /// control is withheld for want of a server capability. + #[must_use] + pub fn new( + review: &PlatformReviewSemantic, + target: Option<&PlatformReviewTarget>, + capabilities: Option<&ReviewCapabilities>, + custody_available: bool, + ) -> Self { + // "Exact" is about attribution, not content: a capability response for + // this project, workspace and snapshot revision was received. Two + // empty git lists inside it is a different, more specific answer, and + // the surface must be able to say which one it got. + let exact_capability = target + .zip(capabilities) + .is_some_and(|(target, capabilities)| { + exact_capability_attribution(capabilities, target, review) + }); + let advertised_staging = target.map_or_else(Vec::new, |target| { + advertised_staging_proposals(capabilities, target, review) + }); + let advertised_conflict_resolutions = target.map_or_else(Vec::new, |target| { + advertised_conflict_resolutions(capabilities, target, review) + }); + let staging = review_staging_control( + exact_capability, + !advertised_staging.is_empty() || !advertised_conflict_resolutions.is_empty(), + custody_available, + ); + // Every git entry in one response shares one repository read, so the + // first admitted entry carries the whole document's observation. + let observation = advertised_staging + .first() + .map(|entry| ReviewWorktreeObservation { + head_revision: entry.expected_head_revision.clone(), + index_digest: entry.expected_index_digest.clone(), + }) + .or_else(|| { + advertised_conflict_resolutions + .first() + .map(|entry| ReviewWorktreeObservation { + head_revision: entry.expected_head_revision.clone(), + index_digest: entry.expected_index_digest.clone(), + }) + }); + let lanes = ReviewWorktreeLane::ALL + .into_iter() + .map(|lane| ReviewWorktreeLaneGroup { + lane, + semantic_key: lane.semantic_key(), + files: review + .files + .iter() + .filter(|file| file_lanes(file).contains(&lane)) + .map(|file| worktree_file(review, file)) + .collect(), + }) + .collect(); + Self { + source_revision: review.revision, + lanes, + staging, + advertised_staging, + advertised_conflict_resolutions, + observation, + } + } + + /// Files listed in at least one lane, counting a partially staged file + /// once rather than once per lane. + #[must_use] + pub fn distinct_file_count(&self) -> usize { + let mut ids = self + .lanes + .iter() + .flat_map(|group| group.files.iter().map(|file| file.id.as_str())) + .collect::>(); + ids.sort_unstable(); + ids.dedup(); + ids.len() + } + + /// The advertised staging set, in the capability's own order. + #[must_use] + pub fn advertised_staging(&self) -> &[AdvertisedReviewStaging] { + &self.advertised_staging + } + + /// The advertised conflict-resolution set, in the capability's own order. + #[must_use] + pub fn advertised_conflict_resolutions(&self) -> &[AdvertisedReviewConflictResolution] { + &self.advertised_conflict_resolutions + } + + /// The `HEAD` and index the server read, when it advertised anything. + #[must_use] + pub const fn observation(&self) -> Option<&ReviewWorktreeObservation> { + self.observation.as_ref() + } + + /// Whether this exact proposal may carry a staging control. + /// + /// This is the whole of the withholding rule at the render site: a + /// `Commit` proposal the server did not advertise answers `false` here and + /// simply grows no button, while its `Stage` and `Unstage` siblings do. + #[must_use] + pub fn advertises_staging(&self, proposal_id: &str) -> bool { + self.advertised_staging + .iter() + .any(|entry| entry.proposal_id == proposal_id) + } + + /// Every admissible side for one conflicted file, in advertisement order. + /// + /// The server lists one entry per side it actually recorded, so this + /// returns two for a file holding both and one for a delete/modify pair. + #[must_use] + pub fn advertised_sides(&self, proposal_id: &str, file_id: &str) -> Vec { + self.advertised_conflict_resolutions + .iter() + .filter(|entry| entry.proposal_id == proposal_id && entry.file_id == file_id) + .map(|entry| entry.resolution) + .collect() + } +} + +/// The lanes one file belongs to, in presentation order. +/// +/// An unresolved conflict owns the file outright: git reports it as unmerged, +/// and staging it is refused, so listing it under staged or unstaged would +/// claim a state the repository does not have. `partially_staged` genuinely +/// has content on both sides and is therefore listed twice. +fn file_lanes(file: &ReviewFileSemantic) -> Vec { + if file.conflict == ConflictState::Unresolved { + return vec![ReviewWorktreeLane::Conflicted]; + } + match file.worktree { + WorktreeFileState::Staged => vec![ReviewWorktreeLane::Staged], + WorktreeFileState::Unstaged => vec![ReviewWorktreeLane::Unstaged], + WorktreeFileState::Untracked => vec![ReviewWorktreeLane::Untracked], + WorktreeFileState::PartiallyStaged => { + vec![ReviewWorktreeLane::Staged, ReviewWorktreeLane::Unstaged] + } + } +} + +fn worktree_file(review: &PlatformReviewSemantic, file: &ReviewFileSemantic) -> ReviewWorktreeFile { + ReviewWorktreeFile { + id: file.id.clone(), + path: review_safe_text(&file.path), + change: file.change, + worktree: file.worktree, + conflict: file.conflict, + partial: file.worktree == WorktreeFileState::PartiallyStaged + && file.conflict != ConflictState::Unresolved, + preview: review_safe_preview(file), + hunks: file + .hunks + .iter() + .map(|hunk| ReviewWorktreeHunk { + id: hunk.id.clone(), + anchor: ReviewAnchorSemantic { + file_id: file.id.clone(), + hunk_id: hunk.id.clone(), + side: if hunk.new_lines > 0 { + DiffSide::New + } else { + DiffSide::Old + }, + line: if hunk.new_lines > 0 { + hunk.new_start + } else { + hunk.old_start + }, + }, + text: review_safe_text(&hunk.preview), + }) + .collect(), + staging: review + .proposals + .iter() + .filter(|proposal| proposal.files.iter().any(|id| id == &file.id)) + .map(|proposal| ReviewStagingProposal { + proposal_id: proposal.id.clone(), + kind: proposal.kind, + admissible: review.proposal_is_actionable(&proposal.id), + }) + .collect(), + } +} + +/// Bound and neutralize one server-supplied string before it is painted. +/// +/// The protocol already refuses control characters, but this is the last stop +/// before pixels: line structure, invisible reordering scalars, line length, +/// and line count are all bounded here rather than trusted upstream. +#[must_use] +pub fn review_safe_text(raw: &str) -> ReviewSafeText { + let mut lines: Vec = Vec::new(); + let mut truncated = false; + for raw_line in raw.split(['\n', '\r']) { + if lines.len() == MAX_SAFE_PREVIEW_LINES { + truncated = true; + break; + } + let mut line = String::new(); + for (characters, character) in raw_line.chars().enumerate() { + if characters == MAX_SAFE_PREVIEW_LINE_CHARS { + truncated = true; + line.push('…'); + break; + } + if character.is_control() || INVISIBLE_FORMATTING.contains(&character) { + line.push('\u{fffd}'); + } else { + line.push(character); + } + } + lines.push(line); + } + while lines.last().is_some_and(String::is_empty) { + lines.pop(); + } + ReviewSafeText { lines, truncated } +} + +/// Decide what may be painted for one file, from its declared preview only. +/// +/// The snapshot carries preview *metadata*, never a payload: text arrives as +/// bounded hunk previews and everything else arrives as a description. Every +/// declared quantity is therefore treated as a claim to bound, not a budget to +/// honour. +#[must_use] +pub fn review_safe_preview(file: &ReviewFileSemantic) -> ReviewSafePreview { + let preview = &file.preview; + // The contract admits hunks only for a text preview. A snapshot that + // disagrees with itself is described, never painted. + if preview.kind != PreviewKind::Text && !file.hunks.is_empty() { + return ReviewSafePreview::Withheld(ReviewPreviewWithheld::Incoherent); + } + match preview.kind { + PreviewKind::None => ReviewSafePreview::Withheld(ReviewPreviewWithheld::NoContent), + PreviewKind::Binary => ReviewSafePreview::Withheld(ReviewPreviewWithheld::Binary), + PreviewKind::Text => { + if !preview.sanitized { + return ReviewSafePreview::Withheld(ReviewPreviewWithheld::Unsanitized); + } + if preview + .byte_size + .is_some_and(|size| size > MAX_SAFE_PREVIEW_BYTES) + { + return ReviewSafePreview::Withheld(ReviewPreviewWithheld::Oversized); + } + if preview.width.is_some() || preview.height.is_some() { + return ReviewSafePreview::Withheld(ReviewPreviewWithheld::Incoherent); + } + let text = review_safe_text( + &file + .hunks + .iter() + .map(|hunk| hunk.preview.as_str()) + .collect::>() + .join("\n"), + ); + if text.is_empty() { + ReviewSafePreview::Withheld(ReviewPreviewWithheld::NoContent) + } else { + ReviewSafePreview::Text(text) + } + } + PreviewKind::Image => { + if !preview.sanitized { + return ReviewSafePreview::Withheld(ReviewPreviewWithheld::Unsanitized); + } + let Some(media_type) = preview + .media_type + .as_deref() + .filter(|value| value.starts_with("image/")) + else { + return ReviewSafePreview::Withheld(ReviewPreviewWithheld::Incoherent); + }; + let Some(byte_size) = preview + .byte_size + .filter(|size| *size <= MAX_SAFE_PREVIEW_BYTES) + else { + return ReviewSafePreview::Withheld(ReviewPreviewWithheld::Oversized); + }; + let (Some(width), Some(height)) = (preview.width, preview.height) else { + return ReviewSafePreview::Withheld(ReviewPreviewWithheld::OversizedRaster); + }; + if width == 0 + || height == 0 + || width > MAX_SAFE_PREVIEW_EDGE + || height > MAX_SAFE_PREVIEW_EDGE + || u64::from(width) * u64::from(height) > MAX_SAFE_PREVIEW_PIXELS + { + return ReviewSafePreview::Withheld(ReviewPreviewWithheld::OversizedRaster); + } + let (box_width, box_height) = bounded_box(width, height); + ReviewSafePreview::Image(ReviewSafeImage { + media_type: media_type.to_owned(), + width, + height, + byte_size, + box_width, + box_height, + }) + } + PreviewKind::Html => { + if !preview.sanitized { + return ReviewSafePreview::Withheld(ReviewPreviewWithheld::Unsanitized); + } + if preview.media_type.as_deref() != Some("text/html") + || preview.width.is_some() + || preview.height.is_some() + { + return ReviewSafePreview::Withheld(ReviewPreviewWithheld::Incoherent); + } + let Some(byte_size) = preview + .byte_size + .filter(|size| *size <= MAX_SAFE_PREVIEW_BYTES) + else { + return ReviewSafePreview::Withheld(ReviewPreviewWithheld::Oversized); + }; + ReviewSafePreview::Html(ReviewSafeHtml { + media_type: "text/html".to_owned(), + byte_size, + }) + } + } +} + +/// Fit a declared raster into the placeholder box without ever exceeding it. +fn bounded_box(width: u32, height: u32) -> (u32, u32) { + if width <= SAFE_PREVIEW_BOX_EDGE && height <= SAFE_PREVIEW_BOX_EDGE { + return (width, height); + } + let long_edge = u64::from(width.max(height)); + let scaled = |edge: u32| -> u32 { + let value = u64::from(edge) * u64::from(SAFE_PREVIEW_BOX_EDGE) / long_edge; + u32::try_from(value).unwrap_or(SAFE_PREVIEW_BOX_EDGE).max(1) + }; + (scaled(width), scaled(height)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::platform_review::{ + AttentionState, DeliverySemantic, DeliveryState, MergeReadiness, + PlatformReviewActionPreview, PlatformReviewTarget, PullRequestSemantic, PullRequestState, + ReviewAction, ReviewAttentionSemantic, ReviewAuthority, ReviewAuthorityKind, + ReviewAuthoritySemantic, ReviewConfirmationDigest, ReviewConflictResolutionCapability, + ReviewDecision, ReviewField, ReviewFreshnessSemantic, ReviewFreshnessState, + ReviewGitStagingCapabilities, ReviewHunkSemantic, ReviewIndexDigest, ReviewPreviewSemantic, + ReviewProposalId, ReviewProposalSemantic, ReviewPullRequestCapabilities, + ReviewReceiptCorrelationDigest, ReviewSchemaVersion, ReviewStagingCapability, + ReviewStatusSemantic, + }; + use crate::config::workspace_catalog::{ + PlatformContextRef, PlatformMappingReconciliation, PlatformV2Mapping, + }; + use automonique_protocol::platform_v2::WorkContextTargetKind; + use automonique_protocol::platform_v2_review::{ReviewAuthorityId, ReviewFileId}; + use automonique_protocol::platform_v2_review_api::decode_review_snapshot; + + const CANONICAL_FIXTURE: &[u8] = + include_bytes!("../../../tests/fixtures/platform-v2-review-v2.json"); + + fn revision(value: u64) -> Revision { + Revision::new(value).expect("revision") + } + + fn freshness() -> ReviewFreshnessSemantic { + ReviewFreshnessSemantic { + state: ReviewFreshnessState::Fresh, + observed_revision: revision(9), + observed_at_ms: 1_800_000_000_000, + } + } + + fn authority(kind: ReviewAuthorityKind) -> ReviewAuthoritySemantic { + ReviewAuthoritySemantic { + kind, + id: "authority-1".to_owned(), + } + } + + fn preview(kind: PreviewKind) -> ReviewPreviewSemantic { + ReviewPreviewSemantic { + kind, + media_type: None, + byte_size: None, + width: None, + height: None, + sanitized: kind == PreviewKind::Text, + } + } + + fn text_file( + id: &str, + worktree: WorktreeFileState, + conflict: ConflictState, + ) -> ReviewFileSemantic { + ReviewFileSemantic { + id: id.to_owned(), + path: format!("src/{id}.rs"), + change: DiffChangeKind::Modified, + worktree, + conflict, + preview: preview(PreviewKind::Text), + hunks: vec![ReviewHunkSemantic { + id: format!("{id}-hunk-1"), + old_start: 10, + old_lines: 2, + new_start: 12, + new_lines: 3, + preview: "@@ -10,2 +12,3 @@ typed".to_owned(), + }], + } + } + + fn review_with( + files: Vec, + proposals: Vec, + ) -> PlatformReviewSemantic { + PlatformReviewSemantic { + schema: ReviewSchemaVersion::V2, + workspace_kind: WorkContextTargetKind::UserWorkspace, + workspace_id: "wc_user_1".to_owned(), + revision: revision(9), + attention: ReviewAttentionSemantic { + state: AttentionState::NeedsYou, + reason: None, + source_revision: None, + unread: 0, + needs_user_action: false, + }, + attention_events: Vec::new(), + review: ReviewStatusSemantic { + decision: ReviewDecision::Pending, + authority: authority(ReviewAuthorityKind::Review), + freshness: freshness(), + }, + checks: Vec::new(), + pull_request: PullRequestSemantic { + id: None, + state: PullRequestState::Absent, + readiness: MergeReadiness::Unknown, + head_revision: None, + authority: authority(ReviewAuthorityKind::PullRequest), + freshness: freshness(), + }, + delivery: DeliverySemantic { + id: None, + state: DeliveryState::NotDelivered, + authority: authority(ReviewAuthorityKind::Delivery), + freshness: freshness(), + }, + files, + comments: Vec::new(), + proposals, + } + } + + fn target() -> PlatformReviewTarget { + PlatformReviewTarget::from_exact_mapping(&PlatformV2Mapping { + reconciliation_revision: 1, + project: PlatformContextRef { + id: "project-1".to_owned(), + revision: 1, + }, + checkout: PlatformContextRef { + id: "checkout-1".to_owned(), + revision: 1, + }, + user_workspace: PlatformContextRef { + id: "wc_user_1".to_owned(), + revision: 1, + }, + reconciliation: PlatformMappingReconciliation::Exact { + reconciled_at_millis: 1, + }, + }) + .expect("exact mapping") + } + + fn lane<'a>( + projection: &'a ReviewWorktreeProjection, + lane: ReviewWorktreeLane, + ) -> &'a [ReviewWorktreeFile] { + &projection + .lanes + .iter() + .find(|group| group.lane == lane) + .expect("lane group") + .files + } + + // SDTEST-1843 — one snapshot, four lanes. A partially staged file has real + // content on both sides and must appear twice; an unresolved conflict is + // unmerged and must not be claimed as staged or unstaged. + #[test] + fn sdtest_1843_combined_lanes_place_every_file_where_git_actually_has_it() { + let review = review_with( + vec![ + text_file( + "file-staged", + WorktreeFileState::Staged, + ConflictState::None, + ), + text_file( + "file-partial", + WorktreeFileState::PartiallyStaged, + ConflictState::None, + ), + text_file( + "file-untracked", + WorktreeFileState::Untracked, + ConflictState::None, + ), + text_file( + "file-conflict", + WorktreeFileState::Unstaged, + ConflictState::Unresolved, + ), + text_file( + "file-resolved", + WorktreeFileState::Staged, + ConflictState::Resolved, + ), + ], + Vec::new(), + ); + let projection = ReviewWorktreeProjection::new(&review, None, None, true); + + assert_eq!( + projection + .lanes + .iter() + .map(|group| group.lane) + .collect::>(), + ReviewWorktreeLane::ALL.to_vec(), + "lanes are presented most blocking first" + ); + assert_eq!( + lane(&projection, ReviewWorktreeLane::Conflicted) + .iter() + .map(|file| file.id.as_str()) + .collect::>(), + ["file-conflict"] + ); + assert_eq!( + lane(&projection, ReviewWorktreeLane::Staged) + .iter() + .map(|file| file.id.as_str()) + .collect::>(), + ["file-staged", "file-partial", "file-resolved"], + "a resolved conflict returns to its own worktree lane" + ); + assert_eq!( + lane(&projection, ReviewWorktreeLane::Unstaged) + .iter() + .map(|file| file.id.as_str()) + .collect::>(), + ["file-partial"] + ); + assert_eq!( + lane(&projection, ReviewWorktreeLane::Untracked) + .iter() + .map(|file| file.id.as_str()) + .collect::>(), + ["file-untracked"] + ); + assert!(lane(&projection, ReviewWorktreeLane::Staged)[1].partial); + assert!(!lane(&projection, ReviewWorktreeLane::Staged)[0].partial); + assert_eq!( + projection.distinct_file_count(), + 5, + "a file listed twice is still one file" + ); + } + + // SDTEST-1844 — the lane projection derives each hunk's review anchor. If + // that derivation drifted, the comment composer would offer a line the + // snapshot refuses, so the anchors are proved against the real preview + // constructor rather than against a restatement of the same arithmetic. + #[test] + fn sdtest_1844_every_projected_hunk_anchor_is_admitted_by_the_comment_constructor() { + let mut only_old = text_file("file-old", WorktreeFileState::Staged, ConflictState::None); + only_old.hunks[0].new_lines = 0; + only_old.hunks[0].new_start = 0; + let review = review_with( + vec![ + text_file("file-new", WorktreeFileState::Staged, ConflictState::None), + only_old, + ], + Vec::new(), + ); + let projection = ReviewWorktreeProjection::new(&review, None, None, true); + let anchors = projection + .lanes + .iter() + .flat_map(|group| group.files.iter()) + .flat_map(|file| file.hunks.iter()) + .map(|hunk| hunk.anchor.clone()) + .collect::>(); + + assert_eq!(anchors.len(), 2); + assert_eq!(anchors[0].side, DiffSide::New); + assert_eq!(anchors[0].line, 12); + assert_eq!(anchors[1].side, DiffSide::Old); + assert_eq!(anchors[1].line, 10); + for anchor in &anchors { + PlatformReviewActionPreview::add_comment(target(), &review, anchor, "typed") + .unwrap_or_else(|error| panic!("anchor {anchor:?} refused: {error}")); + } + } + + // SDTEST-1845 — the protocol bounds hunk previews to 512 control-free + // bytes, which still admits a right-to-left override that repaints a diff + // line as something else. This is the last stop before pixels. + #[test] + fn sdtest_1845_safe_text_neutralizes_invisible_reordering_and_bounds_the_shape() { + let bidi = review_safe_text("fn drop(\u{202e}) // safe\u{202d}"); + assert_eq!(bidi.lines, ["fn drop(\u{fffd}) // safe\u{fffd}"]); + assert!(!bidi.truncated); + + let vertical = review_safe_text("first\nsecond\r\nthird"); + assert_eq!(vertical.lines, ["first", "second", "", "third"]); + + let long = review_safe_text(&"a".repeat(MAX_SAFE_PREVIEW_LINE_CHARS + 40)); + assert!(long.truncated); + assert_eq!(long.lines.len(), 1); + assert_eq!( + long.lines[0].chars().count(), + MAX_SAFE_PREVIEW_LINE_CHARS + 1, + "the ellipsis is the only character added past the bound" + ); + + let tall = review_safe_text(&"x\n".repeat(MAX_SAFE_PREVIEW_LINES + 5)); + assert!(tall.truncated); + assert_eq!(tall.lines.len(), MAX_SAFE_PREVIEW_LINES); + + assert!(review_safe_text("").is_empty()); + assert!(review_safe_text("\n\n").is_empty()); + } + + // SDTEST-1846 — declared raster dimensions are a claim. A sanitized image + // is described inside a clamped aspect-preserving box; a degenerate or + // enormous claim is refused instead of becoming a layout or decode budget. + #[test] + fn sdtest_1846_image_previews_are_clamped_and_absurd_rasters_are_refused() { + let image = |width: Option, height: Option, bytes: Option| { + let mut file = text_file("file-1", WorktreeFileState::Staged, ConflictState::None); + file.hunks.clear(); + file.preview = ReviewPreviewSemantic { + kind: PreviewKind::Image, + media_type: Some("image/png".to_owned()), + byte_size: bytes, + width, + height, + sanitized: true, + }; + file + }; + + let ReviewSafePreview::Image(small) = + review_safe_preview(&image(Some(120), Some(60), Some(4_096))) + else { + panic!("a bounded sanitized raster is describable"); + }; + assert_eq!((small.box_width, small.box_height), (120, 60)); + + let ReviewSafePreview::Image(large) = + review_safe_preview(&image(Some(4_000), Some(1_000), Some(4_096))) + else { + panic!("a bounded sanitized raster is describable"); + }; + assert_eq!(large.box_width, SAFE_PREVIEW_BOX_EDGE); + assert_eq!(large.box_height, SAFE_PREVIEW_BOX_EDGE / 4); + assert_eq!((large.width, large.height), (4_000, 1_000)); + + let ReviewSafePreview::Image(sliver) = + review_safe_preview(&image(Some(4_000), Some(1), Some(4_096))) + else { + panic!("a bounded sanitized raster is describable"); + }; + assert_eq!( + (sliver.box_width, sliver.box_height), + (SAFE_PREVIEW_BOX_EDGE, 1), + "an extreme aspect ratio still fits the box" + ); + + for absurd in [ + image(Some(0), Some(10), Some(4_096)), + image(Some(MAX_SAFE_PREVIEW_EDGE + 1), Some(2), Some(4_096)), + image(Some(8_192), Some(8_192), Some(4_096)), + image(Some(10), None, Some(4_096)), + ] { + assert_eq!( + review_safe_preview(&absurd).withheld(), + Some(ReviewPreviewWithheld::OversizedRaster), + "{:?}", + absurd.preview + ); + } + assert_eq!( + review_safe_preview(&image(Some(10), Some(10), None)).withheld(), + Some(ReviewPreviewWithheld::Oversized) + ); + assert_eq!( + review_safe_preview(&image(Some(10), Some(10), Some(MAX_SAFE_PREVIEW_BYTES + 1))) + .withheld(), + Some(ReviewPreviewWithheld::Oversized) + ); + } + + // SDTEST-1847 — HTML is described, never painted as markup and never + // re-emitted as source; unsanitized, incoherent, binary and empty previews + // are withheld with the reason the surface must show. + #[test] + fn sdtest_1847_html_and_opaque_previews_are_described_but_never_rendered() { + let mut html = text_file("file-1", WorktreeFileState::Staged, ConflictState::None); + html.hunks.clear(); + html.preview = ReviewPreviewSemantic { + kind: PreviewKind::Html, + media_type: Some("text/html".to_owned()), + byte_size: Some(2_048), + width: None, + height: None, + sanitized: true, + }; + let ReviewSafePreview::Html(described) = review_safe_preview(&html) else { + panic!("a sanitized bounded HTML preview is describable"); + }; + assert_eq!(described.media_type, "text/html"); + assert_eq!(described.byte_size, 2_048); + + let mut unsanitized = html.clone(); + unsanitized.preview.sanitized = false; + assert_eq!( + review_safe_preview(&unsanitized).withheld(), + Some(ReviewPreviewWithheld::Unsanitized) + ); + + let mut wrong_media = html.clone(); + wrong_media.preview.media_type = Some("text/plain".to_owned()); + assert_eq!( + review_safe_preview(&wrong_media).withheld(), + Some(ReviewPreviewWithheld::Incoherent) + ); + + let mut with_hunks = html.clone(); + with_hunks.hunks = text_file("x", WorktreeFileState::Staged, ConflictState::None).hunks; + assert_eq!( + review_safe_preview(&with_hunks).withheld(), + Some(ReviewPreviewWithheld::Incoherent), + "only a text preview may carry hunks" + ); + + let mut binary = html.clone(); + binary.preview = ReviewPreviewSemantic { + kind: PreviewKind::Binary, + media_type: Some("application/octet-stream".to_owned()), + byte_size: Some(64), + width: None, + height: None, + sanitized: false, + }; + assert_eq!( + review_safe_preview(&binary).withheld(), + Some(ReviewPreviewWithheld::Binary) + ); + + let mut none = html.clone(); + none.preview = preview(PreviewKind::None); + assert_eq!( + review_safe_preview(&none).withheld(), + Some(ReviewPreviewWithheld::NoContent) + ); + + let mut unsanitized_text = + text_file("file-2", WorktreeFileState::Staged, ConflictState::None); + unsanitized_text.preview.sanitized = false; + assert_eq!( + review_safe_preview(&unsanitized_text).withheld(), + Some(ReviewPreviewWithheld::Unsanitized), + "an unsanitized payload is never shown as source text" + ); + + let mut empty_text = text_file("file-3", WorktreeFileState::Staged, ConflictState::None); + empty_text.hunks.clear(); + assert_eq!( + review_safe_preview(&empty_text).withheld(), + Some(ReviewPreviewWithheld::NoContent) + ); + } + + const OBSERVED_HEAD: &str = "1f0e4b8c2a6d9e3f7051c8b4a2d6e9f30517b2c4"; + const OTHER_HEAD: &str = "9a8b7c6d5e4f30219a8b7c6d5e4f30219a8b7c6d"; + + fn index_digest(seed: char) -> ReviewIndexDigest { + ReviewIndexDigest::new(seed.to_string().repeat(64)).expect("index digest") + } + + /// Two distinct lowercase-hex digests per seed, so a test can assert that + /// the confirmation and the correlation are carried separately rather than + /// one being reused for both. + fn digests(seed: char) -> (ReviewConfirmationDigest, ReviewReceiptCorrelationDigest) { + let confirmation = format!("{}{}", seed.to_string().repeat(63), '0'); + let correlation = format!("{}{}", seed.to_string().repeat(63), '1'); + ( + ReviewConfirmationDigest::new(confirmation).expect("confirmation"), + ReviewReceiptCorrelationDigest::new(correlation).expect("correlation"), + ) + } + + fn git_authority() -> ReviewAuthority { + ReviewAuthority::new( + ReviewAuthorityKind::Git, + ReviewAuthorityId::new("authority-1".to_owned()).expect("authority id"), + ) + } + + fn staging_capability( + proposal_id: &str, + kind: ReviewProposalKind, + head: &str, + index: char, + seed: char, + ) -> ReviewStagingCapability { + let (confirmation, correlation) = digests(seed); + ReviewStagingCapability::new( + ReviewProposalId::new(proposal_id.to_owned()).expect("proposal id"), + kind, + ReviewField::new(head.to_owned()).expect("head revision"), + index_digest(index), + git_authority(), + confirmation, + correlation, + ) + .expect("staging capability") + } + + fn conflict_capability( + proposal_id: &str, + file_id: &str, + resolution: ConflictResolution, + index: char, + seed: char, + ) -> ReviewConflictResolutionCapability { + let (confirmation, correlation) = digests(seed); + ReviewConflictResolutionCapability::new( + ReviewProposalId::new(proposal_id.to_owned()).expect("proposal id"), + ReviewFileId::new(file_id.to_owned()).expect("file id"), + resolution, + ReviewField::new(OBSERVED_HEAD.to_owned()).expect("head revision"), + index_digest(index), + git_authority(), + confirmation, + correlation, + ) + .expect("conflict capability") + } + + fn capabilities( + target: &PlatformReviewTarget, + review: &PlatformReviewSemantic, + git: ReviewGitStagingCapabilities, + ) -> ReviewCapabilities { + ReviewCapabilities::new( + target.project.clone(), + target.workspace.clone(), + review.revision, + revision(91), + Vec::new(), + Vec::new(), + ReviewPullRequestCapabilities::default(), + git, + ) + .expect("capability response") + } + + /// One unstaged file with a `Stage` proposal, one with an `Unstage`, one + /// with a `Commit`, and one unmerged file carrying a `ResolveConflict` + /// proposal over both sides. + fn staging_review() -> PlatformReviewSemantic { + review_with( + vec![ + text_file("file-1", WorktreeFileState::Unstaged, ConflictState::None), + text_file("file-2", WorktreeFileState::Staged, ConflictState::None), + text_file("file-3", WorktreeFileState::Staged, ConflictState::None), + text_file( + "file-conflict", + WorktreeFileState::Unstaged, + ConflictState::Unresolved, + ), + ], + vec![ + ReviewProposalSemantic { + id: "proposal-stage".to_owned(), + kind: ReviewProposalKind::Stage, + authority: Some(authority(ReviewAuthorityKind::Git)), + files: vec!["file-1".to_owned()], + subject: None, + }, + ReviewProposalSemantic { + id: "proposal-unstage".to_owned(), + kind: ReviewProposalKind::Unstage, + authority: Some(authority(ReviewAuthorityKind::Git)), + files: vec!["file-2".to_owned()], + subject: None, + }, + ReviewProposalSemantic { + id: "proposal-commit".to_owned(), + kind: ReviewProposalKind::Commit, + authority: Some(authority(ReviewAuthorityKind::Git)), + files: vec!["file-3".to_owned()], + subject: Some("record the reviewed change".to_owned()), + }, + ReviewProposalSemantic { + id: "proposal-conflict".to_owned(), + kind: ReviewProposalKind::ResolveConflict, + authority: Some(authority(ReviewAuthorityKind::Git)), + files: vec!["file-conflict".to_owned()], + subject: None, + }, + ], + ) + } + + // SDTEST-1867 — the withholding demonstration. A deployment that installs + // `index_write` but not `commit` advertises `Stage` and `Unstage` and no + // `Commit`. The commit control must be *absent*, not disabled: the three + // kinds share one capability type but not one grant, so the only honest + // rendering of a missing grant is a missing control. The two granted kinds + // must go all the way through to a dispatchable confirmed preview in the + // same run, or the test would prove nothing about the ones that are there. + #[test] + fn sdtest_1867_withheld_commit_grant_renders_no_control_while_its_siblings_go_end_to_end() { + let review = staging_review(); + let target = target(); + let granted = capabilities( + &target, + &review, + ReviewGitStagingCapabilities { + staging: vec![ + staging_capability( + "proposal-stage", + ReviewProposalKind::Stage, + OBSERVED_HEAD, + 'a', + 'c', + ), + staging_capability( + "proposal-unstage", + ReviewProposalKind::Unstage, + OBSERVED_HEAD, + 'a', + 'd', + ), + ], + conflict_resolutions: Vec::new(), + }, + ); + + let projection = + ReviewWorktreeProjection::new(&review, Some(&target), Some(&granted), true); + assert_eq!(projection.staging, Ok(())); + assert_eq!( + projection + .advertised_staging() + .iter() + .map(|entry| (entry.proposal_id.as_str(), entry.kind)) + .collect::>(), + [ + ("proposal-stage", ReviewProposalKind::Stage), + ("proposal-unstage", ReviewProposalKind::Unstage), + ] + ); + assert!( + !projection.advertises_staging("proposal-commit"), + "a grant the server withheld must produce no control at all" + ); + // The commit proposal is still *observed* — the read side never lost + // it — which is exactly what distinguishes absence-of-control from + // absence-of-proposal. + let staged = lane(&projection, ReviewWorktreeLane::Staged); + assert!(staged.iter().any(|file| file + .staging + .iter() + .any(|proposal| proposal.proposal_id == "proposal-commit" && proposal.admissible))); + + // The withheld kind refuses at the preview constructor too, so nothing + // downstream can reconstruct it from the snapshot's own proposal. + assert!(PlatformReviewActionPreview::stage_proposal( + target.clone(), + &review, + &granted, + "proposal-commit", + ) + .is_err()); + + // The two granted kinds go end to end: a confirmed preview carrying the + // server's own digests verbatim, which the fresh capability document + // still admits. + for (proposal_id, seed) in [("proposal-stage", 'c'), ("proposal-unstage", 'd')] { + let preview = PlatformReviewActionPreview::stage_proposal( + target.clone(), + &review, + &granted, + proposal_id, + ) + .expect("granted staging proposal is dispatchable"); + let (confirmation, correlation) = digests(seed); + let carried = preview.confirmation().expect("staging is a confirmed lane"); + assert_eq!(carried.confirmation_digest(), &confirmation); + assert_eq!(carried.receipt_correlation_digest(), &correlation); + assert_eq!(carried.expected_workspace_revision(), revision(91)); + assert!(preview.requires_capability_revalidation()); + assert!(preview.matches_capabilities(&granted)); + } + + // Grant the commit too and the third control appears, from the same + // projection code and with no other change. + let full = capabilities( + &target, + &review, + ReviewGitStagingCapabilities { + staging: vec![ + staging_capability( + "proposal-stage", + ReviewProposalKind::Stage, + OBSERVED_HEAD, + 'a', + 'c', + ), + staging_capability( + "proposal-unstage", + ReviewProposalKind::Unstage, + OBSERVED_HEAD, + 'a', + 'd', + ), + staging_capability( + "proposal-commit", + ReviewProposalKind::Commit, + OBSERVED_HEAD, + 'a', + 'e', + ), + ], + conflict_resolutions: Vec::new(), + }, + ); + let projection = ReviewWorktreeProjection::new(&review, Some(&target), Some(&full), true); + assert!(projection.advertises_staging("proposal-commit")); + assert!(PlatformReviewActionPreview::stage_proposal( + target.clone(), + &review, + &full, + "proposal-commit", + ) + .is_ok()); + } + + // SDTEST-1868 — every other way the lane can be withheld, and the fence + // that a worktree moving under the daemon has to trip. + #[test] + fn sdtest_1868_staging_lane_names_its_missing_fence_and_refuses_a_moved_worktree() { + let review = staging_review(); + let target = target(); + let granted = capabilities( + &target, + &review, + ReviewGitStagingCapabilities { + staging: vec![staging_capability( + "proposal-stage", + ReviewProposalKind::Stage, + OBSERVED_HEAD, + 'a', + 'c', + )], + conflict_resolutions: Vec::new(), + }, + ); + + assert_eq!( + ReviewWorktreeProjection::new(&review, Some(&target), None, true).staging, + Err(ReviewStagingWithheld::NoServerCapability) + ); + assert_eq!( + ReviewWorktreeProjection::new(&review, None, Some(&granted), true).staging, + Err(ReviewStagingWithheld::NoServerCapability), + "no exactly reconciled target means nothing can be attributed" + ); + // An exact response with two empty git lists is a different, more + // specific answer than no response at all, and the surface must be + // able to say which one it got. + let empty = capabilities(&target, &review, ReviewGitStagingCapabilities::default()); + assert_eq!( + ReviewWorktreeProjection::new(&review, Some(&target), Some(&empty), true).staging, + Err(ReviewStagingWithheld::NoStageableProposal) + ); + assert_eq!( + ReviewWorktreeProjection::new(&review, Some(&target), Some(&granted), false).staging, + Err(ReviewStagingWithheld::NoCustodyLane), + "the durable lane is reported last, once the server has proved a slot" + ); + // The lanes render in every one of those states; only the control is + // withheld. + for projection in [ + ReviewWorktreeProjection::new(&review, Some(&target), None, true), + ReviewWorktreeProjection::new(&review, Some(&target), Some(&empty), true), + ] { + assert_eq!(projection.distinct_file_count(), 4); + assert!(projection.advertised_staging().is_empty()); + assert!(projection.observation().is_none()); + } + + // The observation is exposed so a reader can see the fence. + let projection = + ReviewWorktreeProjection::new(&review, Some(&target), Some(&granted), true); + let observation = projection + .observation() + .expect("granted lane names its read"); + assert_eq!(observation.head_revision, OBSERVED_HEAD); + assert_eq!(observation.index_digest, "a".repeat(64)); + + // A preview minted against one worktree read must stop matching once a + // fresher document reports a different `HEAD` or a different index. + // The server mints the digest over both, so the comparison is the + // digest — and the refusal is local, before anything reaches the + // daemon. + let preview = PlatformReviewActionPreview::stage_proposal( + target.clone(), + &review, + &granted, + "proposal-stage", + ) + .expect("advertised proposal"); + for moved in [ + staging_capability( + "proposal-stage", + ReviewProposalKind::Stage, + OTHER_HEAD, + 'a', + 'f', + ), + staging_capability( + "proposal-stage", + ReviewProposalKind::Stage, + OBSERVED_HEAD, + 'b', + 'f', + ), + ] { + let refreshed = capabilities( + &target, + &review, + ReviewGitStagingCapabilities { + staging: vec![moved], + conflict_resolutions: Vec::new(), + }, + ); + assert!( + !preview.matches_capabilities(&refreshed), + "a worktree that moved must withdraw the control locally" + ); + } + + // A capability whose kind disagrees with the snapshot's proposal is a + // torn read between two server-side reads, and is refused rather than + // letting the client pick which one to believe. + let torn = capabilities( + &target, + &review, + ReviewGitStagingCapabilities { + staging: vec![staging_capability( + "proposal-stage", + ReviewProposalKind::Commit, + OBSERVED_HEAD, + 'a', + 'c', + )], + conflict_resolutions: Vec::new(), + }, + ); + assert!( + ReviewWorktreeProjection::new(&review, Some(&target), Some(&torn), true) + .advertised_staging() + .is_empty() + ); + } + + // SDTEST-1869 — `conflict_resolutions` lists one entry per admissible + // side, so a file with both sides recorded yields two controls and a + // delete/modify pair yields one. The side is part of the identity, not a + // parameter chosen after the fact: each entry carries the digest minted + // over the blob that side would write. + #[test] + fn sdtest_1869_both_recorded_sides_render_two_controls_and_a_single_side_renders_one() { + let review = staging_review(); + let target = target(); + let both = capabilities( + &target, + &review, + ReviewGitStagingCapabilities { + staging: Vec::new(), + conflict_resolutions: vec![ + conflict_capability( + "proposal-conflict", + "file-conflict", + ConflictResolution::KeepCurrent, + 'a', + 'c', + ), + conflict_capability( + "proposal-conflict", + "file-conflict", + ConflictResolution::KeepIncoming, + 'a', + 'd', + ), + ], + }, + ); + let projection = ReviewWorktreeProjection::new(&review, Some(&target), Some(&both), true); + assert_eq!(projection.staging, Ok(())); + assert_eq!( + projection.advertised_sides("proposal-conflict", "file-conflict"), + [ + ConflictResolution::KeepCurrent, + ConflictResolution::KeepIncoming + ], + "one entry per admissible side means two buttons, not one with a choice" + ); + + for (resolution, seed) in [ + (ConflictResolution::KeepCurrent, 'c'), + (ConflictResolution::KeepIncoming, 'd'), + ] { + let preview = PlatformReviewActionPreview::resolve_conflict( + target.clone(), + &review, + &both, + "proposal-conflict", + "file-conflict", + resolution, + ) + .expect("advertised side"); + let (confirmation, correlation) = digests(seed); + let carried = preview.confirmation().expect("confirmed lane"); + assert_eq!( + carried.confirmation_digest(), + &confirmation, + "each side carries its own digest, so they can never be swapped" + ); + assert_eq!(carried.receipt_correlation_digest(), &correlation); + assert!(matches!( + preview.action(), + ReviewAction::ResolveConflict { resolution: sent, .. } if *sent == resolution + )); + assert!(preview.matches_capabilities(&both)); + } + + // A delete/modify conflict has one side recorded, so only that side is + // ever offered and the other refuses locally. + let one_side = capabilities( + &target, + &review, + ReviewGitStagingCapabilities { + staging: Vec::new(), + conflict_resolutions: vec![conflict_capability( + "proposal-conflict", + "file-conflict", + ConflictResolution::KeepCurrent, + 'a', + 'c', + )], + }, + ); + let projection = + ReviewWorktreeProjection::new(&review, Some(&target), Some(&one_side), true); + assert_eq!( + projection.advertised_sides("proposal-conflict", "file-conflict"), + [ConflictResolution::KeepCurrent] + ); + assert!(PlatformReviewActionPreview::resolve_conflict( + target.clone(), + &review, + &one_side, + "proposal-conflict", + "file-conflict", + ConflictResolution::KeepIncoming, + ) + .is_err()); + + // A file the snapshot no longer reports as unmerged cannot be + // collapsed, whatever an older capability read said. + let mut resolved = review.clone(); + resolved + .files + .iter_mut() + .find(|file| file.id == "file-conflict") + .expect("conflicted file") + .conflict = ConflictState::Resolved; + assert!( + ReviewWorktreeProjection::new(&resolved, Some(&target), Some(&both), true) + .advertised_conflict_resolutions() + .is_empty() + ); + } + + // SDTEST-1849 — the shipped canonical snapshot, not a hand-built one, + // reaches the combined lanes, the safe text preview, and the staging + // observation the surface renders. + #[test] + fn sdtest_1849_canonical_snapshot_projects_to_lanes_previews_and_observations() { + let snapshot = decode_review_snapshot(CANONICAL_FIXTURE).expect("canonical fixture"); + let review = PlatformReviewSemantic::from(&snapshot); + let projection = ReviewWorktreeProjection::new(&review, None, None, true); + + assert_eq!(projection.source_revision, review.revision); + assert_eq!(projection.distinct_file_count(), 1); + for lane_kind in [ReviewWorktreeLane::Staged, ReviewWorktreeLane::Unstaged] { + let files = lane(&projection, lane_kind); + assert_eq!(files.len(), 1, "{lane_kind:?}"); + assert_eq!(files[0].path.lines, ["src/review.rs"]); + assert!(files[0].partial); + assert_eq!(files[0].hunks.len(), 1); + assert_eq!(files[0].hunks[0].anchor.side, DiffSide::New); + assert_eq!(files[0].hunks[0].anchor.line, 10); + assert_eq!( + files[0].preview, + ReviewSafePreview::Text(ReviewSafeText { + lines: vec!["@@ -10,2 +10,3 @@ · sanitized preview".to_owned()], + truncated: false, + }) + ); + assert_eq!( + files[0].staging, + vec![ReviewStagingProposal { + proposal_id: "proposal-1".to_owned(), + kind: ReviewProposalKind::Commit, + admissible: true, + }] + ); + } + assert!(lane(&projection, ReviewWorktreeLane::Conflicted).is_empty()); + assert!(lane(&projection, ReviewWorktreeLane::Untracked).is_empty()); + assert_eq!( + projection.staging, + Err(ReviewStagingWithheld::NoServerCapability) + ); + } +} diff --git a/crates/shelldeck-core/src/lib.rs b/crates/shelldeck-core/src/lib.rs index c23c05f5..27b98080 100644 --- a/crates/shelldeck-core/src/lib.rs +++ b/crates/shelldeck-core/src/lib.rs @@ -1,5 +1,6 @@ pub mod acp; pub mod agent_runtime; +pub mod agent_session; pub mod ai; pub mod companion; pub mod config; diff --git a/crates/shelldeck-core/src/workspace_navigation.rs b/crates/shelldeck-core/src/workspace_navigation.rs index 38943f89..3f3c69e0 100644 --- a/crates/shelldeck-core/src/workspace_navigation.rs +++ b/crates/shelldeck-core/src/workspace_navigation.rs @@ -80,6 +80,15 @@ pub struct ProviderSessionBinding { pub run_id: Option, } +/// Binding for a local CLI-agent runtime retained by a workspace. Runtime +/// existence/liveness belongs to the UI session collection; this DTO only +/// carries stable navigation identity and checkout authority. +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct AgentSessionBinding { + pub checkout_id: CatalogCheckoutId, + pub session_id: Uuid, +} + #[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] pub struct TerminalViewport { pub scrollback_offset_lines: usize, @@ -113,6 +122,7 @@ pub enum WorkspaceTabContent { Browser { location: String, }, + AgentSession(AgentSessionBinding), ProviderSession(ProviderSessionBinding), } @@ -326,6 +336,13 @@ impl WorkspaceSurfaceState { return Err(SurfaceValidationError::PlatformWorkspaceMismatch); } } + WorkspaceTabContent::AgentSession(binding) + if binding.checkout_id != workspace.checkout_id() => + { + return Err(SurfaceValidationError::CheckoutAuthorityMismatch( + binding.checkout_id, + )); + } _ => {} } } @@ -1594,4 +1611,41 @@ mod tests { .validate_for(&catalog, id) .expect("exact provider mapping"); } + + // SDTEST-1884 + #[test] + fn local_agent_session_is_bound_to_the_workspace_checkout() { + let catalog = catalog(); + let id = workspace(10); + let pane = PaneId::from_uuid(uuid(70)); + let tab = WorkspaceTabId::from_uuid(uuid(71)); + let surface = |checkout_id| WorkspaceSurfaceState { + root: Some(PaneNode::Leaf(PaneLeaf { + id: pane, + tabs: vec![WorkspaceTab { + id: tab, + title: "Implement cockpit".into(), + content: WorkspaceTabContent::AgentSession(AgentSessionBinding { + checkout_id, + session_id: uuid(72), + }), + }], + active_tab: Some(tab), + })), + focus: Some(WorkspaceFocus { + pane_id: pane, + tab_id: tab, + }), + }; + + surface(checkout(2)) + .validate_for(&catalog, id) + .expect("workspace checkout owns the local agent session"); + assert_eq!( + surface(checkout(3)).validate_for(&catalog, id), + Err(SurfaceValidationError::CheckoutAuthorityMismatch(checkout( + 3 + ))) + ); + } } diff --git a/crates/shelldeck-core/src/workspace_review.rs b/crates/shelldeck-core/src/workspace_review.rs index 5fd2ce86..4681bd50 100644 --- a/crates/shelldeck-core/src/workspace_review.rs +++ b/crates/shelldeck-core/src/workspace_review.rs @@ -17,7 +17,7 @@ use crate::config::workspace_catalog::{ }; use crate::workspace_navigation::{ PaneId, PaneNode, WorkspaceFocus, WorkspaceNavigationState, WorkspaceSurfaceState, - WorkspaceTabContent, + WorkspaceTabContent, WorkspaceTabId, }; #[path = "workspace_review_preview.rs"] @@ -30,7 +30,7 @@ use validation::{ validate_pending_record, validate_preview_bounds, validate_provider_evidence, }; #[path = "workspace_review_storage.rs"] -mod storage; +pub(crate) mod storage; use storage::{ bounded_read, ensure_private_directory, lock_path, open_lock_file, read_disk_identity, secure_atomic_write, workflow_bounded_read, workflow_disk_revision, workspace_review_root, @@ -696,12 +696,38 @@ pub enum ApprovalDecision { #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] pub enum ReviewMutationKind { - StageHunks { - hunks: Vec, - }, - UnstageHunks { - hunks: Vec, - }, + /// Modelled, and deliberately without an adapter. Do not give it one as + /// written; `benfavre/shelldeck#163` records the investigation, and the + /// short version is that nothing here can close a git write. + /// + /// * A hunk has no identity to close over. It is not a property of the + /// repository but of a rendering: the same pair of blobs decomposes into + /// a different number of hunks at a different context width, moves when + /// `diff.indentHeuristic` is toggled, yields a different set under `-w`, + /// and produces no hunks at all under a `.gitattributes` textconv driver. + /// Two hunks in one file can also have byte-identical bodies, context + /// included, so content addressing alone is ambiguous within one file. + /// * `ReviewHunkId` is a v4 UUID and nothing outside tests produces a + /// [`ReviewHunk`], so it labels a projection rather than naming anything + /// a worktree holds. The platform contract agrees: its own hunk id exists + /// for comment anchors, and its `DiffHunk` carries positions plus a + /// bounded preview, never the hunk's content. + /// * [`MutationTargetFence::LocalReview`] pins the checkout and this + /// crate's `review_revision`. Neither moves when another process running + /// as the same uid moves `HEAD`, the index or the file, so it would fence + /// a staging write against nothing. `bext-stack/automonique#225` is the + /// standard a local git write has to meet, and `config::platform_review` + /// is where the file-level controls that meet it live: a confirmation + /// minted over the observed `HEAD` object id, a digest of the whole + /// index, and each named path's objects. + /// * Git offers no fenced mechanism anyway. `git add -p` is `git diff-files + /// -p` followed by `git apply --cached`, and `git apply` matches context + /// at any line offset: it exits 0 against an index the patch was not + /// minted from, and it will write into a different region of the file + /// when the reviewed one is gone. + StageHunks { hunks: Vec }, + /// Unwired for the same reasons as [`ReviewMutationKind::StageHunks`]. + UnstageHunks { hunks: Vec }, SendComments { session_id: String, comments: Vec, @@ -1758,6 +1784,11 @@ pub enum AttentionState { pub struct AttentionTarget { pub workspace: CatalogWorkspaceId, pub pane: PaneId, + /// Exact retained native tab coordinate. Provider-only projections may + /// instead use `session_id`, which is resolved without consulting the + /// pane's mutable active-tab state. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tab_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub session_id: Option, } @@ -1860,14 +1891,18 @@ impl AttentionBoard { Ok(notify) } - pub fn open_target( - &mut self, + pub fn resolve_target( + &self, id: AttentionItemId, + expected_revision: u64, workspace: CatalogWorkspaceId, catalog: &ProjectCatalog, navigation: &WorkspaceNavigationState, ) -> Result { - let item = self.items.get_mut(&id).ok_or(AttentionError::InvalidItem)?; + let item = self.items.get(&id).ok_or(AttentionError::InvalidItem)?; + if item.revision != expected_revision { + return Err(AttentionError::StaleObservation); + } if self.workspace != workspace || item.target.workspace != workspace { return Err(AttentionError::WrongWorkspace); } @@ -1880,8 +1915,24 @@ impl AttentionBoard { .map_err(|_| AttentionError::InvalidSurface)?; let leaf = find_pane(surface.root.as_ref(), item.target.pane) .ok_or(AttentionError::UnknownPane)?; - let tab = match item.target.session_id.as_ref() { - Some(session_id) => { + let exact_tab = item + .target + .tab_id + .and_then(|tab_id| leaf.tabs.iter().find(|tab| tab.id == tab_id)); + if item.target.tab_id.is_some() && exact_tab.is_none() { + return Err(AttentionError::SessionOutsidePane); + } + let tab = match (exact_tab, item.target.session_id.as_ref()) { + (Some(tab), Some(session_id)) => match &tab.content { + WorkspaceTabContent::ProviderSession(binding) + if binding.session_id == *session_id => + { + Some(tab) + } + _ => None, + }, + (Some(tab), None) => Some(tab), + (None, Some(session_id)) => { let mut matches = leaf.tabs.iter().filter(|tab| { matches!( &tab.content, @@ -1895,18 +1946,43 @@ impl AttentionBoard { } tab } - None => leaf - .active_tab - .and_then(|active| leaf.tabs.iter().find(|tab| tab.id == active)), + (None, None) => None, } .ok_or(AttentionError::SessionOutsidePane)?; - self.locally_read.insert((id, item.revision)); Ok(WorkspaceFocus { pane_id: leaf.id, tab_id: tab.id, }) } + /// Mark only the exact observation revision that was successfully opened. + /// A newer replay must remain unread rather than inheriting an older click. + pub fn mark_read( + &mut self, + id: AttentionItemId, + expected_revision: u64, + ) -> Result<(), AttentionError> { + let item = self.items.get(&id).ok_or(AttentionError::InvalidItem)?; + if item.revision != expected_revision { + return Err(AttentionError::StaleObservation); + } + self.locally_read.insert((id, expected_revision)); + Ok(()) + } + + pub fn open_target( + &mut self, + id: AttentionItemId, + expected_revision: u64, + workspace: CatalogWorkspaceId, + catalog: &ProjectCatalog, + navigation: &WorkspaceNavigationState, + ) -> Result { + let focus = self.resolve_target(id, expected_revision, workspace, catalog, navigation)?; + self.mark_read(id, expected_revision)?; + Ok(focus) + } + #[must_use] pub fn is_unread(&self, id: AttentionItemId) -> bool { self.items.get(&id).is_some_and(|item| { diff --git a/crates/shelldeck-core/src/workspace_review_storage.rs b/crates/shelldeck-core/src/workspace_review_storage.rs index 00dcbcb6..1aee78de 100644 --- a/crates/shelldeck-core/src/workspace_review_storage.rs +++ b/crates/shelldeck-core/src/workspace_review_storage.rs @@ -17,6 +17,13 @@ pub(super) fn workspace_state_path( } pub(super) fn ensure_private_directory(path: &Path) -> Result<(), ReviewDraftError> { + ensure_private_directory_io(path)?; + Ok(()) +} + +/// Create and validate the two-level private storage boundary shared by +/// review and attention custody. +pub(crate) fn ensure_private_directory_io(path: &Path) -> std::io::Result<()> { let root = path.parent().ok_or_else(invalid_private_path)?; if let Some(parent) = root.parent() { std::fs::create_dir_all(parent)?; @@ -26,11 +33,11 @@ pub(super) fn ensure_private_directory(path: &Path) -> Result<(), ReviewDraftErr Ok(()) } -fn create_owned_directory(path: &Path) -> Result<(), ReviewDraftError> { +fn create_owned_directory(path: &Path) -> std::io::Result<()> { match std::fs::create_dir(path) { Ok(()) => {} Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {} - Err(error) => return Err(error.into()), + Err(error) => return Err(error), } validate_directory_shape(path)?; #[cfg(unix)] @@ -139,7 +146,7 @@ fn open_no_follow(path: &Path, configure: impl FnOnce(&mut OpenOptions)) -> std: Ok(file) } -fn bounded_descriptor_read_after_open( +pub(crate) fn bounded_descriptor_read_after_open( path: &Path, maximum: u64, after_open: impl FnOnce(), @@ -161,11 +168,14 @@ fn bounded_descriptor_read_after_open( Ok(Some(bytes)) } -fn bounded_descriptor_read(path: &Path, maximum: u64) -> std::io::Result>> { +pub(crate) fn bounded_descriptor_read( + path: &Path, + maximum: u64, +) -> std::io::Result>> { bounded_descriptor_read_after_open(path, maximum, || {}) } -#[cfg(test)] +#[cfg(all(test, unix))] pub(super) fn bounded_read_after_open_for_test( path: &Path, maximum: u64, @@ -199,13 +209,13 @@ pub(super) fn workflow_bounded_read(path: &Path) -> Result>, Revi Ok(bytes) } -pub(super) fn open_lock_file(path: &Path) -> std::io::Result { +pub(crate) fn open_lock_file(path: &Path) -> std::io::Result { open_no_follow(path, |options| { options.create(true).truncate(false).read(true).write(true); }) } -pub(super) fn secure_atomic_write(path: &Path, payload: &[u8]) -> std::io::Result<()> { +pub(crate) fn secure_atomic_write(path: &Path, payload: &[u8]) -> std::io::Result<()> { validate_owned_parent(path)?; if let Ok(metadata) = std::fs::symlink_metadata(path) { if metadata.file_type().is_symlink() || is_windows_reparse(&metadata) { @@ -215,7 +225,7 @@ pub(super) fn secure_atomic_write(path: &Path, payload: &[u8]) -> std::io::Resul crate::util::atomic_write(path, payload) } -pub(super) fn lock_path(path: &Path) -> PathBuf { +pub(crate) fn lock_path(path: &Path) -> PathBuf { let mut name = path.as_os_str().to_os_string(); name.push(".lock"); PathBuf::from(name) diff --git a/crates/shelldeck-core/src/workspace_review_tests.rs b/crates/shelldeck-core/src/workspace_review_tests.rs index 03b472a0..17605409 100644 --- a/crates/shelldeck-core/src/workspace_review_tests.rs +++ b/crates/shelldeck-core/src/workspace_review_tests.rs @@ -1116,6 +1116,7 @@ fn sdtest_1755_attention_read_state_replays_and_duplicate_coordinates_fail_close target: AttentionTarget { workspace: workspace(1), pane, + tab_id: None, session_id: Some("session-1".into()), }, state: AttentionState::NeedsYou, @@ -1137,11 +1138,11 @@ fn sdtest_1755_attention_read_state_replays_and_duplicate_coordinates_fail_close ) .unwrap(); assert_eq!( - board.open_target(id, workspace(2), &catalog, &navigation), + board.open_target(id, 1, workspace(2), &catalog, &navigation), Err(AttentionError::WrongWorkspace) ); board - .open_target(id, workspace(1), &catalog, &navigation) + .open_target(id, 1, workspace(1), &catalog, &navigation) .unwrap(); assert!(!board.is_unread(id)); assert!(!board.apply(item).unwrap()); @@ -1164,7 +1165,7 @@ fn sdtest_1755_attention_read_state_replays_and_duplicate_coordinates_fail_close ) .unwrap(); assert_eq!( - board.open_target(id, workspace(1), &catalog, &navigation), + board.open_target(id, 1, workspace(1), &catalog, &navigation), Err(AttentionError::DuplicateSessionCoordinate) ); @@ -1206,6 +1207,7 @@ fn sdtest_1755_attention_read_state_replays_and_duplicate_coordinates_fail_close target: AttentionTarget { workspace: workspace(1), pane: browser_pane, + tab_id: Some(local_browser_tab), session_id: None, }, state: AttentionState::NeedsYou, @@ -1215,7 +1217,7 @@ fn sdtest_1755_attention_read_state_replays_and_duplicate_coordinates_fail_close }) .unwrap(); assert_eq!( - board.open_target(browser_id, workspace(1), &catalog, &keyed_navigation), + board.open_target(browser_id, 1, workspace(1), &catalog, &keyed_navigation,), Err(AttentionError::InvalidSurface) ); assert!(board.is_unread(browser_id)); @@ -1231,7 +1233,7 @@ fn sdtest_1755_attention_read_state_replays_and_duplicate_coordinates_fail_close .unwrap(); assert_eq!( board - .open_target(browser_id, workspace(1), &catalog, &keyed_navigation) + .open_target(browser_id, 1, workspace(1), &catalog, &keyed_navigation,) .unwrap(), WorkspaceFocus { pane_id: browser_pane, @@ -1241,6 +1243,150 @@ fn sdtest_1755_attention_read_state_replays_and_duplicate_coordinates_fail_close assert!(!board.is_unread(browser_id)); } +// SDTEST-1810 +#[test] +fn sdtest_1810_attention_activation_requires_exact_revision_and_coordinates() { + let catalog = catalog_for(workspace(1), "platform-workspace"); + let pane = PaneId::from_uuid(uuid(30)); + let provider_tab = |id, session: &str| WorkspaceTab { + id: WorkspaceTabId::from_uuid(uuid(id)), + title: "Agent".into(), + content: WorkspaceTabContent::ProviderSession(ProviderSessionBinding { + platform_user_workspace_id: "platform-workspace".into(), + session_id: session.into(), + run_id: None, + }), + }; + let surface = |tabs: Vec| WorkspaceSurfaceState { + root: Some(PaneNode::Leaf(PaneLeaf { + id: pane, + active_tab: tabs.first().map(|tab| tab.id), + tabs, + })), + focus: None, + }; + let mut navigation = WorkspaceNavigationState::default(); + navigation + .reduce( + &catalog, + WorkspaceNavigationAction::Retain { + id: workspace(1), + surface: surface(vec![provider_tab(31, "session-1")]), + card: WorkspaceCardAggregate::default(), + }, + ) + .unwrap(); + + let id = AttentionItemId::from_uuid(uuid(32)); + let item = |id, revision, pane, session: Option<&str>| AttentionItem { + id, + revision, + observed_at_millis: 100 + revision, + target: AttentionTarget { + workspace: workspace(1), + pane, + tab_id: None, + session_id: session.map(str::to_owned), + }, + state: AttentionState::NeedsYou, + title: "Exact attention".into(), + unread: true, + agent_path: vec!["root".into(), "reviewer".into()], + }; + let mut board = AttentionBoard::new(workspace(1)); + board.apply(item(id, 2, pane, Some("session-1"))).unwrap(); + + assert_eq!( + board.resolve_target(id, 1, workspace(1), &catalog, &navigation), + Err(AttentionError::StaleObservation) + ); + assert_eq!( + board.mark_read(id, 1), + Err(AttentionError::StaleObservation) + ); + assert!(board.is_unread(id)); + + let missing_pane = AttentionItemId::from_uuid(uuid(33)); + board + .apply(item(missing_pane, 1, PaneId::from_uuid(uuid(34)), None)) + .unwrap(); + assert_eq!( + board.resolve_target(missing_pane, 1, workspace(1), &catalog, &navigation), + Err(AttentionError::UnknownPane) + ); + + let missing_session = AttentionItemId::from_uuid(uuid(35)); + board + .apply(item(missing_session, 1, pane, Some("missing-session"))) + .unwrap(); + assert_eq!( + board.resolve_target(missing_session, 1, workspace(1), &catalog, &navigation,), + Err(AttentionError::SessionOutsidePane) + ); + + let mismatched_family = AttentionItemId::from_uuid(uuid(39)); + board + .apply(AttentionItem { + target: AttentionTarget { + workspace: workspace(1), + pane, + tab_id: Some(WorkspaceTabId::from_uuid(uuid(31))), + session_id: Some("different-provider-session".into()), + }, + ..item(mismatched_family, 1, pane, None) + }) + .unwrap(); + assert_eq!( + board.resolve_target(mismatched_family, 1, workspace(1), &catalog, &navigation), + Err(AttentionError::SessionOutsidePane) + ); + + let unbound_target = AttentionItemId::from_uuid(uuid(40)); + board.apply(item(unbound_target, 1, pane, None)).unwrap(); + assert_eq!( + board.resolve_target(unbound_target, 1, workspace(1), &catalog, &navigation), + Err(AttentionError::SessionOutsidePane) + ); + + navigation + .reduce( + &catalog, + WorkspaceNavigationAction::UpdateSurface { + id: workspace(1), + surface: surface(vec![ + provider_tab(36, "session-1"), + provider_tab(37, "session-1"), + ]), + }, + ) + .unwrap(); + assert_eq!( + board.resolve_target(id, 2, workspace(1), &catalog, &navigation), + Err(AttentionError::DuplicateSessionCoordinate) + ); + + let foreign = AttentionItem { + target: AttentionTarget { + workspace: workspace(2), + pane, + tab_id: None, + session_id: None, + }, + ..item(AttentionItemId::from_uuid(uuid(38)), 1, pane, None) + }; + assert_eq!(board.apply(foreign), Err(AttentionError::WrongWorkspace)); + assert_eq!( + board.resolve_target( + AttentionItemId::from_uuid(uuid(41)), + 1, + workspace(1), + &catalog, + &navigation, + ), + Err(AttentionError::InvalidItem) + ); +} + // SDTEST-1756 #[test] fn sdtest_1756_delivery_mutations_require_exact_fresh_projection_revision() { diff --git a/crates/shelldeck-core/tests/fixtures/README.md b/crates/shelldeck-core/tests/fixtures/README.md index 6922686d..1516cc66 100644 --- a/crates/shelldeck-core/tests/fixtures/README.md +++ b/crates/shelldeck-core/tests/fixtures/README.md @@ -24,3 +24,21 @@ at Automonique commit `1dd5c13f7a53aac9e28d3cd004bf73789ca66bb7`. Keep this corpus exact as well. It intentionally carries revisions above the JavaScript safe-integer ceiling so every client proves lossless source revision custody while rendering the same semantic keys. + +## Attention succession corpus + +`platform-v2-attention-conformance-v1.json` is copied byte-for-byte from +`rust/crates/automonique-protocol/fixtures/platform-v2-attention-conformance-v1.json` +at Automonique commit `4a4cab534b35dc29a37e2c6424d23396049b1003` (merged PR #214). + +- Upstream: +- SHA-256: `67cdb58ed7cbf6cd0a2f471c722b1dc1616d7200736c44ed702bd8b07ef21f8f` +- License: Elastic License 2.0 (`SPDX-License-Identifier: Elastic-2.0`), matching the upstream protocol fixture and repository license. + +`automonique.platform/attention/v1` is `atomic_replace`, so no single snapshot +says what a client must conclude after a sequence of reads. This corpus fixes +that sequence, including the cases where the honest answer is that the source +is hidden rather than empty: a retention gap, a refusal, and an inventoried +source nobody has read yet. Keep it exact — a scenario variant belongs in test +memory so the shared file stays a reliable cross-client input. + diff --git a/crates/shelldeck-core/tests/fixtures/platform-v2-attention-conformance-v1.json b/crates/shelldeck-core/tests/fixtures/platform-v2-attention-conformance-v1.json new file mode 100644 index 00000000..59b51503 --- /dev/null +++ b/crates/shelldeck-core/tests/fixtures/platform-v2-attention-conformance-v1.json @@ -0,0 +1 @@ +{"cases":[{"expected":{"available":true,"visible_items":["item-a"]},"id":"continuous-first-read-requires-revision-one","reads":[{"kind":"snapshot","mode":"continuous","outcome":"initial_revision_required","snapshot":{"items":[{"id":"item-a","nested_agent_path":[],"observed_at_ms":"1000","platform_session":null,"reason":"review_requested","revision":"9007199254741102","state":"needs_you","unread":true}],"observed_at_ms":"1000","previous_revision":"9007199254741101","project":"project-conformance","revision":"9007199254741102","schema":"automonique.platform/attention/v1","semantics":"atomic_replace","source":{"id":"workspace-conformance","kind":"review"},"user_workspace":"workspace-conformance"}},{"kind":"snapshot","mode":"continuous","outcome":"inserted","snapshot":{"items":[{"id":"item-a","nested_agent_path":[],"observed_at_ms":"1000","platform_session":null,"reason":"review_requested","revision":"1","state":"needs_you","unread":true}],"observed_at_ms":"1000","previous_revision":null,"project":"project-conformance","revision":"1","schema":"automonique.platform/attention/v1","semantics":"atomic_replace","source":{"id":"workspace-conformance","kind":"review"},"user_workspace":"workspace-conformance"}}],"source":{"id":"workspace-conformance","kind":"review"}},{"expected":{"available":true,"visible_items":["item-a","item-b"]},"id":"continuous-successor-replaces-atomically","reads":[{"kind":"snapshot","mode":"continuous","outcome":"inserted","snapshot":{"items":[{"id":"item-a","nested_agent_path":[],"observed_at_ms":"1000","platform_session":null,"reason":"review_requested","revision":"1","state":"needs_you","unread":true}],"observed_at_ms":"1000","previous_revision":null,"project":"project-conformance","revision":"1","schema":"automonique.platform/attention/v1","semantics":"atomic_replace","source":{"id":"workspace-conformance","kind":"review"},"user_workspace":"workspace-conformance"}},{"kind":"snapshot","mode":"continuous","outcome":"replaced","snapshot":{"items":[{"id":"item-a","nested_agent_path":[],"observed_at_ms":"2000","platform_session":null,"reason":"review_requested","revision":"2","state":"needs_you","unread":true},{"id":"item-b","nested_agent_path":[],"observed_at_ms":"2000","platform_session":null,"reason":"agent_working","revision":"2","state":"working","unread":true}],"observed_at_ms":"2000","previous_revision":"1","project":"project-conformance","revision":"2","schema":"automonique.platform/attention/v1","semantics":"atomic_replace","source":{"id":"workspace-conformance","kind":"review"},"user_workspace":"workspace-conformance"}}],"source":{"id":"workspace-conformance","kind":"review"}},{"expected":{"available":true,"visible_items":["item-a"]},"id":"exact-replay-changes-nothing","reads":[{"kind":"snapshot","mode":"continuous","outcome":"inserted","snapshot":{"items":[{"id":"item-a","nested_agent_path":[],"observed_at_ms":"1000","platform_session":null,"reason":"review_requested","revision":"1","state":"needs_you","unread":true}],"observed_at_ms":"1000","previous_revision":null,"project":"project-conformance","revision":"1","schema":"automonique.platform/attention/v1","semantics":"atomic_replace","source":{"id":"workspace-conformance","kind":"review"},"user_workspace":"workspace-conformance"}},{"kind":"snapshot","mode":"continuous","outcome":"exact_replay","snapshot":{"items":[{"id":"item-a","nested_agent_path":[],"observed_at_ms":"1000","platform_session":null,"reason":"review_requested","revision":"1","state":"needs_you","unread":true}],"observed_at_ms":"1000","previous_revision":null,"project":"project-conformance","revision":"1","schema":"automonique.platform/attention/v1","semantics":"atomic_replace","source":{"id":"workspace-conformance","kind":"review"},"user_workspace":"workspace-conformance"}}],"source":{"id":"workspace-conformance","kind":"review"}},{"expected":{"available":true,"visible_items":["item-a"]},"id":"conflicting-replay-is-refused","reads":[{"kind":"snapshot","mode":"continuous","outcome":"inserted","snapshot":{"items":[{"id":"item-a","nested_agent_path":[],"observed_at_ms":"1000","platform_session":null,"reason":"review_requested","revision":"1","state":"needs_you","unread":true}],"observed_at_ms":"1000","previous_revision":null,"project":"project-conformance","revision":"1","schema":"automonique.platform/attention/v1","semantics":"atomic_replace","source":{"id":"workspace-conformance","kind":"review"},"user_workspace":"workspace-conformance"}},{"kind":"snapshot","mode":"continuous","outcome":"conflicting_replay","snapshot":{"items":[{"id":"item-a","nested_agent_path":[],"observed_at_ms":"1000","platform_session":null,"reason":"review_requested","revision":"1","state":"needs_you","unread":false}],"observed_at_ms":"1000","previous_revision":null,"project":"project-conformance","revision":"1","schema":"automonique.platform/attention/v1","semantics":"atomic_replace","source":{"id":"workspace-conformance","kind":"review"},"user_workspace":"workspace-conformance"}}],"source":{"id":"workspace-conformance","kind":"review"}},{"expected":{"available":false,"visible_items":[]},"id":"retention-gap-refuses-and-hides-the-source","reads":[{"kind":"snapshot","mode":"continuous","outcome":"inserted","snapshot":{"items":[{"id":"item-a","nested_agent_path":[],"observed_at_ms":"1000","platform_session":null,"reason":"review_requested","revision":"1","state":"needs_you","unread":true}],"observed_at_ms":"1000","previous_revision":null,"project":"project-conformance","revision":"1","schema":"automonique.platform/attention/v1","semantics":"atomic_replace","source":{"id":"workspace-conformance","kind":"review"},"user_workspace":"workspace-conformance"}},{"kind":"snapshot","mode":"continuous","outcome":"invalid_successor","snapshot":{"items":[{"id":"item-a","nested_agent_path":[],"observed_at_ms":"3000","platform_session":null,"reason":"review_requested","revision":"3","state":"needs_you","unread":true}],"observed_at_ms":"3000","previous_revision":"2","project":"project-conformance","revision":"3","schema":"automonique.platform/attention/v1","semantics":"atomic_replace","source":{"id":"workspace-conformance","kind":"review"},"user_workspace":"workspace-conformance"}},{"kind":"unavailable","reason":"inventory_incomplete"}],"source":{"id":"workspace-conformance","kind":"review"}},{"expected":{"available":true,"visible_items":["item-c"]},"id":"authenticated-baseline-bridges-a-gap","reads":[{"kind":"snapshot","mode":"continuous","outcome":"inserted","snapshot":{"items":[{"id":"item-a","nested_agent_path":[],"observed_at_ms":"1000","platform_session":null,"reason":"review_requested","revision":"1","state":"needs_you","unread":true}],"observed_at_ms":"1000","previous_revision":null,"project":"project-conformance","revision":"1","schema":"automonique.platform/attention/v1","semantics":"atomic_replace","source":{"id":"workspace-conformance","kind":"review"},"user_workspace":"workspace-conformance"}},{"kind":"unavailable","reason":"inventory_incomplete"},{"kind":"snapshot","mode":"baseline","outcome":"replaced","snapshot":{"items":[{"id":"item-c","nested_agent_path":[],"observed_at_ms":"9000","platform_session":null,"reason":"review_requested","revision":"9","state":"needs_you","unread":true}],"observed_at_ms":"9000","previous_revision":"8","project":"project-conformance","revision":"9","schema":"automonique.platform/attention/v1","semantics":"atomic_replace","source":{"id":"workspace-conformance","kind":"review"},"user_workspace":"workspace-conformance"}}],"source":{"id":"workspace-conformance","kind":"review"}},{"expected":{"available":true,"visible_items":["item-a"]},"id":"baseline-rollback-is-refused","reads":[{"kind":"snapshot","mode":"baseline","outcome":"inserted","snapshot":{"items":[{"id":"item-a","nested_agent_path":[],"observed_at_ms":"5000","platform_session":null,"reason":"review_requested","revision":"9007199254741105","state":"needs_you","unread":true}],"observed_at_ms":"5000","previous_revision":"9007199254741104","project":"project-conformance","revision":"9007199254741105","schema":"automonique.platform/attention/v1","semantics":"atomic_replace","source":{"id":"workspace-conformance","kind":"review"},"user_workspace":"workspace-conformance"}},{"kind":"snapshot","mode":"baseline","outcome":"baseline_invalid","snapshot":{"items":[{"id":"item-a","nested_agent_path":[],"observed_at_ms":"4000","platform_session":null,"reason":"review_requested","revision":"9007199254741104","state":"needs_you","unread":true}],"observed_at_ms":"4000","previous_revision":"9007199254741103","project":"project-conformance","revision":"9007199254741104","schema":"automonique.platform/attention/v1","semantics":"atomic_replace","source":{"id":"workspace-conformance","kind":"review"},"user_workspace":"workspace-conformance"}}],"source":{"id":"workspace-conformance","kind":"review"}},{"expected":{"available":true,"visible_items":["item-a"]},"id":"baseline-below-the-retained-predecessor-is-refused","reads":[{"kind":"snapshot","mode":"baseline","outcome":"inserted","snapshot":{"items":[{"id":"item-a","nested_agent_path":[],"observed_at_ms":"5000","platform_session":null,"reason":"review_requested","revision":"9007199254741105","state":"needs_you","unread":true}],"observed_at_ms":"5000","previous_revision":"9007199254741104","project":"project-conformance","revision":"9007199254741105","schema":"automonique.platform/attention/v1","semantics":"atomic_replace","source":{"id":"workspace-conformance","kind":"review"},"user_workspace":"workspace-conformance"}},{"kind":"snapshot","mode":"baseline","outcome":"baseline_invalid","snapshot":{"items":[{"id":"item-c","nested_agent_path":[],"observed_at_ms":"9000","platform_session":null,"reason":"review_requested","revision":"9007199254741109","state":"needs_you","unread":true}],"observed_at_ms":"9000","previous_revision":"9007199254741102","project":"project-conformance","revision":"9007199254741109","schema":"automonique.platform/attention/v1","semantics":"atomic_replace","source":{"id":"workspace-conformance","kind":"review"},"user_workspace":"workspace-conformance"}}],"source":{"id":"workspace-conformance","kind":"review"}},{"expected":{"available":true,"visible_items":["item-a"]},"id":"item-revision-regression-is-refused","reads":[{"kind":"snapshot","mode":"baseline","outcome":"inserted","snapshot":{"items":[{"id":"item-a","nested_agent_path":[],"observed_at_ms":"5000","platform_session":null,"reason":"review_requested","revision":"9007199254741105","state":"needs_you","unread":true}],"observed_at_ms":"5000","previous_revision":"9007199254741104","project":"project-conformance","revision":"9007199254741105","schema":"automonique.platform/attention/v1","semantics":"atomic_replace","source":{"id":"workspace-conformance","kind":"review"},"user_workspace":"workspace-conformance"}},{"kind":"snapshot","mode":"continuous","outcome":"invalid_successor","snapshot":{"items":[{"id":"item-a","nested_agent_path":[],"observed_at_ms":"6000","platform_session":null,"reason":"review_requested","revision":"9007199254741104","state":"needs_you","unread":true}],"observed_at_ms":"6000","previous_revision":"9007199254741105","project":"project-conformance","revision":"9007199254741106","schema":"automonique.platform/attention/v1","semantics":"atomic_replace","source":{"id":"workspace-conformance","kind":"review"},"user_workspace":"workspace-conformance"}}],"source":{"id":"workspace-conformance","kind":"review"}},{"expected":{"available":false,"visible_items":[]},"id":"refusal-hides-the-projection-and-keeps-the-chain","reads":[{"kind":"snapshot","mode":"continuous","outcome":"inserted","snapshot":{"items":[{"id":"item-a","nested_agent_path":[],"observed_at_ms":"1000","platform_session":null,"reason":"review_requested","revision":"1","state":"needs_you","unread":true}],"observed_at_ms":"1000","previous_revision":null,"project":"project-conformance","revision":"1","schema":"automonique.platform/attention/v1","semantics":"atomic_replace","source":{"id":"workspace-conformance","kind":"review"},"user_workspace":"workspace-conformance"}},{"category":"unauthorized","kind":"refusal"}],"source":{"id":"workspace-conformance","kind":"review"}},{"expected":{"available":true,"visible_items":["item-a"]},"id":"exact-replay-restores-availability","reads":[{"kind":"snapshot","mode":"continuous","outcome":"inserted","snapshot":{"items":[{"id":"item-a","nested_agent_path":[],"observed_at_ms":"1000","platform_session":null,"reason":"review_requested","revision":"1","state":"needs_you","unread":true}],"observed_at_ms":"1000","previous_revision":null,"project":"project-conformance","revision":"1","schema":"automonique.platform/attention/v1","semantics":"atomic_replace","source":{"id":"workspace-conformance","kind":"review"},"user_workspace":"workspace-conformance"}},{"category":"unauthorized","kind":"refusal"},{"kind":"snapshot","mode":"continuous","outcome":"availability_restored","snapshot":{"items":[{"id":"item-a","nested_agent_path":[],"observed_at_ms":"1000","platform_session":null,"reason":"review_requested","revision":"1","state":"needs_you","unread":true}],"observed_at_ms":"1000","previous_revision":null,"project":"project-conformance","revision":"1","schema":"automonique.platform/attention/v1","semantics":"atomic_replace","source":{"id":"workspace-conformance","kind":"review"},"user_workspace":"workspace-conformance"}}],"source":{"id":"workspace-conformance","kind":"review"}},{"expected":{"available":false,"visible_items":[]},"id":"never-read-source-is-unobserved","reads":[],"source":{"id":"workspace-conformance","kind":"review"}},{"expected":{"available":true,"visible_items":["item-nested","item-root"]},"id":"provider-source-retains-its-published-session","reads":[{"kind":"snapshot","mode":"baseline","outcome":"inserted","snapshot":{"items":[{"id":"item-nested","nested_agent_path":["item-root"],"observed_at_ms":"7000","platform_session":{"authority":"automonique","id":"platform-session-conformance","kind":"session"},"reason":"agent_working","revision":"9007199254741107","state":"working","unread":true},{"id":"item-root","nested_agent_path":[],"observed_at_ms":"7000","platform_session":{"authority":"automonique","id":"platform-session-conformance","kind":"session"},"reason":"agent_working","revision":"9007199254741107","state":"working","unread":true}],"observed_at_ms":"7000","previous_revision":"9007199254741106","project":"project-conformance","revision":"9007199254741107","schema":"automonique.platform/attention/v1","semantics":"atomic_replace","source":{"id":"session-conformance","kind":"provider_session"},"user_workspace":"workspace-conformance"}}],"source":{"id":"session-conformance","kind":"provider_session"}}],"schema":"automonique.attention-conformance/v1","target":{"project":"project-conformance","user_workspace":"workspace-conformance"},"version":"1"} \ No newline at end of file diff --git a/crates/shelldeck-ui/Cargo.toml b/crates/shelldeck-ui/Cargo.toml index c9ffbf2b..bf1fa160 100644 --- a/crates/shelldeck-ui/Cargo.toml +++ b/crates/shelldeck-ui/Cargo.toml @@ -56,6 +56,7 @@ windows-sys = { workspace = true } [dev-dependencies] gpui = { workspace = true, features = ["test-support"] } toml = { workspace = true } +automonique-protocol = { workspace = true } [package.metadata.i18n] available-locales = ["fr", "en"] diff --git a/crates/shelldeck-ui/src/agent_console_view.rs b/crates/shelldeck-ui/src/agent_console_view.rs index 288832be..f17c43d9 100644 --- a/crates/shelldeck-ui/src/agent_console_view.rs +++ b/crates/shelldeck-ui/src/agent_console_view.rs @@ -1,7 +1,11 @@ -//! First-class local/SSH coding-agent console for Dev mode. +//! Worktree-first coding-agent cockpit for Dev mode. //! -//! The view owns presentation and draft state only. Workspace owns process and -//! SSH lifetimes, feeds normalized stream events back, and enforces stop. +//! Durable session/message/trace state is owned by `shelldeck-core`. This view +//! adds GPUI drafts, navigation, execution controls, and run-id routing while +//! Workspace retains process and SSH lifetimes. + +use std::collections::{HashMap, HashSet}; +use std::path::PathBuf; use adabraka_ui::components::confirm_dialog::Dialog as UiDialog; use adabraka_ui::components::icon_source::IconSource; @@ -12,14 +16,22 @@ use adabraka_ui::overlays::popover::PopoverContent; use adabraka_ui::prelude::{ Badge, BadgeVariant, Button, ButtonSize, ButtonVariant, Composer, Markdown, Popover, }; +use chrono::Utc; use gpui::prelude::*; use gpui::*; use shelldeck_core::agent_runtime::{ AgentAccessMode, AgentProvider, AgentRunRequest, AgentStreamEvent, AgentTarget, - MAX_AGENT_MODEL_BYTES, MAX_AGENT_PROMPT_BYTES, MAX_AGENT_WORKDIR_BYTES, + AGENT_REMOTE_HOME_WORKDIR, MAX_AGENT_MODEL_BYTES, MAX_AGENT_PROMPT_BYTES, + MAX_AGENT_WORKDIR_BYTES, +}; +use shelldeck_core::agent_session::{ + AgentExecutionContext, AgentMessage, AgentMessageRole, AgentSession, AgentSessionAttention, + AgentSessionCollection, AgentSessionStatus, AgentTraceEvent, AgentTraceKind, AgentTraceStatus, + DEFAULT_MAX_CONCURRENT_AGENT_SESSIONS, }; use uuid::Uuid; +use crate::file_editor::file_browser::FileBrowserPanel; use crate::follow_scroll::{follow_latest_if_at_end, pin_to_latest}; use crate::icons::lucide_icon; use crate::monolith::{animated_loading_text, animated_monolith, MonolithMotion}; @@ -27,39 +39,48 @@ use crate::scale::px; use crate::t; use crate::theme::ShellDeckColors; -const MAX_TRANSCRIPT_BYTES: usize = 2 * 1024 * 1024; -const MAX_ACTIVITY_ITEMS: usize = 250; -const MAX_POPOVER_ACTIVITY_ITEMS: usize = 12; const MAX_ACTIVITY_BYTES: usize = 4 * 1024; +const NAVIGATOR_WIDTH: f32 = 238.0; +const INSPECTOR_WIDTH: f32 = 286.0; -fn push_activity(activity: &mut Vec, item: String) { - if activity.last() == Some(&item) { - return; - } - activity.push(item); - let overflow = activity.len().saturating_sub(MAX_ACTIVITY_ITEMS); - if overflow > 0 { - activity.drain(..overflow); - } -} - -fn popover_activity(activity: &[String]) -> &[String] { - let start = activity.len().saturating_sub(MAX_POPOVER_ACTIVITY_ITEMS); - &activity[start..] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +enum AgentInspectorTab { + #[default] + Files, + Changes, } -fn show_agent_preparing(running: bool, received_output: bool, has_error: bool) -> bool { - running && !received_output && !has_error +#[derive(Debug, Clone, PartialEq, Eq)] +struct InspectorFile { + path: String, + latest_trace_id: Uuid, + latest_sequence: u64, + diff: Option, } -fn conversation_turn_separator() -> &'static str { - "\n\n" +#[derive(Debug, Clone, PartialEq, Eq)] +struct InspectorDiff { + trace_id: Uuid, + sequence: u64, + additions: u32, + deletions: u32, + preview: Option, } #[derive(Debug, Clone)] pub enum AgentConsoleEvent { Run(AgentRunRequest), Stop(Uuid), + CloseSession(Uuid), + OpenLocalFile { + session_id: Uuid, + path: PathBuf, + }, + BrowseRemoteDirectory { + session_id: Uuid, + connection_id: Uuid, + path: String, + }, } impl EventEmitter for AgentConsoleView {} @@ -72,57 +93,75 @@ pub struct AgentConnectionOption { } #[derive(Debug, Clone, PartialEq, Eq)] -struct AgentSessionContext { - provider: AgentProvider, - target: AgentTarget, - access: AgentAccessMode, - workdir: String, - model: Option, +pub struct AgentRemoteFileEntry { + pub path: String, + pub name: String, + pub is_dir: bool, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum AgentRunDisposition { - Run, - Confirm, +/// Project/worktree data supplied by the workspace catalog. Sessions are +/// associated by their execution-context workdir, so the UI has no duplicate +/// checkout identity model. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AgentProjectGroup { + pub id: String, + pub label: String, + pub worktrees: Vec, } -fn run_disposition(access: AgentAccessMode) -> AgentRunDisposition { - match access { - AgentAccessMode::ReadOnly => AgentRunDisposition::Run, - AgentAccessMode::WorkspaceWrite | AgentAccessMode::FullAccess => { - AgentRunDisposition::Confirm - } - } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AgentWorktreeOption { + pub id: String, + pub label: String, + pub path: String, + pub target: AgentTarget, + pub branch: Option, + pub is_primary: bool, +} + +struct AgentSessionUi { + prompt: Entity, + run_id: Option, + received_delta: bool, + received_output: bool, } -impl From<&AgentRunRequest> for AgentSessionContext { - fn from(request: &AgentRunRequest) -> Self { +impl AgentSessionUi { + fn new(cx: &mut Context) -> Self { Self { - provider: request.provider, - target: request.target.clone(), - access: request.access, - workdir: request.workdir.clone(), - model: request.model.clone(), + prompt: cx.new(|cx| InputState::new(cx).multi_line(true)), + run_id: None, + received_delta: false, + received_output: false, } } } -fn matching_resume_token( - context: Option<&AgentSessionContext>, - token: Option<&String>, - request: &AgentRunRequest, -) -> Option { - (context == Some(&AgentSessionContext::from(request))) - .then(|| token.cloned()) - .flatten() +struct PendingRun { + session_id: Uuid, + context: AgentExecutionContext, + prompt: String, } -fn target_label(target: &AgentTarget) -> String { - match target { - AgentTarget::Local => t!("agents.target.local").to_string(), - AgentTarget::Ssh { - connection_label, .. - } => connection_label.clone(), +#[derive(Clone)] +enum TimelineItem { + Message(AgentMessage), + Trace(AgentTraceEvent), +} + +impl TimelineItem { + fn sequence(&self) -> u64 { + match self { + Self::Message(message) => message.sequence, + Self::Trace(trace) => trace.sequence, + } + } + + fn at_ms(&self) -> i64 { + match self { + Self::Message(message) => message.at_ms, + Self::Trace(trace) => trace.at_ms, + } } } @@ -134,18 +173,27 @@ pub struct AgentConsoleView { provider_select: Entity>, access_select: Entity>, target_select: Entity>>, + default_local_workdir: String, workdir_state: Entity, model_state: Entity, - prompt_state: Entity, - running: Option, - session_context: Option, - session_token: Option, - run_received_delta: bool, - run_received_output: bool, - pending_confirm: Option, - transcript: String, - activity: Vec, - error: Option, + navigator_search: Entity, + sessions: AgentSessionCollection, + session_ui: HashMap, + run_sessions: HashMap, + project_groups: Vec, + context_locks: HashSet, + pending_confirm: Option, + context_expanded: bool, + inspector_tab: AgentInspectorTab, + focused_trace: Option, + inspector_local_browser: FileBrowserPanel, + inspector_remote_path: Option, + inspector_remote_pending_path: Option, + inspector_remote_session: Option, + inspector_remote_connection: Option, + inspector_remote_entries: Vec, + inspector_remote_loading: bool, + inspector_remote_error: Option, scroll: ScrollHandle, } @@ -160,67 +208,31 @@ fn input_state(cx: &mut Context, initial: String) -> Entity) -> Self { let parent = cx.entity(); - let provider_select = cx.new({ - let parent = parent.clone(); - move |select_cx| { - Select::new(select_cx) - .options(vec![ - SelectOption::new(AgentProvider::Claude, "Claude Code") - .with_icon("icons/simple/claudecode.svg"), - SelectOption::new(AgentProvider::Codex, "Codex") - .with_icon("icons/simple/openai.svg"), - SelectOption::new(AgentProvider::Jcode, "Jcode (auto)") - .with_icon("icons/lucide/sparkles.svg"), - SelectOption::new(AgentProvider::DeepSeek, "DeepSeek (Jcode)") - .with_icon("icons/lucide/bot.svg"), - ]) - .selected_index(Some(0)) - .context_label(t!("agents.provider").to_string()) - .on_change(move |provider, _window, cx| { - parent.update(cx, |this, cx| { - this.provider = *provider; - cx.notify(); - }); - }) - } - }); - let access_select = cx.new({ - let parent = parent.clone(); - move |select_cx| { - Select::new(select_cx) - .options(vec![ - SelectOption::new( - AgentAccessMode::ReadOnly, - t!("agents.access.read_only").to_string(), - ) - .with_icon("icons/lucide/lock.svg"), - SelectOption::new( - AgentAccessMode::WorkspaceWrite, - t!("agents.access.workspace_write").to_string(), - ) - .with_icon("icons/lucide/pencil.svg"), - SelectOption::new( - AgentAccessMode::FullAccess, - t!("agents.access.full").to_string(), - ) - .with_icon("icons/lucide/shield.svg"), - ]) - .selected_index(Some(0)) - .context_label(t!("agents.access.label").to_string()) - .on_change(move |access, _window, cx| { - parent.update(cx, |this, cx| { - this.access = *access; - cx.notify(); - }); - }) - } - }); - let target_select = Self::build_target_select(&[], None, parent, cx); + let provider_select = + Self::build_provider_select(AgentProvider::Claude, false, parent.clone(), cx); + let access_select = + Self::build_access_select(AgentAccessMode::ReadOnly, false, parent.clone(), cx); + let target_select = Self::build_target_select(&[], None, false, parent, cx); let default_workdir = std::env::current_dir() .ok() .map(|path| path.display().to_string()) .filter(|path| !path.is_empty()) .unwrap_or_else(|| "/tmp".to_string()); + let context = AgentExecutionContext { + provider: AgentProvider::Claude, + target: AgentTarget::Local, + access: AgentAccessMode::ReadOnly, + workdir: default_workdir.clone(), + model: None, + }; + let mut sessions = AgentSessionCollection::default(); + let session_id = sessions + .create(t!("agents.session.new").to_string(), context, now_ms()) + .expect("default agent execution context is valid"); + let mut session_ui = HashMap::new(); + session_ui.insert(session_id, AgentSessionUi::new(cx)); + let mut inspector_local_browser = FileBrowserPanel::new(); + inspector_local_browser.set_root(PathBuf::from(&default_workdir)); Self { provider: AgentProvider::Claude, access: AgentAccessMode::ReadOnly, @@ -229,25 +241,111 @@ impl AgentConsoleView { provider_select, access_select, target_select, + default_local_workdir: default_workdir.clone(), workdir_state: input_state(cx, default_workdir), model_state: input_state(cx, String::new()), - prompt_state: cx.new(|cx| InputState::new(cx).multi_line(true)), - running: None, - session_context: None, - session_token: None, - run_received_delta: false, - run_received_output: false, + navigator_search: input_state(cx, String::new()), + sessions, + session_ui, + run_sessions: HashMap::new(), + project_groups: Vec::new(), + context_locks: HashSet::new(), pending_confirm: None, - transcript: String::new(), - activity: Vec::new(), - error: None, + context_expanded: false, + inspector_tab: AgentInspectorTab::Files, + focused_trace: None, + inspector_local_browser, + inspector_remote_path: None, + inspector_remote_pending_path: None, + inspector_remote_session: None, + inspector_remote_connection: None, + inspector_remote_entries: Vec::new(), + inspector_remote_loading: false, + inspector_remote_error: None, scroll: ScrollHandle::new(), } } + fn build_provider_select( + selected: AgentProvider, + disabled: bool, + parent: Entity, + cx: &mut Context, + ) -> Entity> { + let options = vec![ + SelectOption::new(AgentProvider::Claude, "Claude Code") + .with_icon("icons/simple/claudecode.svg"), + SelectOption::new(AgentProvider::Codex, "Codex").with_icon("icons/simple/openai.svg"), + SelectOption::new(AgentProvider::Automonique, "Automonique ACP") + .with_icon("icons/lucide/bot.svg"), + SelectOption::new(AgentProvider::Jcode, "Jcode").with_icon("icons/lucide/sparkles.svg"), + SelectOption::new(AgentProvider::DeepSeek, "DeepSeek") + .with_icon("icons/lucide/bot.svg"), + ]; + let selected_index = options.iter().position(|option| option.value == selected); + cx.new(move |select_cx| { + Select::new(select_cx) + .options(options) + .selected_index(selected_index) + .disabled(disabled) + .context_label(t!("agents.provider").to_string()) + .on_change(move |provider, window, cx| { + parent.update(cx, |this, cx| { + this.provider = *provider; + if *provider == AgentProvider::Automonique { + this.model_state.update(cx, |state, cx| { + state.set_value("", window, cx); + }); + } + cx.notify(); + }); + }) + }) + } + + fn build_access_select( + selected: AgentAccessMode, + disabled: bool, + parent: Entity, + cx: &mut Context, + ) -> Entity> { + let options = vec![ + SelectOption::new( + AgentAccessMode::ReadOnly, + t!("agents.access.read_only").to_string(), + ) + .with_icon("icons/lucide/lock.svg"), + SelectOption::new( + AgentAccessMode::WorkspaceWrite, + t!("agents.access.workspace_write").to_string(), + ) + .with_icon("icons/lucide/pencil.svg"), + SelectOption::new( + AgentAccessMode::FullAccess, + t!("agents.access.full").to_string(), + ) + .with_icon("icons/lucide/shield.svg"), + ]; + let selected_index = options.iter().position(|option| option.value == selected); + cx.new(move |select_cx| { + Select::new(select_cx) + .options(options) + .selected_index(selected_index) + .disabled(disabled) + .context_label(t!("agents.access.label").to_string()) + .on_change(move |access, _window, cx| { + parent.update(cx, |this, cx| { + this.access = *access; + cx.notify(); + }); + }) + }) + } + fn build_target_select( connections: &[AgentConnectionOption], selected: Option, + disabled: bool, parent: Entity, cx: &mut Context, ) -> Entity>> { @@ -269,11 +367,22 @@ impl AgentConsoleView { Select::new(select_cx) .options(options) .selected_index(selected_index) + .disabled(disabled) .searchable(true) .search_placeholder(t!("agents.target.search").to_string()) .context_label(t!("agents.target.label").to_string()) - .on_change(move |connection_id, _window, cx| { + .on_change(move |connection_id, window, cx| { parent.update(cx, |this, cx| { + if let Some(next_workdir) = target_switch_workdir( + this.selected_connection, + *connection_id, + this.workdir_state.read(cx).content(), + &this.default_local_workdir, + ) { + this.workdir_state.update(cx, |state, cx| { + state.set_value(next_workdir, window, cx); + }); + } this.selected_connection = *connection_id; cx.notify(); }); @@ -293,94 +402,253 @@ impl AgentConsoleView { self.selected_connection = None; } self.connections = connections; - self.target_select = - Self::build_target_select(&self.connections, self.selected_connection, cx.entity(), cx); + let locked = self + .sessions + .selected_id() + .is_some_and(|id| self.context_locks.contains(&id)); + self.target_select = Self::build_target_select( + &self.connections, + self.selected_connection, + locked, + cx.entity(), + cx, + ); cx.notify(); } - pub fn begin_run(&mut self, request: AgentRunRequest, cx: &mut Context) { - pin_to_latest(&self.scroll); - self.prompt_state.update(cx, |state, cx| state.reset(cx)); - let continuing = request.resume_session.is_some(); - if !continuing { - self.transcript.clear(); - self.activity.clear(); - self.session_token = None; + pub fn set_project_groups( + &mut self, + project_groups: Vec, + cx: &mut Context, + ) { + self.project_groups = project_groups; + cx.notify(); + } + + pub fn set_remote_directory_listing( + &mut self, + session_id: Uuid, + connection_id: Uuid, + requested_path: String, + resolved_path: String, + result: Result, String>, + cx: &mut Context, + ) { + let selected_matches = self.sessions.selected().is_some_and(|session| { + session.id == session_id + && matches!( + session.context.target, + AgentTarget::Ssh { + connection_id: selected, + .. + } if selected == connection_id + ) + }); + if !selected_matches + || self.inspector_remote_connection != Some(connection_id) + || self.inspector_remote_session != Some(session_id) + || self.inspector_remote_pending_path.as_deref() != Some(requested_path.as_str()) + { + return; + } + self.inspector_remote_loading = false; + self.inspector_remote_pending_path = None; + match result { + Ok(entries) => { + self.inspector_remote_path = Some(resolved_path); + self.inspector_remote_entries = entries; + self.inspector_remote_error = None; + } + Err(error) => { + self.inspector_remote_entries.clear(); + self.inspector_remote_error = Some(bounded_utf8(error, MAX_ACTIVITY_BYTES)); + } + } + cx.notify(); + } + + fn request_remote_directory( + &mut self, + session_id: Uuid, + connection_id: Uuid, + path: String, + cx: &mut Context, + ) { + self.inspector_remote_session = Some(session_id); + self.inspector_remote_connection = Some(connection_id); + self.inspector_remote_path = Some(path.clone()); + self.inspector_remote_pending_path = Some(path.clone()); + self.inspector_remote_loading = true; + self.inspector_remote_error = None; + cx.emit(AgentConsoleEvent::BrowseRemoteDirectory { + session_id, + connection_id, + path, + }); + cx.notify(); + } + + pub fn sessions(&self) -> &[AgentSession] { + self.sessions.sessions() + } + + pub fn selected_session_id(&self) -> Option { + self.sessions.selected_id() + } + + pub fn session_id_for_run(&self, run_id: Uuid) -> Option { + self.run_sessions.get(&run_id).copied() + } + + pub fn set_session_context_locked( + &mut self, + session_id: Uuid, + locked: bool, + cx: &mut Context, + ) -> bool { + if self.sessions.get(session_id).is_none() { + return false; + } + if locked { + self.context_locks.insert(session_id); + } else { + self.context_locks.remove(&session_id); } - if !self.transcript.is_empty() { - self.transcript.push_str(conversation_turn_separator()); + if self.sessions.selected_id() == Some(session_id) { + self.apply_selected_context(cx); + } + cx.notify(); + true + } + + pub fn select_session(&mut self, session_id: Uuid, cx: &mut Context) -> bool { + if self.sessions.select(session_id).is_err() { + return false; + } + self.apply_selected_context(cx); + pin_to_latest(&self.scroll); + cx.notify(); + true + } + + pub fn create_session( + &mut self, + name: impl Into, + context: AgentExecutionContext, + cx: &mut Context, + ) -> Result { + let session_id = self + .sessions + .create(name, context, now_ms()) + .map_err(|error| error.to_string())?; + self.session_ui.insert(session_id, AgentSessionUi::new(cx)); + self.apply_selected_context(cx); + pin_to_latest(&self.scroll); + cx.notify(); + Ok(session_id) + } + + pub fn begin_run(&mut self, request: AgentRunRequest, cx: &mut Context) { + let session_id = self + .run_sessions + .get(&request.id) + .copied() + .or_else(|| self.sessions.selected_id()); + let Some(session_id) = session_id else { return }; + self.run_sessions.insert(request.id, session_id); + if let Some(ui) = self.session_ui.get_mut(&session_id) { + ui.run_id = Some(request.id); + ui.received_delta = false; + ui.received_output = false; + ui.prompt.update(cx, |state, cx| state.reset(cx)); } - self.transcript.push_str(&format!( - "**{}**\n\n{}\n\n**{}**\n\n", - t!("agents.role.you"), - markdown_quote(&request.prompt), - t!("agents.role.agent") - )); - retain_recent_utf8(&mut self.transcript, MAX_TRANSCRIPT_BYTES); - self.session_context = Some(AgentSessionContext::from(&request)); - self.running = Some(request); - self.run_received_delta = false; - self.run_received_output = false; - self.error = None; cx.notify(); } + pub fn set_surface_visible(&mut self, visible: bool, cx: &mut Context) { + if self.sessions.set_surface_visible(visible) { + cx.notify(); + } + } + pub fn reject_run(&mut self, error: String, cx: &mut Context) { - self.error = Some(bounded_utf8(error, MAX_ACTIVITY_BYTES)); + let run_id = self + .sessions + .selected_id() + .and_then(|session_id| self.session_ui.get(&session_id)?.run_id) + .or_else(|| self.run_sessions.keys().next().copied()); + if let Some(run_id) = run_id { + self.reject_run_for(run_id, error, cx); + } + } + + pub fn reject_run_for(&mut self, run_id: Uuid, error: String, cx: &mut Context) { + let Some(session_id) = self.run_sessions.remove(&run_id) else { + return; + }; + let _ = self.sessions.apply_stream_event( + session_id, + AgentStreamEvent::Error(bounded_utf8(error, MAX_ACTIVITY_BYTES)), + now_ms(), + ); + let _ = self + .sessions + .finish(session_id, AgentSessionStatus::Failed, now_ms()); + if let Some(ui) = self.session_ui.get_mut(&session_id) { + ui.run_id = None; + } cx.notify(); } + /// Compatibility route for the previous singleton workspace runtime. + /// Parallel runtimes must call `push_stream_event_for`. pub fn push_stream_event(&mut self, event: AgentStreamEvent, cx: &mut Context) { + if let Some(run_id) = self.run_sessions.keys().next().copied() { + self.push_stream_event_for(run_id, event, cx); + } + } + + pub fn push_stream_event_for( + &mut self, + run_id: Uuid, + event: AgentStreamEvent, + cx: &mut Context, + ) { + let Some(session_id) = self.run_sessions.get(&run_id).copied() else { + return; + }; follow_latest_if_at_end(&self.scroll); - match event { - AgentStreamEvent::Text(text) => { - let streamed_jcode = self.run_received_delta - && self.running.as_ref().is_some_and(|run| { - matches!(run.provider, AgentProvider::Jcode | AgentProvider::DeepSeek) - }); - if streamed_jcode { - return; - } - if !text.is_empty() { - self.run_received_output = true; - } - if !self.transcript.is_empty() && !self.transcript.ends_with('\n') { - self.transcript.push('\n'); - } - self.transcript.push_str(&text); - retain_recent_utf8(&mut self.transcript, MAX_TRANSCRIPT_BYTES); - } - AgentStreamEvent::TextDelta(delta) => { - self.run_received_delta = true; - if !delta.is_empty() { - self.run_received_output = true; + let suppress_complete_jcode = matches!(event, AgentStreamEvent::Text(_)) + && self + .session_ui + .get(&session_id) + .is_some_and(|ui| ui.received_delta) + && self.sessions.get(session_id).is_some_and(|session| { + matches!( + session.context.provider, + AgentProvider::Jcode | AgentProvider::DeepSeek + ) + }); + if suppress_complete_jcode { + return; + } + if let Some(ui) = self.session_ui.get_mut(&session_id) { + match &event { + AgentStreamEvent::Text(text) | AgentStreamEvent::TextDelta(text) => { + if !text.is_empty() { + ui.received_output = true; + } + if matches!(event, AgentStreamEvent::TextDelta(_)) { + ui.received_delta = true; + } } - self.transcript.push_str(&delta); - retain_recent_utf8(&mut self.transcript, MAX_TRANSCRIPT_BYTES); - } - AgentStreamEvent::Session(session) => { - self.session_token = Some(session); - } - AgentStreamEvent::Ready => { - let provider = self - .running - .as_ref() - .map(|run| run.provider.display_name()) - .unwrap_or_else(|| self.provider.display_name()); - push_activity( - &mut self.activity, - t!("agents.status.ready", provider = provider).to_string(), - ); - } - AgentStreamEvent::Activity(activity) => { - let activity = bounded_utf8(activity, MAX_ACTIVITY_BYTES); - push_activity(&mut self.activity, activity); - } - AgentStreamEvent::Error(error) => { - self.run_received_output = true; - self.error = Some(bounded_utf8(error, MAX_ACTIVITY_BYTES)) + AgentStreamEvent::Error(_) => ui.received_output = true, + _ => {} } } + let _ = self + .sessions + .apply_stream_event(session_id, event, now_ms()); cx.notify(); } @@ -391,47 +659,103 @@ impl AgentConsoleView { error: Option, cx: &mut Context, ) { - if self.running.as_ref().is_none_or(|run| run.id != run_id) { + let Some(session_id) = self.run_sessions.remove(&run_id) else { return; - } - let run = self.running.take(); + }; + let error_present = error.is_some(); + let error_label = bounded_utf8(error.clone().unwrap_or_default(), MAX_ACTIVITY_BYTES); if let Some(error) = error { - self.error = Some(bounded_utf8(error, MAX_ACTIVITY_BYTES)); + let _ = self.sessions.apply_stream_event( + session_id, + AgentStreamEvent::Error(bounded_utf8(error, MAX_ACTIVITY_BYTES)), + now_ms(), + ); } - push_activity( - &mut self.activity, - match exit_code { - Some(0) => t!("agents.status.completed").to_string(), - Some(code) => t!("agents.status.exit", code = code).to_string(), - None => t!("agents.status.stopped").to_string(), - }, + let status = terminal_session_status(exit_code, error_present); + let activity = match (exit_code, error_present) { + (Some(0), false) => t!("agents.status.completed").to_string(), + (Some(code), _) => t!("agents.status.exit", code = code).to_string(), + (None, true) => t!("agents.toast.failed", error = error_label).to_string(), + (None, false) => t!("agents.status.stopped").to_string(), + }; + let _ = self.sessions.apply_stream_event( + session_id, + AgentStreamEvent::Activity(activity), + now_ms(), ); - if exit_code == Some(0) && self.session_token.is_none() { - if let Some(run) = &run { - if run.provider == AgentProvider::Claude && run.resume_session.is_none() { - self.session_token = Some(run.id.to_string()); - } - } - } - if exit_code != Some(0) { - self.session_context = None; - self.session_token = None; + let _ = self.sessions.finish(session_id, status, now_ms()); + if let Some(ui) = self.session_ui.get_mut(&session_id) { + ui.run_id = None; } follow_latest_if_at_end(&self.scroll); cx.notify(); } - fn request_from_draft(&self, cx: &Context) -> Result { - let prompt = self.prompt_state.read(cx).content().trim().to_string(); - let workdir = self.workdir_state.read(cx).content().trim().to_string(); - let model = self.model_state.read(cx).content().trim().to_string(); - if prompt.is_empty() { - return Err(t!("agents.error.prompt_empty").to_string()); + fn apply_selected_context(&mut self, cx: &mut Context) { + let Some(session) = self.sessions.selected() else { + return; + }; + self.provider = session.context.provider; + self.access = session.context.access; + self.selected_connection = match &session.context.target { + AgentTarget::Local => None, + AgentTarget::Ssh { connection_id, .. } => Some(*connection_id), + }; + let locked = self.context_locks.contains(&session.id); + self.provider_select = Self::build_provider_select(self.provider, locked, cx.entity(), cx); + self.access_select = Self::build_access_select(self.access, locked, cx.entity(), cx); + self.target_select = Self::build_target_select( + &self.connections, + self.selected_connection, + locked, + cx.entity(), + cx, + ); + self.workdir_state = input_state(cx, session.context.workdir.clone()); + self.model_state = input_state(cx, session.context.model.clone().unwrap_or_default()); + match &session.context.target { + AgentTarget::Local => { + let root = PathBuf::from(&session.context.workdir); + if root.is_dir() && self.inspector_local_browser.root() != root.as_path() { + self.inspector_local_browser.set_root(root); + } + self.inspector_remote_connection = None; + self.inspector_remote_path = None; + self.inspector_remote_pending_path = None; + self.inspector_remote_session = None; + self.inspector_remote_entries.clear(); + self.inspector_remote_error = None; + self.inspector_remote_loading = false; + } + AgentTarget::Ssh { connection_id, .. } => { + if self.inspector_remote_session != Some(session.id) + || self.inspector_remote_connection != Some(*connection_id) + { + self.inspector_remote_session = Some(session.id); + self.inspector_remote_connection = Some(*connection_id); + self.inspector_remote_path = None; + self.inspector_remote_pending_path = None; + self.inspector_remote_entries.clear(); + self.inspector_remote_error = None; + self.inspector_remote_loading = false; + } + } } - if prompt.len() > MAX_AGENT_PROMPT_BYTES { - return Err(t!("agents.error.prompt_too_long").to_string()); + } + + fn context_from_controls(&self, cx: &Context) -> Result { + if let Some(session_id) = self.sessions.selected_id() { + if self.context_locks.contains(&session_id) { + return self + .sessions + .get(session_id) + .map(|session| session.context.clone()) + .ok_or_else(|| t!("agents.error.runtime_lost").to_string()); + } } - if workdir.is_empty() || !std::path::Path::new(&workdir).is_absolute() { + let workdir = self.workdir_state.read(cx).content().trim().to_string(); + let model = self.model_state.read(cx).content().trim().to_string(); + if !workdir_is_valid_for_target(&workdir, self.selected_connection.is_some()) { return Err(t!("agents.error.workdir_absolute").to_string()); } if workdir.len() > MAX_AGENT_WORKDIR_BYTES { @@ -454,72 +778,171 @@ impl AgentConsoleView { } } }; - let request = AgentRunRequest::new( - self.provider, + let context = AgentExecutionContext { + provider: self.provider, target, - self.access, + access: self.access, workdir, - (!model.is_empty()).then_some(model), + model: (self.provider != AgentProvider::Automonique && !model.is_empty()) + .then_some(model), + }; + context + .run_request("validate") + .validate() + .map_err(|error| error.to_string())?; + Ok(context) + } + + fn pending_from_draft(&self, cx: &Context) -> Result { + let session_id = self + .sessions + .selected_id() + .ok_or_else(|| t!("agents.error.prompt_empty").to_string())?; + let prompt = self + .session_ui + .get(&session_id) + .expect("selected session has UI state") + .prompt + .read(cx) + .content() + .trim() + .to_string(); + if prompt.is_empty() { + return Err(t!("agents.error.prompt_empty").to_string()); + } + if prompt.len() > MAX_AGENT_PROMPT_BYTES { + return Err(t!("agents.error.prompt_too_long").to_string()); + } + Ok(PendingRun { + session_id, + context: self.context_from_controls(cx)?, prompt, - ); - let resume = matching_resume_token( - self.session_context.as_ref(), - self.session_token.as_ref(), - &request, - ); - let request = request.with_resume_session(resume); - request.validate().map_err(|error| error.to_string())?; - Ok(request) + }) } fn submit(&mut self, cx: &mut Context) { - if self.running.is_some() { + let selected_active = self + .sessions + .selected() + .is_some_and(|session| session.status.is_active()); + if selected_active { return; } - match self.request_from_draft(cx) { - Ok(request) => match run_disposition(request.access) { - AgentRunDisposition::Run => cx.emit(AgentConsoleEvent::Run(request)), - AgentRunDisposition::Confirm => { - self.pending_confirm = Some(request); - cx.notify(); - } - }, - Err(error) => { - self.error = Some(error); + match self.pending_from_draft(cx) { + Ok(pending) if pending.context.access == AgentAccessMode::ReadOnly => { + self.start_pending(pending, cx) + } + Ok(pending) => { + self.pending_confirm = Some(pending); cx.notify(); } + Err(error) => self.apply_draft_error(error, cx), } } - fn confirm_pending_run(&mut self, cx: &mut Context) { - let Some(request) = self.pending_confirm.take() else { + fn start_pending(&mut self, pending: PendingRun, cx: &mut Context) { + let session_id = pending.session_id; + let Some(session) = self.sessions.get_mut(session_id) else { + self.apply_draft_error(t!("agents.error.runtime_lost").to_string(), cx); return; }; - cx.emit(AgentConsoleEvent::Run(request)); + if let Err(error) = session.set_context(pending.context, now_ms()) { + self.apply_draft_error(error.to_string(), cx); + return; + } + if session.messages.is_empty() { + let _ = session.rename(session_name(&pending.prompt), now_ms()); + } + match self + .sessions + .begin_run(session_id, pending.prompt, now_ms()) + { + Ok(request) => { + self.run_sessions.insert(request.id, session_id); + if let Some(ui) = self.session_ui.get_mut(&session_id) { + ui.run_id = Some(request.id); + ui.received_delta = false; + ui.received_output = false; + } + cx.emit(AgentConsoleEvent::Run(request)); + } + Err(error) => self.apply_draft_error(error.to_string(), cx), + } + } + + fn apply_draft_error(&mut self, error: String, cx: &mut Context) { + if let Some(session_id) = self.sessions.selected_id() { + let _ = self.sessions.apply_stream_event( + session_id, + AgentStreamEvent::Error(error), + now_ms(), + ); + } cx.notify(); } fn new_session(&mut self, cx: &mut Context) { - if self.running.is_some() { + let Ok(context) = self.context_from_controls(cx) else { return; + }; + let _ = self.create_session(t!("agents.session.new").to_string(), context, cx); + } + + pub fn select_worktree_session( + &mut self, + worktree: &AgentWorktreeOption, + cx: &mut Context, + ) -> Option { + if let Some(session_id) = worktree_bound_session(self.sessions.sessions(), worktree) { + self.select_session(session_id, cx); + return Some(session_id); + } + let base = self.sessions.selected()?.context.clone(); + let context = context_for_worktree(&base, worktree); + self.create_session(worktree.label.clone(), context, cx) + .ok() + } + + fn request_stop(&mut self, run_id: Uuid, cx: &mut Context) { + let Some(session_id) = self.run_sessions.get(&run_id).copied() else { + return; + }; + let Some(session) = self.sessions.get_mut(session_id) else { + return; + }; + if session.request_stop(now_ms()) { + cx.emit(AgentConsoleEvent::Stop(run_id)); + cx.notify(); + } + } + + fn close_session(&mut self, session_id: Uuid, cx: &mut Context) { + if self.sessions.sessions().len() <= 1 { + return; + } + if self.sessions.remove(session_id).is_ok() { + self.session_ui.remove(&session_id); + self.context_locks.remove(&session_id); + self.apply_selected_context(cx); + cx.emit(AgentConsoleEvent::CloseSession(session_id)); + cx.notify(); } - self.session_context = None; - self.session_token = None; - self.transcript.clear(); - self.activity.clear(); - self.run_received_output = false; - self.error = None; - cx.notify(); } - fn render_confirmation(&self, request: AgentRunRequest, cx: &mut Context) -> AnyElement { - let target = target_label(&request.target); - let access = match request.access { - AgentAccessMode::ReadOnly => t!("agents.access.read_only").to_string(), - AgentAccessMode::WorkspaceWrite => t!("agents.access.workspace_write").to_string(), - AgentAccessMode::FullAccess => t!("agents.access.full").to_string(), + fn acknowledge_selected_attention(&mut self, cx: &mut Context) { + let Some(session_id) = self.sessions.selected_id() else { + return; }; - let destructive = request.access == AgentAccessMode::FullAccess; + if let Some(session) = self.sessions.get_mut(session_id) { + session.acknowledge_attention(); + cx.notify(); + } + } + + fn render_confirmation(&self, pending: &PendingRun, cx: &mut Context) -> AnyElement { + let target = target_label(&pending.context.target); + let access = access_label(pending.context.access); + let destructive = pending.context.access == AgentAccessMode::FullAccess; UiDialog::new() .width(gpui::px(520.0)) .header( @@ -562,7 +985,7 @@ impl AgentConsoleView { .flex_wrap() .gap(px(6.0)) .child( - Badge::new(request.provider.display_name()) + Badge::new(pending.context.provider.display_name()) .variant(BadgeVariant::Outline), ) .child(Badge::new(target).variant(BadgeVariant::Outline)) @@ -578,7 +1001,7 @@ impl AgentConsoleView { .font_family("JetBrains Mono") .text_size(px(11.0)) .line_clamp(3) - .child(request.workdir.clone()), + .child(pending.context.workdir.clone()), ), ) .footer( @@ -608,7 +1031,9 @@ impl AgentConsoleView { .size(ButtonSize::Sm) .icon(IconSource::from("play")) .on_click(cx.listener(|this, _, _, cx| { - this.confirm_pending_run(cx); + if let Some(pending) = this.pending_confirm.take() { + this.start_pending(pending, cx); + } })), ), ) @@ -624,108 +1049,17 @@ impl AgentConsoleView { .into_any_element() } - fn render_activity_popover(&self) -> AnyElement { - let entries = popover_activity(&self.activity).to_vec(); - let entry_count = self.activity.len(); - let title = t!("agents.activity.title").to_string(); - let trigger = div() - .id("agent-activity-trigger") - .flex() - .items_center() - .justify_center() - .size(px(28.0)) - .flex_shrink_0() - .rounded_full() - .border_1() - .border_color(ShellDeckColors::border()) - .cursor_pointer() - .text_color(ShellDeckColors::text_muted()) - .hover(|style| style.bg(ShellDeckColors::hover_bg())) - .child(lucide_icon("activity", 13.0, ShellDeckColors::text_muted())); - - Popover::new("agent-activity-popover") - .anchor(Corner::TopRight) - .trigger(trigger) - .content(move |window, cx| { - let entries = entries.clone(); - let title = title.clone(); - cx.new(move |content_cx| { - PopoverContent::new(window, content_cx, move |_window, _cx| { - div() - .w(px(280.0)) - .flex() - .flex_col() - .gap(px(8.0)) - .child( - div() - .flex() - .items_center() - .gap(px(7.0)) - .child(lucide_icon( - "activity", - 13.0, - ShellDeckColors::text_muted(), - )) - .child( - div() - .flex_1() - .text_size(px(12.0)) - .font_weight(FontWeight::SEMIBOLD) - .child(title.clone()), - ) - .child( - Badge::new(entry_count.to_string()) - .variant(BadgeVariant::Secondary), - ), - ) - .child( - div() - .id("agent-activity-list") - .flex() - .flex_col() - .max_h(px(280.0)) - .overflow_y_scroll() - .children(entries.iter().enumerate().map( - |(index, activity)| { - div() - .id(("agent-activity-row", index)) - .flex() - .items_start() - .gap(px(8.0)) - .py(px(5.0)) - .text_size(px(11.0)) - .text_color(ShellDeckColors::text_muted()) - .child( - div() - .mt(px(5.0)) - .size(px(4.0)) - .flex_shrink_0() - .rounded_full() - .bg(ShellDeckColors::text_muted()), - ) - .child( - div() - .min_w(px(0.0)) - .whitespace_normal() - .child(activity.clone()), - ) - }, - )), - ) - .into_any_element() - }) - }) - }) - .into_any_element() - } - - fn render_model_popover(&self, cx: &mut Context) -> AnyElement { - let configured = self.model_state.read(cx).content().trim().to_string(); - let display = if configured.is_empty() { - t!("agents.model.auto").to_string() - } else { - configured - }; + fn render_model_popover(&self, cx: &mut Context) -> AnyElement { + let locked = self + .sessions + .selected_id() + .is_some_and(|id| self.context_locks.contains(&id)); + let configured = self.model_state.read(cx).content().trim().to_string(); + let display = if configured.is_empty() { + t!("agents.model.auto").to_string() + } else { + configured + }; let trigger = div() .id("agent-model-trigger") .h(px(26.0)) @@ -734,7 +1068,7 @@ impl AgentConsoleView { .items_center() .gap(px(5.0)) .px(px(6.0)) - .rounded(px(7.0)) + .rounded(px(6.0)) .cursor_pointer() .text_size(px(10.5)) .text_color(ShellDeckColors::text_muted()) @@ -754,19 +1088,12 @@ impl AgentConsoleView { )); let model_state = self.model_state.clone(); let parent = cx.entity(); - let title = t!("agents.model.label").to_string(); - let hint = t!("agents.model.hint").to_string(); - let placeholder = t!("agents.model.auto").to_string(); - Popover::new("agent-model-popover") .anchor(Corner::BottomRight) .trigger(trigger) .content(move |window, cx| { let model_state = model_state.clone(); let parent = parent.clone(); - let title = title.clone(); - let hint = hint.clone(); - let placeholder = placeholder.clone(); cx.new(move |content_cx| { PopoverContent::new(window, content_cx, move |_window, _cx| { let notify_parent = parent.clone(); @@ -779,12 +1106,13 @@ impl AgentConsoleView { div() .text_size(px(11.0)) .font_weight(FontWeight::SEMIBOLD) - .child(title.clone()), + .child(t!("agents.model.label").to_string()), ) .child( Input::new(&model_state) .size(InputSize::Sm) - .placeholder(placeholder.clone()) + .disabled(locked) + .placeholder(t!("agents.model.auto").to_string()) .on_change(move |_value, cx| { notify_parent.update(cx, |_this, cx| cx.notify()); }), @@ -792,9 +1120,8 @@ impl AgentConsoleView { .child( div() .text_size(px(10.0)) - .line_height(relative(1.35)) .text_color(ShellDeckColors::text_muted()) - .child(hint.clone()), + .child(t!("agents.model.hint").to_string()), ) .into_any_element() }) @@ -802,250 +1129,1300 @@ impl AgentConsoleView { }) .into_any_element() } -} -impl Render for AgentConsoleView { - fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - let running = self.running.clone(); - let is_running = running.is_some(); - let submit = { - let parent = cx.entity(); - move |cx: &mut App| { - parent.update(cx, |this, cx| this.submit(cx)); + fn render_navigator(&self, cx: &mut Context) -> AnyElement { + let parent = cx.entity(); + let running_count = self.sessions.active_count(); + let unread_count = self + .sessions + .sessions() + .iter() + .map(|session| u64::from(session.unread_count)) + .sum::(); + let mut body = div().flex().flex_col().gap(px(4.0)).px(px(6.0)).py(px(7.0)); + if self.project_groups.is_empty() { + body = body.child(self.render_session_rows(None, cx)); + } else { + for project in &self.project_groups { + body = body.child( + div() + .flex() + .items_center() + .gap(px(6.0)) + .px(px(6.0)) + .pt(px(6.0)) + .pb(px(3.0)) + .child(lucide_icon( + "git-branch", + 12.0, + ShellDeckColors::text_muted(), + )) + .child( + div() + .flex_1() + .min_w(px(0.0)) + .overflow_hidden() + .text_ellipsis() + .text_size(px(10.5)) + .font_weight(FontWeight::SEMIBOLD) + .child(project.label.clone()), + ), + ); + for worktree in &project.worktrees { + let worktree_option = worktree.clone(); + let worktree_selected = self + .sessions + .selected() + .is_some_and(|session| session_worktree_matches(session, worktree)); + body = body.child( + div() + .id(("agent-worktree", stable_string_key(&worktree.id))) + .flex() + .items_center() + .gap(px(5.0)) + .px(px(11.0)) + .py(px(3.0)) + .rounded(px(4.0)) + .cursor_pointer() + .when(worktree_selected, |row| { + row.bg(ShellDeckColors::selected_bg()) + }) + .when(!worktree_selected, |row| { + row.hover(|style| style.bg(ShellDeckColors::hover_bg())) + }) + .on_click(cx.listener(move |this, _, _, cx| { + this.select_worktree_session(&worktree_option, cx); + })) + .text_size(px(9.5)) + .text_color(ShellDeckColors::text_muted()) + .child(lucide_icon( + "git-branch", + 10.0, + ShellDeckColors::text_muted(), + )) + .child(worktree.label.clone()) + .when_some(worktree.branch.clone(), |row, branch| { + row.child( + div() + .flex_1() + .min_w(px(0.0)) + .overflow_hidden() + .text_ellipsis() + .font_family("JetBrains Mono") + .text_size(px(8.5)) + .child(branch), + ) + }) + .when(worktree.is_primary, |row| { + row.child( + div() + .size(px(5.0)) + .rounded_full() + .bg(ShellDeckColors::success()), + ) + }), + ); + body = body.child(self.render_session_rows(Some(worktree), cx)); + } } - }; + let known_worktrees = self + .project_groups + .iter() + .flat_map(|project| project.worktrees.iter()) + .collect::>(); + body = body.child(self.render_unmatched_session_rows(&known_worktrees, cx)); + } + div() + .w(px(NAVIGATOR_WIDTH)) + .h_full() + .flex() + .flex_col() + .flex_shrink_0() + .bg(ShellDeckColors::bg_sidebar()) + .border_r_1() + .border_color(ShellDeckColors::border()) + .child( + div() + .h(px(42.0)) + .flex() + .items_center() + .gap(px(7.0)) + .px(px(10.0)) + .border_b_1() + .border_color(ShellDeckColors::border()) + .child(lucide_icon("grid-2x2", 14.0, ShellDeckColors::primary())) + .child( + div() + .flex_1() + .text_size(px(11.0)) + .font_weight(FontWeight::SEMIBOLD) + .child(t!("agents.title").to_string()), + ) + .when(running_count > 0, |row| { + row.child( + Badge::new(running_count.to_string()).variant(BadgeVariant::Secondary), + ) + }) + .child( + Button::new("agent-nav-new", "") + .variant(ButtonVariant::Ghost) + .size(ButtonSize::Icon) + .icon(IconSource::from("plus")) + .w(px(26.0)) + .h(px(26.0)) + .tooltip(t!("agents.session.new").to_string()) + .on_click(cx.listener(|this, _, _, cx| this.new_session(cx))), + ), + ) + .child( + div() + .flex() + .items_center() + .gap(px(6.0)) + .px(px(8.0)) + .py(px(6.0)) + .border_b_1() + .border_color(ShellDeckColors::border()) + .child(lucide_icon("search", 11.0, ShellDeckColors::text_muted())) + .child( + div().flex_1().min_w(px(0.0)).child( + Input::new(&self.navigator_search) + .variant(InputVariant::Bare) + .size(InputSize::Sm) + .placeholder(t!("fleet.sessions.search").to_string()) + .on_change(move |_value, cx| { + parent.update(cx, |_this, cx| cx.notify()); + }), + ), + ), + ) + .child( + div() + .id("agent-navigator-scroll") + .flex_1() + .min_h(px(0.0)) + .overflow_y_scroll() + .child(body), + ) + .child( + div() + .h(px(30.0)) + .flex() + .items_center() + .gap(px(6.0)) + .px(px(10.0)) + .border_t_1() + .border_color(ShellDeckColors::border()) + .text_size(px(9.0)) + .text_color(ShellDeckColors::text_muted()) + .child(lucide_icon("activity", 11.0, ShellDeckColors::warning())) + .child(format!( + "{running_count}/{DEFAULT_MAX_CONCURRENT_AGENT_SESSIONS}" + )) + .child(t!("workspaces.agent.running").to_string()) + .child(div().flex_1()) + .when(unread_count > 0, |row| { + row.child(lucide_icon("activity", 10.0, ShellDeckColors::primary())) + .child(unread_count.to_string()) + .child(t!("workspaces.attention.unread").to_string()) + }), + ) + .into_any_element() + } - let controls_layout = - agent_controls_layout(window.viewport_size().width, window.rem_size()); - let provider = context_select_cell(self.provider_select.clone()); - let target = context_select_cell(self.target_select.clone()); - let access = context_select_cell(self.access_select.clone()); - let select_rows = match controls_layout { - AgentControlsLayout::Wide => div() + fn render_session_rows( + &self, + worktree: Option<&AgentWorktreeOption>, + cx: &mut Context, + ) -> AnyElement { + self.render_session_rows_matching( + |session| worktree.is_none_or(|worktree| session_worktree_matches(session, worktree)), + cx, + ) + } + + fn render_unmatched_session_rows( + &self, + known_worktrees: &[&AgentWorktreeOption], + cx: &mut Context, + ) -> AnyElement { + self.render_session_rows_matching( + |session| { + !known_worktrees + .iter() + .any(|worktree| session_worktree_matches(session, worktree)) + }, + cx, + ) + } + + fn render_session_rows_matching( + &self, + include: impl Fn(&AgentSession) -> bool, + cx: &mut Context, + ) -> AnyElement { + let query = self + .navigator_search + .read(cx) + .content() + .trim() + .to_ascii_lowercase(); + let mut list = div().flex().flex_col().gap(px(2.0)); + for session in self.sessions.sessions().iter().filter(|session| { + include(session) + && (query.is_empty() + || session.name.to_ascii_lowercase().contains(&query) + || session + .context + .workdir + .to_ascii_lowercase() + .contains(&query) + || session + .context + .provider + .display_name() + .to_ascii_lowercase() + .contains(&query)) + }) { + let id = session.id; + let selected = self.sessions.selected_id() == Some(id); + let removable = self.sessions.sessions().len() > 1 && !session.status.is_active(); + let mut row = div() + .id(("agent-nav-session", uuid_key(id))) .flex() - .w_full() + .items_center() + .gap(px(7.0)) + .h(px(33.0)) + .px(px(8.0)) + .rounded(px(5.0)) + .cursor_pointer() + .when(selected, |row| row.bg(ShellDeckColors::selected_bg())) + .when(!selected, |row| { + row.hover(|style| style.bg(ShellDeckColors::hover_bg())) + }) + .on_click(cx.listener(move |this, _, _, cx| { + this.select_session(id, cx); + })) .child( - provider - .border_r_1() - .border_color(ShellDeckColors::border()), + div() + .size(px(6.0)) + .rounded_full() + .bg(status_color(session.status)), ) - .child(target.border_r_1().border_color(ShellDeckColors::border())) - .child(access), - AgentControlsLayout::Compact => div() - .flex() - .flex_col() - .w_full() + .child(lucide_icon( + provider_icon(session.context.provider), + 12.0, + ShellDeckColors::text_muted(), + )) .child( div() - .flex() - .w_full() - .child( - provider - .border_r_1() - .border_color(ShellDeckColors::border()), - ) - .child(target), + .flex_1() + .min_w(px(0.0)) + .overflow_hidden() + .whitespace_nowrap() + .text_ellipsis() + .text_size(px(10.5)) + .font_weight(if selected { + FontWeight::SEMIBOLD + } else { + FontWeight::NORMAL + }) + .child(session.name.clone()), ) - .child(access.border_t_1().border_color(ShellDeckColors::border())), - AgentControlsLayout::Narrow => div() - .flex() - .flex_col() - .w_full() - .child(provider) - .child(target.border_t_1().border_color(ShellDeckColors::border())) - .child(access.border_t_1().border_color(ShellDeckColors::border())), - }; - let workdir_focused = self - .workdir_state - .read(cx) - .focus_handle(cx) - .is_focused(window); - let workdir = div() + .when_some(session_elapsed(session), |row, elapsed| { + row.child( + div() + .font_family("JetBrains Mono") + .text_size(px(8.5)) + .text_color(ShellDeckColors::text_muted()) + .child(elapsed), + ) + }) + .when(session.unread_count > 0, |row| { + row.child( + Badge::new(session.unread_count.to_string()) + .variant(BadgeVariant::Secondary), + ) + }); + if removable { + row = row.child( + div() + .id(("agent-close-session", uuid_key(id))) + .size(px(19.0)) + .flex() + .items_center() + .justify_center() + .rounded(px(4.0)) + .hover(|style| style.bg(ShellDeckColors::hover_bg())) + .on_click(cx.listener(move |this, _event: &ClickEvent, _, cx| { + cx.stop_propagation(); + this.close_session(id, cx); + })) + .child(lucide_icon("x", 10.0, ShellDeckColors::text_muted())), + ); + } + list = list.child(row); + } + list.into_any_element() + } + + fn render_tabs(&self, cx: &mut Context) -> AnyElement { + let mut tabs = div() + .id("agent-session-tabs") .flex() - .items_center() - .gap(px(8.0)) + .flex_1() .min_w(px(0.0)) - .h(px(42.0)) - .px(px(11.0)) - .border_t_1() - .border_color(ShellDeckColors::border()) - .child(lucide_icon("folder", 14.0, ShellDeckColors::text_muted())) - .when(controls_layout != AgentControlsLayout::Narrow, |row| { - row.child( + .h_full() + .overflow_x_scroll(); + for session in self.sessions.sessions() { + let id = session.id; + let selected = self.sessions.selected_id() == Some(id); + let removable = self.sessions.sessions().len() > 1 && !session.status.is_active(); + let mut tab = div() + .id(("agent-session-tab", uuid_key(id))) + .h_full() + .max_w(px(220.0)) + .flex() + .items_center() + .gap(px(7.0)) + .px(px(11.0)) + .border_r_1() + .border_color(ShellDeckColors::border()) + .cursor_pointer() + .when(selected, |tab| { + tab.bg(ShellDeckColors::bg_primary()) + .border_b_2() + .border_color(ShellDeckColors::primary()) + }) + .when(!selected, |tab| { + tab.hover(|style| style.bg(ShellDeckColors::hover_bg())) + }) + .on_click(cx.listener(move |this, _, _, cx| { + this.select_session(id, cx); + })) + .child( div() - .flex_shrink_0() - .text_size(px(9.0)) - .text_color(ShellDeckColors::text_muted()) - .child(t!("agents.workdir_short").to_string()), + .size(px(6.0)) + .rounded_full() + .bg(status_color(session.status)), ) + .child(lucide_icon( + provider_icon(session.context.provider), + 12.0, + ShellDeckColors::text_muted(), + )) + .child( + div() + .min_w(px(0.0)) + .overflow_hidden() + .whitespace_nowrap() + .text_ellipsis() + .text_size(px(10.5)) + .child(session.name.clone()), + ) + .when(session.attention != AgentSessionAttention::None, |tab| { + tab.child( + div() + .size(px(5.0)) + .rounded_full() + .bg(attention_color(session.attention)), + ) + }); + if removable { + tab = tab.child( + div() + .id(("agent-close-tab", uuid_key(id))) + .size(px(18.0)) + .flex() + .items_center() + .justify_center() + .rounded(px(4.0)) + .hover(|style| style.bg(ShellDeckColors::hover_bg())) + .on_click(cx.listener(move |this, _event: &ClickEvent, _, cx| { + cx.stop_propagation(); + this.close_session(id, cx); + })) + .child(lucide_icon("x", 10.0, ShellDeckColors::text_muted())), + ); + } + tabs = tabs.child(tab); + } + tabs.into_any_element() + } + + fn render_trace(trace: &AgentTraceEvent, focused: bool) -> AnyElement { + let (icon, color, title, detail, stats) = trace_presentation(&trace.detail); + div() + .id(("agent-trace", uuid_key(trace.id))) + .w_full() + .flex() + .items_start() + .gap(px(8.0)) + .px(px(9.0)) + .py(px(7.0)) + .border_1() + .border_color(if focused { + ShellDeckColors::primary() + } else { + ShellDeckColors::border() + }) + .rounded(px(5.0)) + .bg(if focused { + ShellDeckColors::primary().opacity(0.08) + } else { + ShellDeckColors::bg_surface() }) + .child( + div() + .mt(px(1.0)) + .size(px(20.0)) + .flex() + .items_center() + .justify_center() + .rounded(px(4.0)) + .bg(color.opacity(0.10)) + .child(lucide_icon(icon, 11.0, color)), + ) .child( div() .flex_1() .min_w(px(0.0)) - .h(px(32.0)) + .flex() + .flex_col() + .gap(px(2.0)) + .child( + div() + .flex() + .items_center() + .gap(px(7.0)) + .child( + div() + .flex_1() + .min_w(px(0.0)) + .overflow_hidden() + .text_ellipsis() + .text_size(px(10.0)) + .font_weight(FontWeight::SEMIBOLD) + .text_color(color) + .child(title), + ) + .when_some(stats, |row, stats| { + row.child( + div() + .font_family("JetBrains Mono") + .text_size(px(9.0)) + .text_color(ShellDeckColors::text_muted()) + .child(stats), + ) + }), + ) + .when_some(detail, |column, detail| { + column.child( + div() + .font_family("JetBrains Mono") + .text_size(px(10.0)) + .line_height(relative(1.35)) + .text_color(ShellDeckColors::text_muted()) + .whitespace_normal() + .child(detail), + ) + }), + ) + .into_any_element() + } + + fn render_inspector(&mut self, session: &AgentSession, cx: &mut Context) -> AnyElement { + let remote = matches!(session.context.target, AgentTarget::Ssh { .. }); + let files = inspector_files(session); + let changed_count = files.iter().filter(|file| file.diff.is_some()).count(); + let files_selected = self.inspector_tab == AgentInspectorTab::Files; + let changes_selected = self.inspector_tab == AgentInspectorTab::Changes; + let remote_parent = remote + .then(|| { + self.inspector_remote_path + .as_deref() + .unwrap_or(&session.context.workdir) + }) + .and_then(remote_parent_path); + + let tabs = div() + .h(px(42.0)) + .flex() + .items_center() + .gap(px(4.0)) + .px(px(8.0)) + .border_b_1() + .border_color(ShellDeckColors::border()) + .child( + Button::new( + "agent-inspector-files", + t!("agents.inspector.files").to_string(), + ) + .variant(if files_selected { + ButtonVariant::Secondary + } else { + ButtonVariant::Ghost + }) + .size(ButtonSize::Sm) + .icon(IconSource::from("file-text")) + .on_click(cx.listener(|this, _, _, cx| { + this.inspector_tab = AgentInspectorTab::Files; + cx.notify(); + })), + ) + .child( + Button::new( + "agent-inspector-changes", + t!("agents.inspector.changes").to_string(), + ) + .variant(if changes_selected { + ButtonVariant::Secondary + } else { + ButtonVariant::Ghost + }) + .size(ButtonSize::Sm) + .icon(IconSource::from("git-branch")) + .on_click(cx.listener(|this, _, _, cx| { + this.inspector_tab = AgentInspectorTab::Changes; + cx.notify(); + })), + ); + + let mut list = div() + .id("agent-inspector-list") + .flex() + .flex_col() + .flex_1() + .min_h(px(0.0)) + .overflow_y_scroll() + .px(px(7.0)) + .py(px(6.0)) + .gap(px(4.0)); + if files_selected && !remote { + let entries = self.inspector_local_browser.visible_entries(); + if entries.is_empty() { + list = list.child(inspector_empty_state( + "file-text", + t!("agents.inspector.empty_files").to_string(), + )); + } else { + for entry in entries { + let path = entry.path.clone(); + let event_path = path.clone(); + let is_dir = entry.is_dir; + let session_id = session.id; + let icon = if is_dir { + if entry.is_expanded { + "chevron-down" + } else { + "chevron-right" + } + } else { + "file-text" + }; + list = list.child( + div() + .id(SharedString::from(format!("agent-file-{}", path.display()))) + .h(px(25.0)) + .w_full() + .flex() + .items_center() + .gap(px(5.0)) + .pl(px(6.0 + entry.depth as f32 * 12.0)) + .pr(px(6.0)) + .rounded(px(4.0)) + .cursor_pointer() + .hover(|style| style.bg(ShellDeckColors::hover_bg())) + .on_click(cx.listener(move |this, _, _, cx| { + if is_dir { + this.inspector_local_browser.toggle_dir(&path); + cx.notify(); + } else { + cx.emit(AgentConsoleEvent::OpenLocalFile { + session_id, + path: event_path.clone(), + }); + } + })) + .child(lucide_icon(icon, 11.0, ShellDeckColors::text_muted())) + .child( + div() + .flex_1() + .min_w(px(0.0)) + .overflow_hidden() + .whitespace_nowrap() + .text_ellipsis() + .text_size(px(10.0)) + .text_color(if is_dir { + ShellDeckColors::text_primary() + } else { + ShellDeckColors::text_muted() + }) + .child(entry.name), + ), + ); + } + } + } else if files_selected { + let (session_id, connection_id) = match &session.context.target { + AgentTarget::Ssh { connection_id, .. } => (session.id, *connection_id), + AgentTarget::Local => unreachable!("local files use the local browser"), + }; + let request_path = self + .inspector_remote_path + .clone() + .unwrap_or_else(|| session.context.workdir.clone()); + if self.inspector_remote_loading { + list = list.child(inspector_empty_state( + "refresh-cw", + t!("agents.inspector.remote_loading").to_string(), + )); + } else if let Some(error) = self.inspector_remote_error.clone() { + let retry_path = request_path.clone(); + list = list + .child(inspector_empty_state("circle-alert", error)) + .child( + Button::new( + "agent-remote-files-retry", + t!("agents.inspector.retry").to_string(), + ) + .variant(ButtonVariant::Secondary) + .size(ButtonSize::Sm) + .on_click(cx.listener(move |this, _, _, cx| { + this.request_remote_directory( + session_id, + connection_id, + retry_path.clone(), + cx, + ); + })), + ); + } else if self.inspector_remote_entries.is_empty() { + let load_path = request_path.clone(); + list = list + .child(inspector_empty_state( + "server", + t!("agents.inspector.remote_empty").to_string(), + )) + .child( + Button::new( + "agent-remote-files-load", + t!("agents.inspector.remote_load").to_string(), + ) + .variant(ButtonVariant::Secondary) + .size(ButtonSize::Sm) + .on_click(cx.listener(move |this, _, _, cx| { + this.request_remote_directory( + session_id, + connection_id, + load_path.clone(), + cx, + ); + })), + ); + } else { + for entry in self.inspector_remote_entries.clone() { + let path = entry.path.clone(); + let is_dir = entry.is_dir; + let mut row = div() + .id(SharedString::from(format!( + "agent-remote-file-{}", + entry.path + ))) + .h(px(25.0)) + .w_full() + .flex() + .items_center() + .gap(px(6.0)) + .px(px(6.0)) + .rounded(px(4.0)) + .text_size(px(10.0)) + .text_color(if is_dir { + ShellDeckColors::text_primary() + } else { + ShellDeckColors::text_muted() + }) + .child(lucide_icon( + if is_dir { "archive" } else { "file-text" }, + 11.0, + ShellDeckColors::text_muted(), + )) + .child( + div() + .flex_1() + .min_w(px(0.0)) + .overflow_hidden() + .whitespace_nowrap() + .text_ellipsis() + .child(entry.name), + ); + if is_dir { + row = row + .cursor_pointer() + .hover(|style| style.bg(ShellDeckColors::hover_bg())) + .on_click(cx.listener(move |this, _, _, cx| { + this.request_remote_directory( + session_id, + connection_id, + path.clone(), + cx, + ); + })); + } else { + row = row.child( + Badge::new(t!("agents.inspector.remote").to_string()) + .variant(BadgeVariant::Secondary), + ); + } + list = list.child(row); + } + } + } else { + let visible = files + .iter() + .filter(|file| file.diff.is_some()) + .collect::>(); + if visible.is_empty() { + list = list.child(inspector_empty_state( + "git-branch", + t!("agents.inspector.empty_changes").to_string(), + )); + } + for file in visible { + let diff = file.diff.as_ref().expect("changed file has a diff"); + let trace_id = diff.trace_id; + let selected = self.focused_trace == Some(trace_id); + let mut row = div() + .id(("agent-inspector-trace", uuid_key(trace_id))) + .w_full() + .flex() + .flex_col() + .gap(px(4.0)) + .px(px(8.0)) + .py(px(7.0)) .overflow_hidden() .border_1() - .border_color(if workdir_focused { + .border_color(if selected { ShellDeckColors::primary() } else { - gpui::transparent_black() + ShellDeckColors::border() }) - .rounded(px(7.0)) - .when(workdir_focused, |field| { - field.bg(ShellDeckColors::selected_bg()) + .rounded(px(5.0)) + .bg(if selected { + ShellDeckColors::primary().opacity(0.08) + } else { + ShellDeckColors::bg_surface() }) + .cursor_pointer() + .hover(|style| style.bg(ShellDeckColors::hover_bg())) + .on_click(cx.listener(move |this, _, _, cx| { + this.focused_trace = Some(trace_id); + pin_to_latest(&this.scroll); + cx.notify(); + })) .child( - Input::new(&self.workdir_state) - .variant(InputVariant::Bare) - .size(InputSize::Sm) - .placeholder("/srv/project"), - ), - ); - let controls = div() + div() + .flex() + .items_center() + .gap(px(6.0)) + .child(lucide_icon("git-branch", 12.0, ShellDeckColors::warning())) + .child( + div() + .flex_1() + .min_w(px(0.0)) + .overflow_hidden() + .whitespace_nowrap() + .text_ellipsis() + .font_family("JetBrains Mono") + .text_size(px(9.5)) + .text_color(ShellDeckColors::text_primary()) + .child(file.path.clone()), + ) + .child( + div() + .flex_shrink_0() + .font_family("JetBrains Mono") + .text_size(px(8.5)) + .text_color(ShellDeckColors::text_muted()) + .child(format!("+{} −{}", diff.additions, diff.deletions)), + ), + ); + if let Some(preview) = diff.preview.as_ref() { + row = row.child( + div() + .max_h(px(88.0)) + .overflow_hidden() + .font_family("JetBrains Mono") + .text_size(px(8.5)) + .line_height(relative(1.3)) + .text_color(ShellDeckColors::text_muted()) + .whitespace_normal() + .child(preview.clone()), + ); + } + list = list.child(row); + } + } + + div() + .w(px(INSPECTOR_WIDTH)) + .h_full() .flex() .flex_col() - .gap(px(7.0)) - .px(px(18.0)) - .py(px(12.0)) - .border_b_1() + .flex_shrink_0() + .border_l_1() .border_color(ShellDeckColors::border()) + .bg(ShellDeckColors::bg_sidebar()) + .child(tabs) .child( div() .flex() - .items_center() - .gap(px(8.0)) + .flex_col() + .gap(px(4.0)) + .px(px(10.0)) + .py(px(8.0)) + .border_b_1() + .border_color(ShellDeckColors::border()) .child( div() - .text_size(px(11.0)) - .font_weight(FontWeight::SEMIBOLD) - .child(t!("agents.context.title").to_string()), + .flex() + .items_center() + .gap(px(6.0)) + .when_some(remote_parent, |row, parent_path| { + let (session_id, connection_id) = match &session.context.target { + AgentTarget::Ssh { connection_id, .. } => { + (session.id, *connection_id) + } + AgentTarget::Local => { + unreachable!("a remote parent requires an SSH target") + } + }; + row.child( + Button::new("agent-remote-files-parent", "") + .variant(ButtonVariant::Ghost) + .size(ButtonSize::Icon) + .icon(IconSource::from("arrow-up")) + .tooltip(t!("agents.inspector.remote_parent").to_string()) + .w(px(24.0)) + .h(px(24.0)) + .on_click(cx.listener(move |this, _, _, cx| { + this.request_remote_directory( + session_id, + connection_id, + parent_path.clone(), + cx, + ); + })), + ) + }) + .child( + Badge::new(if remote { + t!("agents.inspector.remote").to_string() + } else { + t!("agents.inspector.local").to_string() + }) + .variant(BadgeVariant::Secondary), + ) + .child( + div() + .flex_1() + .min_w(px(0.0)) + .overflow_hidden() + .whitespace_nowrap() + .text_ellipsis() + .font_family("JetBrains Mono") + .text_size(px(9.0)) + .text_color(ShellDeckColors::text_muted()) + .child(session.context.workdir.clone()), + ), ) - .when(controls_layout != AgentControlsLayout::Narrow, |row| { - row.child( - div() - .text_size(px(10.5)) - .text_color(ShellDeckColors::text_muted()) - .child(t!("agents.context.hint").to_string()), - ) - }), + .child( + div() + .flex() + .items_center() + .gap(px(5.0)) + .text_size(px(9.0)) + .text_color(ShellDeckColors::text_muted()) + .child(format!( + "{} · {} {}", + t!("agents.inspector.observed"), + files.len(), + t!("agents.inspector.files") + )) + .when(changed_count > 0, |row| { + row.child(format!( + "· {changed_count} {}", + t!("agents.inspector.changes") + )) + }), + ), ) + .child(list) + .when(changes_selected, |panel| { + panel.child( + div() + .flex_shrink_0() + .px(px(10.0)) + .py(px(7.0)) + .border_t_1() + .border_color(ShellDeckColors::border()) + .text_size(px(8.5)) + .text_color(ShellDeckColors::text_muted()) + .child(t!("agents.inspector.focus_hint").to_string()), + ) + }) + .into_any_element() + } + + fn render_context(&self, narrow: bool, window: &Window, cx: &mut Context) -> AnyElement { + let locked = self + .sessions + .selected_id() + .is_some_and(|id| self.context_locks.contains(&id)); + let workdir_focused = self + .workdir_state + .read(cx) + .focus_handle(cx) + .is_focused(window); + let provider = context_select_cell(self.provider_select.clone()); + let target = context_select_cell(self.target_select.clone()); + let access = context_select_cell(self.access_select.clone()); + let selects = if narrow { + div() + .flex() + .flex_col() + .child(provider) + .child(target.border_t_1().border_color(ShellDeckColors::border())) + .child(access.border_t_1().border_color(ShellDeckColors::border())) + } else { + div() + .flex() + .child( + provider + .border_r_1() + .border_color(ShellDeckColors::border()), + ) + .child(target.border_r_1().border_color(ShellDeckColors::border())) + .child(access) + }; + div() + .flex() + .flex_col() + .border_b_1() + .border_color(ShellDeckColors::border()) + .bg(ShellDeckColors::bg_surface()) + .child(selects) .child( div() - .w_full() - .min_w(px(0.0)) - .overflow_hidden() - .border_1() + .h(px(34.0)) + .flex() + .items_center() + .gap(px(7.0)) + .px(px(9.0)) + .border_t_1() .border_color(ShellDeckColors::border()) - .rounded(px(10.0)) - .child(select_rows) - .child(workdir), + .child(lucide_icon("archive", 12.0, ShellDeckColors::text_muted())) + .child( + div() + .flex_1() + .min_w(px(0.0)) + .h(px(28.0)) + .overflow_hidden() + .border_1() + .border_color(if workdir_focused { + ShellDeckColors::primary() + } else { + gpui::transparent_black() + }) + .rounded(px(5.0)) + .child( + Input::new(&self.workdir_state) + .variant(InputVariant::Bare) + .size(InputSize::Sm) + .disabled(locked) + .placeholder("/srv/project"), + ), + ), + ) + .into_any_element() + } +} + +impl Render for AgentConsoleView { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let wide = window.viewport_size().width >= px(840.0).to_pixels(window.rem_size()); + let inspector_wide = + window.viewport_size().width >= px(1180.0).to_pixels(window.rem_size()); + let session = self + .sessions + .selected() + .cloned() + .expect("cockpit retains one session"); + if matches!(session.context.target, AgentTarget::Local) { + let root = PathBuf::from(&session.context.workdir); + if root.is_dir() && self.inspector_local_browser.root() != root.as_path() { + self.inspector_local_browser.set_root(root); + } + } + let session_id = session.id; + let session_name = session.name.clone(); + let context = session.context.clone(); + let status = session.status; + let attention = session.attention; + let elapsed = session_elapsed(&session); + let timeline = merged_timeline(&session); + let running = self.session_ui.get(&session_id).and_then(|ui| ui.run_id); + let received_output = self + .session_ui + .get(&session_id) + .is_some_and(|ui| ui.received_output); + let prompt = self + .session_ui + .get(&session_id) + .expect("selected session has UI state") + .prompt + .clone(); + let has_content = !timeline.is_empty(); + let context_expanded = self.context_expanded; + + let submit = { + let parent = cx.entity(); + move |cx: &mut App| parent.update(cx, |this, cx| this.submit(cx)) + }; + let header = div() + .h(px(42.0)) + .flex() + .items_center() + .flex_shrink_0() + .bg(ShellDeckColors::bg_sidebar()) + .border_b_1() + .border_color(ShellDeckColors::border()) + .child(self.render_tabs(cx)) + .child( + Button::new("agent-add-tab", "") + .variant(ButtonVariant::Ghost) + .size(ButtonSize::Icon) + .icon(IconSource::from("plus")) + .w(px(30.0)) + .h(px(30.0)) + .tooltip(t!("agents.session.new").to_string()) + .on_click(cx.listener(|this, _, _, cx| this.new_session(cx))), + ); + let metadata = div() + .h(if wide { px(42.0) } else { px(38.0) }) + .flex() + .items_center() + .gap(px(8.0)) + .px(px(12.0)) + .flex_shrink_0() + .border_b_1() + .border_color(ShellDeckColors::border()) + .child(lucide_icon( + provider_icon(context.provider), + 14.0, + ShellDeckColors::primary(), + )) + .when(!wide, |row| { + row.child( + div() + .max_w(px(260.0)) + .overflow_hidden() + .text_ellipsis() + .text_size(px(11.5)) + .font_weight(FontWeight::SEMIBOLD) + .child(session_name), + ) + }) + .when(wide, |row| { + row.child( + div() + .text_size(px(11.0)) + .font_weight(FontWeight::SEMIBOLD) + .child(context.provider.display_name()), + ) + }) + .child(div().size(px(6.0)).rounded_full().bg(status_color(status))) + .when_some(elapsed, |row, elapsed| { + row.child( + div() + .font_family("JetBrains Mono") + .text_size(px(9.0)) + .text_color(ShellDeckColors::text_muted()) + .child(elapsed), + ) + }) + .when(attention != AgentSessionAttention::None, |row| { + row.child( + div() + .size(px(5.0)) + .rounded_full() + .bg(attention_color(attention)), + ) + .child( + Button::new("agent-acknowledge-attention", "") + .variant(ButtonVariant::Ghost) + .size(ButtonSize::Icon) + .icon(IconSource::from("check")) + .w(px(24.0)) + .h(px(24.0)) + .tooltip(t!("agents.attention.acknowledge").to_string()) + .on_click( + cx.listener(|this, _, _, cx| this.acknowledge_selected_attention(cx)), + ), + ) + }) + .child(div().flex_1()) + .when(wide, |row| { + row.child( + Badge::new(target_label(&context.target)).variant(BadgeVariant::Secondary), + ) + .child( + div() + .max_w(px(300.0)) + .min_w(px(0.0)) + .overflow_hidden() + .whitespace_nowrap() + .text_ellipsis() + .font_family("JetBrains Mono") + .text_size(px(9.0)) + .text_color(ShellDeckColors::text_muted()) + .child(context.workdir.clone()), + ) + }) + .when(!wide, |row| { + row.child( + div() + .text_size(px(9.5)) + .text_color(ShellDeckColors::text_muted()) + .child(context.provider.display_name()), + ) + }) + .child( + Button::new("agent-context-toggle", "") + .variant(ButtonVariant::Ghost) + .size(ButtonSize::Icon) + .icon(IconSource::from(if context_expanded { + "panel-right-close" + } else { + "settings" + })) + .w(px(28.0)) + .h(px(28.0)) + .tooltip(t!("agents.context.title").to_string()) + .on_click(cx.listener(|this, _, _, cx| { + this.context_expanded = !this.context_expanded; + cx.notify(); + })), ); let mut output = div() - .id("agent-console-output") + .id(("agent-console-output", uuid_key(session_id))) .flex() .flex_col() .flex_1() .min_h(px(0.0)) .overflow_y_scroll() .track_scroll(&self.scroll) - .px(px(22.0)) - .py(px(18.0)); - if self.transcript.is_empty() && self.activity.is_empty() && self.error.is_none() { - let narrow_empty = controls_layout == AgentControlsLayout::Narrow; - let empty_gap = if narrow_empty { 5.0 } else { 10.0 }; - let empty_icon_size = if narrow_empty { 22.0 } else { 30.0 }; - let empty_title_size = if narrow_empty { 14.0 } else { 15.0 }; - let mut empty_state = div() + .px(if wide { px(24.0) } else { px(12.0) }) + .py(px(16.0)); + if !has_content { + output = output.child( + div() + .flex() + .flex_col() + .items_center() + .justify_center() + .flex_1() + .gap(px(8.0)) + .text_color(ShellDeckColors::text_muted()) + .child(lucide_icon("bot", 27.0, ShellDeckColors::primary())) + .child( + div() + .text_size(px(14.0)) + .font_weight(FontWeight::SEMIBOLD) + .text_color(ShellDeckColors::text_primary()) + .child(t!("agents.empty.title").to_string()), + ) + .child( + div() + .max_w(px(520.0)) + .text_size(px(11.0)) + .text_align(TextAlign::Center) + .child(t!("agents.empty.description").to_string()), + ), + ); + } else { + let mut timeline_view = div() + .w_full() + .max_w(px(920.0)) + .mx_auto() .flex() .flex_col() - .items_center() - .justify_center() - .flex_1() - .gap(px(empty_gap)) - .text_color(ShellDeckColors::text_muted()) - .child(lucide_icon( - "bot", - empty_icon_size, - ShellDeckColors::primary(), - )) - .child( - div() - .text_size(px(empty_title_size)) - .font_weight(FontWeight::SEMIBOLD) - .text_color(ShellDeckColors::text_primary()) - .child(t!("agents.empty.title").to_string()), - ); - if !narrow_empty { - empty_state = empty_state.child( - div() - .w(px(520.0)) - .max_w_full() - .min_w(px(0.0)) - .overflow_hidden() - .text_size(px(12.0)) - .line_height(relative(1.35)) - .text_align(TextAlign::Center) - .whitespace_normal() - .child(t!("agents.empty.description").to_string()), - ); - } - output = output.child(empty_state); - } else { - if let Some(run) = running.as_ref() { - output = output.child( - div() - .flex() - .flex_shrink_0() - .flex_wrap() - .items_center() - .gap(px(6.0)) - .mb(px(10.0)) - .child( - Badge::new(run.provider.display_name()).variant(BadgeVariant::Outline), - ) - .child(Badge::new(target_label(&run.target)).variant(BadgeVariant::Outline)) - .child(Badge::new(run.workdir.clone()).variant(BadgeVariant::Secondary)), - ); - } - if !self.transcript.is_empty() { - output = output.child( - div() - .w_full() - .flex_shrink_0() - .max_w(px(860.0)) - .mx_auto() - .min_w(px(0.0)) - .overflow_hidden() - .py(px(8.0)) - .text_color(ShellDeckColors::text_primary()) - .child( - Markdown::new(self.transcript.clone()) - .base_font_size(px(13.0).to_pixels(window.rem_size())) - .compact() + .gap(px(8.0)); + for item in &timeline { + match item { + TimelineItem::Message(message) => { + let color = match message.role { + AgentMessageRole::User => ShellDeckColors::text_muted(), + AgentMessageRole::Agent => ShellDeckColors::primary(), + AgentMessageRole::Error => ShellDeckColors::error(), + }; + let label = match message.role { + AgentMessageRole::User => t!("agents.role.you").to_string(), + AgentMessageRole::Agent => context.provider.display_name().to_string(), + AgentMessageRole::Error => t!("agents.activity.title").to_string(), + }; + timeline_view = timeline_view.child( + div() + .id(("agent-message", uuid_key(message.id))) .w_full() - .min_w_0() - .whitespace_normal(), - ), - ); + .min_w(px(0.0)) + .overflow_hidden() + .py(px(5.0)) + .when(message.role == AgentMessageRole::Error, |message| { + message + .px(px(9.0)) + .rounded(px(5.0)) + .bg(ShellDeckColors::error().opacity(0.10)) + }) + .child( + div() + .mb(px(4.0)) + .text_size(px(9.5)) + .font_weight(FontWeight::SEMIBOLD) + .text_color(color) + .child(label), + ) + .child( + Markdown::new(message.text.clone()) + .base_font_size(px(12.0).to_pixels(window.rem_size())) + .compact() + .w_full() + .min_w_0() + .whitespace_normal(), + ), + ); + } + TimelineItem::Trace(trace) => { + timeline_view = timeline_view.child(Self::render_trace( + trace, + self.focused_trace == Some(trace.id), + )); + } + } } - if show_agent_preparing(is_running, self.run_received_output, self.error.is_some()) { - output = output.child( + if status.is_active() && !received_output { + timeline_view = timeline_view.child( div() - .w_full() - .flex_shrink_0() - .max_w(px(860.0)) - .mx_auto() .flex() .items_center() - .gap(px(8.0)) + .gap(px(7.0)) .py(px(4.0)) - .text_size(px(12.0)) + .text_size(px(11.0)) .text_color(ShellDeckColors::text_muted()) .child(animated_monolith( "agent-console-thinking", - 32.0, + 28.0, MonolithMotion::Thinking, cx, )) @@ -1056,129 +2433,72 @@ impl Render for AgentConsoleView { )), ); } - } - if let Some(error) = &self.error { - output = output.child( - div() - .flex() - .flex_shrink_0() - .items_start() - .gap(px(8.0)) - .mt(px(10.0)) - .px(px(10.0)) - .py(px(8.0)) - .overflow_hidden() - .rounded(px(7.0)) - .bg(ShellDeckColors::error().opacity(0.10)) - .text_size(px(12.0)) - .text_color(ShellDeckColors::error()) - .child(lucide_icon( - "triangle-alert", - 14.0, - ShellDeckColors::error(), - )) - .child( - div() - .flex_1() - .min_w(px(0.0)) - .line_clamp(4) - .child(error.clone()), - ), - ); + output = output.child(timeline_view); } - let mut composer = Composer::new("agent-console-composer", &self.prompt_state) + let mut composer = Composer::new(format!("agent-composer-{session_id}"), &prompt) .placeholder(t!("agents.prompt.placeholder").to_string()) .min_rows(2) .max_rows(8) - .disabled(is_running) - .on_commit(submit) - .option(self.render_model_popover(cx)); - if let Some(run) = running { - let run_id = run.id; - let stop_label = t!("agents.stop").to_string(); + .disabled(status.is_active()) + .on_commit(submit); + if context.provider != AgentProvider::Automonique { + composer = composer.option(self.render_model_popover(cx)); + } + if let Some(run_id) = running { composer = composer.without_commit().option( - Button::new("agent-console-stop", "") + Button::new(("agent-stop", uuid_key(run_id)), "") .variant(ButtonVariant::Destructive) .size(ButtonSize::Icon) .icon(IconSource::from("square")) - .tooltip(stop_label) + .tooltip(t!("agents.stop").to_string()) .w(px(28.0)) .h(px(28.0)) - .px(px(0.0)) .rounded_full() - .on_click(cx.listener(move |_this, _, _, cx| { - cx.emit(AgentConsoleEvent::Stop(run_id)); + .on_click(cx.listener(move |this, _, _, cx| { + this.request_stop(run_id, cx); })), ); } - - let mut header = div() + let mut content = div() .flex() - .items_center() - .gap(px(9.0)) - .px(px(18.0)) - .h(px(48.0)) - .border_b_1() - .border_color(ShellDeckColors::border()) - .child(lucide_icon("bot", 17.0, ShellDeckColors::primary())) - .child( - div() - .text_size(px(15.0)) - .font_weight(FontWeight::SEMIBOLD) - .child(t!("agents.title").to_string()), - ) - .child(Badge::new(t!("agents.scope").to_string()).variant(BadgeVariant::Outline)); - if self.session_token.is_some() { - header = header.child( - Badge::new(t!("agents.session.active").to_string()) - .variant(BadgeVariant::Secondary), - ); + .flex_col() + .flex_1() + .min_w(px(0.0)) + .min_h(px(0.0)); + if !wide { + content = content.child(header); } - header = header.child(div().flex_1()); - if !self.activity.is_empty() { - header = header.child(self.render_activity_popover()); + content = content.child(metadata); + if context_expanded { + content = content.child(self.render_context(!wide, window, cx)); } - header = header.child( - Button::new( - "agent-console-new-session", - t!("agents.session.new").to_string(), - ) - .variant(ButtonVariant::Ghost) - .size(ButtonSize::Sm) - .disabled(is_running) - .icon(IconSource::from("plus")) - .on_click(cx.listener(|this, _, _, cx| this.new_session(cx))), + content = content.child(output).child( + div() + .flex_shrink_0() + .w_full() + .px(if wide { px(18.0) } else { px(10.0) }) + .pt(px(7.0)) + .pb(px(11.0)) + .border_t_1() + .border_color(ShellDeckColors::border()) + .child(div().w_full().max_w(px(940.0)).mx_auto().child(composer)), ); - let mut root = div() .relative() .flex() - .flex_col() .size_full() .min_h(px(0.0)) - .bg(ShellDeckColors::bg_primary()) - .child(header) - .child(controls) - .child(output) - .child( - div() - .flex_shrink_0() - .w_full() - .px(px(18.0)) - .pt(px(8.0)) - .pb(px(14.0)) - .child( - div() - .w_full() - .min_w(px(0.0)) - .max_w(px(900.0)) - .mx_auto() - .child(composer), - ), - ); - if let Some(request) = self.pending_confirm.clone() { - root = root.child(self.render_confirmation(request, cx)); + .bg(ShellDeckColors::bg_primary()); + if wide { + root = root.child(self.render_navigator(cx)); + } + root = root.child(content); + if inspector_wide { + root = root.child(self.render_inspector(&session, cx)); + } + if let Some(pending) = self.pending_confirm.as_ref() { + root = root.child(self.render_confirmation(pending, cx)); } root } @@ -1193,20 +2513,381 @@ fn context_select_cell(select: Entity>) -> Div { .child(select) } -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum AgentControlsLayout { - Wide, - Compact, - Narrow, +fn inspector_empty_state(icon: &'static str, label: String) -> Div { + div() + .flex() + .flex_col() + .items_center() + .justify_center() + .flex_1() + .gap(px(8.0)) + .px(px(18.0)) + .text_align(TextAlign::Center) + .text_size(px(10.5)) + .text_color(ShellDeckColors::text_muted()) + .child(lucide_icon(icon, 22.0, ShellDeckColors::text_muted())) + .child(label) +} + +fn now_ms() -> i64 { + Utc::now().timestamp_millis() +} + +fn uuid_key(id: Uuid) -> u64 { + (id.as_u128() ^ (id.as_u128() >> 64)) as u64 +} + +fn stable_string_key(value: &str) -> u64 { + value + .as_bytes() + .iter() + .fold(0xcbf29ce484222325, |hash, byte| { + (hash ^ u64::from(*byte)).wrapping_mul(0x100000001b3) + }) +} + +fn session_elapsed(session: &AgentSession) -> Option { + let started = session.started_at_ms?; + let finished = session.finished_at_ms.unwrap_or_else(now_ms); + let seconds = finished.saturating_sub(started) / 1_000; + Some(if seconds < 60 { + format!("{seconds}s") + } else if seconds < 3_600 { + format!("{}m {:02}s", seconds / 60, seconds % 60) + } else { + format!("{}h {:02}m", seconds / 3_600, (seconds % 3_600) / 60) + }) +} + +fn merged_timeline(session: &AgentSession) -> Vec { + let mut timeline = Vec::with_capacity(session.messages.len() + session.trace.len()); + timeline.extend(session.messages.iter().cloned().map(TimelineItem::Message)); + timeline.extend(session.trace.iter().cloned().map(TimelineItem::Trace)); + timeline.sort_by(|left, right| match (left.sequence(), right.sequence()) { + // Pre-sequence durable sessions deserialize with zero. Preserve their + // historical interleaving, then place all newly sequenced rows after + // that recovered prefix. + (0, 0) => left.at_ms().cmp(&right.at_ms()), + (0, _) => std::cmp::Ordering::Less, + (_, 0) => std::cmp::Ordering::Greater, + (left, right) => left.cmp(&right), + }); + timeline +} + +fn target_switch_workdir( + previous_connection: Option, + next_connection: Option, + current_workdir: &str, + local_workdir: &str, +) -> Option { + match (previous_connection, next_connection) { + (None, Some(_)) if current_workdir == local_workdir => { + Some(AGENT_REMOTE_HOME_WORKDIR.to_string()) + } + (Some(_), None) => Some(local_workdir.to_string()), + _ => None, + } +} + +fn workdir_is_valid_for_target(workdir: &str, remote: bool) -> bool { + if workdir.is_empty() { + return false; + } + if remote { + workdir == AGENT_REMOTE_HOME_WORKDIR || workdir.starts_with('/') + } else { + std::path::Path::new(workdir).is_absolute() + } +} + +fn remote_parent_path(path: &str) -> Option { + if path == AGENT_REMOTE_HOME_WORKDIR || path == "/" { + return None; + } + let path = path.trim_end_matches('/'); + let separator = path.rfind('/')?; + Some(if separator == 0 { + "/".to_string() + } else { + path[..separator].to_string() + }) +} + +fn inspector_files(session: &AgentSession) -> Vec { + let mut by_path = HashMap::::new(); + for trace in &session.trace { + match &trace.detail { + AgentTraceKind::FileRead { path, .. } => { + let entry = by_path + .entry(path.clone()) + .or_insert_with(|| InspectorFile { + path: path.clone(), + latest_trace_id: trace.id, + latest_sequence: trace.sequence, + diff: None, + }); + if trace.sequence >= entry.latest_sequence { + entry.latest_trace_id = trace.id; + entry.latest_sequence = trace.sequence; + } + } + AgentTraceKind::Diff { + path, + additions, + deletions, + preview, + .. + } => { + let entry = by_path + .entry(path.clone()) + .or_insert_with(|| InspectorFile { + path: path.clone(), + latest_trace_id: trace.id, + latest_sequence: trace.sequence, + diff: None, + }); + if trace.sequence >= entry.latest_sequence { + entry.latest_trace_id = trace.id; + entry.latest_sequence = trace.sequence; + } + if entry + .diff + .as_ref() + .is_none_or(|diff| trace.sequence >= diff.sequence) + { + entry.diff = Some(InspectorDiff { + trace_id: trace.id, + sequence: trace.sequence, + additions: *additions, + deletions: *deletions, + preview: preview.clone(), + }); + } + } + _ => {} + } + } + let mut files = by_path.into_values().collect::>(); + files.sort_by(|left, right| { + right + .latest_sequence + .cmp(&left.latest_sequence) + .then_with(|| left.path.cmp(&right.path)) + }); + files +} + +fn session_name(prompt: &str) -> String { + let first = prompt.lines().next().unwrap_or_default().trim(); + let mut name = bounded_utf8(first.to_string(), 72); + if name.len() < first.len() { + name.push('…'); + } + if name.is_empty() { + t!("agents.session.new").to_string() + } else { + name + } +} + +fn provider_icon(provider: AgentProvider) -> &'static str { + match provider { + AgentProvider::Claude => "bot", + AgentProvider::Codex => "terminal", + AgentProvider::Automonique => "bot", + AgentProvider::Jcode => "sparkles", + AgentProvider::DeepSeek => "bot", + } +} + +fn target_label(target: &AgentTarget) -> String { + match target { + AgentTarget::Local => t!("agents.target.local").to_string(), + AgentTarget::Ssh { + connection_label, .. + } => connection_label.clone(), + } +} + +fn session_worktree_matches(session: &AgentSession, worktree: &AgentWorktreeOption) -> bool { + if !same_target_authority(&session.context.target, &worktree.target) { + return false; + } + match &worktree.target { + AgentTarget::Local => std::path::Path::new(&session.context.workdir) + .starts_with(std::path::Path::new(&worktree.path)), + AgentTarget::Ssh { .. } => { + session.context.workdir == worktree.path + || session + .context + .workdir + .strip_prefix(worktree.path.trim_end_matches('/')) + .is_some_and(|suffix| suffix.starts_with('/')) + } + } +} + +fn same_target_authority(left: &AgentTarget, right: &AgentTarget) -> bool { + match (left, right) { + (AgentTarget::Local, AgentTarget::Local) => true, + ( + AgentTarget::Ssh { + connection_id: left, + .. + }, + AgentTarget::Ssh { + connection_id: right, + .. + }, + ) => left == right, + _ => false, + } +} + +fn worktree_bound_session( + sessions: &[AgentSession], + worktree: &AgentWorktreeOption, +) -> Option { + sessions + .iter() + .find(|session| session_worktree_matches(session, worktree)) + .map(|session| session.id) +} + +fn context_for_worktree( + base: &AgentExecutionContext, + worktree: &AgentWorktreeOption, +) -> AgentExecutionContext { + AgentExecutionContext { + target: worktree.target.clone(), + workdir: worktree.path.clone(), + ..base.clone() + } } -fn agent_controls_layout(viewport_width: Pixels, rem_size: Pixels) -> AgentControlsLayout { - if viewport_width >= px(960.0).to_pixels(rem_size) { - AgentControlsLayout::Wide - } else if viewport_width >= px(720.0).to_pixels(rem_size) { - AgentControlsLayout::Compact +fn terminal_session_status(exit_code: Option, error_present: bool) -> AgentSessionStatus { + if error_present { + AgentSessionStatus::Failed } else { - AgentControlsLayout::Narrow + match exit_code { + Some(0) => AgentSessionStatus::Completed, + Some(_) => AgentSessionStatus::Failed, + None => AgentSessionStatus::Cancelled, + } + } +} + +fn access_label(access: AgentAccessMode) -> String { + match access { + AgentAccessMode::ReadOnly => t!("agents.access.read_only").to_string(), + AgentAccessMode::WorkspaceWrite => t!("agents.access.workspace_write").to_string(), + AgentAccessMode::FullAccess => t!("agents.access.full").to_string(), + } +} + +fn status_color(status: AgentSessionStatus) -> Hsla { + match status { + AgentSessionStatus::Idle | AgentSessionStatus::Cancelled => ShellDeckColors::text_muted(), + AgentSessionStatus::Starting + | AgentSessionStatus::Running + | AgentSessionStatus::Stopping => ShellDeckColors::warning(), + AgentSessionStatus::Completed => ShellDeckColors::success(), + AgentSessionStatus::Failed => ShellDeckColors::error(), + } +} + +fn attention_color(attention: AgentSessionAttention) -> Hsla { + match attention { + AgentSessionAttention::None => ShellDeckColors::text_muted(), + AgentSessionAttention::Unread => ShellDeckColors::primary(), + AgentSessionAttention::NeedsAttention => ShellDeckColors::error(), + } +} + +fn trace_status_color(status: AgentTraceStatus) -> Hsla { + match status { + AgentTraceStatus::Succeeded => ShellDeckColors::success(), + AgentTraceStatus::Failed | AgentTraceStatus::Cancelled => ShellDeckColors::error(), + AgentTraceStatus::Pending | AgentTraceStatus::Running => ShellDeckColors::warning(), + AgentTraceStatus::Unknown => ShellDeckColors::text_muted(), + } +} + +fn trace_presentation( + detail: &AgentTraceKind, +) -> (&'static str, Hsla, String, Option, Option) { + match detail { + AgentTraceKind::Command { + command, + status, + exit_code, + summary, + } => ( + "terminal", + trace_status_color(*status), + command.clone(), + summary.clone(), + exit_code.map(|code| code.to_string()), + ), + AgentTraceKind::FileRead { + path, + line_start, + line_end, + .. + } => ( + "search", + ShellDeckColors::text_muted(), + path.clone(), + None, + match (line_start, line_end) { + (Some(start), Some(end)) => Some(format!("{start}:{end}")), + (Some(start), None) => Some(start.to_string()), + _ => None, + }, + ), + AgentTraceKind::Diff { + path, + additions, + deletions, + preview, + .. + } => ( + "git-branch", + ShellDeckColors::warning(), + path.clone(), + preview.clone(), + Some(format!("+{additions} −{deletions}")), + ), + AgentTraceKind::Test { + name, + status, + summary, + } => ( + "check-check", + trace_status_color(*status), + name.clone(), + summary.clone(), + None, + ), + AgentTraceKind::Tool { + name, + status, + summary, + } => ( + "settings", + trace_status_color(*status), + name.clone(), + summary.clone(), + None, + ), + AgentTraceKind::Activity { label } => ( + "activity", + ShellDeckColors::text_muted(), + label.clone(), + None, + None, + ), } } @@ -1222,157 +2903,242 @@ fn bounded_utf8(mut value: String, max_bytes: usize) -> String { value } -fn markdown_quote(value: &str) -> String { - value - .lines() - .map(|line| format!("> {line}")) - .collect::>() - .join("\n") -} - -fn retain_recent_utf8(value: &mut String, max_bytes: usize) { - if value.len() <= max_bytes { - return; - } - let mut start = value.len() - max_bytes; - while !value.is_char_boundary(start) { - start += 1; - } - value.drain(..start); -} - #[cfg(test)] mod tests { use super::{ - agent_controls_layout, conversation_turn_separator, matching_resume_token, - popover_activity, push_activity, run_disposition, show_agent_preparing, - AgentControlsLayout, AgentRunDisposition, AgentSessionContext, + context_for_worktree, inspector_files, remote_parent_path, session_name, + target_switch_workdir, terminal_session_status, trace_presentation, + workdir_is_valid_for_target, worktree_bound_session, AgentWorktreeOption, }; use shelldeck_core::agent_runtime::{ - AgentAccessMode, AgentProvider, AgentRunRequest, AgentTarget, + AgentAccessMode, AgentProvider, AgentTarget, AGENT_REMOTE_HOME_WORKDIR, + }; + use shelldeck_core::agent_session::{ + AgentExecutionContext, AgentSession, AgentSessionStatus, AgentTraceEvent, AgentTraceKind, + AgentTraceStatus, }; - // SDTEST-1678 — SDUC-475 + // SDTEST-1882 #[test] - fn sdtest_1678_session_resume_requires_the_exact_execution_context() { - let request = AgentRunRequest::new( - AgentProvider::Codex, - AgentTarget::Local, - AgentAccessMode::ReadOnly, - "/srv/project", - Some("gpt-5.6".to_string()), - "inspect", - ); - let context = AgentSessionContext::from(&request); - let token = "thread-123".to_string(); - assert_eq!( - matching_resume_token(Some(&context), Some(&token), &request), - Some(token.clone()) - ); - - let changed_access = AgentRunRequest { - access: AgentAccessMode::WorkspaceWrite, - ..request.clone() + fn sdtest_1882_cockpit_trace_presentations_cover_structured_events() { + let command = AgentTraceKind::Command { + command: "cargo test".to_string(), + status: AgentTraceStatus::Succeeded, + exit_code: Some(0), + summary: None, }; - assert_eq!( - matching_resume_token(Some(&context), Some(&token), &changed_access), - None - ); - let changed_target = AgentRunRequest { - target: AgentTarget::Ssh { - connection_id: uuid::Uuid::new_v4(), - connection_label: "prod".to_string(), - }, - ..request + let diff = AgentTraceKind::Diff { + path: "src/main.rs".to_string(), + status: AgentTraceStatus::Succeeded, + additions: 4, + deletions: 2, + preview: None, }; - assert_eq!( - matching_resume_token(Some(&context), Some(&token), &changed_target), - None - ); - } - - // SDTEST-1679 — SDUC-475 - #[test] - fn sdtest_1679_mutating_access_always_requires_confirmation() { - assert_eq!( - run_disposition(AgentAccessMode::ReadOnly), - AgentRunDisposition::Run - ); - for access in [AgentAccessMode::WorkspaceWrite, AgentAccessMode::FullAccess] { - assert_eq!(run_disposition(access), AgentRunDisposition::Confirm); - } + assert_eq!(trace_presentation(&command).0, "terminal"); + assert_eq!(trace_presentation(&command).4.as_deref(), Some("0")); + assert_eq!(trace_presentation(&diff).4.as_deref(), Some("+4 −2")); + assert!(session_name(&"x".repeat(100)).ends_with('…')); } - // SDTEST-1705 — SDUC-475 + // SDTEST-1899 #[test] - fn sdtest_1705_agent_context_panel_uses_scale_aware_explicit_rows() { + fn sdtest_1899_terminal_errors_always_fail_and_only_clean_stop_cancels() { assert_eq!( - agent_controls_layout(gpui::px(959.0), gpui::px(16.0)), - AgentControlsLayout::Compact + terminal_session_status(Some(0), true), + AgentSessionStatus::Failed ); assert_eq!( - agent_controls_layout(gpui::px(960.0), gpui::px(16.0)), - AgentControlsLayout::Wide + terminal_session_status(None, true), + AgentSessionStatus::Failed ); assert_eq!( - agent_controls_layout(gpui::px(719.0), gpui::px(16.0)), - AgentControlsLayout::Narrow + terminal_session_status(None, false), + AgentSessionStatus::Cancelled ); assert_eq!( - agent_controls_layout(gpui::px(1_919.0), gpui::px(32.0)), - AgentControlsLayout::Compact + terminal_session_status(Some(0), false), + AgentSessionStatus::Completed ); assert_eq!( - agent_controls_layout(gpui::px(1_920.0), gpui::px(32.0)), - AgentControlsLayout::Wide + terminal_session_status(Some(17), false), + AgentSessionStatus::Failed ); } - // SDTEST-1707 — SDUC-475 + // SDTEST-1900 #[test] - fn sdtest_1707_agent_activity_deduplicates_and_keeps_a_bounded_popover_tail() { - let mut activity = Vec::new(); - for _ in 0..8 { - push_activity(&mut activity, "Claude Code est prêt".to_string()); - } - assert_eq!(activity, ["Claude Code est prêt"]); + fn sdtest_1900_worktree_selection_reuses_bound_session_or_clones_context() { + let base = AgentExecutionContext { + provider: AgentProvider::Codex, + target: AgentTarget::Local, + access: AgentAccessMode::ReadOnly, + workdir: "/srv/original".to_string(), + model: Some("gpt-5".to_string()), + }; + let worktree = AgentWorktreeOption { + id: "feature".to_string(), + label: "feature".to_string(), + path: "/srv/feature".to_string(), + target: AgentTarget::Local, + branch: Some("feature".to_string()), + is_primary: false, + }; + let bound = AgentSession::new("bound", context_for_worktree(&base, &worktree), 1) + .expect("valid session"); + let bound_id = bound.id; + assert_eq!(worktree_bound_session(&[bound], &worktree), Some(bound_id)); + let next_worktree = AgentWorktreeOption { + path: "/srv/next".to_string(), + ..worktree + }; + let next = context_for_worktree(&base, &next_worktree); + assert_eq!(base.workdir, "/srv/original"); + assert_eq!(next.workdir, "/srv/next"); + assert_eq!(next.provider, AgentProvider::Codex); + assert_eq!(next.model.as_deref(), Some("gpt-5")); - for index in 1..=14 { - push_activity(&mut activity, format!("étape {index}")); - } - assert_eq!( - popover_activity(&activity), - [ - "étape 3", - "étape 4", - "étape 5", - "étape 6", - "étape 7", - "étape 8", - "étape 9", - "étape 10", - "étape 11", - "étape 12", - "étape 13", - "étape 14", - ] - ); + let remote_a = uuid::Uuid::new_v4(); + let remote_b = uuid::Uuid::new_v4(); + let remote_context = AgentExecutionContext { + target: AgentTarget::Ssh { + connection_id: remote_a, + connection_label: "host-a".to_string(), + }, + workdir: "/srv/shared".to_string(), + ..base.clone() + }; + let remote_session = + AgentSession::new("remote", remote_context, 1).expect("valid SSH session"); + let other_host = AgentWorktreeOption { + target: AgentTarget::Ssh { + connection_id: remote_b, + connection_label: "host-b".to_string(), + }, + path: "/srv/shared".to_string(), + ..next_worktree + }; + assert_eq!(worktree_bound_session(&[remote_session], &other_host), None); + } + + // SDTEST-1901 + #[test] + fn sdtest_1901_stop_request_enters_stopping_before_runtime_completion() { + let context = AgentExecutionContext { + provider: AgentProvider::Claude, + target: AgentTarget::Local, + access: AgentAccessMode::ReadOnly, + workdir: "/srv/project".to_string(), + model: None, + }; + let mut session = AgentSession::new("stop", context, 1).expect("valid session"); + session.begin_run("inspect", 2).expect("run starts"); + assert!(session.request_stop(3)); + assert_eq!(session.status, AgentSessionStatus::Stopping); } - // SDTEST-1711 — SDUC-475 + // SDTEST-1902 #[test] - fn sdtest_1711_agent_preparing_is_visible_only_before_the_first_output() { - assert!(show_agent_preparing(true, false, false)); - assert!(!show_agent_preparing(false, false, false)); - assert!(!show_agent_preparing(true, true, false)); - assert!(!show_agent_preparing(true, false, true)); + fn sdtest_1902_inspector_projects_latest_files_and_diff_focus_from_trace() { + let context = AgentExecutionContext { + provider: AgentProvider::Claude, + target: AgentTarget::Local, + access: AgentAccessMode::ReadOnly, + workdir: "/srv/project".to_string(), + model: None, + }; + let mut session = AgentSession::new("inspect", context, 1).expect("valid session"); + let read_id = uuid::Uuid::new_v4(); + let diff_id = uuid::Uuid::new_v4(); + session.trace = vec![ + AgentTraceEvent { + id: read_id, + sequence: 1, + at_ms: 2, + correlation_id: None, + detail: AgentTraceKind::FileRead { + path: "src/lib.rs".to_string(), + status: AgentTraceStatus::Succeeded, + line_start: Some(1), + line_end: Some(40), + }, + }, + AgentTraceEvent { + id: diff_id, + sequence: 2, + at_ms: 3, + correlation_id: None, + detail: AgentTraceKind::Diff { + path: "src/lib.rs".to_string(), + status: AgentTraceStatus::Succeeded, + additions: 7, + deletions: 2, + preview: Some("+safe change".to_string()), + }, + }, + AgentTraceEvent { + id: uuid::Uuid::new_v4(), + sequence: 3, + at_ms: 4, + correlation_id: None, + detail: AgentTraceKind::FileRead { + path: "README.md".to_string(), + status: AgentTraceStatus::Succeeded, + line_start: None, + line_end: None, + }, + }, + ]; + + let files = inspector_files(&session); + assert_eq!(files.len(), 2); + assert_eq!(files[0].path, "README.md"); + let changed = files + .iter() + .find(|file| file.path == "src/lib.rs") + .expect("changed file remains in the projection"); + assert_eq!(changed.latest_trace_id, diff_id); + assert_eq!( + changed.diff.as_ref().map(|diff| diff.trace_id), + Some(diff_id) + ); + assert_eq!(changed.diff.as_ref().map(|diff| diff.additions), Some(7)); } - // SDTEST-1712 — SDUC-475 + // SDTEST-1903 #[test] - fn sdtest_1712_agent_turns_use_spacing_without_a_markdown_rule() { - let separator = conversation_turn_separator(); - assert_eq!(separator, "\n\n"); - assert!(!separator.contains('-')); + fn sdtest_1903_target_switch_defaults_and_validation_are_target_aware() { + let host = uuid::Uuid::new_v4(); + assert_eq!( + target_switch_workdir(None, Some(host), "/home/me/project", "/home/me/project"), + Some(AGENT_REMOTE_HOME_WORKDIR.to_string()) + ); + assert_eq!( + target_switch_workdir(None, Some(host), "/srv/explicit", "/home/me/project"), + None, + "an explicit remote/worktree path is retained" + ); + assert_eq!( + target_switch_workdir( + Some(host), + None, + AGENT_REMOTE_HOME_WORKDIR, + "/home/me/project" + ), + Some("/home/me/project".to_string()) + ); + assert!(workdir_is_valid_for_target(AGENT_REMOTE_HOME_WORKDIR, true)); + assert!(workdir_is_valid_for_target("/srv/project", true)); + assert!(!workdir_is_valid_for_target("relative/project", true)); + assert!(!workdir_is_valid_for_target( + AGENT_REMOTE_HOME_WORKDIR, + false + )); + assert!(workdir_is_valid_for_target("/home/me/project", false)); + assert_eq!( + remote_parent_path("/home/me/project/"), + Some("/home/me".to_string()) + ); + assert_eq!(remote_parent_path("/home"), Some("/".to_string())); + assert_eq!(remote_parent_path("/"), None); } } diff --git a/crates/shelldeck-ui/src/fleet_view.rs b/crates/shelldeck-ui/src/fleet_view.rs index a7716813..05a49aeb 100644 --- a/crates/shelldeck-ui/src/fleet_view.rs +++ b/crates/shelldeck-ui/src/fleet_view.rs @@ -22,16 +22,27 @@ use shelldeck_core::config::platform::{ SessionHistoryEvent, SessionRecord, }; use shelldeck_core::config::platform_review::{ - CommentAgentState, ConflictResolution, DiffSide, PlatformReviewActionPreview, - PlatformReviewConfirmationCoordinates, PlatformReviewLoad, PlatformReviewRenderSemantic, - PlatformReviewSemantic, PlatformReviewTarget, ReviewAction, ReviewAnchorSemantic, - ReviewProposalKind, ReviewReceiptOutcome, + ConflictResolution, PlatformReviewActionPreview, PlatformReviewCapabilitiesLoad, + PlatformReviewConfirmationCoordinates, PlatformReviewCustodyStore, PlatformReviewLoad, + PlatformReviewNote, PlatformReviewNoteStore, PlatformReviewRenderSemantic, + PlatformReviewSemantic, PlatformReviewTarget, ReviewAction, ReviewAgentDeliveryProjection, + ReviewAnchorSemantic, ReviewCustodyRecovery, ReviewDeliveryWithheld, ReviewPreviewWithheld, + ReviewProposalKind, ReviewReceiptOutcome, ReviewSafePreview, ReviewSafeText, + ReviewStagingWithheld, ReviewWorktreeFile, ReviewWorktreeLane, ReviewWorktreeLaneGroup, + ReviewWorktreeProjection, }; use crate::icons::lucide_icon; use crate::scale::px; use crate::t; use crate::theme::ShellDeckColors; +use crate::workspace::platform_attention::{ + attention_reason_label, platform_attention_state_label, PlatformAttentionPresentation, +}; + +/// Bound on the chronological activity rows drawn at once. The full count +/// stays visible in the section badge so nothing is silently dropped. +const MAX_ATTENTION_ACTIVITY_ROWS: usize = 8; #[derive(Debug, Clone)] pub enum FleetViewEvent { @@ -43,6 +54,7 @@ pub enum FleetViewEvent { Execute(PlatformActionPreview), ExecuteReview(PlatformReviewActionPreview), FollowUp(PlatformFollowUp), + OpenAttention(shelldeck_core::config::platform_attention::PlatformAttentionActivation), } impl EventEmitter for FleetView {} @@ -92,6 +104,174 @@ fn same_exact_review_snapshot( current_target == next_target && current_revision.is_some() && current_revision == next_revision } +fn review_rerun_is_visible( + review: &PlatformReviewSemantic, + target: Option<&PlatformReviewTarget>, + capabilities: Option<&shelldeck_core::config::platform_review::ReviewCapabilities>, + custody_available: bool, + check_id: &str, +) -> bool { + if !review_rerun_control_gate(capabilities.is_some(), custody_available) { + return false; + } + let Some((target, capabilities)) = target.zip(capabilities) else { + return false; + }; + let Some(check) = review.checks.iter().find(|check| check.id == check_id) else { + return false; + }; + review.check_is_rerunnable(check_id) + && capabilities.project() == &target.project + && capabilities.workspace() == &target.workspace + && capabilities.snapshot_revision() == review.revision + && capabilities.rerunnable_checks().iter().any(|capability| { + capability.check_id().as_str() == check.id + && capability.expected_check_revision() == check.freshness.observed_revision + && capability.authority().kind() == check.authority.kind + && capability.authority().id().as_str() == check.authority.id + }) +} + +/// Why a preparation was refused, so the surface can say which fence stopped +/// it instead of blaming the snapshot for a custody problem. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum ReviewPrepareRefusal { + Preview, + Custody, + Note, +} + +impl ReviewPrepareRefusal { + fn detail(self) -> String { + match self { + Self::Preview => t!("fleet.review.invalid_preview"), + Self::Custody => t!("fleet.review.custody_failed"), + Self::Note => t!("fleet.review.note_failed"), + } + .to_string() + } +} + +/// Every review mutation Fleet exposes now crosses the durable dispatch +/// fence, so an unavailable custody store disables preparation outright +/// rather than letting a comment or an approval reach the provider outside +/// the at-most-once boundary. +const fn review_preparation_gate(busy: bool, unresolved: bool, custody_available: bool) -> bool { + !busy && !unresolved && custody_available +} + +const fn review_rerun_control_gate( + authoritative_capability_available: bool, + custody_available: bool, +) -> bool { + authoritative_capability_available && custody_available +} + +/// Lanes worth painting, most blocking first. +/// +/// An empty lane is omitted rather than rendered as a zero-count header: a +/// clean index should not look like four unfinished sections. +/// +/// Takes the lane slice rather than the projection: this decision is about +/// lane contents alone, and the projection also carries the advertisement, +/// which has no bearing on which lanes are drawn. +fn visible_review_lanes(lanes: &[ReviewWorktreeLaneGroup]) -> Vec<&ReviewWorktreeLaneGroup> { + lanes + .iter() + .filter(|group| !group.files.is_empty()) + .collect() +} + +const fn review_lane_label_key(lane: ReviewWorktreeLane) -> &'static str { + match lane { + ReviewWorktreeLane::Conflicted => "fleet.review.lane_conflicted", + ReviewWorktreeLane::Staged => "fleet.review.lane_staged", + ReviewWorktreeLane::Unstaged => "fleet.review.lane_unstaged", + ReviewWorktreeLane::Untracked => "fleet.review.lane_untracked", + } +} + +/// An unresolved conflict blocks everything downstream, so it is the only lane +/// that reads as destructive. +const fn review_lane_variant(lane: ReviewWorktreeLane) -> BadgeVariant { + match lane { + ReviewWorktreeLane::Conflicted => BadgeVariant::Destructive, + _ => BadgeVariant::Secondary, + } +} + +const fn review_preview_withheld_key(reason: ReviewPreviewWithheld) -> &'static str { + match reason { + ReviewPreviewWithheld::NoContent => "fleet.review.preview_withheld_no_content", + ReviewPreviewWithheld::Binary => "fleet.review.preview_withheld_binary", + ReviewPreviewWithheld::Unsanitized => "fleet.review.preview_withheld_unsanitized", + ReviewPreviewWithheld::Oversized => "fleet.review.preview_withheld_oversized", + ReviewPreviewWithheld::OversizedRaster => "fleet.review.preview_withheld_oversized_raster", + ReviewPreviewWithheld::Incoherent => "fleet.review.preview_withheld_incoherent", + } +} + +/// Deterministic identity for one hunk anchor button. +/// +/// A partially staged file is listed in both the staged and the unstaged lane, +/// so the lane has to take part in the identity. Two GPUI elements sharing an +/// id route each other's clicks, which would silently select the wrong anchor. +fn review_anchor_element_id(lane: ReviewWorktreeLane, file_id: &str, hunk_id: &str) -> String { + format!("review-anchor-{}-{file_id}-{hunk_id}", lane.as_str()) +} + +const fn review_staging_withheld_key(reason: ReviewStagingWithheld) -> &'static str { + match reason { + ReviewStagingWithheld::NoServerCapability => { + "fleet.review.staging_withheld_no_server_capability" + } + ReviewStagingWithheld::NoStageableProposal => { + "fleet.review.staging_withheld_no_stageable_proposal" + } + ReviewStagingWithheld::NoCustodyLane => "fleet.review.staging_withheld_no_custody_lane", + } +} + +/// The label for one side of a conflict, named by what it keeps. +/// +/// A file with both sides recorded renders both, so the two labels have to be +/// distinguishable at a glance rather than differing only by a suffix. +const fn review_conflict_side_key(resolution: ConflictResolution) -> &'static str { + match resolution { + ConflictResolution::KeepCurrent => "fleet.review.resolve_keep_current", + ConflictResolution::KeepIncoming => "fleet.review.resolve_keep_incoming", + } +} + +/// The label for one advertised staging control. +/// +/// Separate from [`localized_review_proposal_kind`], which yields the phrase +/// embedded in a confirmation sentence. A button needs the bare verb, and the +/// three verbs are separately granted, so each gets its own key rather than +/// one parameterised string that could render a grant nobody installed. +const fn review_staging_button_key(kind: ReviewProposalKind) -> &'static str { + match kind { + ReviewProposalKind::Stage => "fleet.review.prepare_stage", + ReviewProposalKind::Unstage => "fleet.review.prepare_unstage", + ReviewProposalKind::Commit => "fleet.review.prepare_commit", + // Unreachable: a conflict resolution is never advertised as a staging + // capability, so it never reaches a staging button. + ReviewProposalKind::ResolveConflict => "fleet.review.action_resolve_conflict", + } +} + +const fn review_delivery_withheld_key(reason: ReviewDeliveryWithheld) -> &'static str { + match reason { + ReviewDeliveryWithheld::NoServerCapability => { + "fleet.review.delivery_withheld_no_server_capability" + } + ReviewDeliveryWithheld::NoDeliverableComment => { + "fleet.review.delivery_withheld_no_deliverable_comment" + } + ReviewDeliveryWithheld::NoCustodyLane => "fleet.review.delivery_withheld_no_custody_lane", + } +} + fn localized_review_proposal_kind(kind: ReviewProposalKind) -> String { match kind { ReviewProposalKind::Stage => t!("fleet.review.action_stage").to_string(), @@ -211,7 +391,11 @@ fn localized_external_review_coordinates( pub struct FleetView { snapshot: Option, review: Option, + review_capabilities: Option, review_target: Option, + review_custody: Option, + review_notes: Option, + review_note_rows: Vec, cockpit: PlatformCockpitState, search_state: Entity, search_query: String, @@ -232,14 +416,21 @@ pub struct FleetView { loading: bool, operation_busy: bool, error: Option, + platform_attention: Vec, } impl FleetView { pub fn new(cx: &mut Context) -> Self { + let review_custody = PlatformReviewCustodyStore::open_default().ok(); + let review_notes = PlatformReviewNoteStore::open_default().ok(); Self { snapshot: None, review: None, + review_capabilities: None, review_target: None, + review_custody, + review_notes, + review_note_rows: Vec::new(), cockpit: PlatformCockpitState::default(), search_state: cx.new(InputState::new), search_query: String::new(), @@ -260,6 +451,7 @@ impl FleetView { loading: false, operation_busy: false, error: None, + platform_attention: Vec::new(), } } @@ -282,6 +474,7 @@ impl FleetView { &mut self, target: Option, review: Option, + capabilities: Option, ) { let prior_revision = match &self.review { Some(PlatformReviewLoad::Available(review)) => Some(review.revision.get()), @@ -314,21 +507,110 @@ impl FleetView { if !selected_is_current { self.selected_review_anchor = None; } - match &review { - Some(PlatformReviewLoad::Available(review)) => { + // The selection is retained against the *advertisement*, never against + // the snapshot alone. A settled delivery moves its comments out of the + // sendable states and bumps the revision, so the freshly re-read + // capability no longer lists them and the selection empties itself — + // which is what stops the previous list from being reused. + match (&review, &target) { + (Some(PlatformReviewLoad::Available(review)), Some(target)) => { + let delivery = ReviewAgentDeliveryProjection::new( + review, + target, + capabilities + .as_ref() + .and_then(PlatformReviewCapabilitiesLoad::available), + self.review_custody.is_some(), + ); self.selected_review_comments - .retain(|id| review.comment_is_batch_actionable(id)); + .retain(|id| delivery.advertises(id)); } _ => self.selected_review_comments.clear(), } - if self.pending_review_preview.as_ref().is_some_and(|preview| { + let preview_is_stale = self.pending_review_preview.as_ref().is_some_and(|preview| { target.as_ref() != Some(preview.target()) || !matches!(&review, Some(PlatformReviewLoad::Available(value)) if value.revision == preview.expected_revision()) - }) { - self.pending_review_preview = None; + || (preview.requires_capability_revalidation() + && !capabilities + .as_ref() + .and_then(PlatformReviewCapabilitiesLoad::available) + .is_some_and(|value| preview.matches_capabilities(value))) + }); + if preview_is_stale { + self.cancel_pending_review_preview(); } self.review_target = target; self.review = review; + self.review_capabilities = capabilities; + self.restore_review_custody(); + self.refresh_review_notes(); + } + + /// Reload the durable notes for the exact active target. A read failure + /// shows no notes rather than a partial or foreign list. + fn refresh_review_notes(&mut self) { + self.review_note_rows = match (&self.review_notes, &self.review_target) { + (Some(store), Some(target)) => store.notes(target).unwrap_or_default(), + _ => Vec::new(), + }; + } + + fn available_review_capabilities( + &self, + ) -> Result<&shelldeck_core::config::platform_review::ReviewCapabilities, &'static str> { + self.review_capabilities + .as_ref() + .and_then(PlatformReviewCapabilitiesLoad::available) + .ok_or("review capabilities are unavailable") + } + + fn restore_review_custody(&mut self) { + let (Some(store), Some(target)) = (&self.review_custody, &self.review_target) else { + return; + }; + match store.recovery(target) { + Ok(Some(ReviewCustodyRecovery::NeverStarted(preview))) => { + // A preview never crossed the durable dispatch fence. Remove + // it on restart; it must never trigger a provider lookup. + if store.cancel_prepared(&preview).is_ok() { + self.review_receipt = Some(( + t!("fleet.review.state_refused").to_string(), + t!("fleet.review.never_dispatched").to_string(), + )); + } + } + Ok(Some(ReviewCustodyRecovery::LookupOnly(preview))) => { + if !self + .unresolved_review_actions + .iter() + .any(|candidate| candidate.idempotency_key() == preview.idempotency_key()) + { + self.unresolved_review_actions.push(preview); + } + } + Ok(Some(ReviewCustodyRecovery::Terminal(presentation))) => { + self.review_receipt = Some(( + presentation.outcome, + presentation + .actor + .map_or(presentation.detail.clone(), |actor| { + t!( + "fleet.review.receipt_actor", + detail = presentation.detail, + actor = actor + ) + .to_string() + }), + )); + } + Ok(None) => {} + Err(error) => { + self.review_receipt = Some(( + t!("fleet.review.state_refused").to_string(), + error.to_string(), + )); + } + } } pub fn pending_review_reconciliation(&self) -> Option { @@ -546,41 +828,105 @@ impl FleetView { } } - fn prepare_review_comment(&mut self) -> Result<(), &'static str> { + /// Reserve the inert preview in durable custody before it may be + /// confirmed. Comments, approvals and reruns share this one fence: none of + /// them reaches the provider without a record that survives restart. + fn admit_review_preview( + &mut self, + preview: PlatformReviewActionPreview, + ) -> Result<(), ReviewPrepareRefusal> { + self.review_custody + .as_ref() + .ok_or(ReviewPrepareRefusal::Custody)? + .prepare(&preview) + .map_err(|_| ReviewPrepareRefusal::Custody)?; + self.pending_review_preview = Some(preview); + Ok(()) + } + + fn prepare_review_comment(&mut self) -> Result<(), ReviewPrepareRefusal> { let target = self .review_target .clone() - .ok_or("exact review target is unavailable")?; + .ok_or(ReviewPrepareRefusal::Preview)?; let anchor = self .selected_review_anchor .clone() - .ok_or("exact review anchor is unavailable")?; + .ok_or(ReviewPrepareRefusal::Preview)?; let body = self.review_comment_value.clone(); let preview = PlatformReviewActionPreview::add_comment( target, - self.available_review()?, + self.available_review() + .map_err(|_| ReviewPrepareRefusal::Preview)?, &anchor, &body, - )?; - self.pending_review_preview = Some(preview); - Ok(()) + ) + .map_err(|_| ReviewPrepareRefusal::Preview)?; + self.admit_review_preview(preview) } - fn prepare_review_approval(&mut self) -> Result<(), &'static str> { + /// Promote one persisted note into the typed comment mutation. The note is + /// never re-anchored: a snapshot the note was not written against refuses. + fn prepare_review_note_comment(&mut self, id: &str) -> Result<(), ReviewPrepareRefusal> { let target = self .review_target .clone() - .ok_or("exact review target is unavailable")?; - let preview = PlatformReviewActionPreview::approve(target, self.available_review()?)?; - self.pending_review_preview = Some(preview); - Ok(()) + .ok_or(ReviewPrepareRefusal::Preview)?; + let review = self + .available_review() + .map_err(|_| ReviewPrepareRefusal::Preview)?; + let note = self + .review_note_rows + .iter() + .find(|note| note.id == id) + .filter(|note| note.is_actionable(review)) + .ok_or(ReviewPrepareRefusal::Note)? + .clone(); + let preview = + PlatformReviewActionPreview::add_comment(target, review, ¬e.anchor, ¬e.body) + .map_err(|_| ReviewPrepareRefusal::Preview)?; + self.admit_review_preview(preview) + } + + /// Toggle one advertised comment in the batch-delivery selection. + /// + /// A comment the current advertisement does not list can never enter the + /// selection, so a control that raced a capability refresh selects nothing + /// rather than staging an unsendable id. + fn set_review_comment_selected(&mut self, id: &str, selected: bool) { + if !selected { + self.selected_review_comments.remove(id); + return; + } + if self + .review_delivery() + .is_some_and(|delivery| delivery.advertises(id)) + { + self.selected_review_comments.insert(id.to_owned()); + } + } + + /// The advertised delivery projection for the exact active snapshot. + fn review_delivery(&self) -> Option { + let review = self.available_review().ok()?; + let target = self.review_target.as_ref()?; + Some(ReviewAgentDeliveryProjection::new( + review, + target, + self.available_review_capabilities().ok(), + self.review_custody.is_some(), + )) } - fn prepare_review_comment_batch(&mut self) -> Result<(), &'static str> { + /// Prepare the unconfirmed batch delivery of the selected comments. + /// + /// The preview takes every coordinate from the capability, so this refuses + /// locally whenever the advertisement no longer covers the selection. + fn prepare_review_batch_send(&mut self) -> Result<(), ReviewPrepareRefusal> { let target = self .review_target .clone() - .ok_or("exact review target is unavailable")?; + .ok_or(ReviewPrepareRefusal::Preview)?; let selected = self .selected_review_comments .iter() @@ -588,49 +934,135 @@ impl FleetView { .collect::>(); let preview = PlatformReviewActionPreview::batch_send_comments( target, - self.available_review()?, + self.available_review() + .map_err(|_| ReviewPrepareRefusal::Preview)?, + self.available_review_capabilities() + .map_err(|_| ReviewPrepareRefusal::Preview)?, &selected, - )?; - self.pending_review_preview = Some(preview); - Ok(()) + ) + .map_err(|_| ReviewPrepareRefusal::Preview)?; + self.admit_review_preview(preview) } - fn prepare_review_proposal(&mut self, proposal_id: &str) -> Result<(), &'static str> { + /// Prepare one confirmed index-level staging transition. + /// + /// The preview takes the kind and both digests from the capability, so + /// this refuses locally whenever the advertisement no longer covers the + /// proposal — including when it covers it at a `HEAD` or an index that has + /// since moved, because the server minted the digest over both. + fn prepare_review_staging(&mut self, proposal_id: &str) -> Result<(), ReviewPrepareRefusal> { let target = self .review_target .clone() - .ok_or("exact review target is unavailable")?; - let preview = PlatformReviewActionPreview::apply_proposal( + .ok_or(ReviewPrepareRefusal::Preview)?; + let preview = PlatformReviewActionPreview::stage_proposal( target, - self.available_review()?, + self.available_review() + .map_err(|_| ReviewPrepareRefusal::Preview)?, + self.available_review_capabilities() + .map_err(|_| ReviewPrepareRefusal::Preview)?, proposal_id, - )?; - self.pending_review_preview = Some(preview); - Ok(()) + ) + .map_err(|_| ReviewPrepareRefusal::Preview)?; + self.admit_review_preview(preview) } - fn prepare_review_check_rerun(&mut self, check_id: &str) -> Result<(), &'static str> { + /// Prepare one confirmed collapse of a conflicted file to a recorded side. + /// + /// `resolution` names which of the two blobs git is already holding would + /// land. No content is supplied here or anywhere below it. + fn prepare_review_conflict_resolution( + &mut self, + proposal_id: &str, + file_id: &str, + resolution: ConflictResolution, + ) -> Result<(), ReviewPrepareRefusal> { let target = self .review_target .clone() - .ok_or("exact review target is unavailable")?; - let preview = - PlatformReviewActionPreview::rerun_check(target, self.available_review()?, check_id)?; - self.pending_review_preview = Some(preview); - Ok(()) + .ok_or(ReviewPrepareRefusal::Preview)?; + let preview = PlatformReviewActionPreview::resolve_conflict( + target, + self.available_review() + .map_err(|_| ReviewPrepareRefusal::Preview)?, + self.available_review_capabilities() + .map_err(|_| ReviewPrepareRefusal::Preview)?, + proposal_id, + file_id, + resolution, + ) + .map_err(|_| ReviewPrepareRefusal::Preview)?; + self.admit_review_preview(preview) } - fn prepare_review_merge(&mut self) -> Result<(), &'static str> { + fn prepare_review_approval(&mut self) -> Result<(), ReviewPrepareRefusal> { let target = self .review_target .clone() - .ok_or("exact review target is unavailable")?; - let preview = - PlatformReviewActionPreview::merge_pull_request(target, self.available_review()?)?; - self.pending_review_preview = Some(preview); + .ok_or(ReviewPrepareRefusal::Preview)?; + let preview = PlatformReviewActionPreview::approve( + target, + self.available_review() + .map_err(|_| ReviewPrepareRefusal::Preview)?, + ) + .map_err(|_| ReviewPrepareRefusal::Preview)?; + self.admit_review_preview(preview) + } + + fn prepare_review_check_rerun(&mut self, check_id: &str) -> Result<(), ReviewPrepareRefusal> { + let target = self + .review_target + .clone() + .ok_or(ReviewPrepareRefusal::Preview)?; + let preview = PlatformReviewActionPreview::rerun_check( + target, + self.available_review() + .map_err(|_| ReviewPrepareRefusal::Preview)?, + self.available_review_capabilities() + .map_err(|_| ReviewPrepareRefusal::Preview)?, + check_id, + ) + .map_err(|_| ReviewPrepareRefusal::Preview)?; + self.admit_review_preview(preview) + } + + /// Persist one line-anchored note against the exact active snapshot. + fn save_review_note(&mut self) -> Result<(), ReviewPrepareRefusal> { + let target = self + .review_target + .clone() + .ok_or(ReviewPrepareRefusal::Preview)?; + let anchor = self + .selected_review_anchor + .clone() + .ok_or(ReviewPrepareRefusal::Preview)?; + let body = self.review_comment_value.clone(); + let review = self + .available_review() + .map_err(|_| ReviewPrepareRefusal::Preview)?; + self.review_notes + .as_ref() + .ok_or(ReviewPrepareRefusal::Note)? + .add(&target, review, &anchor, &body) + .map_err(|_| ReviewPrepareRefusal::Note)?; + self.refresh_review_notes(); Ok(()) } + fn set_review_note_selected(&mut self, id: &str, selected: bool) { + if let (Some(store), Some(target)) = (&self.review_notes, &self.review_target) { + let _ = store.set_selected(target, id, selected); + } + self.refresh_review_notes(); + } + + fn remove_review_note(&mut self, id: &str) { + if let (Some(store), Some(target)) = (&self.review_notes, &self.review_target) { + let _ = store.remove(target, id); + } + self.refresh_review_notes(); + } + fn dispatch_review_preview(&mut self) -> Option { let has_unresolved = self.current_unresolved_review_action().is_some(); if self.operation_busy @@ -639,6 +1071,37 @@ impl FleetView { { return None; } + let preview = self.pending_review_preview.as_ref()?; + if preview.requires_capability_revalidation() { + let stale_detail = if preview.confirmation().is_some() { + "fleet.review.capability_stale" + } else { + "fleet.review.delivery_capability_stale" + }; + let capabilities = self.available_review_capabilities().ok()?; + if !preview.matches_capabilities(capabilities) { + self.review_receipt = Some(( + t!("fleet.review.state_refused").to_string(), + t!(stale_detail).to_string(), + )); + return None; + } + } + // The durable dispatch fence is crossed for every action family, not + // only the confirmed ones: a comment or an approval that is posted + // once must also be recoverable as posted once after a restart. + if self + .review_custody + .as_ref()? + .mark_dispatched(preview) + .is_err() + { + self.review_receipt = Some(( + t!("fleet.review.state_refused").to_string(), + t!("fleet.review.custody_failed").to_string(), + )); + return None; + } let preview = self.pending_review_preview.take()?; self.operation_busy = true; self.unresolved_review_actions.push(preview.clone()); @@ -649,6 +1112,17 @@ impl FleetView { Some(preview) } + fn cancel_pending_review_preview(&mut self) { + let Some(preview) = self.pending_review_preview.take() else { + return; + }; + if let Some(store) = &self.review_custody { + // Only a record that provably never crossed the fence is removed; + // custody itself refuses anything already dispatched. + let _ = store.cancel_prepared(&preview); + } + } + pub fn set_review_action_result( &mut self, result: PlatformReviewActionResult, @@ -656,14 +1130,55 @@ impl FleetView { ) { let keep_lookup = result.requires_lookup(); let preview = result.preview().clone(); + let persisted = match &result { + PlatformReviewActionResult::Receipt { receipt, .. } => self + .review_custody + .as_ref() + .ok_or("review custody is unavailable") + .and_then(|store| { + store + .record_receipt(&preview, receipt) + .map_err(|_| "review receipt custody failed") + }), + PlatformReviewActionResult::Refused { + category, + explanation, + .. + } => self + .review_custody + .as_ref() + .ok_or("review custody is unavailable") + .and_then(|store| { + store + .record_refusal(&preview, category, explanation) + .map_err(|_| "review refusal custody failed") + }), + PlatformReviewActionResult::ReconciliationPending { .. } => Ok(()), + }; + if let Err(category) = persisted { + self.review_receipt = Some(( + t!("fleet.review.state_ambiguous").to_string(), + category.to_owned(), + )); + self.unresolved_review_actions + .retain(|candidate| candidate.idempotency_key() != preview.idempotency_key()); + self.unresolved_review_actions.push(preview); + self.operation_busy = false; + self.loading = false; + return; + } match result { PlatformReviewActionResult::Receipt { receipt, .. } => { self.review_receipt = Some(( receipt.outcome().as_str().to_owned(), format!( - "{} · {}", + "{} · {} · {}", receipt.action_id().as_str(), - receipt.reconciliation().as_str() + receipt.reconciliation().as_str(), + t!( + "fleet.review.receipt_actor_short", + actor = receipt.actor().as_str() + ) ), )); if matches!(receipt.outcome(), ReviewReceiptOutcome::Completed) @@ -704,7 +1219,7 @@ impl FleetView { } pub fn reset_review_action_for_context_change(&mut self) { - self.pending_review_preview = None; + self.cancel_pending_review_preview(); self.review_receipt = None; self.operation_busy = false; } @@ -722,6 +1237,7 @@ impl FleetView { pub fn reset(&mut self) { self.snapshot = None; self.review = None; + self.review_capabilities = None; self.review_target = None; self.cockpit = PlatformCockpitState::default(); self.search_query.clear(); @@ -732,13 +1248,15 @@ impl FleetView { self.review_comment_value.clear(); self.selected_review_anchor = None; self.selected_review_comments.clear(); - self.pending_review_preview = None; + self.review_note_rows.clear(); + self.cancel_pending_review_preview(); self.unresolved_review_actions.clear(); self.review_receipt = None; self.refusal = None; self.loading = false; self.operation_busy = false; self.error = None; + self.platform_attention.clear(); } pub fn open_session_by_id(&mut self, session_id: &str) -> bool { @@ -762,6 +1280,156 @@ impl FleetView { exists } + pub fn open_session_exact(&mut self, coordinate: &ResourceCoordinate) -> bool { + let matches = self + .snapshot + .as_ref() + .map(|snapshot| { + snapshot + .sessions + .iter() + .filter(|session| &session.session.resource == coordinate) + .collect::>() + }) + .unwrap_or_default(); + let [session] = matches.as_slice() else { + return false; + }; + self.selected_session = Some(coordinate.id.as_str().to_owned()); + self.cockpit.select(&session.session.resource); + true + } + + pub(crate) fn session_is_open_exact(&self, coordinate: &ResourceCoordinate) -> bool { + self.selected_session.as_deref() == Some(coordinate.id.as_str()) + && self.snapshot.as_ref().is_some_and(|snapshot| { + snapshot + .sessions + .iter() + .filter(|session| &session.session.resource == coordinate) + .count() + == 1 + }) + } + + pub(crate) fn set_platform_attention(&mut self, rows: Vec) { + self.platform_attention = rows; + } + + /// Chronological attention activity, newest first. + /// + /// The rows arrive already ordered on the authoritative observation + /// instant; this surface only bounds how many of them it draws. Each row + /// carries the exact activation token, so opening it re-resolves the + /// workspace, session and pane against the live catalogue instead of + /// following a destination captured when the row was built. + fn render_platform_attention(&self, cx: &mut Context) -> AnyElement { + if self.platform_attention.is_empty() { + return div().into_any_element(); + } + let entity = cx.entity(); + let mut column = div() + .flex() + .flex_col() + .gap(px(4.0)) + .px(px(12.0)) + .py(px(7.0)) + .border_b_1() + .border_color(ShellDeckColors::border()) + .child( + div() + .flex() + .items_center() + .gap(px(6.0)) + .child( + Badge::new(t!("platform_attention.activity_title").to_string()) + .variant(BadgeVariant::Outline), + ) + .child( + Badge::new(self.platform_attention.len().to_string()) + .variant(BadgeVariant::Secondary), + ), + ); + for item in self + .platform_attention + .iter() + .take(MAX_ATTENTION_ACTIVITY_ROWS) + { + let activation = item.activation; + let entity = entity.clone(); + let mut row = div().flex().items_center().gap(px(6.0)).min_w(px(0.0)); + row = row + .child( + div() + .flex_shrink_0() + .w(px(76.0)) + .truncate() + .text_size(px(10.0)) + .text_color(ShellDeckColors::text_muted()) + .child(crate::i18n::rel_time(item.observed_at_ms as f64)), + ) + .child( + div().flex_shrink_0().child( + Badge::new(platform_attention_state_label(item.state)) + .variant(BadgeVariant::Outline), + ), + ) + .child( + Button::new( + SharedString::from(format!( + "fleet-attention-{}-{}", + activation.item.uuid(), + activation.item_revision.get() + )), + attention_reason_label(item.reason), + ) + .size(ButtonSize::Sm) + .variant(if item.unread { + ButtonVariant::Default + } else { + ButtonVariant::Ghost + }) + .on_click(move |_, _, cx| { + entity.update(cx, |_this, cx| { + cx.emit(FleetViewEvent::OpenAttention(activation)); + }); + }), + ); + if !item.workspace_label.is_empty() { + row = row.child( + div() + .flex_1() + .min_w(px(0.0)) + .truncate() + .text_size(px(10.0)) + .text_color(ShellDeckColors::text_muted()) + .child( + t!( + "platform_attention.activity_workspace", + workspace = item.workspace_label.clone() + ) + .to_string(), + ), + ); + } + column = column.child(row); + } + if let Some(hidden) = self + .platform_attention + .len() + .checked_sub(MAX_ATTENTION_ACTIVITY_ROWS) + .filter(|hidden| *hidden > 0) + { + column = column.child( + div() + .text_size(px(10.0)) + .text_color(ShellDeckColors::text_muted()) + .child(t!("platform_attention.activity_more", count = hidden).to_string()), + ); + } + column.into_any_element() + } + fn render_header(&self, compact: bool, cx: &mut Context) -> AnyElement { let entity = cx.entity(); let (resources, sessions, models, receipts, methods) = @@ -1125,160 +1793,145 @@ impl FleetView { ) -> AnyElement { let entity = cx.entity(); let unresolved = self.current_unresolved_review_action().is_some(); - let can_prepare = !self.operation_busy && !unresolved; + let can_anchor = !self.operation_busy && !unresolved; + let can_prepare = review_preparation_gate( + self.operation_busy, + unresolved, + self.review_custody.is_some(), + ); + let can_note = can_anchor && self.review_notes.is_some(); let selected_anchor = self.selected_review_anchor.as_ref(); - let mut files = div().flex().flex_col().gap(px(6.0)); - for (file_index, file) in review.files.iter().enumerate() { - let mut hunks = div().flex().flex_col().gap(px(4.0)); - for (hunk_index, hunk) in file.hunks.iter().enumerate() { - let (side, line) = if hunk.new_lines > 0 { - (DiffSide::New, hunk.new_start) - } else { - (DiffSide::Old, hunk.old_start) - }; - let anchor = ReviewAnchorSemantic { - file_id: file.id.clone(), - hunk_id: hunk.id.clone(), - side, - line, - }; - let selected = selected_anchor == Some(&anchor); - let select_entity = entity.clone(); - let anchor_for_click = anchor.clone(); - hunks = hunks.child( - div() - .flex() - .items_start() - .gap(px(8.0)) - .min_w(px(0.0)) - .child( - Button::new( - ("review-anchor", file_index * 1024 + hunk_index), - t!("fleet.review.anchor", side = side.as_str(), line = line) - .to_string(), - ) - .size(ButtonSize::Sm) - .variant(if selected { - ButtonVariant::Default - } else { - ButtonVariant::Outline - }) - .disabled(!can_prepare) - .on_click(move |_, _, cx| { - select_entity.update(cx, |this, cx| { - this.selected_review_anchor = Some(anchor_for_click.clone()); - this.pending_review_preview = None; - cx.notify(); - }); - }), - ) - .child( - div() - .flex_1() - .min_w(px(0.0)) - .overflow_hidden() - .text_size(px(11.0)) - .text_color(ShellDeckColors::text_muted()) - .line_clamp(3) - .child(hunk.preview.clone()), - ), - ); + // The lanes render either way; without an exactly reconciled target no + // capability response can be attributed to this read, so every control + // is withheld for want of a server capability. + let projection = ReviewWorktreeProjection::new( + review, + self.review_target.as_ref(), + self.available_review_capabilities().ok(), + self.review_custody.is_some(), + ); + let mut files = div().flex().flex_col().gap(px(10.0)); + for group in visible_review_lanes(&projection.lanes) { + let mut lane_files = div().flex().flex_col().gap(px(6.0)); + for file in &group.files { + lane_files = lane_files.child(Self::render_review_file( + group.lane, + file, + &projection, + selected_anchor, + can_anchor, + can_prepare, + &entity, + )); } files = files.child( div() .min_w(px(0.0)) - .overflow_hidden() - .p(px(8.0)) - .border_1() - .border_color(ShellDeckColors::border()) - .rounded(px(6.0)) .child( div() .flex() .items_center() + .justify_between() .gap(px(6.0)) .mb(px(6.0)) .child( - div() - .flex_1() - .min_w(px(0.0)) - .truncate() - .text_size(px(12.0)) - .font_weight(FontWeight::SEMIBOLD) - .child(file.path.clone()), + Badge::new(t!(review_lane_label_key(group.lane)).to_string()) + .variant(review_lane_variant(group.lane)), ) .child( - Badge::new( - t!( - "fleet.review.file_state", - change = file.change.as_str(), - worktree = file.worktree.as_str(), - conflict = file.conflict.as_str() - ) - .to_string(), - ) - .variant(BadgeVariant::Outline), - ) - .child( - Badge::new( - t!( - "fleet.review.preview_metadata", - kind = file.preview.kind.as_str(), - bytes = file.preview.byte_size.map_or_else( - || "—".to_owned(), - |value| value.to_string() - ), - sanitized = if file.preview.sanitized { - t!("fleet.review.sanitized_yes").to_string() - } else { - t!("fleet.review.sanitized_no").to_string() - } - ) - .to_string(), - ) - .variant(BadgeVariant::Secondary), + Badge::new(group.files.len().to_string()) + .variant(BadgeVariant::Outline), ), ) - .child(hunks), + .child(lane_files), ); } - - let mut comments = div().flex().flex_col().gap(px(4.0)); - for comment in &review.comments { - let selectable = matches!( - comment.agent_state, - CommentAgentState::NotSent | CommentAgentState::Refused - ) && review.comment_is_batch_actionable(&comment.id); - let selected = self.selected_review_comments.contains(&comment.id); - let select_entity = entity.clone(); - let comment_id = comment.id.clone(); - comments = comments.child( + // The lane-level answer, shown only when the snapshot holds something + // a staging control could have applied to. A review with no actionable + // proposal and no unresolved conflict has no gap to explain, and a + // permanent "nothing to stage" banner would be noise rather than an + // answer — the same rule the delivery sibling follows. + if let Err(reason) = projection.staging { + let has_local_candidate = review.proposals.iter().any(|proposal| { + review.proposal_is_actionable(&proposal.id) + || proposal.files.iter().any(|file_id| { + review.conflict_resolution_is_actionable(&proposal.id, file_id) + }) + }); + if has_local_candidate { + files = files.child( + div().min_w(px(0.0)).overflow_hidden().child( + Alert::info() + .title(t!("fleet.review.staging_withheld_title").to_string()) + .description(t!(review_staging_withheld_key(reason)).to_string()), + ), + ); + } + } else if let Some(observation) = projection.observation() { + // What the server read, so a reader can see the fence rather than + // trust that one exists. The controls below are bound to exactly + // this `HEAD` and this index. + files = files.child( div() - .flex() - .items_center() - .gap(px(5.0)) .min_w(px(0.0)) .overflow_hidden() - .text_size(px(11.0)) + .text_size(px(10.0)) .text_color(ShellDeckColors::text_muted()) .child( - div().flex_1().min_w(px(0.0)).child( - t!( - "fleet.review.comment", - actor = comment.actor.as_str(), - file = comment.anchor.file_id.as_str(), - side = comment.anchor.side.as_str(), - line = comment.anchor.line, - revision = comment.revision.get(), - body = comment.body.as_str() - ) - .to_string(), - ), - ) - .child( + t!( + "fleet.review.staging_observation", + head = observation.head_revision.as_str(), + index = observation.index_digest.as_str() + ) + .to_string(), + ), + ); + } + + // Batch delivery to the authorized session. The send control exists + // only for the comments the server advertised in + // `agent_deliverable_comments` for this exact snapshot: the + // advertisement is the whole fence for this lane, because it carries + // no confirmation digest by design. + let delivery = self + .review_delivery() + .unwrap_or_else(ReviewAgentDeliveryProjection::unavailable); + let mut comments = div().flex().flex_col().gap(px(4.0)); + for comment in &review.comments { + let deliverable = delivery.advertises(&comment.id); + let selected = self.selected_review_comments.contains(&comment.id); + let mut row = div() + .flex() + .items_center() + .gap(px(5.0)) + .min_w(px(0.0)) + .overflow_hidden() + .text_size(px(11.0)) + .text_color(ShellDeckColors::text_muted()) + .child( + div().flex_1().min_w(px(0.0)).child( + t!( + "fleet.review.comment", + actor = comment.actor.as_str(), + file = comment.anchor.file_id.as_str(), + side = comment.anchor.side.as_str(), + line = comment.anchor.line, + revision = comment.revision.get(), + body = comment.body.as_str() + ) + .to_string(), + ), + ); + if deliverable { + let select_entity = entity.clone(); + let select_id = comment.id.clone(); + // Same widget, same variants and same copy as the note + // selection toggle: one selection affordance, two lists. + row = row.child( + div().flex_shrink_0().child( Button::new( ElementId::from(SharedString::from(format!( - "select-review-comment-{}", + "review-comment-select-{}", comment.id ))), if selected { @@ -1289,22 +1942,40 @@ impl FleetView { ) .size(ButtonSize::Sm) .variant(if selected { - ButtonVariant::Secondary + ButtonVariant::Default } else { - ButtonVariant::Ghost + ButtonVariant::Outline }) - .disabled(!can_prepare || !selectable) + .disabled(!can_anchor) .on_click(move |_, _, cx| { select_entity.update(cx, |this, cx| { - if !this.selected_review_comments.remove(&comment_id) { - this.selected_review_comments.insert(comment_id.clone()); - } - this.pending_review_preview = None; + this.set_review_comment_selected(&select_id, !selected); cx.notify(); }); }), ), - ); + ); + } + comments = comments.child(row); + } + // Unlike the staging sibling, this reason is shown only when a comment + // in the snapshot looks locally sendable. A review with nothing in + // `not_sent`/`refused` has no gap to explain, and a permanent + // "nothing to deliver" banner would be noise rather than an answer. + if let Err(reason) = delivery.control { + if review + .comments + .iter() + .any(|comment| review.comment_is_batch_actionable(&comment.id)) + { + comments = comments.child( + div().min_w(px(0.0)).overflow_hidden().child( + Alert::info() + .title(t!("fleet.review.delivery_withheld_title").to_string()) + .description(t!(review_delivery_withheld_key(reason)).to_string()), + ), + ); + } } let mut attention = div().flex().flex_col().gap(px(4.0)); @@ -1328,12 +1999,149 @@ impl FleetView { ); } + // Durable line-anchored notes. They are local drafts: nothing here + // observes the server and nothing here dispatches. A note keeps the + // snapshot revision it was written against, so a newer snapshot makes + // it explicitly stale instead of quietly moving it to another line. + let mut notes = div().flex().flex_col().gap(px(5.0)); + let mut selected_notes = 0_usize; + for note in &self.review_note_rows { + let actionable = note.is_actionable(review); + if note.selected && actionable { + selected_notes += 1; + } + let select_entity = entity.clone(); + let prepare_entity = entity.clone(); + let delete_entity = entity.clone(); + let select_id = note.id.clone(); + let prepare_id = note.id.clone(); + let delete_id = note.id.clone(); + let next_selected = !note.selected; + let mut actions = div().flex().items_center().flex_wrap().gap(px(5.0)).child( + Button::new( + ElementId::from(SharedString::from(format!( + "review-note-select-{}", + note.id + ))), + if note.selected { + t!("fleet.review.comment_selected").to_string() + } else { + t!("fleet.review.comment_select").to_string() + }, + ) + .size(ButtonSize::Sm) + .variant(if note.selected { + ButtonVariant::Default + } else { + ButtonVariant::Outline + }) + .disabled(!can_anchor) + .on_click(move |_, _, cx| { + select_entity.update(cx, |this, cx| { + this.set_review_note_selected(&select_id, next_selected); + cx.notify(); + }); + }), + ); + if actionable { + actions = actions.child( + Button::new( + ElementId::from(SharedString::from(format!( + "review-note-prepare-{}", + note.id + ))), + t!("fleet.review.note_prepare").to_string(), + ) + .size(ButtonSize::Sm) + .variant(ButtonVariant::Outline) + .disabled(!can_prepare) + .on_click(move |_, _, cx| { + prepare_entity.update(cx, |this, cx| { + if let Err(refusal) = this.prepare_review_note_comment(&prepare_id) { + this.review_receipt = Some(( + t!("fleet.review.state_refused").to_string(), + refusal.detail(), + )); + } + cx.notify(); + }); + }), + ); + } else { + actions = actions.child( + Badge::new(t!("fleet.review.note_stale").to_string()) + .variant(BadgeVariant::Secondary), + ); + } + actions = actions.child( + Button::new( + ElementId::from(SharedString::from(format!( + "review-note-delete-{}", + note.id + ))), + t!("fleet.review.note_delete").to_string(), + ) + .size(ButtonSize::Sm) + .variant(ButtonVariant::Ghost) + .disabled(!can_anchor) + .on_click(move |_, _, cx| { + delete_entity.update(cx, |this, cx| { + this.remove_review_note(&delete_id); + cx.notify(); + }); + }), + ); + notes = notes.child( + div() + .min_w(px(0.0)) + .flex() + .flex_col() + .gap(px(4.0)) + .p(px(6.0)) + .border_1() + .border_color(ShellDeckColors::border()) + .rounded(px(6.0)) + .child( + div() + .min_w(px(0.0)) + .text_size(px(11.0)) + .text_color(ShellDeckColors::text_muted()) + .child( + t!( + "fleet.review.note", + file = note.anchor.file_id.as_str(), + side = note.anchor.side.as_str(), + line = note.anchor.line, + revision = note.captured_revision.get(), + body = note.body.as_str() + ) + .to_string(), + ), + ) + .child(actions), + ); + } + if selected_notes > 0 { + // A note is a local draft and is not on the server, so it cannot + // be delivered directly: batch delivery names server comment ids, + // which a note only acquires once it has been prepared and posted. + // The durable selection says which drafts the user means to send. + notes = notes.child( + div() + .text_size(px(10.0)) + .text_color(ShellDeckColors::text_muted()) + .child(t!("fleet.review.notes_selected", count = selected_notes).to_string()), + ); + } + let comment_entity = entity.clone(); let approve_entity = entity.clone(); - let batch_entity = entity.clone(); + let note_entity = entity.clone(); let can_comment = can_prepare && selected_anchor.is_some() && !self.review_comment_value.trim().is_empty(); + let can_save_note = + can_note && selected_anchor.is_some() && !self.review_comment_value.trim().is_empty(); let selected_label = selected_anchor.map_or_else( || t!("fleet.review.anchor_none").to_string(), |anchor| { @@ -1347,59 +2155,28 @@ impl FleetView { .to_string() }, ); - let mut effect_controls = div().flex().flex_wrap().gap(px(6.0)).child( - Button::new( - "prepare-review-comment-batch", - t!( - "fleet.review.prepare_comment_batch", - count = self.selected_review_comments.len() - ) - .to_string(), - ) - .size(ButtonSize::Sm) - .variant(ButtonVariant::Outline) - .disabled(!can_prepare || self.selected_review_comments.is_empty()) - .on_click(move |_, _, cx| { - batch_entity.update(cx, |this, cx| { - if this.prepare_review_comment_batch().is_err() { - this.review_receipt = Some(( - t!("fleet.review.state_refused").to_string(), - t!("fleet.review.invalid_preview").to_string(), - )); - } - cx.notify(); - }); - }), - ); - for proposal in review - .proposals - .iter() - .filter(|proposal| review.proposal_is_actionable(&proposal.id)) - { - let proposal_entity = entity.clone(); - let proposal_id = proposal.id.clone(); + // External Git and pull-request actions remain absent: Platform v2 + // advertises check reruns and agent delivery, and ships the three + // pull-request slots empty because no provider adapter can preflight + // one yet. + let mut effect_controls = div().flex().flex_wrap().gap(px(6.0)); + if delivery.control.is_ok() && !self.selected_review_comments.is_empty() { + let batch_entity = entity.clone(); + let selected_comments = self.selected_review_comments.len(); effect_controls = effect_controls.child( Button::new( - ElementId::from(SharedString::from(format!( - "prepare-review-proposal-{}", - proposal.id - ))), - t!( - "fleet.review.prepare_proposal", - action = localized_review_proposal_kind(proposal.kind), - files = proposal.files.len() - ) - .to_string(), + "prepare-review-batch-send", + t!("fleet.review.prepare_batch_send", count = selected_comments).to_string(), ) .size(ButtonSize::Sm) .variant(ButtonVariant::Outline) .disabled(!can_prepare) .on_click(move |_, _, cx| { - proposal_entity.update(cx, |this, cx| { - if this.prepare_review_proposal(&proposal_id).is_err() { + batch_entity.update(cx, |this, cx| { + if let Err(refusal) = this.prepare_review_batch_send() { this.review_receipt = Some(( t!("fleet.review.state_refused").to_string(), - t!("fleet.review.invalid_preview").to_string(), + refusal.detail(), )); } cx.notify(); @@ -1407,11 +2184,20 @@ impl FleetView { }), ); } - for check in review - .checks - .iter() - .filter(|check| review.check_is_rerunnable(&check.id)) - { + let capabilities = self + .review_capabilities + .as_ref() + .and_then(PlatformReviewCapabilitiesLoad::available); + let target = self.review_target.as_ref(); + for check in review.checks.iter().filter(|check| { + review_rerun_is_visible( + review, + target, + capabilities, + self.review_custody.is_some(), + &check.id, + ) + }) { let check_entity = entity.clone(); let check_id = check.id.clone(); effect_controls = effect_controls.child( @@ -1427,33 +2213,10 @@ impl FleetView { .disabled(!can_prepare) .on_click(move |_, _, cx| { check_entity.update(cx, |this, cx| { - if this.prepare_review_check_rerun(&check_id).is_err() { + if let Err(refusal) = this.prepare_review_check_rerun(&check_id) { this.review_receipt = Some(( t!("fleet.review.state_refused").to_string(), - t!("fleet.review.invalid_preview").to_string(), - )); - } - cx.notify(); - }); - }), - ); - } - if review.pull_request_is_mergeable() { - let merge_entity = entity.clone(); - effect_controls = effect_controls.child( - Button::new( - "prepare-review-merge", - t!("fleet.review.prepare_merge").to_string(), - ) - .size(ButtonSize::Sm) - .variant(ButtonVariant::Outline) - .disabled(!can_prepare) - .on_click(move |_, _, cx| { - merge_entity.update(cx, |this, cx| { - if this.prepare_review_merge().is_err() { - this.review_receipt = Some(( - t!("fleet.review.state_refused").to_string(), - t!("fleet.review.invalid_preview").to_string(), + refusal.detail(), )); } cx.notify(); @@ -1491,13 +2254,13 @@ impl FleetView { Input::new(&self.review_comment_state) .size(InputSize::Sm) .placeholder(t!("fleet.review.comment_placeholder").to_string()) - .disabled(!can_prepare) + .disabled(!can_anchor) .on_change({ let entity = entity.clone(); move |value, cx| { entity.update(cx, |this, cx| { this.review_comment_value = value.to_string(); - this.pending_review_preview = None; + this.cancel_pending_review_preview(); cx.notify(); }); } @@ -1508,90 +2271,478 @@ impl FleetView { "prepare-review-comment", t!("fleet.review.prepare_comment").to_string(), ) - .size(ButtonSize::Sm) - .variant(ButtonVariant::Outline) - .disabled(!can_comment) - .on_click(move |_, _, cx| { - comment_entity.update(cx, |this, cx| { - if this.prepare_review_comment().is_err() { - this.review_receipt = Some(( - t!("fleet.review.state_refused").to_string(), - t!("fleet.review.invalid_preview").to_string(), - )); - } - cx.notify(); - }); - }), + .size(ButtonSize::Sm) + .variant(ButtonVariant::Outline) + .disabled(!can_comment) + .on_click(move |_, _, cx| { + comment_entity.update(cx, |this, cx| { + if let Err(refusal) = this.prepare_review_comment() { + this.review_receipt = Some(( + t!("fleet.review.state_refused").to_string(), + refusal.detail(), + )); + } + cx.notify(); + }); + }), + ) + .child( + Button::new("save-review-note", t!("fleet.review.save_note").to_string()) + .size(ButtonSize::Sm) + .variant(ButtonVariant::Outline) + .disabled(!can_save_note) + .on_click(move |_, _, cx| { + note_entity.update(cx, |this, cx| { + match this.save_review_note() { + Ok(()) => { + this.review_comment_value.clear(); + this.review_comment_state + .update(cx, |state, cx| state.reset(cx)); + } + Err(refusal) => { + this.review_receipt = Some(( + t!("fleet.review.state_refused").to_string(), + refusal.detail(), + )); + } + } + cx.notify(); + }); + }), + ) + .child( + Button::new( + "prepare-review-approval", + t!("fleet.review.prepare_approval").to_string(), + ) + .size(ButtonSize::Sm) + .variant(ButtonVariant::Outline) + .disabled(!can_prepare || !review.approval_is_actionable()) + .on_click(move |_, _, cx| { + approve_entity.update(cx, |this, cx| { + if let Err(refusal) = this.prepare_review_approval() { + this.review_receipt = Some(( + t!("fleet.review.state_refused").to_string(), + refusal.detail(), + )); + } + cx.notify(); + }); + }), + ), + ) + .child(effect_controls) + .child(self.render_review_confirmation(cx)); + + div() + .flex() + .flex_col() + .max_h(px(360.0)) + .min_h(px(0.0)) + .overflow_hidden() + .border_b_1() + .border_color(ShellDeckColors::border()) + .child(Self::render_review_badges(review)) + .child( + div() + .id("platform-review-detail") + .flex_1() + .min_h(px(0.0)) + .overflow_y_scroll() + .p(px(10.0)) + .flex() + .gap(px(10.0)) + .child( + div() + .flex_1() + .min_w(px(0.0)) + .child(Self::review_column_title( + t!("fleet.review.files_title").to_string(), + projection.distinct_file_count(), + )) + .child(files), + ) + .child( + div() + .w(px(320.0)) + .min_w(px(240.0)) + .child(Self::review_column_title( + t!("fleet.review.comments_title").to_string(), + review.comments.len(), + )) + .child(comments) + .child(Self::review_column_title( + t!("fleet.review.notes_title").to_string(), + self.review_note_rows.len(), + )) + .child(notes) + .child(Self::review_column_title( + t!("fleet.review.attention_title").to_string(), + review.attention_events.len(), + )) + .child(attention), + ), + ) + .child(controls) + .into_any_element() + } + + /// Paint already-bounded review text as plain characters. + /// + /// The core projection has replaced every control and invisible + /// reordering scalar and capped line count and length, so the only work + /// left here is containment: one definite-width row per line, clipped. + fn render_safe_text(text: &ReviewSafeText, size: f32, color: Hsla) -> AnyElement { + let mut column = div() + .flex() + .flex_col() + .min_w(px(0.0)) + .overflow_hidden() + .text_size(px(size)) + .text_color(color); + for line in &text.lines { + let display: SharedString = if line.is_empty() { + " ".into() + } else { + SharedString::from(line.clone()) + }; + column = column.child(div().min_w(px(0.0)).truncate().child(display)); + } + if text.truncated { + column = column.child( + div().mt(px(2.0)).child( + Badge::new(t!("fleet.review.preview_truncated").to_string()) + .variant(BadgeVariant::Outline), + ), + ); + } + column.into_any_element() + } + + /// Render only what the projection judged safe for this file. + /// + /// Text is painted as characters, an image becomes a bounded empty box + /// beside its described metadata (the contract ships no pixels and none + /// are fetched), sanitized HTML is described but never interpreted or + /// re-emitted as source, and anything withheld states its reason. + fn render_review_preview(preview: &ReviewSafePreview) -> AnyElement { + let shell = div() + .min_w(px(0.0)) + .overflow_hidden() + .mb(px(6.0)) + .p(px(6.0)) + .border_1() + .border_color(ShellDeckColors::border()) + .rounded(px(4.0)); + match preview { + ReviewSafePreview::Text(text) => shell + .child( + div().mb(px(4.0)).child( + Badge::new(t!("fleet.review.preview_text").to_string()) + .variant(BadgeVariant::Secondary), + ), + ) + .child(Self::render_safe_text( + text, + 11.0, + ShellDeckColors::text_muted(), + )) + .into_any_element(), + ReviewSafePreview::Image(image) => shell + .flex() + .items_center() + .gap(px(8.0)) + .child( + // adabraka has no "described but undecoded raster" shape: + // the contract ships metadata only, so the box is an empty + // bounded placeholder, never an image element. + div() + .flex_shrink_0() + .w(px(f32::from( + u16::try_from(image.box_width).unwrap_or(u16::MAX), + ))) + .h(px(f32::from( + u16::try_from(image.box_height).unwrap_or(u16::MAX), + ))) + .border_1() + .border_color(ShellDeckColors::border()) + .rounded(px(4.0)) + .bg(ShellDeckColors::hint_bg()), + ) + .child( + div() + .flex_1() + .min_w(px(0.0)) + .overflow_hidden() + .child( + Badge::new( + t!( + "fleet.review.preview_image", + media = image.media_type.as_str(), + width = image.width, + height = image.height, + bytes = image.byte_size + ) + .to_string(), + ) + .variant(BadgeVariant::Secondary), + ) + .child( + div() + .mt(px(4.0)) + .text_size(px(10.0)) + .text_color(ShellDeckColors::text_muted()) + .child(t!("fleet.review.preview_image_box").to_string()), + ), + ) + .into_any_element(), + ReviewSafePreview::Html(html) => shell + .child( + Badge::new( + t!( + "fleet.review.preview_html", + media = html.media_type.as_str(), + bytes = html.byte_size + ) + .to_string(), ) + .variant(BadgeVariant::Secondary), + ) + .into_any_element(), + ReviewSafePreview::Withheld(reason) => shell + .child( + Badge::new(t!(review_preview_withheld_key(*reason)).to_string()) + .variant(BadgeVariant::Outline), + ) + .into_any_element(), + } + } + + /// `can_anchor` gates line selection only. Selecting an anchor prepares + /// nothing: it feeds both the durable note and the typed comment, so it + /// stays available even when the custody store that fences a mutation is + /// not. + fn render_review_file( + lane: ReviewWorktreeLane, + file: &ReviewWorktreeFile, + projection: &ReviewWorktreeProjection, + selected_anchor: Option<&ReviewAnchorSemantic>, + can_anchor: bool, + can_prepare: bool, + entity: &Entity, + ) -> AnyElement { + let mut hunks = div().flex().flex_col().gap(px(4.0)); + for hunk in &file.hunks { + let selected = selected_anchor == Some(&hunk.anchor); + let select_entity = entity.clone(); + let anchor_for_click = hunk.anchor.clone(); + hunks = hunks.child( + div() + .flex() + .items_start() + .gap(px(8.0)) + .min_w(px(0.0)) .child( Button::new( - "prepare-review-approval", - t!("fleet.review.prepare_approval").to_string(), + ElementId::from(SharedString::from(review_anchor_element_id( + lane, &file.id, &hunk.id, + ))), + t!( + "fleet.review.anchor", + side = hunk.anchor.side.as_str(), + line = hunk.anchor.line + ) + .to_string(), ) .size(ButtonSize::Sm) - .variant(ButtonVariant::Outline) - .disabled(!can_prepare || !review.approval_is_actionable()) + .variant(if selected { + ButtonVariant::Default + } else { + ButtonVariant::Outline + }) + .disabled(!can_anchor) .on_click(move |_, _, cx| { - approve_entity.update(cx, |this, cx| { - if this.prepare_review_approval().is_err() { - this.review_receipt = Some(( - t!("fleet.review.state_refused").to_string(), - t!("fleet.review.invalid_preview").to_string(), - )); - } + select_entity.update(cx, |this, cx| { + this.selected_review_anchor = Some(anchor_for_click.clone()); + this.cancel_pending_review_preview(); cx.notify(); }); }), - ), - ) - .child(effect_controls) - .child(self.render_review_confirmation(cx)); + ) + .child(div().flex_1().min_w(px(0.0)).child(Self::render_safe_text( + &hunk.text, + 11.0, + ShellDeckColors::text_muted(), + ))), + ); + } - div() + let mut header = div() .flex() - .flex_col() - .max_h(px(360.0)) - .min_h(px(0.0)) - .overflow_hidden() - .border_b_1() - .border_color(ShellDeckColors::border()) - .child(Self::render_review_badges(review)) + .flex_wrap() + .items_center() + .gap(px(6.0)) + .mb(px(6.0)) .child( div() - .id("platform-review-detail") .flex_1() - .min_h(px(0.0)) - .overflow_y_scroll() - .p(px(10.0)) - .flex() - .gap(px(10.0)) - .child( - div() - .flex_1() - .min_w(px(0.0)) - .child(Self::review_column_title( - t!("fleet.review.files_title").to_string(), - review.files.len(), - )) - .child(files), - ) - .child( - div() - .w(px(320.0)) - .min_w(px(240.0)) - .child(Self::review_column_title( - t!("fleet.review.comments_title").to_string(), - review.comments.len(), - )) - .child(comments) - .child(Self::review_column_title( - t!("fleet.review.attention_title").to_string(), - review.attention_events.len(), - )) - .child(attention), - ), + .min_w(px(0.0)) + .overflow_hidden() + .text_size(px(12.0)) + .text_color(ShellDeckColors::text_primary()) + .font_weight(FontWeight::SEMIBOLD) + .child(Self::render_safe_text( + &file.path, + 12.0, + ShellDeckColors::text_primary(), + )), ) - .child(controls) + .child( + Badge::new( + t!( + "fleet.review.file_state", + change = file.change.as_str(), + worktree = file.worktree.as_str(), + conflict = file.conflict.as_str() + ) + .to_string(), + ) + .variant(BadgeVariant::Outline), + ); + if file.partial { + header = header.child( + Badge::new(t!("fleet.review.lane_partial").to_string()) + .variant(BadgeVariant::Warning), + ); + } + // Server-proposed staging transitions, shown as observations. A + // control joins one only where the server advertised a slot for that + // exact proposal; the rest stay observations. That is the whole of the + // withholding rule: a deployment granting index writes but not + // committing advertises `Stage` and `Unstage` and no `Commit`, so the + // commit proposal keeps its badge and simply grows no button. + // + // These sit on the file card rather than in the footer's + // `effect_controls` row, unlike the check rerun and the batch send: a + // staging proposal names files, so the control belongs beside the + // badge naming it. Same widgets and same sizing as those two — the + // placement is the only divergence. + let mut staging_controls = div().flex().items_center().flex_wrap().gap(px(5.0)); + let mut has_staging_control = false; + for proposal in &file.staging { + let action = localized_review_proposal_kind(proposal.kind); + header = header.child( + Badge::new( + if proposal.admissible { + t!( + "fleet.review.staging_proposed", + action = action, + id = proposal.proposal_id.as_str() + ) + } else { + t!( + "fleet.review.staging_blocked", + action = action, + id = proposal.proposal_id.as_str() + ) + } + .to_string(), + ) + .variant(if proposal.admissible { + BadgeVariant::Secondary + } else { + BadgeVariant::Destructive + }), + ); + if projection.advertises_staging(&proposal.proposal_id) { + has_staging_control = true; + let stage_entity = entity.clone(); + let stage_id = proposal.proposal_id.clone(); + staging_controls = staging_controls.child( + Button::new( + ElementId::from(SharedString::from(format!( + "review-stage-{}-{}-{}", + lane.as_str(), + file.id, + proposal.proposal_id + ))), + t!(review_staging_button_key(proposal.kind)).to_string(), + ) + .size(ButtonSize::Sm) + .variant(ButtonVariant::Outline) + .disabled(!can_prepare) + .on_click(move |_, _, cx| { + stage_entity.update(cx, |this, cx| { + if let Err(refusal) = this.prepare_review_staging(&stage_id) { + this.review_receipt = Some(( + t!("fleet.review.state_refused").to_string(), + refusal.detail(), + )); + } + cx.notify(); + }); + }), + ); + } + // One control per admissible side, because the server advertises + // one entry per side git actually recorded and the side is inside + // the confirmation digest. A file holding both stage 2 and stage 3 + // therefore renders two buttons; a delete/modify pair renders one. + for side in projection.advertised_sides(&proposal.proposal_id, &file.id) { + has_staging_control = true; + let resolve_entity = entity.clone(); + let resolve_proposal = proposal.proposal_id.clone(); + let resolve_file = file.id.clone(); + staging_controls = staging_controls.child( + Button::new( + ElementId::from(SharedString::from(format!( + "review-resolve-{}-{}-{}-{}", + lane.as_str(), + file.id, + proposal.proposal_id, + side.as_str() + ))), + t!(review_conflict_side_key(side)).to_string(), + ) + .size(ButtonSize::Sm) + .variant(ButtonVariant::Outline) + .disabled(!can_prepare) + .on_click(move |_, _, cx| { + resolve_entity.update(cx, |this, cx| { + if let Err(refusal) = this.prepare_review_conflict_resolution( + &resolve_proposal, + &resolve_file, + side, + ) { + this.review_receipt = Some(( + t!("fleet.review.state_refused").to_string(), + refusal.detail(), + )); + } + cx.notify(); + }); + }), + ); + } + } + + // Only claim the row's height when it holds something: an empty + // control row would still push the preview down by its own margin. + if has_staging_control { + staging_controls = staging_controls.mb(px(6.0)); + } + + div() + .min_w(px(0.0)) + .overflow_hidden() + .p(px(8.0)) + .border_1() + .border_color(ShellDeckColors::border()) + .rounded(px(6.0)) + .child(header) + .child(staging_controls) + .child(Self::render_review_preview(&file.preview)) + .child(hunks) .into_any_element() } @@ -1692,7 +2843,7 @@ impl FleetView { .variant(ButtonVariant::Ghost) .on_click(move |_, _, cx| { cancel_entity.update(cx, |this, cx| { - this.pending_review_preview = None; + this.cancel_pending_review_preview(); cx.notify(); }); }), @@ -3024,6 +4175,7 @@ impl Render for FleetView { .child(self.render_header(compact, cx)) .child(self.render_action_preview(compact, cx)) .child(self.render_review_summary(cx)) + .child(self.render_platform_attention(cx)) .children(notice) .child(content) } @@ -3032,8 +4184,13 @@ impl Render for FleetView { #[cfg(test)] mod review_render_tests { use super::{ - exact_review_target_index, fleet_uses_compact_layout, review_dispatch_directive, - same_exact_review_snapshot, semantic_words, ReviewDispatchDirective, + exact_review_target_index, fleet_uses_compact_layout, review_anchor_element_id, + review_conflict_side_key, review_delivery_withheld_key, review_dispatch_directive, + review_lane_label_key, review_lane_variant, review_preparation_gate, + review_preview_withheld_key, review_rerun_control_gate, review_staging_button_key, + review_staging_withheld_key, same_exact_review_snapshot, semantic_words, + visible_review_lanes, BadgeVariant, ConflictResolution, ReviewDeliveryWithheld, + ReviewDispatchDirective, ReviewPrepareRefusal, }; use shelldeck_core::config::platform_review::PlatformReviewTarget; use shelldeck_core::config::workspace_catalog::{ @@ -3192,4 +4349,326 @@ mod review_render_tests { )); assert!(!same_exact_review_snapshot(None, None, None, None)); } + + // SDTEST-1828 — without both a server-advertised capability and durable + // custody, the rerun control is absent rather than disabled optimistically. + #[test] + fn rerun_control_is_absent_without_capability_or_custody() { + assert!(!review_rerun_control_gate(false, true)); + assert!(!review_rerun_control_gate(true, false)); + assert!(review_rerun_control_gate(true, true)); + } + + // SDTEST-1850 — the combined review paints only the lanes that hold a + // file, most blocking first, and only the conflicted lane reads as + // destructive. An empty index must not render four zero-count headers. + #[test] + fn sdtest_1850_only_populated_lanes_render_and_conflicts_lead() { + use shelldeck_core::config::platform_review::{ + review_safe_text, ConflictState, DiffChangeKind, ReviewPreviewWithheld, + ReviewSafePreview, ReviewWorktreeFile, ReviewWorktreeLane, ReviewWorktreeLaneGroup, + WorktreeFileState, + }; + + let file = |id: &str| ReviewWorktreeFile { + id: id.to_owned(), + path: review_safe_text("src/a.rs"), + change: DiffChangeKind::Modified, + worktree: WorktreeFileState::Staged, + conflict: ConflictState::None, + partial: false, + preview: ReviewSafePreview::Withheld(ReviewPreviewWithheld::NoContent), + hunks: Vec::new(), + staging: Vec::new(), + }; + let lanes = vec![ + ReviewWorktreeLaneGroup { + lane: ReviewWorktreeLane::Conflicted, + semantic_key: ReviewWorktreeLane::Conflicted.semantic_key(), + files: vec![file("file-conflict")], + }, + ReviewWorktreeLaneGroup { + lane: ReviewWorktreeLane::Staged, + semantic_key: ReviewWorktreeLane::Staged.semantic_key(), + files: Vec::new(), + }, + ReviewWorktreeLaneGroup { + lane: ReviewWorktreeLane::Unstaged, + semantic_key: ReviewWorktreeLane::Unstaged.semantic_key(), + files: vec![file("file-unstaged")], + }, + ReviewWorktreeLaneGroup { + lane: ReviewWorktreeLane::Untracked, + semantic_key: ReviewWorktreeLane::Untracked.semantic_key(), + files: Vec::new(), + }, + ]; + + assert_eq!( + visible_review_lanes(&lanes) + .iter() + .map(|group| group.lane) + .collect::>(), + [ReviewWorktreeLane::Conflicted, ReviewWorktreeLane::Unstaged] + ); + assert_eq!( + review_lane_variant(ReviewWorktreeLane::Conflicted), + BadgeVariant::Destructive + ); + for lane in [ + ReviewWorktreeLane::Staged, + ReviewWorktreeLane::Unstaged, + ReviewWorktreeLane::Untracked, + ] { + assert_eq!(review_lane_variant(lane), BadgeVariant::Secondary); + } + } + + // SDTEST-1851 — a preview or staging refusal must reach the user as words, + // not as a raw key. Every declared reason resolves to distinct localized + // copy present in both shipped locales. + #[test] + fn sdtest_1851_every_withheld_reason_has_distinct_copy_in_both_locales() { + use shelldeck_core::config::platform_review::{ + ReviewPreviewWithheld, ReviewStagingWithheld, ReviewWorktreeLane, + }; + use std::collections::BTreeSet; + + let fr: toml::Table = + toml::from_str(include_str!("../../shelldeck-core/locales/fr.toml")).expect("fr"); + let en: toml::Table = + toml::from_str(include_str!("../../shelldeck-core/locales/en.toml")).expect("en"); + + let keys = ReviewWorktreeLane::ALL + .into_iter() + .map(review_lane_label_key) + .chain( + ReviewPreviewWithheld::ALL + .into_iter() + .map(review_preview_withheld_key), + ) + .chain( + ReviewStagingWithheld::ALL + .into_iter() + .map(review_staging_withheld_key), + ) + .collect::>(); + + assert_eq!( + keys.iter().collect::>().len(), + keys.len(), + "two states must not share one label" + ); + // The locale files use unquoted dotted keys, so TOML nests them. + fn localized<'a>(table: &'a toml::Table, key: &str) -> Option<&'a str> { + let mut current = table; + let mut segments = key.split('.').peekable(); + while let Some(segment) = segments.next() { + let value = current.get(segment)?; + if segments.peek().is_none() { + return value.as_str(); + } + current = value.as_table()?; + } + None + } + + for key in keys { + for (locale, table) in [("fr", &fr), ("en", &en)] { + let value = localized(table, key) + .unwrap_or_else(|| panic!("{locale}.toml is missing {key}")); + assert!(!value.trim().is_empty(), "{locale}.toml {key} is empty"); + } + } + } + + // SDTEST-1852 — a partially staged file is rendered in two lanes. Its + // anchor buttons must not collide: GPUI routes a click by element id, so + // two identical ids would select the other lane's anchor. + #[test] + fn sdtest_1852_anchor_identity_survives_a_file_listed_in_two_lanes() { + use shelldeck_core::config::platform_review::ReviewWorktreeLane; + use std::collections::BTreeSet; + + let ids = [ + review_anchor_element_id(ReviewWorktreeLane::Staged, "file-1", "hunk-1"), + review_anchor_element_id(ReviewWorktreeLane::Unstaged, "file-1", "hunk-1"), + review_anchor_element_id(ReviewWorktreeLane::Unstaged, "file-1", "hunk-2"), + review_anchor_element_id(ReviewWorktreeLane::Unstaged, "file-2", "hunk-1"), + ]; + assert_eq!( + ids.iter().collect::>().len(), + ids.len(), + "lane, file and hunk must all take part in the identity: {ids:?}" + ); + assert_eq!( + review_anchor_element_id(ReviewWorktreeLane::Staged, "file-1", "hunk-1"), + ids[0], + "the identity must be stable across renders" + ); + } + + // SDTEST-1859 — SDUC-495 + #[test] + fn preparation_requires_custody_for_every_action_family() { + // Idle and unblocked is no longer enough: comments and approvals used + // to prepare on `!busy && !unresolved` alone, which let them reach the + // provider with no durable record of having been sent once. + assert!(!review_preparation_gate(false, false, false)); + assert!(review_preparation_gate(false, false, true)); + assert!(!review_preparation_gate(true, false, true)); + assert!(!review_preparation_gate(false, true, true)); + // The rerun gate stays strictly narrower: it also needs the server + // capability that only the rerun family has. + assert!(!review_rerun_control_gate(false, true)); + + // A custody refusal must never be reported as a moved snapshot: the + // user's next action differs (retry later vs. re-anchor). + let details = [ + ReviewPrepareRefusal::Preview.detail(), + ReviewPrepareRefusal::Custody.detail(), + ReviewPrepareRefusal::Note.detail(), + ]; + assert_eq!( + details + .iter() + .collect::>() + .len(), + 3, + "{details:?}" + ); + assert!(details.iter().all(|detail| !detail.trim().is_empty())); + } + + // SDTEST-1866 — SDUC-495 + #[test] + fn every_delivery_withheld_reason_has_its_own_key_in_both_shipped_locales() { + // A surface that cannot tell "the server advertised nothing" from + // "this process has no custody lane" sends the user to the wrong fix, + // so the three reasons must never collapse onto one string. + let keys = ReviewDeliveryWithheld::ALL + .iter() + .map(|reason| review_delivery_withheld_key(*reason)) + .collect::>(); + assert_eq!(keys.len(), ReviewDeliveryWithheld::ALL.len()); + + for locale in ["fr", "en"] { + let copies = keys + .iter() + .map(|key| { + let copy = crate::t!(*key, locale = locale).to_string(); + assert_ne!(©, *key, "{locale} is missing {key}"); + copy + }) + .collect::>(); + assert_eq!(copies.len(), keys.len(), "{locale} reuses one explanation"); + // The delivery lane must not be explained with the staging lane's + // copy: they are withheld for different reasons and are fixed + // differently. + for key in [ + "fleet.review.staging_withheld_no_server_capability", + "fleet.review.staging_withheld_no_custody_lane", + ] { + assert!(!copies.contains(&crate::t!(key, locale = locale).to_string())); + } + let stale = + crate::t!("fleet.review.delivery_capability_stale", locale = locale).to_string(); + assert_ne!( + stale, + crate::t!("fleet.review.capability_stale", locale = locale).to_string(), + "{locale} tells a retracted advertisement and a changed rerun digest apart" + ); + } + } + + // SDTEST-1872 — SDUC-495. Every staging control the surface can paint has + // its own label in both shipped locales, and the three staging verbs never + // collapse onto one string. + // + // They are separately granted: a deployment can install `index_write` + // without `commit`, so a shared label would let a reader believe the + // control they are looking at is the one they were denied. The two + // conflict sides matter for the same reason — a file with both recorded + // renders both buttons side by side, and only the label distinguishes + // which blob would land. + #[test] + fn sdtest_1872_every_staging_control_has_its_own_label_in_both_shipped_locales() { + use shelldeck_core::config::platform_review::ReviewProposalKind; + use std::collections::BTreeSet; + + let keys = [ + review_staging_button_key(ReviewProposalKind::Stage), + review_staging_button_key(ReviewProposalKind::Unstage), + review_staging_button_key(ReviewProposalKind::Commit), + review_conflict_side_key(ConflictResolution::KeepCurrent), + review_conflict_side_key(ConflictResolution::KeepIncoming), + ]; + assert_eq!( + keys.iter().collect::>().len(), + keys.len(), + "two separately granted controls must not share one label" + ); + + for locale in ["fr", "en"] { + let copies = keys + .iter() + .map(|key| { + let copy = crate::t!(*key, locale = locale).to_string(); + assert_ne!(©, key, "{locale} is missing {key}"); + assert!(!copy.trim().is_empty(), "{locale} {key} is empty"); + copy + }) + .collect::>(); + assert_eq!(copies.len(), keys.len(), "{locale} reuses one label"); + } + } + + // SDTEST-1873 — SDUC-495. The staging lane's own withheld reasons stay + // distinct from each other and from the delivery lane's, including the new + // "the server proved nothing" answer that only exists once a capability + // response can be attributed at all. + #[test] + fn sdtest_1873_staging_withheld_reasons_stay_distinct_from_the_delivery_lane() { + use shelldeck_core::config::platform_review::ReviewStagingWithheld; + use std::collections::BTreeSet; + + let staging = ReviewStagingWithheld::ALL + .iter() + .map(|reason| review_staging_withheld_key(*reason)) + .collect::>(); + assert_eq!(staging.len(), ReviewStagingWithheld::ALL.len()); + + for locale in ["fr", "en"] { + let staging_copies = staging + .iter() + .map(|key| { + let copy = crate::t!(*key, locale = locale).to_string(); + assert_ne!(©, *key, "{locale} is missing {key}"); + copy + }) + .collect::>(); + assert_eq!( + staging_copies.len(), + staging.len(), + "{locale} reuses one staging explanation" + ); + for reason in ReviewDeliveryWithheld::ALL { + let delivery = + crate::t!(review_delivery_withheld_key(reason), locale = locale).to_string(); + assert!( + !staging_copies.contains(&delivery), + "{locale} explains staging with the delivery lane's copy" + ); + } + // The retired copy named a contract gap that no longer exists: the + // server now defines both staging digests. Shipping it would tell + // the user to wait for something that already landed. + for copy in &staging_copies { + assert!( + !copy.contains("Platform v2") && !copy.contains("hunk"), + "{locale} still explains staging by the closed contract gap: {copy}" + ); + } + } + } } diff --git a/crates/shelldeck-ui/src/lib.rs b/crates/shelldeck-ui/src/lib.rs index b7dcbe15..59937dbc 100644 --- a/crates/shelldeck-ui/src/lib.rs +++ b/crates/shelldeck-ui/src/lib.rs @@ -64,4 +64,6 @@ pub use ai_companion::{AiCompanionController, AiCompanionEvent}; pub use ai_dock::{AiDockView, AiDockVisibilityHandler}; pub use command_palette_window::CommandPaletteWindowView; pub use i18n::{apply_ui_language, rel_time}; -pub use workspace::{TrayCounters, TrayNotification, Workspace, WorkspaceAiBindings}; +pub use workspace::{ + PlatformAttentionNotification, TrayCounters, TrayNotification, Workspace, WorkspaceAiBindings, +}; diff --git a/crates/shelldeck-ui/src/workspace/account.rs b/crates/shelldeck-ui/src/workspace/account.rs index 6b8e15bb..faa4e6db 100644 --- a/crates/shelldeck-ui/src/workspace/account.rs +++ b/crates/shelldeck-ui/src/workspace/account.rs @@ -489,6 +489,7 @@ impl Workspace { self.monique_processes = None; self.fleet_request_epoch = self.fleet_request_epoch.wrapping_add(1); self.fleet_snapshot = None; + self.clear_platform_attention(cx); self.fleet_view.update(cx, |view, cx| { view.reset(); cx.notify(); diff --git a/crates/shelldeck-ui/src/workspace/activity.rs b/crates/shelldeck-ui/src/workspace/activity.rs index 4e20081c..9041c53f 100644 --- a/crates/shelldeck-ui/src/workspace/activity.rs +++ b/crates/shelldeck-ui/src/workspace/activity.rs @@ -154,7 +154,9 @@ impl Workspace { cx.notify(); } ActivityAction::OpenMonique => self.open_monique_console(cx), - ActivityAction::OpenFleet => self.open_fleet(cx), + ActivityAction::OpenFleet => { + self.open_fleet(cx); + } ActivityAction::OpenBext => self.open_bext_cloud(cx), } } diff --git a/crates/shelldeck-ui/src/workspace/agents.rs b/crates/shelldeck-ui/src/workspace/agents.rs index 0eff6c26..9df7e4d7 100644 --- a/crates/shelldeck-ui/src/workspace/agents.rs +++ b/crates/shelldeck-ui/src/workspace/agents.rs @@ -1,25 +1,74 @@ use super::*; +use crate::agent_console_view::{AgentProjectGroup, AgentRemoteFileEntry, AgentWorktreeOption}; use shelldeck_core::agent_runtime::{ - configure_local_process, parse_stream_line, terminate_local_process, AgentCommandSpec, - AgentRunRequest, AgentStreamEvent, AgentTarget, LocalProcessTree, + configure_local_process, parse_stream_line, terminate_local_process, AcpAgentPermissionBroker, + AgentCommandSpec, AgentProvider, AgentRunRequest, AgentStreamEvent, AgentStreamFrame, + AgentStreamFramer, AgentTarget, LocalProcessTree, AGENT_STREAM_OVERSIZED_LABEL, }; +use shelldeck_core::agent_session::DEFAULT_MAX_CONCURRENT_AGENT_SESSIONS; +use shelldeck_core::config::workspace_catalog::{ + CatalogCheckoutId, CatalogWorkspaceId, CheckoutHost, ProjectCatalog, RemotePosixPath, + UserWorkspaceLifecycle, WorkspaceRelativePath, +}; +use shelldeck_core::workspace_navigation::AgentSessionBinding; use shelldeck_ssh::client::SshClient; -use std::io::{BufRead, BufReader, Write}; +use std::io::{Read, Write}; use std::process::{Command, Stdio}; pub(super) struct ActiveAgentRun { - shutdown_tx: tokio::sync::mpsc::Sender<()>, + shutdown: AgentShutdown, _thread: std::thread::JoinHandle<()>, } +enum AgentShutdown { + Process(tokio::sync::mpsc::Sender<()>), + Acp(parking_lot::Mutex>), +} + impl ActiveAgentRun { fn stop(&self) { - let _ = self.shutdown_tx.try_send(()); + match &self.shutdown { + AgentShutdown::Process(sender) => { + let _ = sender.try_send(()); + } + AgentShutdown::Acp(sender) => { + if let Some(sender) = sender.lock().take() { + let _ = sender.send(()); + } + } + } } } type AgentDone = (Option, Option); +/// Keep parallel coding work useful without allowing an accidental prompt +/// fan-out to exhaust the desktop host. Every admitted run still owns an +/// independently stoppable process tree or SSH channel. +fn agent_run_has_capacity(active: usize) -> bool { + active < DEFAULT_MAX_CONCURRENT_AGENT_SESSIONS +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum AutomoniqueAdmissionError { + SshTarget, + ModelOverride, +} + +fn automonique_agent_admission(request: &AgentRunRequest) -> Result<(), AutomoniqueAdmissionError> { + if !matches!(request.target, AgentTarget::Local) { + return Err(AutomoniqueAdmissionError::SshTarget); + } + if request + .model + .as_ref() + .is_some_and(|model| !model.trim().is_empty()) + { + return Err(AutomoniqueAdmissionError::ModelOverride); + } + Ok(()) +} + impl Workspace { pub(super) fn handle_agent_console_event( &mut self, @@ -32,22 +81,78 @@ impl Workspace { match event { AgentConsoleEvent::Run(request) => self.start_agent_run(request, cx), AgentConsoleEvent::Stop(run_id) => self.stop_agent_run(run_id, cx), + AgentConsoleEvent::OpenLocalFile { session_id, path } => { + let path = authorized_local_agent_file( + self.agent_console.read(cx).sessions(), + session_id, + &path, + ); + if let Some(path) = path { + self.file_editor.update(cx, |editor, cx| { + editor.open_file(path, cx); + }); + self.set_active_view(ActiveView::FileEditor); + } + } + AgentConsoleEvent::BrowseRemoteDirectory { + session_id, + connection_id, + path, + } => self.browse_remote_agent_directory(session_id, connection_id, path, cx), + AgentConsoleEvent::CloseSession(session_id) => { + self.agent_session_bindings.remove(&session_id); + self.workspace_hub.update(cx, |hub, cx| { + if let Err(error) = hub.remove_agent_session_tabs(session_id, cx) { + tracing::warn!(%error, %session_id, "could not remove closed agent session tabs"); + } + }); + if self.active_view == ActiveView::Workspaces { + let visible_session = self.workspace_hub.read(cx).active_agent_session_id(); + self.agent_console.update(cx, |view, cx| { + if let Some(session_id) = visible_session { + view.select_session(session_id, cx); + } + view.set_surface_visible(visible_session.is_some(), cx); + }); + } + } } } - fn start_agent_run(&mut self, request: AgentRunRequest, cx: &mut Context) { - if !self.active_agent_runs.is_empty() { - self.show_toast( - t!("agents.error.already_running").to_string(), - ToastLevel::Warning, - cx, - ); + fn start_agent_run(&mut self, mut request: AgentRunRequest, cx: &mut Context) { + if !agent_run_has_capacity(self.active_agent_runs.len()) { + let message = t!( + "agents.error.parallel_limit", + count = DEFAULT_MAX_CONCURRENT_AGENT_SESSIONS.to_string() + ) + .to_string(); + self.agent_console.update(cx, |view, cx| { + view.reject_run_for(request.id, message.clone(), cx) + }); + self.show_toast(message, ToastLevel::Warning, cx); return; } + if request.provider == AgentProvider::Automonique { + self.start_automonique_agent_run(request, cx); + return; + } + if let AgentTarget::Ssh { connection_id, .. } = &request.target { + if let Some(workdir) = mapped_remote_agent_workdir( + self.workspace_hub.read(cx).catalog(), + *connection_id, + &request.workdir, + ) { + request.workdir = workdir; + } + } let mut spec = match AgentCommandSpec::for_request(&request) { Ok(spec) => spec, Err(error) => { - self.show_toast(error.to_string(), ToastLevel::Error, cx); + let message = error.to_string(); + self.agent_console.update(cx, |view, cx| { + view.reject_run_for(request.id, message.clone(), cx) + }); + self.show_toast(message, ToastLevel::Error, cx); return; } }; @@ -60,11 +165,11 @@ impl Workspace { .find(|connection| connection.id == *connection_id) .cloned() else { - self.show_toast( - t!("agents.error.target_missing").to_string(), - ToastLevel::Error, - cx, - ); + let message = t!("agents.error.target_missing").to_string(); + self.agent_console.update(cx, |view, cx| { + view.reject_run_for(request.id, message.clone(), cx) + }); + self.show_toast(message, ToastLevel::Error, cx); return; }; Some(connection) @@ -79,8 +184,9 @@ impl Workspace { binary = spec.program.as_str() ) .to_string(); - self.agent_console - .update(cx, |view, cx| view.reject_run(message.clone(), cx)); + self.agent_console.update(cx, |view, cx| { + view.reject_run_for(request.id, message.clone(), cx) + }); self.show_toast(message, ToastLevel::Error, cx); return; } @@ -91,8 +197,9 @@ impl Workspace { workdir = request.workdir.as_str() ) .to_string(); - self.agent_console - .update(cx, |view, cx| view.reject_run(message.clone(), cx)); + self.agent_console.update(cx, |view, cx| { + view.reject_run_for(request.id, message.clone(), cx) + }); self.show_toast(message, ToastLevel::Error, cx); return; } @@ -101,6 +208,13 @@ impl Workspace { let provider = request.provider; self.agent_console .update(cx, |view, cx| view.begin_run(request.clone(), cx)); + if let Err(message) = self.bind_agent_run_to_workspace(&request, cx) { + self.agent_console.update(cx, |view, cx| { + view.reject_run_for(request.id, message.clone(), cx) + }); + self.show_toast(message, ToastLevel::Error, cx); + return; + } let (shutdown_tx, shutdown_rx) = tokio::sync::mpsc::channel::<()>(1); let (event_tx, event_rx) = std::sync::mpsc::channel::(); @@ -129,7 +243,7 @@ impl Workspace { self.active_agent_runs.insert( run_id, ActiveAgentRun { - shutdown_tx, + shutdown: AgentShutdown::Process(shutdown_tx), _thread: thread, }, ); @@ -146,7 +260,7 @@ impl Workspace { if !events.is_empty() { let _ = console.update(cx, |view, cx| { for event in events { - view.push_stream_event(event, cx); + view.push_stream_event_for(run_id, event, cx); } }); } @@ -210,12 +324,351 @@ impl Workspace { .detach(); } + fn start_automonique_agent_run(&mut self, request: AgentRunRequest, cx: &mut Context) { + if let Err(error) = request.validate() { + self.reject_agent_run(&request, error.to_string(), ToastLevel::Error, cx); + return; + } + if let Err(error) = automonique_agent_admission(&request) { + let message = match error { + AutomoniqueAdmissionError::SshTarget => { + t!("agents.error.automonique_ssh_boundary").to_string() + } + AutomoniqueAdmissionError::ModelOverride => { + t!("agents.error.automonique_model_override").to_string() + } + }; + self.reject_agent_run(&request, message, ToastLevel::Warning, cx); + return; + } + let Some(program) = resolve_local_agent_program(request.provider.binary()) else { + self.reject_agent_run( + &request, + t!( + "agents.error.binary_missing", + binary = request.provider.binary() + ) + .to_string(), + ToastLevel::Error, + cx, + ); + return; + }; + if !std::path::Path::new(&request.workdir).is_dir() { + self.reject_agent_run( + &request, + t!( + "agents.error.workdir_missing", + workdir = request.workdir.as_str() + ) + .to_string(), + ToastLevel::Error, + cx, + ); + return; + } + + let run_id = request.id; + self.agent_console + .update(cx, |view, cx| view.begin_run(request.clone(), cx)); + if let Err(message) = self.bind_agent_run_to_workspace(&request, cx) { + self.agent_console.update(cx, |view, cx| { + view.reject_run_for(request.id, message.clone(), cx) + }); + self.show_toast(message, ToastLevel::Error, cx); + return; + } + + let (cancel_tx, cancel_rx) = shelldeck_core::acp::cancel_channel(); + let (event_tx, event_rx) = std::sync::mpsc::channel::(); + let (done_tx, done_rx) = std::sync::mpsc::channel::(); + let thread = match spawn_local_automonique_agent( + run_id, request, program, cancel_rx, event_tx, done_tx, + ) { + Ok(thread) => thread, + Err(error) => { + self.agent_console.update(cx, |view, cx| { + view.finish_run(run_id, None, Some(error.to_string()), cx) + }); + return; + } + }; + self.active_agent_runs.insert( + run_id, + ActiveAgentRun { + shutdown: AgentShutdown::Acp(parking_lot::Mutex::new(Some(cancel_tx))), + _thread: thread, + }, + ); + + let console = self.agent_console.downgrade(); + cx.spawn(async move |this, cx: &mut AsyncApp| loop { + cx.background_executor() + .timer(std::time::Duration::from_millis(50)) + .await; + let mut events = Vec::new(); + while let Ok(event) = event_rx.try_recv() { + events.push(event); + } + if !events.is_empty() { + let _ = console.update(cx, |view, cx| { + for event in events { + view.push_stream_event_for(run_id, event, cx); + } + }); + } + match done_rx.try_recv() { + Ok((exit_code, error)) => { + let _ = console.update(cx, |view, cx| { + view.finish_run(run_id, exit_code, error.clone(), cx) + }); + let _ = this.update(cx, |workspace, cx| { + workspace.active_agent_runs.remove(&run_id); + let (message, level) = if let Some(error) = error { + ( + t!("agents.toast.failed", error = error).to_string(), + ToastLevel::Error, + ) + } else if exit_code == Some(0) { + ( + t!("agents.toast.completed").to_string(), + ToastLevel::Success, + ) + } else { + (t!("agents.toast.stopped").to_string(), ToastLevel::Info) + }; + workspace.show_toast(message, level, cx); + }); + break; + } + Err(std::sync::mpsc::TryRecvError::Empty) => {} + Err(std::sync::mpsc::TryRecvError::Disconnected) => { + let _ = console.update(cx, |view, cx| { + view.finish_run( + run_id, + None, + Some(t!("agents.error.runtime_lost").to_string()), + cx, + ) + }); + let _ = this.update(cx, |workspace, _| { + workspace.active_agent_runs.remove(&run_id); + }); + break; + } + } + }) + .detach(); + } + + fn reject_agent_run( + &mut self, + request: &AgentRunRequest, + message: String, + level: ToastLevel, + cx: &mut Context, + ) { + self.agent_console.update(cx, |view, cx| { + view.reject_run_for(request.id, message.clone(), cx) + }); + self.show_toast(message, level, cx); + } + fn stop_agent_run(&mut self, run_id: Uuid, _cx: &mut Context) { if let Some(run) = self.active_agent_runs.get(&run_id) { run.stop(); } } + fn browse_remote_agent_directory( + &mut self, + session_id: Uuid, + connection_id: Uuid, + path: String, + cx: &mut Context, + ) { + let selected_context_matches = + self.agent_console + .read(cx) + .sessions() + .iter() + .any(|session| { + session.id == session_id + && matches!( + session.context.target, + AgentTarget::Ssh { + connection_id: selected, + .. + } if selected == connection_id + ) + }); + let Some(connection) = selected_context_matches + .then_some({ + self.connections + .iter() + .find(|connection| connection.id == connection_id) + .cloned() + }) + .flatten() + else { + self.agent_console.update(cx, |view, cx| { + view.set_remote_directory_listing( + session_id, + connection_id, + path.clone(), + path, + Err(t!("agents.error.target_missing").to_string()), + cx, + ) + }); + return; + }; + if path.len() > shelldeck_core::agent_runtime::MAX_AGENT_WORKDIR_BYTES + || (path != shelldeck_core::agent_runtime::AGENT_REMOTE_HOME_WORKDIR + && RemotePosixPath::new(path.clone()).is_err()) + { + self.agent_console.update(cx, |view, cx| { + view.set_remote_directory_listing( + session_id, + connection_id, + path.clone(), + path, + Err(t!("agents.error.workdir_absolute").to_string()), + cx, + ) + }); + return; + } + + let requested_path = path.clone(); + let (result_tx, result_rx) = std::sync::mpsc::channel(); + let spawn_result = std::thread::Builder::new() + .name(format!("agent-remote-files-{session_id}")) + .spawn(move || { + let runtime = match tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + { + Ok(runtime) => runtime, + Err(error) => { + let _ = result_tx.send(Err(error.to_string())); + return; + } + }; + let result = runtime.block_on(async move { + let session = SshClient::new() + .connect(&connection) + .await + .map_err(|error| error.to_string())?; + let canonical_path = if requested_path + == shelldeck_core::agent_runtime::AGENT_REMOTE_HOME_WORKDIR + { + let home = session + .exec("printf '%s\\n' \"$HOME\"") + .await + .map_err(|error| error.to_string())?; + if !home.success() { + return Err("remote home directory is unavailable".to_string()); + } + parse_remote_home(&home.stdout)? + } else { + requested_path + }; + let command = shelldeck_core::models::discovery::ls_command(&canonical_path); + let fallback = + shelldeck_core::models::discovery::ls_command_fallback(&canonical_path); + let primary = session + .exec(&command) + .await + .map_err(|error| error.to_string())?; + let entries = if primary.success() && !primary.stdout.is_empty() { + shelldeck_core::models::discovery::parse_stat_output( + &String::from_utf8_lossy(&primary.stdout), + &canonical_path, + ) + } else { + let fallback = session + .exec(&fallback) + .await + .map_err(|error| error.to_string())?; + if !fallback.success() { + return Err("remote directory is unavailable".to_string()); + } + shelldeck_core::models::discovery::parse_ls_output( + &String::from_utf8_lossy(&fallback.stdout), + &canonical_path, + ) + }; + Ok((canonical_path, remote_agent_file_entries(entries))) + }); + let _ = result_tx.send(result); + }); + if let Err(error) = spawn_result { + self.agent_console.update(cx, |view, cx| { + view.set_remote_directory_listing( + session_id, + connection_id, + path.clone(), + path, + Err(error.to_string()), + cx, + ) + }); + return; + } + + let console = self.agent_console.downgrade(); + cx.spawn(async move |_this, cx: &mut AsyncApp| loop { + match result_rx.try_recv() { + Ok(Ok((canonical_path, entries))) => { + let _ = console.update(cx, |view, cx| { + view.set_remote_directory_listing( + session_id, + connection_id, + path.clone(), + canonical_path, + Ok(entries), + cx, + ) + }); + break; + } + Ok(Err(error)) => { + let _ = console.update(cx, |view, cx| { + view.set_remote_directory_listing( + session_id, + connection_id, + path.clone(), + path, + Err(error), + cx, + ) + }); + break; + } + Err(std::sync::mpsc::TryRecvError::Empty) => { + cx.background_executor() + .timer(std::time::Duration::from_millis(50)) + .await; + } + Err(std::sync::mpsc::TryRecvError::Disconnected) => { + let _ = console.update(cx, |view, cx| { + view.set_remote_directory_listing( + session_id, + connection_id, + path.clone(), + path, + Err(t!("agents.error.runtime_lost").to_string()), + cx, + ) + }); + break; + } + } + }) + .detach(); + } + pub(super) fn stop_all_agent_runs(&mut self) { for run in self.active_agent_runs.values() { run.stop(); @@ -237,7 +690,270 @@ impl Workspace { .collect(); self.agent_console .update(cx, |view, cx| view.set_connections(connections, cx)); + self.refresh_agent_projects(cx); + } + + pub(super) fn refresh_agent_projects(&mut self, cx: &mut Context) { + let groups = agent_project_groups(self.workspace_hub.read(cx).catalog(), &self.connections); + self.agent_console + .update(cx, |view, cx| view.set_project_groups(groups, cx)); + } + + fn bind_agent_run_to_workspace( + &mut self, + request: &AgentRunRequest, + cx: &mut Context, + ) -> Result<(), String> { + let Some(session_id) = self.agent_console.read(cx).session_id_for_run(request.id) else { + return Err(t!("agents.error.session_missing").to_string()); + }; + let candidate = catalog_workspace_for_run(self.workspace_hub.read(cx).catalog(), request); + if let Some(bound) = self.agent_session_bindings.get(&session_id).copied() { + if candidate != Some(bound) { + return Err(t!("agents.error.session_checkout_changed").to_string()); + } + } + let Some((workspace, checkout_id)) = candidate else { + return Ok(()); + }; + let title = self + .agent_console + .read(cx) + .sessions() + .iter() + .find(|session| session.id == session_id) + .map(|session| session.name.clone()) + .unwrap_or_else(|| t!("agents.session.new").to_string()); + self.workspace_hub.update(cx, |hub, cx| { + hub.open_or_focus_agent_session( + workspace, + title, + AgentSessionBinding { + checkout_id, + session_id, + }, + cx, + ) + .map(|_| ()) + })?; + self.agent_session_bindings + .insert(session_id, (workspace, checkout_id)); + self.agent_console.update(cx, |view, cx| { + view.set_session_context_locked(session_id, true, cx) + }); + self.active_view = ActiveView::Workspaces; + self.on_active_view_changed(cx); + Ok(()) + } +} + +pub(super) fn agent_project_groups( + catalog: &ProjectCatalog, + connections: &[Connection], +) -> Vec { + catalog + .projects() + .map(|project| AgentProjectGroup { + id: project.id().to_string(), + label: project.name().to_string(), + worktrees: project + .checkouts() + .enumerate() + .map(|(index, checkout)| { + let (path, target) = match checkout.host() { + CheckoutHost::Local { root, .. } => { + (root.display().to_string(), AgentTarget::Local) + } + CheckoutHost::Ssh { + connection_id, + root, + } => { + let connection_label = connections + .iter() + .find(|connection| connection.id == *connection_id) + .map(|connection| connection.display_name().to_string()) + .unwrap_or_else(|| connection_id.to_string()); + ( + root.as_str().to_string(), + AgentTarget::Ssh { + connection_id: *connection_id, + connection_label, + }, + ) + } + }; + AgentWorktreeOption { + id: checkout.id().to_string(), + label: checkout.label().to_string(), + path, + target, + branch: None, + is_primary: index == 0, + } + }) + .collect(), + }) + .collect() +} + +fn catalog_workspace_for_run( + catalog: &ProjectCatalog, + request: &AgentRunRequest, +) -> Option<(CatalogWorkspaceId, CatalogCheckoutId)> { + catalog + .workspaces() + .filter(|workspace| workspace.lifecycle() == UserWorkspaceLifecycle::Active) + .filter_map(|workspace| { + let checkout = catalog + .checkout_in_project(workspace.project_id(), workspace.checkout_id()) + .ok()?; + let authority_len = match (&request.target, checkout.host()) { + (AgentTarget::Local, CheckoutHost::Local { root, .. }) => { + let canonical_root = std::fs::canonicalize(root).ok()?; + let canonical_workdir = std::fs::canonicalize(&request.workdir).ok()?; + if !canonical_workdir.starts_with(&canonical_root) { + return None; + } + canonical_root.as_os_str().len() + } + ( + AgentTarget::Ssh { connection_id, .. }, + CheckoutHost::Ssh { + connection_id: expected, + root, + }, + ) if connection_id == expected + && RemotePosixPath::new(request.workdir.clone()).is_ok_and(|candidate| { + remote_path_contains(root.as_str(), candidate.as_str()) + }) => + { + root.as_str().len() + } + _ => return None, + }; + Some((authority_len, workspace.id(), workspace.checkout_id())) + }) + .max_by_key(|(authority_len, _, _)| *authority_len) + .map(|(_, workspace, checkout)| (workspace, checkout)) +} + +fn remote_path_contains(root: &str, candidate: &str) -> bool { + candidate == root + || candidate + .strip_prefix(root.trim_end_matches('/')) + .is_some_and(|suffix| suffix.starts_with('/')) +} + +/// Translate an inherited local checkout path to the corresponding SSH +/// checkout in the same catalog project. This is a launch-time safety net for +/// stale sessions and target changes; the cockpit normally applies the remote +/// checkout root before emitting the request. +/// +/// Mapping is deliberately conservative. It requires one unambiguous remote +/// checkout for the selected connection and repository, and preserves only a +/// validated relative suffix beneath the longest matching local authority. +fn mapped_remote_agent_workdir( + catalog: &ProjectCatalog, + connection_id: Uuid, + workdir: &str, +) -> Option { + let local_workdir = std::path::Path::new(workdir); + catalog + .projects() + .filter_map(|project| { + let source = project + .checkouts() + .filter_map(|checkout| match checkout.host() { + CheckoutHost::Local { root, .. } => local_workdir + .strip_prefix(root) + .ok() + .map(|relative| (root.as_os_str().len(), checkout, relative)), + CheckoutHost::Ssh { .. } => None, + }) + .max_by_key(|(authority_len, _, _)| *authority_len)?; + let repository = source.1.repository().slug.as_str(); + let mut targets = project + .checkouts() + .filter_map(|checkout| match checkout.host() { + CheckoutHost::Ssh { + connection_id: candidate, + root, + } if *candidate == connection_id + && checkout.repository().slug == repository => + { + Some(root) + } + _ => None, + }); + let target = targets.next()?; + if targets.next().is_some() { + return None; + } + let relative = source + .2 + .components() + .map(|component| match component { + std::path::Component::Normal(value) => value.to_str(), + _ => None, + }) + .collect::>>()? + .join("/"); + let relative = WorkspaceRelativePath::new(relative).ok()?; + let mapped = if relative.as_str().is_empty() { + target.as_str().to_string() + } else { + format!( + "{}/{}", + target.as_str().trim_end_matches('/'), + relative.as_str() + ) + }; + RemotePosixPath::new(mapped.clone()).ok()?; + Some((source.0, mapped)) + }) + .max_by_key(|(authority_len, _)| *authority_len) + .map(|(_, mapped)| mapped) +} + +fn remote_agent_file_entries( + entries: Vec, +) -> Vec { + entries + .into_iter() + .map(|entry| AgentRemoteFileEntry { + path: entry.path, + name: entry.name, + is_dir: entry.is_dir, + }) + .collect() +} + +fn authorized_local_agent_file( + sessions: &[shelldeck_core::agent_session::AgentSession], + session_id: Uuid, + requested: &std::path::Path, +) -> Option { + let session = sessions.iter().find(|session| { + session.id == session_id && matches!(session.context.target, AgentTarget::Local) + })?; + let root = std::fs::canonicalize(&session.context.workdir).ok()?; + let requested = std::fs::canonicalize(requested).ok()?; + (requested.is_file() && requested.starts_with(root)).then_some(requested) +} + +fn parse_remote_home(stdout: &[u8]) -> Result { + let home = std::str::from_utf8(stdout) + .map_err(|_| "remote home directory is not UTF-8".to_string())?; + let home = home.strip_suffix('\n').unwrap_or(home); + let home = home.strip_suffix('\r').unwrap_or(home); + if home.is_empty() + || home.len() > shelldeck_core::agent_runtime::MAX_AGENT_WORKDIR_BYTES + || home.contains(['\n', '\r']) + || RemotePosixPath::new(home.to_string()).is_err() + { + return Err("remote home directory is invalid".to_string()); } + Ok(home.to_string()) } fn resolve_local_agent_program(program: &str) -> Option { @@ -275,18 +991,91 @@ fn resolve_local_agent_program(program: &str) -> Option { None } -fn complete_stream_lines(pending: &mut Vec, bytes: &[u8]) -> Vec { - pending.extend_from_slice(bytes); - let mut lines = Vec::new(); - while let Some(newline) = pending.iter().position(|byte| *byte == b'\n') { - let mut line = pending[..newline].to_vec(); - pending.drain(..=newline); - if line.last() == Some(&b'\r') { - line.pop(); +fn send_stream_frames( + frames: Vec, + provider: shelldeck_core::agent_runtime::AgentProvider, + parse_json: bool, + event_tx: &std::sync::mpsc::Sender, +) { + for frame in frames { + match frame { + AgentStreamFrame::Line(line) if parse_json => { + for event in parse_stream_line(provider, &line) { + let _ = event_tx.send(event); + } + } + AgentStreamFrame::Line(line) => { + if !line.trim().is_empty() { + let _ = event_tx.send(AgentStreamEvent::Activity(line)); + } + } + AgentStreamFrame::Oversized => { + let _ = event_tx.send(AgentStreamEvent::Activity( + AGENT_STREAM_OVERSIZED_LABEL.to_string(), + )); + } + } + } +} + +fn forward_local_stream( + mut reader: impl Read, + provider: shelldeck_core::agent_runtime::AgentProvider, + parse_json: bool, + event_tx: &std::sync::mpsc::Sender, +) { + let mut framer = AgentStreamFramer::default(); + let mut chunk = [0_u8; 8 * 1024]; + loop { + match reader.read(&mut chunk) { + Ok(0) => break, + Ok(read) => { + send_stream_frames(framer.push(&chunk[..read]), provider, parse_json, event_tx) + } + Err(_) => break, } - lines.push(String::from_utf8_lossy(&line).into_owned()); } - lines + send_stream_frames(framer.finish(), provider, parse_json, event_tx); +} + +fn spawn_local_automonique_agent( + run_id: Uuid, + request: AgentRunRequest, + program: String, + cancel_rx: shelldeck_core::acp::AcpCancelReceiver, + event_tx: std::sync::mpsc::Sender, + done_tx: std::sync::mpsc::Sender, +) -> std::io::Result> { + std::thread::Builder::new() + .name(format!("agent-acp-{run_id}")) + .spawn(move || { + use std::sync::Arc; + + let permissions = Arc::new(AcpAgentPermissionBroker::new(request.access)); + let mut turn = + shelldeck_core::acp::AcpTurnRequest::text(&request.workdir, request.prompt); + if let Some(session) = request.resume_session { + turn = turn.resume(session); + } + let result = shelldeck_core::acp::AcpClient::new( + shelldeck_core::acp::AcpLaunch::automonique(program), + ) + .with_permissions(permissions) + .with_agent_events(event_tx.clone()) + .prompt_cancellable(turn, cancel_rx); + match result { + Ok(Some(result)) => { + let _ = event_tx.send(AgentStreamEvent::Session(result.session_id.to_string())); + let _ = done_tx.send((Some(0), None)); + } + Ok(None) => { + let _ = done_tx.send((None, None)); + } + Err(error) => { + let _ = done_tx.send((None, Some(error.to_string()))); + } + } + }) } fn spawn_local_agent( @@ -344,18 +1133,12 @@ fn spawn_local_agent( let stdout_tx = event_tx.clone(); let stdout_thread = std::thread::spawn(move || { if let Some(stdout) = stdout { - for line in BufReader::new(stdout).lines().map_while(|line| line.ok()) { - for event in parse_stream_line(provider, &line) { - let _ = stdout_tx.send(event); - } - } + forward_local_stream(stdout, provider, true, &stdout_tx); } }); let stderr_thread = std::thread::spawn(move || { if let Some(stderr) = stderr { - for line in BufReader::new(stderr).lines().map_while(|line| line.ok()) { - let _ = event_tx.send(AgentStreamEvent::Activity(line)); - } + forward_local_stream(stderr, provider, false, &event_tx); } }); @@ -431,22 +1214,11 @@ fn spawn_remote_agent( }; let (output_tx, mut output_rx) = tokio::sync::mpsc::unbounded_channel::>(); let forward = tokio::spawn(async move { - let mut pending = Vec::new(); + let mut framer = AgentStreamFramer::default(); while let Some(bytes) = output_rx.recv().await { - for line in complete_stream_lines(&mut pending, &bytes) { - for event in parse_stream_line(provider, &line) { - let _ = event_tx.send(event); - } - } - } - if !pending.is_empty() { - let pending = String::from_utf8_lossy(&pending); - if !pending.trim().is_empty() { - for event in parse_stream_line(provider, pending.trim()) { - let _ = event_tx.send(event); - } - } + send_stream_frames(framer.push(&bytes), provider, true, &event_tx); } + send_stream_frames(framer.finish(), provider, true, &event_tx); }); let result = session .exec_cancellable(&spec.remote_shell_command(), output_tx, shutdown_rx) @@ -466,10 +1238,46 @@ fn spawn_remote_agent( #[cfg(test)] mod tests { - use super::{complete_stream_lines, spawn_local_agent, AgentCommandSpec, AgentStreamEvent}; + use super::{ + agent_run_has_capacity, authorized_local_agent_file, automonique_agent_admission, + forward_local_stream, mapped_remote_agent_workdir, parse_remote_home, spawn_local_agent, + AgentCommandSpec, AgentStreamEvent, AgentStreamFrame, AgentStreamFramer, + AGENT_STREAM_OVERSIZED_LABEL, DEFAULT_MAX_CONCURRENT_AGENT_SESSIONS, + }; + use shelldeck_core::agent_runtime::{ + AgentAccessMode, AgentProvider, AgentRunRequest, AgentTarget, + }; + use shelldeck_core::config::workspace_catalog::{ + CatalogCheckoutId, CatalogProjectId, CheckoutHost, ProjectCatalog, ProjectCheckout, + ProjectRecord, RemotePosixPath, RepositoryIdentity, + }; use std::time::Duration; use uuid::Uuid; + // SDTEST-1907 — SDUC-475, SDUC-480 + #[test] + fn sdtest_1907_automonique_never_falls_back_from_ssh_to_local_acp() { + let mut request = AgentRunRequest::new( + AgentProvider::Automonique, + AgentTarget::Local, + AgentAccessMode::ReadOnly, + std::env::temp_dir().display().to_string(), + None, + "inspect", + ); + assert!(automonique_agent_admission(&request).is_ok()); + + request.target = AgentTarget::Ssh { + connection_id: Uuid::new_v4(), + connection_label: "remote".to_string(), + }; + assert!(automonique_agent_admission(&request).is_err()); + + request.target = AgentTarget::Local; + request.model = Some("provider-owned".to_string()); + assert!(automonique_agent_admission(&request).is_err()); + } + // SDTEST-1676 — SDUC-475 #[cfg(unix)] #[test] @@ -519,12 +1327,170 @@ mod tests { .position(|pair| pair[0] == 0xc3 && pair[1] == 0xa9) .unwrap() + 1; - let mut pending = Vec::new(); - assert!(complete_stream_lines(&mut pending, &payload[..split]).is_empty()); + let mut framer = AgentStreamFramer::default(); + assert!(framer.push(&payload[..split]).is_empty()); assert_eq!( - complete_stream_lines(&mut pending, &payload[split..]), - vec!["{\"type\":\"content.delta\",\"delta\":\"terminé ✅\"}".to_string()] + framer.push(&payload[split..]), + vec![AgentStreamFrame::Line( + "{\"type\":\"content.delta\",\"delta\":\"terminé ✅\"}".to_string() + )] ); - assert!(pending.is_empty()); + assert_eq!(framer.buffered_bytes(), 0); + } + + // SDTEST-1905 — SDUC-475 + #[test] + fn sdtest_1905_remote_launch_maps_an_inherited_local_checkout_path() { + let connection_id = Uuid::new_v4(); + let other_connection = Uuid::new_v4(); + let repository = || RepositoryIdentity { + slug: "inklura/infra".to_string(), + canonical_url: None, + }; + let mut project = ProjectRecord::new(CatalogProjectId::new(), "Infra"); + project.add_checkout(ProjectCheckout::new( + CatalogCheckoutId::new(), + "Laptop", + CheckoutHost::Local { + device_label: "pc1".to_string(), + root: "/home/pc1/dev/infra".into(), + }, + repository(), + )); + project.add_checkout(ProjectCheckout::new( + CatalogCheckoutId::new(), + "Server", + CheckoutHost::Ssh { + connection_id, + root: RemotePosixPath::new("/home/infra-sj278/infra").unwrap(), + }, + repository(), + )); + let mut catalog = ProjectCatalog::default(); + catalog.insert_project(project).unwrap(); + + assert_eq!( + mapped_remote_agent_workdir(&catalog, connection_id, "/home/pc1/dev/infra/crates/api") + .as_deref(), + Some("/home/infra-sj278/infra/crates/api") + ); + assert_eq!( + mapped_remote_agent_workdir(&catalog, connection_id, "/home/pc1/dev/infra").as_deref(), + Some("/home/infra-sj278/infra") + ); + assert!( + mapped_remote_agent_workdir(&catalog, other_connection, "/home/pc1/dev/infra") + .is_none() + ); + assert!(mapped_remote_agent_workdir( + &catalog, + connection_id, + "/home/pc1/dev/infrastructure" + ) + .is_none()); + assert!( + mapped_remote_agent_workdir(&catalog, connection_id, "/srv/explicit-remote-path") + .is_none() + ); + } + + // SDTEST-1908 — SDUC-475 + #[test] + fn sdtest_1908_remote_home_probe_accepts_one_absolute_path_only() { + assert_eq!( + parse_remote_home(b"/home/remote user\n").unwrap(), + "/home/remote user" + ); + assert_eq!(parse_remote_home(b"/root\r\n").unwrap(), "/root"); + assert!(parse_remote_home(b"relative/home\n").is_err()); + assert!(parse_remote_home(b"/home/first\n/home/second\n").is_err()); + assert!(parse_remote_home(b"/home/remote\0escape\n").is_err()); + assert!(parse_remote_home(&[0xff, b'\n']).is_err()); + } + + // SDTEST-1909 — SDUC-499 + #[test] + fn sdtest_1909_agent_file_open_stays_inside_the_selected_local_session_root() { + let root = tempfile::tempdir().unwrap(); + let outside = tempfile::tempdir().unwrap(); + let admitted = root.path().join("src.rs"); + let refused = outside.path().join("secret.txt"); + std::fs::write(&admitted, "safe").unwrap(); + std::fs::write(&refused, "outside").unwrap(); + let context = shelldeck_core::agent_session::AgentExecutionContext { + provider: AgentProvider::Claude, + target: AgentTarget::Local, + access: AgentAccessMode::ReadOnly, + workdir: root.path().display().to_string(), + model: None, + }; + let session = shelldeck_core::agent_session::AgentSession::new("local", context, 1) + .expect("valid local session"); + assert_eq!( + authorized_local_agent_file(std::slice::from_ref(&session), session.id, &admitted), + Some(std::fs::canonicalize(&admitted).unwrap()) + ); + assert!( + authorized_local_agent_file(std::slice::from_ref(&session), session.id, &refused) + .is_none() + ); + assert!(authorized_local_agent_file( + std::slice::from_ref(&session), + Uuid::new_v4(), + &admitted + ) + .is_none()); + + #[cfg(unix)] + { + let escape = root.path().join("escape.txt"); + std::os::unix::fs::symlink(&refused, &escape).unwrap(); + assert!(authorized_local_agent_file( + std::slice::from_ref(&session), + session.id, + &escape + ) + .is_none()); + } + } + + // SDTEST-1897 — SDUC-499 + #[test] + fn sdtest_1897_local_stream_discards_newline_free_oversize_then_recovers() { + use shelldeck_core::agent_runtime::MAX_AGENT_STREAM_LINE_BYTES; + + let mut payload = vec![b'x'; MAX_AGENT_STREAM_LINE_BYTES + 17]; + payload.extend_from_slice(b"\n{\"type\":\"content.delta\",\"delta\":\"recovered\"}\n"); + let (event_tx, event_rx) = std::sync::mpsc::channel(); + forward_local_stream( + std::io::Cursor::new(payload), + shelldeck_core::agent_runtime::AgentProvider::Jcode, + true, + &event_tx, + ); + drop(event_tx); + assert_eq!( + event_rx.into_iter().collect::>(), + vec![ + AgentStreamEvent::Activity(AGENT_STREAM_OVERSIZED_LABEL.to_string()), + AgentStreamEvent::TextDelta("recovered".to_string()), + ] + ); + } + + // SDTEST-1883 — SDUC-475 + #[test] + fn sdtest_1883_parallel_agent_runs_are_bounded_without_a_singleton_gate() { + assert!(agent_run_has_capacity(0)); + assert!(agent_run_has_capacity(1)); + assert!(agent_run_has_capacity( + DEFAULT_MAX_CONCURRENT_AGENT_SESSIONS - 1 + )); + assert!(!agent_run_has_capacity( + DEFAULT_MAX_CONCURRENT_AGENT_SESSIONS + )); + assert!(!agent_run_has_capacity( + DEFAULT_MAX_CONCURRENT_AGENT_SESSIONS + 1 + )); } } diff --git a/crates/shelldeck-ui/src/workspace/fleet.rs b/crates/shelldeck-ui/src/workspace/fleet.rs index 5d7970a9..f4b8de8e 100644 --- a/crates/shelldeck-ui/src/workspace/fleet.rs +++ b/crates/shelldeck-ui/src/workspace/fleet.rs @@ -1,12 +1,16 @@ use super::*; use shelldeck_core::config::platform::{ - stable_client_id, ActionResult, Attachment, ControlClaimResult, PlatformConnection, - PlatformFollowUpResult, PlatformRefresh, PlatformReviewActionResult, PlatformSnapshot, - ResourceCoordinate, RetainedSessionUpdate, + stable_client_id, ActionResult, Attachment, ControlClaimResult, PlatformAttentionLoad, + PlatformConnection, PlatformFollowUpResult, PlatformRefresh, PlatformReviewActionResult, + PlatformSnapshot, ResourceCoordinate, RetainedSessionUpdate, +}; +use shelldeck_core::config::platform_attention::{ + PlatformAttentionTarget, ReviewAttentionPresence, }; use shelldeck_core::config::platform_review::{ - PlatformReviewLoad, PlatformReviewTarget, PlatformReviewUnavailable, + PlatformReviewCapabilitiesLoad, PlatformReviewLoad, PlatformReviewTarget, + PlatformReviewUnavailable, }; enum PlatformActionResult { @@ -15,7 +19,7 @@ enum PlatformActionResult { ControlClaimed(ControlClaimResult), ControlReleased(ResourceCoordinate), Executed(ActionResult), - Review(AttributedPlatformReviewActionResult), + Review(Box), FollowedUp(PlatformFollowUpResult), } @@ -23,6 +27,7 @@ enum PlatformLoadResult { Snapshot { snapshot: PlatformSnapshot, review: Option, + attention: Option, }, Refresh { refresh: PlatformRefresh, @@ -30,12 +35,14 @@ enum PlatformLoadResult { reconciled: Vec, review: Option, review_action: Option>, + attention: Option, }, } struct AttributedPlatformReview { target: PlatformReviewTarget, load: PlatformReviewLoad, + capabilities: PlatformReviewCapabilitiesLoad, } struct AttributedPlatformReviewActionResult { @@ -43,6 +50,49 @@ struct AttributedPlatformReviewActionResult { result: PlatformReviewActionResult, } +struct AttributedPlatformAttention { + workspace: shelldeck_core::config::workspace_catalog::CatalogWorkspaceId, + target: PlatformAttentionTarget, + load: Result, +} + +fn load_attention( + connection: &PlatformConnection, + target: Option<( + shelldeck_core::config::workspace_catalog::CatalogWorkspaceId, + PlatformAttentionTarget, + )>, + review: Option<&AttributedPlatformReview>, +) -> Option { + let (workspace, target) = target?; + let load = review_attention_presence(review.map(|review| &review.load)) + .ok_or_else(|| "attention inventory incomplete".to_owned()) + .and_then(|presence| { + connection + .attention(target.clone(), presence) + .map_err(|error| error.to_string()) + }); + Some(AttributedPlatformAttention { + workspace, + target, + load, + }) +} + +fn review_attention_presence( + review: Option<&PlatformReviewLoad>, +) -> Option { + Some(match review { + Some(PlatformReviewLoad::Available(_)) => ReviewAttentionPresence::Present, + Some(PlatformReviewLoad::Unavailable(unavailable)) + if unavailable.category == "platform_v2_not_found" => + { + ReviewAttentionPresence::Absent + } + _ => return None, + }) +} + fn load_review( connection: &PlatformConnection, target: Option, @@ -51,13 +101,21 @@ fn load_review( let load = connection .review(&target) .unwrap_or_else(|error| unavailable_review(&error)); - AttributedPlatformReview { target, load } + let capabilities = connection + .review_capabilities(&target) + .unwrap_or_else(|error| unavailable_review_capabilities(&error)); + AttributedPlatformReview { + target, + load, + capabilities, + } }) } /// Admit a remote observation only when it is still attributed to the exact /// active catalog mapping that requested it. A switched or now-unmapped /// workspace clears the strip instead of inheriting foreign review state. +#[cfg(test)] fn review_for_active_target( active: Option<&PlatformReviewTarget>, attributed: Option, @@ -66,6 +124,15 @@ fn review_for_active_target( .and_then(|attributed| (Some(&attributed.target) == active).then_some(attributed.load)) } +fn review_and_capabilities_for_active_target( + active: Option<&PlatformReviewTarget>, + attributed: Option, +) -> Option<(PlatformReviewLoad, PlatformReviewCapabilitiesLoad)> { + attributed.and_then(|attributed| { + (Some(&attributed.target) == active).then_some((attributed.load, attributed.capabilities)) + }) +} + fn platform_connection_is_current( current: Option<&PlatformConnection>, captured: &PlatformConnection, @@ -78,9 +145,9 @@ fn review_for_active_context( captured_connection: &PlatformConnection, active_target: Option<&PlatformReviewTarget>, attributed: Option, -) -> Option { +) -> Option<(PlatformReviewLoad, PlatformReviewCapabilitiesLoad)> { platform_connection_is_current(current_connection, captured_connection) - .then(|| review_for_active_target(active_target, attributed)) + .then(|| review_and_capabilities_for_active_target(active_target, attributed)) .flatten() } @@ -103,6 +170,15 @@ fn unavailable_review(error: &shelldeck_core::error::ShellDeckError) -> Platform }) } +fn unavailable_review_capabilities( + error: &shelldeck_core::error::ShellDeckError, +) -> PlatformReviewCapabilitiesLoad { + PlatformReviewCapabilitiesLoad::Unavailable(PlatformReviewUnavailable { + category: "transport_error".to_owned(), + explanation: error.to_string(), + }) +} + const FLEET_POLL_INTERVAL: std::time::Duration = std::time::Duration::from_secs(10); fn fleet_retry_delay(consecutive_failures: u32) -> std::time::Duration { @@ -143,11 +219,11 @@ impl Workspace { PlatformConnection::new_at_endpoint(&endpoint, &self.app_config.cloud_sync.token).ok() } - pub(super) fn fleet_visible(&self) -> bool { + pub(super) fn platform_surface_visible(&self) -> bool { !self.settings_open && self.platform_connection().is_some() && self.effective_mode() == AppMode::Dev - && self.active_view == ActiveView::Fleet + && matches!(self.active_view, ActiveView::Fleet | ActiveView::Workspaces) } pub(super) fn update_fleet_availability(&mut self, cx: &mut Context) { @@ -159,6 +235,11 @@ impl Workspace { } pub(super) fn refresh_fleet_view(&mut self, cx: &mut Context) { + let attention_context = self + .workspace_hub + .read(cx) + .active_platform_attention_context(); + self.reconcile_platform_attention_context(attention_context.clone(), cx); if self.fleet_refresh_in_flight { return; } @@ -187,6 +268,8 @@ impl Workspace { .fleet_view .update(cx, |view, _cx| view.pending_review_reconciliation()); let review_target = self.workspace_hub.read(cx).active_platform_review_target(); + let attention_target = attention_context + .and_then(|(workspace, target)| target.map(|target| (workspace, target))); cx.spawn(async move |this, cx: &mut AsyncApp| { let result = cx .background_executor() @@ -206,17 +289,26 @@ impl Workspace { }) }); let review = load_review(&connection, review_target); + let attention = + load_attention(&connection, attention_target, review.as_ref()); Ok(PlatformLoadResult::Refresh { refresh, retained, reconciled, review, review_action, + attention, }) } else { let snapshot = connection.snapshot()?; let review = load_review(&connection, review_target); - Ok(PlatformLoadResult::Snapshot { snapshot, review }) + let attention = + load_attention(&connection, attention_target, review.as_ref()); + Ok(PlatformLoadResult::Snapshot { + snapshot, + review, + attention, + }) } }) .await; @@ -233,6 +325,7 @@ impl Workspace { // review strip. Never relabel one Platform origin as // another when project/workspace IDs happen to match. workspace.fleet_snapshot = None; + workspace.clear_platform_attention(cx); workspace.fleet_view.update(cx, |view, cx| { view.reset(); cx.notify(); @@ -243,8 +336,17 @@ impl Workspace { .workspace_hub .read(cx) .active_platform_review_target(); + let current_attention_context = workspace + .workspace_hub + .read(cx) + .active_platform_attention_context(); + workspace.reconcile_platform_attention_context(current_attention_context, cx); match result { - Ok(PlatformLoadResult::Snapshot { snapshot, review }) => { + Ok(PlatformLoadResult::Snapshot { + snapshot, + review, + attention, + }) => { let review = review_for_active_context( current_connection.as_ref(), &request_connection, @@ -260,9 +362,34 @@ impl Workspace { workspace.fleet_snapshot = Some(snapshot.clone()); workspace.fleet_view.update(cx, |view, cx| { view.set_snapshot(snapshot); - view.set_review(active_review_target.clone(), review); + let (review, capabilities) = review + .map(|(review, capabilities)| (Some(review), Some(capabilities))) + .unwrap_or((None, None)); + view.set_review(active_review_target.clone(), review, capabilities); cx.notify(); }); + if let Some(attention) = attention { + let current = workspace + .workspace_hub + .read(cx) + .active_platform_attention_target(); + if current.as_ref().is_some_and(|(id, target)| { + *id == attention.workspace && target == &attention.target + }) { + match attention.load { + Ok(load) => workspace.apply_platform_attention_load( + attention.workspace, + load, + cx, + ), + Err(_) => workspace.mark_platform_attention_unavailable( + attention.workspace, + &attention.target, + cx, + ), + } + } + } workspace.focus_pending_fleet_session(cx); } Ok(PlatformLoadResult::Refresh { @@ -271,6 +398,7 @@ impl Workspace { reconciled, review, review_action, + attention, }) => { let review = review_for_active_context( current_connection.as_ref(), @@ -299,15 +427,41 @@ impl Workspace { view.set_follow_up_result(result, cx); } view.apply_retained_updates(retained); - view.set_review(active_review_target.clone(), review); + let (review, capabilities) = review + .map(|(review, capabilities)| (Some(review), Some(capabilities))) + .unwrap_or((None, None)); + view.set_review(active_review_target.clone(), review, capabilities); if let Some(result) = review_action { view.set_review_action_result(result, cx); } cx.notify(); }); + if let Some(attention) = attention { + let current = workspace + .workspace_hub + .read(cx) + .active_platform_attention_target(); + if current.as_ref().is_some_and(|(id, target)| { + *id == attention.workspace && target == &attention.target + }) { + match attention.load { + Ok(load) => workspace.apply_platform_attention_load( + attention.workspace, + load, + cx, + ), + Err(_) => workspace.mark_platform_attention_unavailable( + attention.workspace, + &attention.target, + cx, + ), + } + } + } workspace.focus_pending_fleet_session(cx); } Err(error) => { + workspace.mark_all_platform_attention_unavailable(cx); let log_failure = fleet_should_log_failure(workspace.fleet_refresh_failures); workspace.fleet_refresh_failures = @@ -341,14 +495,14 @@ impl Workspace { } pub(super) fn sync_fleet_view_poll(&mut self, cx: &mut Context) { - if self.fleet_visible() { + if self.platform_surface_visible() { self.refresh_fleet_view(cx); if self._fleet_view_poll.is_none() { self._fleet_view_poll = Some(cx.spawn(async move |this, cx: &mut AsyncApp| loop { cx.background_executor().timer(FLEET_POLL_INTERVAL).await; let keep = this .update(cx, |workspace, cx| { - if workspace.fleet_visible() { + if workspace.platform_surface_visible() { let retry_ready = workspace.fleet_retry_not_before.is_none_or(|not_before| { std::time::Instant::now() >= not_before @@ -380,6 +534,10 @@ impl Workspace { self.refresh_fleet_view(cx); return; } + if let FleetViewEvent::OpenAttention(activation) = &event { + self.activate_platform_attention(*activation, cx); + return; + } let Some(connection) = self.platform_connection() else { return; }; @@ -412,6 +570,7 @@ impl Workspace { .spawn(async move { match event { FleetViewEvent::Refresh => unreachable!(), + FleetViewEvent::OpenAttention(_) => unreachable!(), FleetViewEvent::Attach(session) => connection .attach(session, client) .map(PlatformActionResult::Attached), @@ -429,12 +588,12 @@ impl Workspace { .map(PlatformActionResult::Executed), FleetViewEvent::ExecuteReview(preview) => { let target = preview.target().clone(); - Ok(PlatformActionResult::Review( + Ok(PlatformActionResult::Review(Box::new( AttributedPlatformReviewActionResult { target, result: connection.execute_review_action(preview), }, - )) + ))) } FleetViewEvent::FollowUp(follow_up) => { Ok(PlatformActionResult::FollowedUp( @@ -507,7 +666,7 @@ impl Workspace { current_connection.as_ref(), &request_connection, active_target.as_ref(), - attributed, + *attributed, ) { workspace.fleet_view.update(cx, |view, cx| { view.set_review_action_result(result, cx); @@ -543,9 +702,9 @@ impl Workspace { .detach(); } - pub fn open_fleet(&mut self, cx: &mut Context) { + pub fn open_fleet(&mut self, cx: &mut Context) -> bool { if !self.enter_dev_mode(cx) { - return; + return false; } if self.platform_connection().is_none() { self.show_toast( @@ -553,11 +712,12 @@ impl Workspace { ToastLevel::Warning, cx, ); - return; + return false; } self.active_view = ActiveView::Fleet; self.on_active_view_changed(cx); cx.notify(); + !self.settings_open && self.active_view == ActiveView::Fleet } } @@ -565,15 +725,18 @@ impl Workspace { mod tests { use super::{ fleet_retry_delay, fleet_should_log_failure, platform_connection_is_current, - review_action_for_active_context, review_for_active_context, review_for_active_target, - AttributedPlatformReview, AttributedPlatformReviewActionResult, FLEET_POLL_INTERVAL, + review_action_for_active_context, review_attention_presence, review_for_active_context, + review_for_active_target, AttributedPlatformReview, AttributedPlatformReviewActionResult, + FLEET_POLL_INTERVAL, }; use shelldeck_core::config::platform::{PlatformConnection, PlatformReviewActionResult}; + use shelldeck_core::config::platform_attention::ReviewAttentionPresence; use shelldeck_core::config::platform_review::{ AttentionState, ConflictState, DeliverySemantic, DeliveryState, DiffChangeKind, DiffSide, - MergeReadiness, PlatformReviewActionPreview, PlatformReviewLoad, PlatformReviewSemantic, - PlatformReviewTarget, PlatformReviewUnavailable, PreviewKind, PullRequestSemantic, - PullRequestState, ReviewAnchorSemantic, ReviewAttentionSemantic, ReviewAuthorityKind, + MergeReadiness, PlatformReviewActionPreview, PlatformReviewCapabilitiesLoad, + PlatformReviewLoad, PlatformReviewSemantic, PlatformReviewTarget, + PlatformReviewUnavailable, PreviewKind, PullRequestSemantic, PullRequestState, + ReviewAnchorSemantic, ReviewAttentionSemantic, ReviewAuthorityKind, ReviewAuthoritySemantic, ReviewDecision, ReviewFileSemantic, ReviewFreshnessSemantic, ReviewFreshnessState, ReviewHunkSemantic, ReviewPreviewSemantic, ReviewSchemaVersion, ReviewStatusSemantic, WorktreeFileState, @@ -605,6 +768,25 @@ mod tests { .unwrap() } + // SDTEST-1830 + #[test] + fn orchestration_attention_remains_discoverable_without_a_review_snapshot() { + let missing = PlatformReviewLoad::Unavailable(PlatformReviewUnavailable { + category: "platform_v2_not_found".into(), + explanation: "not found".into(), + }); + assert_eq!( + review_attention_presence(Some(&missing)), + Some(ReviewAttentionPresence::Absent) + ); + let transport = PlatformReviewLoad::Unavailable(PlatformReviewUnavailable { + category: "transport_error".into(), + explanation: "offline".into(), + }); + assert_eq!(review_attention_presence(Some(&transport)), None); + assert_eq!(review_attention_presence(None), None); + } + fn refused(target: PlatformReviewTarget) -> AttributedPlatformReview { AttributedPlatformReview { target, @@ -612,6 +794,10 @@ mod tests { category: "not_available".to_owned(), explanation: "review projection is not available".to_owned(), }), + capabilities: PlatformReviewCapabilitiesLoad::Unavailable(PlatformReviewUnavailable { + category: "not_available".to_owned(), + explanation: "review capabilities are not available".to_owned(), + }), } } diff --git a/crates/shelldeck-ui/src/workspace/mod.rs b/crates/shelldeck-ui/src/workspace/mod.rs index ddb87556..aeb3082b 100644 --- a/crates/shelldeck-ui/src/workspace/mod.rs +++ b/crates/shelldeck-ui/src/workspace/mod.rs @@ -45,7 +45,7 @@ use shelldeck_core::config::themes::TerminalTheme; use shelldeck_core::models::connection::{Connection, ConnectionSource, ConnectionStatus}; use shelldeck_ssh::tunnel::TunnelHandle; use std::cell::RefCell; -use std::collections::{HashMap, VecDeque}; +use std::collections::{BTreeMap, BTreeSet, HashMap, VecDeque}; use std::ops::{DerefMut, Range}; use std::rc::Rc; use uuid::Uuid; @@ -119,6 +119,8 @@ mod cloud_sync; mod discovery; mod events; mod fleet; +pub(crate) mod platform_attention; +pub use platform_attention::PlatformAttentionNotification; mod forwards; mod mentions; mod menu; @@ -497,6 +499,15 @@ pub struct Workspace { active_scripts: HashMap, /// Explicit local/SSH coding-agent runs, keyed by run ID. active_agent_runs: HashMap, + /// Exact catalog authority owned by each retained local-agent session. + /// Once established this binding is immutable for the session lifetime. + agent_session_bindings: HashMap< + Uuid, + ( + shelldeck_core::config::workspace_catalog::CatalogWorkspaceId, + shelldeck_core::config::workspace_catalog::CatalogCheckoutId, + ), + >, // Keep subscriptions alive _sidebar_sub: Subscription, _workspace_hub_sub: Subscription, @@ -600,6 +611,36 @@ pub struct Workspace { fleet_retry_not_before: Option, /// Fences platform responses across sign-out and subsequent sign-in. fleet_request_epoch: u64, + /// Authoritative Platform v2 attention, keyed only after exact catalog + /// reconciliation. Navigation always re-resolves these boards against the + /// current workspace/session/pane catalogues. + platform_attention_boards: BTreeMap< + shelldeck_core::config::workspace_catalog::CatalogWorkspaceId, + shelldeck_core::config::platform_attention::PlatformAttentionBoard, + >, + platform_attention_local: + Option, + platform_attention_resync: BTreeSet<( + shelldeck_core::config::workspace_catalog::CatalogWorkspaceId, + shelldeck_core::config::platform_attention::AttentionSource, + )>, + /// Retirement events which could not yet be written to the durable local + /// document. Once written, the store itself owns retry custody across + /// restart until overlay removal commits atomically. + platform_attention_retirements_pending: + BTreeSet, + /// Exact activations wait here while a real Dev-mode transition completes; + /// destinations are always resolved again from current authority state. + platform_attention_pending_activations: + VecDeque, + /// A destination opened on the previous UI turn is confirmed against the + /// rendered current surface before its exact local tuple becomes read. + platform_attention_visible_confirmations: VecDeque<( + shelldeck_core::config::platform_attention::PlatformAttentionActivation, + shelldeck_core::config::platform_attention::PlatformAttentionDestination, + u8, + )>, + platform_attention_notifier: Option>, /// Mentionable people from Inklura Manage, for the assistant's `@` picker. /// Empty until the directory endpoint ships (`manage_directory`); people /// are the one mention kind that needs server-side role information. @@ -945,6 +986,14 @@ impl Workspace { ); view }); + workspace_hub.update(cx, |hub, cx| { + hub.attach_agent_host(agent_console.clone(), cx); + }); + let agent_projects = + agents::agent_project_groups(workspace_hub.read(cx).catalog(), &connections); + agent_console.update(cx, |view, cx| { + view.set_project_groups(agent_projects, cx); + }); let scripts = cx.new(ScriptEditorView::new); let port_forwards = cx.new(|_| PortForwardView::new()); let server_sync = cx.new(|cx| { @@ -1144,15 +1193,46 @@ impl Workspace { }); let workspace_hub_sub = cx.subscribe( &workspace_hub, - |this, _hub, event: &workspaces::WorkspaceHubEvent, cx| { - let workspaces::WorkspaceHubEvent::ActiveTerminal(terminal) = event; - this.terminal = terminal.clone(); - this._terminal_sub = - cx.subscribe(terminal, |this, _terminal, event: &TerminalEvent, cx| { - this.handle_terminal_event(event, cx); + |this, _hub, event: &workspaces::WorkspaceHubEvent, cx| match event { + workspaces::WorkspaceHubEvent::ActiveTerminal(terminal) => { + let visible_session = this.workspace_hub.read(cx).active_agent_session_id(); + this.agent_console.update(cx, |view, cx| { + if let Some(session_id) = visible_session { + view.select_session(session_id, cx); + } + view.set_surface_visible(visible_session.is_some(), cx); }); - this.hydrate_active_terminal_runtime(cx); - cx.notify(); + this.terminal = terminal.clone(); + this._terminal_sub = + cx.subscribe(terminal, |this, _terminal, event: &TerminalEvent, cx| { + this.handle_terminal_event(event, cx); + }); + this.hydrate_active_terminal_runtime(cx); + cx.notify(); + } + workspaces::WorkspaceHubEvent::OpenPlatformAttention(activation) => { + this.activate_platform_attention(*activation, cx); + } + workspaces::WorkspaceHubEvent::OpenWorkspacePane(activation) => { + let visible_session = this.workspace_hub.read(cx).active_agent_session_id(); + this.agent_console.update(cx, |view, cx| { + if let Some(session_id) = visible_session { + view.select_session(session_id, cx); + } + view.set_surface_visible(visible_session.is_some(), cx); + }); + if let shelldeck_core::workspace_navigation::WorkspaceTabContent::AgentSession( + binding, + ) = &activation.content + { + this.agent_console.update(cx, |view, cx| { + view.select_session(binding.session_id, cx); + }); + } + } + workspaces::WorkspaceHubEvent::CatalogChanged => { + this.refresh_agent_projects(cx); + } }, ); let agent_console_sub = cx.subscribe( @@ -1350,6 +1430,7 @@ impl Workspace { active_tunnels: HashMap::new(), active_scripts: HashMap::new(), active_agent_runs: HashMap::new(), + agent_session_bindings: HashMap::new(), _sidebar_sub: sidebar_sub, _workspace_hub_sub: workspace_hub_sub, _terminal_sub: terminal_sub, @@ -1411,6 +1492,18 @@ impl Workspace { fleet_refresh_failures: 0, fleet_retry_not_before: None, fleet_request_epoch: 0, + platform_attention_boards: BTreeMap::new(), + platform_attention_local: shelldeck_core::config::platform_attention::AttentionLocalStateStore::open_default() + .map_err(|error| { + tracing::warn!(%error, "Platform attention local custody unavailable; notifications and local reads disabled"); + error + }) + .ok(), + platform_attention_resync: BTreeSet::new(), + platform_attention_retirements_pending: BTreeSet::new(), + platform_attention_pending_activations: VecDeque::new(), + platform_attention_visible_confirmations: VecDeque::new(), + platform_attention_notifier: None, mention_people: Vec::new(), issues_list: Vec::new(), issues_counts: IssueCounts::default(), diff --git a/crates/shelldeck-ui/src/workspace/modes.rs b/crates/shelldeck-ui/src/workspace/modes.rs index b10cde4a..da5b0f5c 100644 --- a/crates/shelldeck-ui/src/workspace/modes.rs +++ b/crates/shelldeck-ui/src/workspace/modes.rs @@ -141,6 +141,7 @@ impl Workspace { }; if workspace.mode_transition != Some(expected) || !workspace.can_access_mode(mode) { workspace.mode_transition = None; + workspace.drive_platform_attention_activations(cx); cx.notify(); return false; } @@ -197,6 +198,7 @@ impl Workspace { }; if workspace.mode_transition == Some(expected) { workspace.mode_transition = None; + workspace.drive_platform_attention_activations(cx); cx.notify(); } }); diff --git a/crates/shelldeck-ui/src/workspace/navigation.rs b/crates/shelldeck-ui/src/workspace/navigation.rs index 49ddd2af..eae3b743 100644 --- a/crates/shelldeck-ui/src/workspace/navigation.rs +++ b/crates/shelldeck-ui/src/workspace/navigation.rs @@ -454,6 +454,17 @@ impl Workspace { /// console just became visible. pub(super) fn on_active_view_changed(&mut self, cx: &mut Context) { self.close_titlebar_menus(); + let workspace_agent = (self.active_view == ActiveView::Workspaces) + .then(|| self.workspace_hub.read(cx).active_agent_session_id()) + .flatten(); + let agent_visible = !self.settings_open + && (self.active_view == ActiveView::Agents || workspace_agent.is_some()); + self.agent_console.update(cx, |view, cx| { + if let Some(session_id) = workspace_agent { + view.select_session(session_id, cx); + } + view.set_surface_visible(agent_visible, cx); + }); self.sync_monique_poll(cx); self.sync_fleet_view_poll(cx); self.sync_bext_poll(cx); diff --git a/crates/shelldeck-ui/src/workspace/platform_attention.rs b/crates/shelldeck-ui/src/workspace/platform_attention.rs new file mode 100644 index 00000000..59b04346 --- /dev/null +++ b/crates/shelldeck-ui/src/workspace/platform_attention.rs @@ -0,0 +1,1730 @@ +use super::*; + +use shelldeck_core::config::platform::PlatformAttentionLoad; +use shelldeck_core::config::platform_attention::{ + resolve_platform_attention_activation, AttentionApplyOutcome, AttentionError, + AttentionLocalKey, AttentionRetirement, AttentionUnavailableReason, NotificationReservation, + PlatformAttentionActivation, PlatformAttentionBoard, PlatformAttentionDestination, +}; +use shelldeck_core::config::workspace_catalog::CatalogWorkspaceId; + +fn apply_attention_read_with_resync( + board: &mut PlatformAttentionBoard, + resync: &mut BTreeSet<( + CatalogWorkspaceId, + shelldeck_core::config::platform_attention::AttentionSource, + )>, + workspace: CatalogWorkspaceId, + source: &shelldeck_core::config::platform_attention::AttentionSource, + read: shelldeck_core::config::platform::AttentionReadResult, +) -> Result { + let initial = board.retained_snapshot(source).is_none(); + let explicit_resync = resync.contains(&(workspace, source.clone())); + let outcome = if initial || explicit_resync { + board.apply_authenticated_baseline_read(source, read) + } else { + let outcome = board.apply_read(source, read); + if matches!(outcome, Err(AttentionError::InvalidSuccessor)) { + let _ = board.mark_unavailable(source, AttentionUnavailableReason::InventoryIncomplete); + resync.insert((workspace, source.clone())); + } + outcome + }; + if matches!( + outcome, + Ok(AttentionApplyOutcome::Inserted + | AttentionApplyOutcome::Replaced + | AttentionApplyOutcome::ExactReplay + | AttentionApplyOutcome::AvailabilityRestored) + ) { + resync.remove(&(workspace, source.clone())); + } + outcome +} + +fn mark_attention_boards_unavailable( + boards: &mut BTreeMap, +) { + for board in boards.values_mut() { + for source in board.inventory().sources().to_vec() { + let _ = board.mark_unavailable(&source, AttentionUnavailableReason::Transport); + } + } +} + +fn retire_attention_board_state( + boards: &mut BTreeMap, + resync: &mut BTreeSet<( + CatalogWorkspaceId, + shelldeck_core::config::platform_attention::AttentionSource, + )>, + workspace: CatalogWorkspaceId, +) -> bool { + if boards.remove(&workspace).is_none() { + return false; + } + resync.retain(|(id, _)| *id != workspace); + true +} + +const fn platform_attention_destination_view( + destination: &PlatformAttentionDestination, +) -> ActiveView { + match destination { + PlatformAttentionDestination::FleetSession { .. } => ActiveView::Fleet, + PlatformAttentionDestination::WorkspaceAttention { .. } + | PlatformAttentionDestination::RetainedProviderPane { .. } => ActiveView::Workspaces, + } +} + +fn platform_attention_surface_verified( + mode: AppMode, + transitioning: bool, + settings_open: bool, + active_view: ActiveView, + required_view: ActiveView, +) -> bool { + mode == AppMode::Dev && !transitioning && !settings_open && active_view == required_view +} + +fn attention_context_requires_retirement( + board: &PlatformAttentionBoard, + target: Option<&shelldeck_core::config::platform_attention::PlatformAttentionTarget>, +) -> bool { + target != Some(board.inventory().target()) +} + +fn attention_context_retirements( + boards: &BTreeMap, + context: Option<&( + CatalogWorkspaceId, + Option, + )>, +) -> Vec { + match context { + None => boards.keys().copied().collect(), + Some((workspace, target)) => boards + .get(workspace) + .filter(|board| attention_context_requires_retirement(board, target.as_ref())) + .map(|_| vec![*workspace]) + .unwrap_or_default(), + } +} + +fn record_attention_read_after_visible_open( + store: Option<&mut shelldeck_core::config::platform_attention::AttentionLocalStateStore>, + local: AttentionLocalKey, + opened: bool, +) -> bool { + if !opened { + return false; + } + store.is_some_and(|store| store.record_read(local).is_ok()) +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct PlatformAttentionPresentation { + pub activation: PlatformAttentionActivation, + pub state: shelldeck_core::config::platform_attention::AttentionItemState, + pub reason: shelldeck_core::config::platform_attention::AttentionItemReason, + pub unread: bool, + pub nested_agent_path: Vec, + /// The authoritative observation instant, carried so the chronological + /// surface orders on what the server observed rather than on when this + /// process happened to poll. + pub observed_at_ms: u64, + /// Exact catalog name of the workspace this row activates into. Empty + /// when the catalog no longer names it; the destination is still resolved + /// again at activation and never taken from this label. + pub workspace_label: String, +} + +/// Order attention rows newest observation first. +/// +/// Boards are per workspace, so a cross-workspace list has to be re-ordered +/// after flattening. Equal observations fall back to the item revision and +/// then to the stable presentation identity, so the sequence is total and +/// does not depend on which board was merged first. +pub(crate) fn sort_platform_attention_chronologically(rows: &mut [PlatformAttentionPresentation]) { + rows.sort_by(|left, right| { + right + .observed_at_ms + .cmp(&left.observed_at_ms) + .then_with(|| { + right + .activation + .item_revision + .cmp(&left.activation.item_revision) + }) + .then_with(|| { + left.activation + .item + .uuid() + .cmp(&right.activation.item.uuid()) + }) + }); +} + +/// Same-process only notification payload. No URI/deep-link or cold-launch +/// contract is encoded in this type. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PlatformAttentionNotification { + pub activation: PlatformAttentionActivation, + pub summary: String, + pub body: String, + pub action_label: String, +} + +impl Workspace { + const MAX_PENDING_PLATFORM_ATTENTION_ACTIVATIONS: usize = 64; + + pub fn set_platform_attention_notifier( + &mut self, + notifier: Box, + ) { + self.platform_attention_notifier = Some(notifier); + } + + pub(super) fn apply_platform_attention_load( + &mut self, + workspace: CatalogWorkspaceId, + load: PlatformAttentionLoad, + cx: &mut Context, + ) { + let workspace_label = self + .workspace_hub + .read(cx) + .catalog() + .workspace(workspace) + .map(|record| record.name().to_owned()) + .unwrap_or_default(); + if self + .platform_attention_boards + .get(&workspace) + .is_some_and(|board| { + attention_context_requires_retirement(board, Some(load.inventory.target())) + }) + && !self.retire_platform_attention_board(workspace) + { + let _ = self.flush_retired_platform_attention_sources(); + self.sync_platform_attention_presentations(cx); + return; + } + let removed_sources = self + .platform_attention_boards + .get(&workspace) + .map(|board| { + board + .inventory() + .sources() + .iter() + .filter(|source| !load.inventory.contains(source)) + .cloned() + .collect::>() + }) + .unwrap_or_default(); + if let Some(board) = self.platform_attention_boards.get(&workspace) { + self.platform_attention_retirements_pending.extend( + removed_sources.iter().cloned().map(|source| { + AttentionRetirement::new(board.inventory().target().clone(), source) + }), + ); + } + if !self.flush_retired_platform_attention_sources() { + if let Some(board) = self.platform_attention_boards.get_mut(&workspace) { + for source in removed_sources { + let _ = board + .mark_unavailable(&source, AttentionUnavailableReason::InventoryIncomplete); + } + } + self.sync_platform_attention_presentations(cx); + return; + } + let board = self + .platform_attention_boards + .entry(workspace) + .or_insert_with(|| PlatformAttentionBoard::new(load.inventory.clone())); + if let Err(error) = board.replace_inventory(load.inventory) { + tracing::warn!(%error, %workspace, "Platform attention inventory rejected"); + return; + } + self.platform_attention_resync + .retain(|(id, source)| *id != workspace || board.inventory().contains(source)); + + let mut notifications = Vec::new(); + for (source, read) in load.reads { + let outcome = apply_attention_read_with_resync( + board, + &mut self.platform_attention_resync, + workspace, + &source, + read, + ); + let accepted = matches!( + outcome, + Ok(AttentionApplyOutcome::Inserted + | AttentionApplyOutcome::Replaced + | AttentionApplyOutcome::ExactReplay + | AttentionApplyOutcome::AvailabilityRestored) + ); + if !accepted { + if let Err(error) = outcome { + tracing::warn!(%error, "Platform attention source replacement rejected"); + } + continue; + } + let current = board + .retained_snapshot(&source) + .map(|snapshot| { + snapshot + .items() + .iter() + .map(|item| { + AttentionLocalKey::new( + board.inventory().target().clone(), + shelldeck_core::config::platform_attention::AttentionItemKey::new( + source.clone(), + item.id().clone(), + ), + item.revision(), + ) + }) + .collect() + }) + .unwrap_or_default(); + let Some(store) = self.platform_attention_local.as_mut() else { + continue; + }; + if let Err(error) = store.reconcile_source(board.inventory().target(), &source, current) + { + tracing::warn!(%error, "Platform attention custody reconciliation failed"); + continue; + } + for item in board + .visible_items() + .filter(|item| item.key().source() == &source) + { + let local_key = AttentionLocalKey::new( + board.inventory().target().clone(), + item.key().clone(), + item.value().revision(), + ); + let eligible = item.value().unread() && !store.state().is_read(&local_key); + match store.reserve_notification(local_key, eligible) { + Ok(NotificationReservation::Reserved) => { + notifications.push(PlatformAttentionNotification { + activation: PlatformAttentionActivation { + workspace, + item: item.ui_id(), + item_revision: item.value().revision(), + }, + summary: t!("notification.attention.summary").to_string(), + body: notification_body( + item.value().reason(), + workspace_label.as_str(), + ), + action_label: t!("notification.attention.open").to_string(), + }) + } + Ok( + NotificationReservation::Ineligible + | NotificationReservation::AlreadyReserved, + ) => {} + Err(error) => { + tracing::warn!(%error, "Platform attention notification suppressed because reservation failed"); + } + } + } + } + self.sync_platform_attention_presentations(cx); + if let Some(notifier) = self.platform_attention_notifier.as_ref() { + for notification in notifications { + notifier(notification); + } + } + } + + pub(super) fn clear_platform_attention(&mut self, cx: &mut Context) { + self.platform_attention_pending_activations.clear(); + self.platform_attention_visible_confirmations.clear(); + for workspace in self + .platform_attention_boards + .keys() + .copied() + .collect::>() + { + let _ = self.retire_platform_attention_board(workspace); + } + let _ = self.flush_retired_platform_attention_sources(); + self.sync_platform_attention_presentations(cx); + } + + fn retire_platform_attention_board(&mut self, workspace: CatalogWorkspaceId) -> bool { + if let Some(board) = self.platform_attention_boards.get(&workspace) { + let target = board.inventory().target().clone(); + self.platform_attention_retirements_pending.extend( + board + .inventory() + .sources() + .iter() + .cloned() + .map(|source| AttentionRetirement::new(target.clone(), source)), + ); + let mut fenced = true; + if let Some(store) = self.platform_attention_local.as_mut() { + for retirement in self.platform_attention_retirements_pending.clone() { + if let Err(error) = store.begin_retirement(retirement) { + fenced = false; + tracing::warn!(%error, "Platform attention retirement fence not persisted"); + } + } + } + if !fenced { + if let Some(board) = self.platform_attention_boards.get_mut(&workspace) { + for source in board.inventory().sources().to_vec() { + let _ = board.mark_unavailable( + &source, + AttentionUnavailableReason::InventoryIncomplete, + ); + } + } + return false; + } + } + retire_attention_board_state( + &mut self.platform_attention_boards, + &mut self.platform_attention_resync, + workspace, + ) + } + + fn flush_retired_platform_attention_sources(&mut self) -> bool { + let Some(store) = self.platform_attention_local.as_mut() else { + // With no local store there is no overlay state to inherit. + self.platform_attention_retirements_pending.clear(); + return true; + }; + for retirement in self.platform_attention_retirements_pending.clone() { + match store.begin_retirement(retirement.clone()) { + Ok(_) => { + self.platform_attention_retirements_pending + .remove(&retirement); + } + Err(error) => { + tracing::warn!(%error, "Platform attention retirement fence not persisted"); + } + } + } + let durable = store.pending_retirements().clone(); + for retirement in durable { + if let Err(error) = store.finish_retirement(&retirement) { + tracing::warn!(%error, "Platform attention retired-source custody not removed"); + } + } + self.platform_attention_retirements_pending.is_empty() + && store.pending_retirements().is_empty() + } + + /// Retire a board immediately when the active local workspace loses its + /// exact mapping, or when that mapping now names a different Platform + /// target. A later valid load creates a fresh board without requiring a + /// process restart. + pub(super) fn reconcile_platform_attention_context( + &mut self, + context: Option<( + CatalogWorkspaceId, + Option, + )>, + cx: &mut Context, + ) { + let retire = + attention_context_retirements(&self.platform_attention_boards, context.as_ref()); + if !retire.is_empty() { + for workspace in retire { + let _ = self.retire_platform_attention_board(workspace); + } + self.sync_platform_attention_presentations(cx); + } + let _ = self.flush_retired_platform_attention_sources(); + } + + pub(super) fn mark_platform_attention_unavailable( + &mut self, + workspace: CatalogWorkspaceId, + target: &shelldeck_core::config::platform_attention::PlatformAttentionTarget, + cx: &mut Context, + ) { + let Some(board) = self.platform_attention_boards.get_mut(&workspace) else { + return; + }; + if board.inventory().target() != target { + return; + } + for source in board.inventory().sources().to_vec() { + let _ = board.mark_unavailable(&source, AttentionUnavailableReason::Transport); + } + self.sync_platform_attention_presentations(cx); + } + + pub(super) fn mark_all_platform_attention_unavailable(&mut self, cx: &mut Context) { + mark_attention_boards_unavailable(&mut self.platform_attention_boards); + self.sync_platform_attention_presentations(cx); + } + + fn sync_platform_attention_presentations(&mut self, cx: &mut Context) { + let labels = self + .platform_attention_boards + .keys() + .copied() + .map(|workspace| { + let label = self + .workspace_hub + .read(cx) + .catalog() + .workspace(workspace) + .map(|record| record.name().to_owned()) + .unwrap_or_default(); + (workspace, label) + }) + .collect::>(); + let mut rows = BTreeMap::>::new(); + for (workspace, board) in &self.platform_attention_boards { + let items = board + .chronology() + .into_iter() + .map(|item| { + let key = AttentionLocalKey::new( + board.inventory().target().clone(), + item.key().clone(), + item.value().revision(), + ); + PlatformAttentionPresentation { + activation: PlatformAttentionActivation { + workspace: *workspace, + item: item.ui_id(), + item_revision: item.value().revision(), + }, + state: item.value().state(), + reason: item.value().reason(), + unread: item.value().unread() + && self + .platform_attention_local + .as_ref() + .is_none_or(|store| !store.state().is_read(&key)), + nested_agent_path: item + .value() + .nested_agent_path() + .iter() + .map(|id| id.as_str().to_owned()) + .collect(), + observed_at_ms: item.value().observed_at_ms(), + workspace_label: labels.get(workspace).cloned().unwrap_or_default(), + } + }) + .collect(); + rows.insert(*workspace, items); + } + let mut chronology = rows.values().flatten().cloned().collect::>(); + sort_platform_attention_chronologically(&mut chronology); + self.workspace_hub.update(cx, |hub, cx| { + hub.set_platform_attention(rows, cx); + }); + self.fleet_view.update(cx, |fleet, cx| { + fleet.set_platform_attention(chronology); + cx.notify(); + }); + } + + fn resolve_current_platform_attention( + &mut self, + activation: PlatformAttentionActivation, + cx: &mut Context, + ) -> Option<(PlatformAttentionDestination, AttentionLocalKey)> { + let board = self.platform_attention_boards.get(&activation.workspace)?; + let snapshot = self.fleet_snapshot.as_ref()?; + let destination = self.workspace_hub.update(cx, |hub, _| { + resolve_platform_attention_activation( + activation, + board, + hub.catalog(), + hub.navigation(), + &snapshot.sessions, + ) + .ok() + })?; + let local = board.item_by_ui_id(activation.item).map(|item| { + AttentionLocalKey::new( + board.inventory().target().clone(), + item.key().clone(), + activation.item_revision, + ) + })?; + Some((destination, local)) + } + + /// Admit an exact same-process activation. Opening and read custody are + /// deliberately separate UI turns: a real Dev transition must finish, + /// then the destination is opened, then current authority and rendered + /// visibility are checked again before the tuple becomes read. + pub fn activate_platform_attention( + &mut self, + activation: PlatformAttentionActivation, + cx: &mut Context, + ) -> bool { + if self + .resolve_current_platform_attention(activation, cx) + .is_none() + || !self.can_access_mode(AppMode::Dev) + { + return false; + } + if self + .platform_attention_pending_activations + .iter() + .chain( + self.platform_attention_visible_confirmations + .iter() + .map(|(activation, _, _)| activation), + ) + .any(|pending| *pending == activation) + { + return true; + } + if self.platform_attention_pending_activations.len() + + self.platform_attention_visible_confirmations.len() + == Self::MAX_PENDING_PLATFORM_ATTENTION_ACTIVATIONS + { + return false; + } + self.platform_attention_pending_activations + .push_back(activation); + self.drive_platform_attention_activations(cx); + true + } + + pub(super) fn drive_platform_attention_activations(&mut self, cx: &mut Context) { + if self.platform_attention_pending_activations.is_empty() + || !self.platform_attention_visible_confirmations.is_empty() + { + return; + } + if !self.can_access_mode(AppMode::Dev) { + self.platform_attention_pending_activations.clear(); + return; + } + if self.mode_transition.is_some() { + self.settings_open = false; + return; + } + if self.effective_mode() != AppMode::Dev { + let _ = self.enter_dev_mode(cx); + return; + } + self.settings_open = false; + let Some(activation) = self.platform_attention_pending_activations.pop_front() else { + return; + }; + let Some((destination, _)) = self.resolve_current_platform_attention(activation, cx) else { + self.drive_platform_attention_activations(cx); + return; + }; + let required_view = platform_attention_destination_view(&destination); + let opened = match &destination { + PlatformAttentionDestination::WorkspaceAttention { workspace } => { + self.active_view = required_view; + self.on_active_view_changed(cx); + let opened = self.workspace_hub.update(cx, |hub, cx| { + hub.open_platform_attention_surface(*workspace, cx) + }); + opened + && platform_attention_surface_verified( + self.effective_mode(), + self.mode_transition.is_some(), + self.settings_open, + self.active_view, + required_view, + ) + } + PlatformAttentionDestination::FleetSession { + workspace: _, + session, + } => { + self.open_fleet(cx) + && self + .fleet_view + .update(cx, |view, _| view.open_session_exact(session)) + && platform_attention_surface_verified( + self.effective_mode(), + self.mode_transition.is_some(), + self.settings_open, + self.active_view, + required_view, + ) + } + PlatformAttentionDestination::RetainedProviderPane { + workspace, + session, + focus, + } => { + self.active_view = required_view; + self.on_active_view_changed(cx); + let opened = self.workspace_hub.update(cx, |hub, cx| { + hub.open_retained_provider_pane(*workspace, session, *focus, cx) + }); + opened + && platform_attention_surface_verified( + self.effective_mode(), + self.mode_transition.is_some(), + self.settings_open, + self.active_view, + required_view, + ) + } + }; + if !opened { + self.drive_platform_attention_activations(cx); + return; + } + self.platform_attention_visible_confirmations + .push_back((activation, destination, 0)); + self.schedule_platform_attention_visibility_confirmation(cx); + } + + fn schedule_platform_attention_visibility_confirmation(&mut self, cx: &mut Context) { + cx.spawn(async move |this, cx: &mut AsyncApp| { + cx.background_executor() + .timer(std::time::Duration::from_millis(16)) + .await; + let _ = this.update(cx, |workspace, cx| { + workspace.confirm_visible_platform_attention_activation(cx); + }); + }) + .detach(); + } + + fn confirm_visible_platform_attention_activation(&mut self, cx: &mut Context) { + let Some((activation, opened_destination, attempt)) = + self.platform_attention_visible_confirmations.pop_front() + else { + return; + }; + let Some((current_destination, local)) = + self.resolve_current_platform_attention(activation, cx) + else { + self.drive_platform_attention_activations(cx); + return; + }; + let required_view = platform_attention_destination_view(¤t_destination); + let common_visible = current_destination == opened_destination + && platform_attention_surface_verified( + self.effective_mode(), + self.mode_transition.is_some(), + self.settings_open, + self.active_view, + required_view, + ); + let exact_visible = common_visible + && match ¤t_destination { + PlatformAttentionDestination::WorkspaceAttention { workspace } => self + .workspace_hub + .read(cx) + .platform_attention_surface_is_visible(*workspace), + PlatformAttentionDestination::FleetSession { session, .. } => { + self.fleet_view.read(cx).session_is_open_exact(session) + } + PlatformAttentionDestination::RetainedProviderPane { + workspace, + session, + focus, + } => self + .workspace_hub + .read(cx) + .retained_provider_pane_is_visible(*workspace, session, *focus, cx), + }; + if !exact_visible && attempt < 60 { + self.platform_attention_visible_confirmations.push_front(( + activation, + opened_destination, + attempt + 1, + )); + self.schedule_platform_attention_visibility_confirmation(cx); + return; + } + if record_attention_read_after_visible_open( + self.platform_attention_local.as_mut(), + local, + exact_visible, + ) { + self.sync_platform_attention_presentations(cx); + } + self.drive_platform_attention_activations(cx); + } +} + +/// Name the destination in the toast body. +/// +/// Only the workspace is named: it is part of the activation itself. The +/// session and pane are resolved against the live catalogue when the toast is +/// activated, so naming them here would be a guess with a shelf life. +fn notification_body( + reason: shelldeck_core::config::platform_attention::AttentionItemReason, + workspace_label: &str, +) -> String { + let reason = attention_reason_label(reason); + if workspace_label.is_empty() { + return reason; + } + t!( + "notification.attention.body", + reason = reason, + workspace = workspace_label + ) + .to_string() +} + +pub(crate) fn attention_reason_label( + reason: shelldeck_core::config::platform_attention::AttentionItemReason, +) -> String { + t!(match reason { + shelldeck_core::config::platform_attention::AttentionItemReason::ReviewRequested => + "platform_attention.reason.review_requested", + shelldeck_core::config::platform_attention::AttentionItemReason::CommentReply => + "platform_attention.reason.comment_reply", + shelldeck_core::config::platform_attention::AttentionItemReason::ApprovalRequired => + "platform_attention.reason.approval_required", + shelldeck_core::config::platform_attention::AttentionItemReason::AgentWorking => + "platform_attention.reason.agent_working", + shelldeck_core::config::platform_attention::AttentionItemReason::CheckRunning => + "platform_attention.reason.check_running", + shelldeck_core::config::platform_attention::AttentionItemReason::DeliveryPending => + "platform_attention.reason.delivery_pending", + shelldeck_core::config::platform_attention::AttentionItemReason::Complete => + "platform_attention.reason.complete", + shelldeck_core::config::platform_attention::AttentionItemReason::Conflict => + "platform_attention.reason.conflict", + shelldeck_core::config::platform_attention::AttentionItemReason::CheckFailed => + "platform_attention.reason.check_failed", + shelldeck_core::config::platform_attention::AttentionItemReason::ExternalBlocker => + "platform_attention.reason.external_blocker", + }) + .to_string() +} + +pub(crate) fn platform_attention_state_label( + state: shelldeck_core::config::platform_attention::AttentionItemState, +) -> String { + match state { + shelldeck_core::config::platform_attention::AttentionItemState::NeedsYou => { + t!("workspaces.attention.needs_you") + } + shelldeck_core::config::platform_attention::AttentionItemState::Working => { + t!("workspaces.attention.working") + } + shelldeck_core::config::platform_attention::AttentionItemState::Done => { + t!("workspaces.attention.done") + } + shelldeck_core::config::platform_attention::AttentionItemState::Blocked => { + t!("workspaces.attention.blocked") + } + } + .to_string() +} + +#[cfg(test)] +mod tests { + use super::{ + apply_attention_read_with_resync, attention_context_requires_retirement, + attention_context_retirements, attention_reason_label, mark_attention_boards_unavailable, + notification_body, platform_attention_destination_view, + platform_attention_surface_verified, record_attention_read_after_visible_open, + retire_attention_board_state, sort_platform_attention_chronologically, + PlatformAttentionPresentation, + }; + use crate::workspace::workspaces::WorkspaceHubView; + use crate::workspace::ActiveView; + use crate::{ + ai_assistant::AiAssistantView, + terminal_view::TerminalView, + workspace::{Workspace, WorkspaceAiBindings}, + }; + use automonique_protocol::platform::{Capabilities, CursorTopic, PlatformCursor}; + use automonique_protocol::platform_v2::{ + CheckoutId, WorkContextAttributes, WorkContextIdentity, WorkContextLabel, + WorkContextLifecycle, WorkContextRecord, WorkContextRelation, WorkContextRelationKind, + }; + use automonique_protocol::platform_v2_attention::{ + AttentionItem, AttentionItemId, AttentionItemReason, AttentionItemState, AttentionSourceId, + AttentionSourceKind, AttentionSourceSnapshot, + }; + use automonique_protocol::primitives::Revision; + use gpui::AppContext; + use shelldeck_core::config::platform::{ + AttentionReadResult, PlatformSnapshot, PlatformView, ResourceAuthority, ResourceCoordinate, + ResourceId, ResourceKind, + }; + use shelldeck_core::config::platform_attention::{ + AttentionApplyOutcome, AttentionError, AttentionItemKey, AttentionLocalKey, + AttentionLocalStateStore, AttentionRetirement, AttentionSourceInventory, + AttentionSourceStatus, AttentionUnavailableReason, PlatformAttentionActivation, + PlatformAttentionBoard, PlatformAttentionDestination, PlatformAttentionTarget, + ReviewAttentionPresence, + }; + use shelldeck_core::config::workspace_catalog::{ + CatalogCheckoutId, CatalogProjectId, CatalogWorkspaceId, CheckoutHost, PlatformContextRef, + PlatformMappingReconciliation, PlatformV2Mapping, ProjectCatalog, ProjectCheckout, + ProjectRecord, RepositoryIdentity, WorkspaceLaunchIntake, WorkspaceLaunchRequest, + }; + use shelldeck_core::workspace_navigation::{PaneId, WorkspaceFocus, WorkspaceTabId}; + use shelldeck_core::{ + ai::{AiConfig, AiContext, AiSurface, ClippyConfig}, + config::{ + app_config::AppConfig, + cloud_account::{AccountInfo, AppMode}, + store::ConnectionStore, + }, + }; + use std::collections::{BTreeMap, BTreeSet}; + use std::{cell::RefCell, rc::Rc}; + use uuid::Uuid; + + fn exact_catalog() -> (ProjectCatalog, CatalogWorkspaceId) { + let project = CatalogProjectId::from_uuid(Uuid::from_u128(18_290)); + let checkout = CatalogCheckoutId::from_uuid(Uuid::from_u128(18_291)); + let workspace = CatalogWorkspaceId::from_uuid(Uuid::from_u128(18_292)); + let mut record = ProjectRecord::new(project, "Attention"); + record.add_checkout(ProjectCheckout::new( + checkout, + "local", + CheckoutHost::Local { + device_label: "test".into(), + root: std::env::current_dir().unwrap(), + }, + RepositoryIdentity { + slug: "test/attention".into(), + canonical_url: None, + }, + )); + let mut catalog = ProjectCatalog::default(); + catalog.insert_project(record).unwrap(); + catalog + .create_workspace(WorkspaceLaunchRequest { + id: workspace, + project_id: project, + checkout_id: checkout, + name: "Attention".into(), + intake: WorkspaceLaunchIntake::Manual, + }) + .unwrap(); + catalog + .set_platform_mapping( + workspace, + None, + PlatformV2Mapping { + reconciliation_revision: 1, + project: PlatformContextRef { + id: "project-1".into(), + revision: 1, + }, + checkout: PlatformContextRef { + id: "checkout-1".into(), + revision: 1, + }, + user_workspace: PlatformContextRef { + id: "workspace-1".into(), + revision: 1, + }, + reconciliation: PlatformMappingReconciliation::Exact { + reconciled_at_millis: 1, + }, + }, + ) + .unwrap(); + (catalog, workspace) + } + + fn relation(kind: WorkContextRelationKind, target: WorkContextIdentity) -> WorkContextRelation { + WorkContextRelation::new(kind, target).unwrap() + } + + fn inventory(project: &str, workspace: &str) -> AttentionSourceInventory { + let project_id = automonique_protocol::platform_v2::ProjectId::new(project).unwrap(); + let workspace_id = + automonique_protocol::platform_v2::UserWorkspaceId::new(workspace).unwrap(); + let record = WorkContextRecord::new( + WorkContextIdentity::UserWorkspace(workspace_id.clone()), + Revision::FIRST, + WorkContextLifecycle::Active, + WorkContextLabel::new("workspace").unwrap(), + WorkContextAttributes::EMPTY, + vec![ + relation( + WorkContextRelationKind::UserWorkspaceProject, + WorkContextIdentity::Project(project_id.clone()), + ), + relation( + WorkContextRelationKind::UserWorkspaceCheckout, + WorkContextIdentity::Checkout(CheckoutId::new("checkout-1").unwrap()), + ), + ], + ) + .unwrap(); + AttentionSourceInventory::from_authoritative_records( + PlatformAttentionTarget { + project: project_id, + user_workspace: workspace_id, + }, + &[record], + ReviewAttentionPresence::Absent, + ) + .unwrap() + } + + fn source(workspace: &str) -> shelldeck_core::config::platform_attention::AttentionSource { + shelldeck_core::config::platform_attention::AttentionSource::new( + AttentionSourceKind::Orchestration, + AttentionSourceId::new(workspace).unwrap(), + ) + } + + fn snapshot( + project: &str, + workspace: &str, + revision: u64, + previous: Option, + ) -> AttentionSourceSnapshot { + AttentionSourceSnapshot::new( + source(workspace), + automonique_protocol::platform_v2::ProjectId::new(project).unwrap(), + automonique_protocol::platform_v2::UserWorkspaceId::new(workspace).unwrap(), + Revision::new(revision).unwrap(), + previous.map(|value| Revision::new(value).unwrap()), + revision * 100, + vec![AttentionItem::new( + AttentionItemId::new("item-1").unwrap(), + Revision::new(revision).unwrap(), + revision * 100, + AttentionItemState::Blocked, + AttentionItemReason::ExternalBlocker, + true, + Vec::new(), + None, + ) + .unwrap()], + ) + .unwrap() + } + + fn workspace_activation_fixture( + cx: &mut gpui::TestAppContext, + mode: AppMode, + ) -> ( + gpui::Entity, + PlatformAttentionActivation, + AttentionLocalKey, + tempfile::TempDir, + ) { + let (catalog, catalog_workspace) = exact_catalog(); + let temp = tempfile::tempdir().unwrap(); + let local_path = temp.path().join("private").join("local.json"); + let entity = cx.update(|cx| { + let assistant = cx.new(|cx| { + AiAssistantView::new( + AiContext::new(AiSurface::Global, "test", serde_json::json!({})), + cx, + ) + }); + let mut config = AppConfig::default(); + config.general.ui_font_family = crate::settings::normalize_ui_font_family("Inter", cx); + config.account = Some(AccountInfo { + email: "operator@example.test".into(), + name: "Operator".into(), + is_superadmin: true, + is_admin: true, + is_inklura_support: true, + roles: vec!["superadmin".into()], + }); + config.cloud_sync.enabled = true; + config.cloud_sync.token = "fixture-sensitive-token".into(); + config.cloud_sync.base_url = "https://manage.example.test".into(); + config.cloud_sync.mode = mode; + let workspace = cx.new(|cx| { + Workspace::new( + cx, + config, + Vec::new(), + ConnectionStore::default(), + WorkspaceAiBindings { + assistant, + tasks: Vec::new(), + config: Rc::new(RefCell::new(AiConfig::default())), + clippy_config: Rc::new(RefCell::new(ClippyConfig::default())), + }, + ) + }); + let terminal = cx.new(TerminalView::new); + let hub = cx.new(|cx| WorkspaceHubView::new(Ok(catalog), &[], terminal, cx)); + workspace.update(cx, |workspace, _| { + workspace.workspace_hub = hub; + workspace.platform_attention_local = + Some(AttentionLocalStateStore::open(local_path, 8).unwrap()); + let source = source("workspace-1"); + let mut board = PlatformAttentionBoard::new(inventory("project-1", "workspace-1")); + board + .apply_authenticated_baseline_read( + &source, + AttentionReadResult::Snapshot(Box::new(snapshot( + "project-1", + "workspace-1", + 1, + None, + ))), + ) + .unwrap(); + workspace + .platform_attention_boards + .insert(catalog_workspace, board); + let cursor = PlatformCursor { + authority: ResourceAuthority::Automonique, + topic: CursorTopic::new("resources").unwrap(), + sequence: Revision::FIRST, + }; + workspace.fleet_snapshot = Some(PlatformSnapshot { + capabilities: Capabilities::platform_v1(), + resources: Vec::new(), + cursor: cursor.clone(), + sessions: Vec::new(), + sessions_cursor: cursor, + view: PlatformView::default(), + }); + // Keep this deterministic fixture on its injected authority + // snapshot; the mode poll must not issue a real HTTP refresh. + workspace.fleet_refresh_in_flight = true; + }); + workspace + }); + cx.executor().allow_parking(); + cx.run_until_parked(); + cx.executor().forbid_parking(); + let (activation, key) = entity.read_with(cx, |workspace, _| { + let board = &workspace.platform_attention_boards[&catalog_workspace]; + let item = board.visible_items().next().unwrap(); + ( + PlatformAttentionActivation { + workspace: catalog_workspace, + item: item.ui_id(), + item_revision: item.value().revision(), + }, + AttentionLocalKey::new( + board.inventory().target().clone(), + item.key().clone(), + item.value().revision(), + ), + ) + }); + (entity, activation, key, temp) + } + + // SDTEST-1833 + #[test] + fn workspace_attention_gap_hides_the_source_until_the_next_poll_baseline() { + let workspace = CatalogWorkspaceId::from_uuid(Uuid::from_u128(1826)); + let source = source("workspace-1"); + let mut board = PlatformAttentionBoard::new(inventory("project-1", "workspace-1")); + let mut resync = BTreeSet::new(); + assert_eq!( + apply_attention_read_with_resync( + &mut board, + &mut resync, + workspace, + &source, + AttentionReadResult::Snapshot(Box::new(snapshot( + "project-1", + "workspace-1", + 1, + None, + ))), + ) + .unwrap(), + AttentionApplyOutcome::Inserted + ); + assert!(matches!( + apply_attention_read_with_resync( + &mut board, + &mut resync, + workspace, + &source, + AttentionReadResult::Snapshot(Box::new(snapshot( + "project-1", + "workspace-1", + 3, + Some(2), + ))), + ), + Err(AttentionError::InvalidSuccessor) + )); + assert!(board.visible_items().next().is_none()); + assert!(resync.contains(&(workspace, source.clone()))); + + assert_eq!( + apply_attention_read_with_resync( + &mut board, + &mut resync, + workspace, + &source, + AttentionReadResult::Snapshot(Box::new(snapshot( + "project-1", + "workspace-1", + 3, + Some(2), + ))), + ) + .unwrap(), + AttentionApplyOutcome::Replaced + ); + assert!(!resync.contains(&(workspace, source))); + assert_eq!(board.visible_items().count(), 1); + } + + // SDTEST-1834 + #[test] + fn whole_poll_failure_hides_every_board_and_mapping_replacement_recovers_cleanly() { + let workspace = CatalogWorkspaceId::from_uuid(Uuid::from_u128(1827)); + let source_one = source("workspace-1"); + let mut board = PlatformAttentionBoard::new(inventory("project-1", "workspace-1")); + board + .apply_authenticated_baseline_read( + &source_one, + AttentionReadResult::Snapshot(Box::new(snapshot( + "project-1", + "workspace-1", + 1, + None, + ))), + ) + .unwrap(); + let mut boards = BTreeMap::from([(workspace, board)]); + assert!(!attention_context_requires_retirement( + &boards[&workspace], + Some(boards[&workspace].inventory().target()), + )); + assert!(attention_context_requires_retirement( + &boards[&workspace], + None, + )); + let remapped_target = inventory("project-2", "workspace-2"); + assert!(attention_context_requires_retirement( + &boards[&workspace], + Some(remapped_target.target()), + )); + mark_attention_boards_unavailable(&mut boards); + assert!(boards[&workspace].visible_items().next().is_none()); + assert_eq!( + boards[&workspace].status(&source_one), + Some(&AttentionSourceStatus::Unavailable( + AttentionUnavailableReason::Transport + )) + ); + + let mut resync = BTreeSet::from([(workspace, source_one.clone())]); + assert!(retire_attention_board_state( + &mut boards, + &mut resync, + workspace, + )); + assert!(!boards.contains_key(&workspace)); + assert!(resync.is_empty()); + + let replacement = PlatformAttentionBoard::new(inventory("project-2", "workspace-2")); + boards.insert(workspace, replacement); + assert_eq!( + boards[&workspace].inventory().target(), + &PlatformAttentionTarget { + project: automonique_protocol::platform_v2::ProjectId::new("project-2").unwrap(), + user_workspace: automonique_protocol::platform_v2::UserWorkspaceId::new( + "workspace-2" + ) + .unwrap(), + } + ); + } + + // SDTEST-1835 + #[test] + fn activation_routes_each_destination_to_a_visible_surface_before_read_custody() { + let workspace = CatalogWorkspaceId::from_uuid(Uuid::from_u128(1828)); + let session = ResourceCoordinate::new( + ResourceAuthority::Automonique, + ResourceKind::Session, + ResourceId::new("session-1").unwrap(), + ); + let focus = WorkspaceFocus { + pane_id: PaneId::from_uuid(Uuid::from_u128(1)), + tab_id: WorkspaceTabId::from_uuid(Uuid::from_u128(2)), + }; + for destination in [ + PlatformAttentionDestination::WorkspaceAttention { workspace }, + PlatformAttentionDestination::RetainedProviderPane { + workspace, + session: session.clone(), + focus, + }, + ] { + assert_eq!( + platform_attention_destination_view(&destination), + ActiveView::Workspaces + ); + } + assert_eq!( + platform_attention_destination_view(&PlatformAttentionDestination::FleetSession { + workspace, + session, + }), + ActiveView::Fleet + ); + assert!(platform_attention_surface_verified( + AppMode::Dev, + false, + false, + ActiveView::Workspaces, + ActiveView::Workspaces, + )); + assert!(!platform_attention_surface_verified( + AppMode::Dev, + false, + true, + ActiveView::Workspaces, + ActiveView::Workspaces, + )); + assert!(!platform_attention_surface_verified( + AppMode::Dev, + false, + false, + ActiveView::Fleet, + ActiveView::Workspaces, + )); + assert!(!platform_attention_surface_verified( + AppMode::User, + false, + false, + ActiveView::Workspaces, + ActiveView::Workspaces, + )); + assert!(!platform_attention_surface_verified( + AppMode::Dev, + true, + false, + ActiveView::Workspaces, + ActiveView::Workspaces, + )); + + let temp = tempfile::tempdir().unwrap(); + let mut store = AttentionLocalStateStore::open( + temp.path().join("private").join("state").join("local.json"), + 8, + ) + .unwrap(); + let local = AttentionLocalKey::new( + inventory("project-1", "workspace-1").target().clone(), + AttentionItemKey::new( + source("workspace-1"), + AttentionItemId::new("item-1").unwrap(), + ), + Revision::FIRST, + ); + assert!(!record_attention_read_after_visible_open( + Some(&mut store), + local.clone(), + false, + )); + assert!(!store.state().is_read(&local)); + assert!(record_attention_read_after_visible_open( + Some(&mut store), + local.clone(), + true, + )); + assert!(store.state().is_read(&local)); + } + + // SDTEST-1836 + #[test] + fn attention_mode_admission_closes_settings_and_stages_dev_mode() { + let mut cx = gpui::TestAppContext::single(); + let workspace = cx.update(|cx| { + let assistant = cx.new(|cx| { + AiAssistantView::new( + AiContext::new(AiSurface::Global, "test", serde_json::json!({})), + cx, + ) + }); + let mut config = AppConfig::default(); + config.general.ui_font_family = crate::settings::normalize_ui_font_family("Inter", cx); + config.account = Some(AccountInfo { + email: "operator@example.test".into(), + name: "Operator".into(), + is_superadmin: true, + is_admin: true, + is_inklura_support: true, + roles: vec!["superadmin".into()], + }); + config.cloud_sync.enabled = true; + config.cloud_sync.token = "fixture-sensitive-token".into(); + config.cloud_sync.base_url = "https://manage.example.test".into(); + config.cloud_sync.mode = AppMode::User; + cx.new(|cx| { + Workspace::new( + cx, + config, + Vec::new(), + ConnectionStore::default(), + WorkspaceAiBindings { + assistant, + tasks: Vec::new(), + config: Rc::new(RefCell::new(AiConfig::default())), + clippy_config: Rc::new(RefCell::new(ClippyConfig::default())), + }, + ) + }) + }); + workspace.update(&mut cx, |workspace, cx| { + workspace.settings_open = true; + assert_eq!(workspace.effective_mode(), AppMode::User); + assert!(workspace.enter_dev_mode(cx)); + assert!(!workspace.settings_open); + assert!(workspace.mode_transition.is_some()); + }); + } + + // SDTEST-1838 + #[test] + fn workspace_context_removal_and_restart_flush_durable_retirement_custody() { + let mut cx = gpui::TestAppContext::single(); + let workspace_entity = cx.update(|cx| { + let assistant = cx.new(|cx| { + AiAssistantView::new( + AiContext::new(AiSurface::Global, "test", serde_json::json!({})), + cx, + ) + }); + let mut config = AppConfig::default(); + config.general.ui_font_family = crate::settings::normalize_ui_font_family("Inter", cx); + config.account = Some(AccountInfo { + email: "operator@example.test".into(), + name: "Operator".into(), + is_superadmin: true, + is_admin: true, + is_inklura_support: true, + roles: vec!["superadmin".into()], + }); + config.cloud_sync.enabled = true; + config.cloud_sync.token = "fixture-sensitive-token".into(); + config.cloud_sync.base_url = "https://manage.example.test".into(); + config.cloud_sync.mode = AppMode::Dev; + cx.new(|cx| { + Workspace::new( + cx, + config, + Vec::new(), + ConnectionStore::default(), + WorkspaceAiBindings { + assistant, + tasks: Vec::new(), + config: Rc::new(RefCell::new(AiConfig::default())), + clippy_config: Rc::new(RefCell::new(ClippyConfig::default())), + }, + ) + }) + }); + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("private").join("local.json"); + let catalog_workspace = CatalogWorkspaceId::from_uuid(Uuid::from_u128(1831)); + let source = source("workspace-1"); + let key = AttentionLocalKey::new( + inventory("project-1", "workspace-1").target().clone(), + AttentionItemKey::new(source.clone(), AttentionItemId::new("item-1").unwrap()), + Revision::FIRST, + ); + let mut board = PlatformAttentionBoard::new(inventory("project-1", "workspace-1")); + board + .apply_authenticated_baseline_read( + &source, + AttentionReadResult::Snapshot(Box::new(snapshot( + "project-1", + "workspace-1", + 1, + None, + ))), + ) + .unwrap(); + let mut store = AttentionLocalStateStore::open(path.clone(), 8).unwrap(); + store.record_read(key.clone()).unwrap(); + workspace_entity.update(&mut cx, |workspace, _cx| { + workspace.platform_attention_local = Some(store); + workspace + .platform_attention_boards + .insert(catalog_workspace, board); + }); + let displaced = path.with_extension("before-fence"); + std::fs::rename(&path, &displaced).unwrap(); + std::fs::create_dir(&path).unwrap(); + workspace_entity.update(&mut cx, |workspace, cx| { + workspace.reconcile_platform_attention_context(None, cx); + let retained = workspace + .platform_attention_boards + .get(&catalog_workspace) + .expect("failed fence persistence keeps the board as an unavailable retry fence"); + assert!(retained.visible_items().next().is_none()); + assert!(!workspace.platform_attention_retirements_pending.is_empty()); + let store = workspace.platform_attention_local.as_ref().unwrap(); + assert!(store.state().is_read(&key)); + }); + std::fs::remove_dir(&path).unwrap(); + std::fs::rename(&displaced, &path).unwrap(); + workspace_entity.update(&mut cx, |workspace, cx| { + workspace.reconcile_platform_attention_context(None, cx); + assert!(workspace.platform_attention_boards.is_empty()); + assert!(!workspace + .platform_attention_local + .as_ref() + .unwrap() + .state() + .is_read(&key)); + }); + + let mut crashed = AttentionLocalStateStore::open(path.clone(), 8).unwrap(); + crashed.record_read(key.clone()).unwrap(); + let retirement = AttentionRetirement::new( + inventory("project-1", "workspace-1").target().clone(), + source, + ); + crashed.begin_retirement(retirement).unwrap(); + drop(crashed); + workspace_entity.update(&mut cx, |workspace, cx| { + workspace.platform_attention_local = + Some(AttentionLocalStateStore::open(path.clone(), 8).unwrap()); + workspace.reconcile_platform_attention_context(None, cx); + let store = workspace.platform_attention_local.as_ref().unwrap(); + assert!(!store.state().is_read(&key)); + assert!(store.pending_retirements().is_empty()); + }); + } + + // SDTEST-1839 + #[test] + fn inactive_workspace_revisit_preserves_distinct_board_and_overlay_custody() { + let mut boards = BTreeMap::new(); + let workspace_one = CatalogWorkspaceId::from_uuid(Uuid::from_u128(18_321)); + let workspace_two = CatalogWorkspaceId::from_uuid(Uuid::from_u128(18_322)); + let board_one = PlatformAttentionBoard::new(inventory("project-1", "workspace-1")); + let board_two = PlatformAttentionBoard::new(inventory("project-2", "workspace-2")); + let target_one = board_one.inventory().target().clone(); + let target_two = board_two.inventory().target().clone(); + boards.insert(workspace_one, board_one); + boards.insert(workspace_two, board_two); + + let context_one = (workspace_one, Some(target_one)); + assert!(attention_context_retirements(&boards, Some(&context_one)).is_empty()); + assert!(boards.contains_key(&workspace_two)); + let context_two = (workspace_two, Some(target_two)); + assert!(attention_context_retirements(&boards, Some(&context_two)).is_empty()); + assert_eq!( + boards.len(), + 2, + "switching active workspaces retires neither board" + ); + assert_eq!(attention_context_retirements(&boards, None).len(), 2); + } + + // SDTEST-1840 + #[test] + fn real_workspace_waits_for_dev_visibility_across_modes_settings_and_inflight_transition() { + fn advance_all(cx: &gpui::TestAppContext) { + for _ in 0..8 { + cx.executor() + .advance_clock(std::time::Duration::from_secs(5)); + while cx.executor().tick() {} + } + } + + for initial_mode in [AppMode::User, AppMode::Support] { + let mut cx = gpui::TestAppContext::single(); + let (workspace, activation, key, _temp) = + workspace_activation_fixture(&mut cx, initial_mode); + workspace.update(&mut cx, |workspace, cx| { + assert!(workspace.activate_platform_attention(activation, cx)); + assert!(!workspace + .platform_attention_local + .as_ref() + .unwrap() + .state() + .is_read(&key)); + assert!(workspace.mode_transition.is_some()); + }); + advance_all(&cx); + workspace.read_with(&cx, |workspace, cx| { + assert_eq!(workspace.effective_mode(), AppMode::Dev); + assert!(workspace.mode_transition.is_none()); + assert_eq!(workspace.active_view, ActiveView::Workspaces); + assert!( + workspace.platform_attention_local.as_ref().unwrap().state().is_read(&key), + "mode={:?} view={:?} pending={} confirmations={} settings={} board={} snapshot={} navigation={:?}", + workspace.effective_mode(), + workspace.active_view, + workspace.platform_attention_pending_activations.len(), + workspace.platform_attention_visible_confirmations.len(), + workspace.settings_open, + workspace.platform_attention_boards.contains_key(&activation.workspace), + workspace.fleet_snapshot.is_some(), + workspace.workspace_hub.read(cx).navigation().active(), + ); + }); + } + + let mut cx = gpui::TestAppContext::single(); + let (workspace, activation, key, _temp) = + workspace_activation_fixture(&mut cx, AppMode::Dev); + workspace.update(&mut cx, |workspace, cx| { + workspace.settings_open = true; + assert!(workspace.activate_platform_attention(activation, cx)); + assert!(!workspace.settings_open); + assert!(!workspace + .platform_attention_local + .as_ref() + .unwrap() + .state() + .is_read(&key)); + }); + cx.refresh().unwrap(); + workspace.update(&mut cx, |workspace, cx| { + assert_eq!(workspace.platform_attention_visible_confirmations.len(), 1); + workspace.confirm_visible_platform_attention_activation(cx); + }); + assert!(workspace.read_with(&cx, |workspace, _| workspace + .platform_attention_local + .as_ref() + .unwrap() + .state() + .is_read(&key))); + + let mut cx = gpui::TestAppContext::single(); + let (workspace, activation, key, _temp) = + workspace_activation_fixture(&mut cx, AppMode::Support); + workspace.update(&mut cx, |workspace, cx| { + workspace.set_mode(AppMode::User, cx); + assert!(workspace.mode_transition.is_some()); + assert!(workspace.activate_platform_attention(activation, cx)); + assert!(!workspace + .platform_attention_local + .as_ref() + .unwrap() + .state() + .is_read(&key)); + }); + advance_all(&cx); + workspace.read_with(&cx, |workspace, _| { + assert_eq!(workspace.effective_mode(), AppMode::Dev); + assert!(workspace.mode_transition.is_none()); + assert!(workspace + .platform_attention_local + .as_ref() + .unwrap() + .state() + .is_read(&key)); + }); + } + + // SDTEST-1841 + #[test] + fn authority_change_between_open_and_visible_confirmation_stays_unread() { + let mut cx = gpui::TestAppContext::single(); + let (workspace, activation, key, _temp) = + workspace_activation_fixture(&mut cx, AppMode::Dev); + workspace.update(&mut cx, |workspace, cx| { + assert!(workspace.activate_platform_attention(activation, cx)); + assert_eq!(workspace.platform_attention_visible_confirmations.len(), 1); + let board = workspace + .platform_attention_boards + .get_mut(&activation.workspace) + .unwrap(); + let source = board.inventory().sources()[0].clone(); + board + .mark_unavailable(&source, AttentionUnavailableReason::Transport) + .unwrap(); + workspace.confirm_visible_platform_attention_activation(cx); + assert!(!workspace + .platform_attention_local + .as_ref() + .unwrap() + .state() + .is_read(&key)); + assert!(workspace + .platform_attention_visible_confirmations + .is_empty()); + }); + } + + // SDTEST-1857 — SDUC-493 + #[test] + fn sdtest_1857_cross_workspace_activity_is_ordered_by_observation_not_by_board() { + fn row( + workspace: CatalogWorkspaceId, + item: &str, + item_revision: u64, + observed_at_ms: u64, + ) -> PlatformAttentionPresentation { + PlatformAttentionPresentation { + activation: PlatformAttentionActivation { + workspace, + item: shelldeck_core::config::platform_attention::AttentionUiItemId:: + from_authoritative_key(&AttentionItemKey::new( + shelldeck_core::config::platform_attention::AttentionSource::new( + AttentionSourceKind::Orchestration, + AttentionSourceId::new("source-1".to_owned()).unwrap(), + ), + AttentionItemId::new(item.to_owned()).unwrap(), + )), + item_revision: Revision::new(item_revision).unwrap(), + }, + state: AttentionItemState::Working, + reason: AttentionItemReason::AgentWorking, + unread: true, + nested_agent_path: Vec::new(), + observed_at_ms, + workspace_label: String::new(), + } + } + + let first = CatalogWorkspaceId::new(); + let second = CatalogWorkspaceId::new(); + // Board-by-board flattening would keep every `first` row ahead of + // every `second` row regardless of when the server observed them. + let mut rows = vec![ + row(first, "old-first", 4, 100), + row(first, "tie-low-revision", 2, 500), + row(second, "newest", 9, 900), + row(second, "tie-high-revision", 7, 500), + ]; + sort_platform_attention_chronologically(&mut rows); + assert_eq!( + rows.iter() + .map(|row| row.observed_at_ms) + .collect::>(), + vec![900, 500, 500, 100] + ); + assert_eq!( + rows.iter() + .map(|row| row.activation.item_revision.get()) + .collect::>(), + vec![9, 7, 2, 4], + "equal observations fall back to the item revision" + ); + + // The toast names the destination workspace, and degrades to the + // reason alone when the catalogue no longer names it. + let named = notification_body(AttentionItemReason::AgentWorking, "espace-1"); + assert!(named.contains("espace-1"), "{named}"); + assert_eq!( + notification_body(AttentionItemReason::AgentWorking, ""), + attention_reason_label(AttentionItemReason::AgentWorking) + ); + } +} diff --git a/crates/shelldeck-ui/src/workspace/workspaces.rs b/crates/shelldeck-ui/src/workspace/workspaces.rs index d3aaa6b9..66ed1181 100644 --- a/crates/shelldeck-ui/src/workspace/workspaces.rs +++ b/crates/shelldeck-ui/src/workspace/workspaces.rs @@ -4,32 +4,43 @@ //! catalogue local autorisé, conserve les entités GPUI par workspace et remet //! les demandes de création à une frontière d'exécuteur injectée. +use super::platform_attention::{ + attention_reason_label, platform_attention_state_label, PlatformAttentionPresentation, +}; use super::*; use adabraka_ui::components::input::InputVariant; use adabraka_ui::display::card::Card; use adabraka_ui::prelude::{Alert, AlertVariant}; +use shelldeck_core::config::platform::ResourceCoordinate; +use shelldeck_core::config::platform_attention::PlatformAttentionTarget; use shelldeck_core::config::platform_review::PlatformReviewTarget; use shelldeck_core::config::workspace_catalog::{ CatalogCheckoutId, CatalogProjectId, CatalogWorkspaceId, CheckoutHost, ExternalWorkItem, ExternalWorkItemKind, ProjectCatalog, ProjectCheckout, ProjectRecord, RemotePosixPath, RepositoryIdentity, UserWorkspaceLifecycle, UserWorkspaceRecord, WorkspaceLaunchIntake, - WorkspaceLaunchRequest, + WorkspaceLaunchRequest, WorkspaceRelativePath, }; use shelldeck_core::models::connection::Connection; use shelldeck_core::workspace_navigation::{ - BackgroundWorkspaceCreateState, CreationOperationId, GitDirtyState, PaneId, PaneLeaf, - TerminalAuthority, TerminalBinding, TerminalBindingId, TerminalSurface, TerminalViewport, - WorkspaceAgentState, WorkspaceCardState, WorkspaceCreateConflict, WorkspaceCreateEvent, - WorkspaceCreateFailure, WorkspaceCreateFailureKind, WorkspaceCreatePhase, - WorkspaceCreationReducer, WorkspaceFreshness, WorkspaceNavigationAction, + AgentSessionBinding, BackgroundWorkspaceCreateState, CreationOperationId, GitDirtyState, + PaneId, PaneLeaf, TerminalAuthority, TerminalBinding, TerminalBindingId, TerminalSurface, + TerminalViewport, WorkspaceAgentState, WorkspaceCardState, WorkspaceCreateConflict, + WorkspaceCreateEvent, WorkspaceCreateFailure, WorkspaceCreateFailureKind, WorkspaceCreatePhase, + WorkspaceCreationReducer, WorkspaceFocus, WorkspaceFreshness, WorkspaceNavigationAction, WorkspaceNavigationState, WorkspaceSurfaceState, WorkspaceTab, WorkspaceTabContent, WorkspaceTabId, }; +use shelldeck_core::workspace_review::{ + AttentionBoard, AttentionError, AttentionItem, AttentionItemId, AttentionState, +}; use std::collections::{BTreeMap, HashMap}; use std::path::PathBuf; use std::sync::Arc; use tokio::sync::mpsc; +#[path = "workspaces_panes.rs"] +mod workspaces_panes; + #[path = "native_lifecycle.rs"] mod native_lifecycle; use native_lifecycle::{ @@ -217,6 +228,17 @@ struct WorkspaceCardPresentation { provider_bound: bool, } +#[derive(Clone, Debug, Eq, PartialEq)] +struct AttentionItemPresentation { + workspace: CatalogWorkspaceId, + id: AttentionItemId, + revision: u64, + state: AttentionState, + title: String, + unread: bool, + agent_path: Vec, +} + #[derive(Clone, Debug)] struct GitCardSource { branch: Option, @@ -387,6 +409,11 @@ fn workspace_card_presentation( struct RetainedWorkspaceSurface { workspace: CatalogWorkspaceId, terminal: Entity, + editor: Entity, + agent_host: Option>, + checkout: ProjectCheckout, + resolved_local_tab: Option, + surface: WorkspaceSurfaceState, native_snapshot: Option, } @@ -422,10 +449,22 @@ pub(super) fn apply_terminal_config( } impl RetainedWorkspaceSurface { - fn new(workspace: CatalogWorkspaceId, terminal: Entity) -> Self { + fn new( + workspace: CatalogWorkspaceId, + terminal: Entity, + editor: Entity, + agent_host: Option>, + checkout: ProjectCheckout, + surface: WorkspaceSurfaceState, + ) -> Self { Self { workspace, terminal, + editor, + agent_host, + checkout, + resolved_local_tab: None, + surface, native_snapshot: None, } } @@ -443,24 +482,6 @@ impl RetainedWorkspaceSurface { } } -impl Render for RetainedWorkspaceSurface { - fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { - div() - .id(( - "retained-workspace", - self.workspace.as_uuid().as_u128() as u64, - )) - .flex() - .flex_col() - .size_full() - .child( - Alert::info() - .description(t!("workspaces.surface.native_terminal_only").to_string()), - ) - .child(self.terminal.clone()) - } -} - fn terminal_surface( catalog: &ProjectCatalog, workspace_id: CatalogWorkspaceId, @@ -526,12 +547,152 @@ fn terminal_surface( } } +/// Refresh native terminal bindings without flattening the typed pane tree. +/// Provider, editor, file, and browser tabs keep their exact pane/focus +/// identities across workspace switches. +fn reconcile_terminal_surface( + retained: &WorkspaceSurfaceState, + native: WorkspaceSurfaceState, +) -> WorkspaceSurfaceState { + use shelldeck_core::workspace_navigation::PaneNode; + + let Some(mut root) = retained.root.clone() else { + return native; + }; + fn collect_nonterminal_tab_ids( + node: &shelldeck_core::workspace_navigation::PaneNode, + ids: &mut std::collections::BTreeSet, + ) { + match node { + shelldeck_core::workspace_navigation::PaneNode::Leaf(leaf) => { + ids.extend(leaf.tabs.iter().filter_map(|tab| { + (!matches!(tab.content, WorkspaceTabContent::Terminal(_))).then_some(tab.id) + })); + } + shelldeck_core::workspace_navigation::PaneNode::Split { first, second, .. } => { + collect_nonterminal_tab_ids(first, ids); + collect_nonterminal_tab_ids(second, ids); + } + } + } + let mut typed_tab_ids = std::collections::BTreeSet::new(); + collect_nonterminal_tab_ids(&root, &mut typed_tab_ids); + let mut native_tabs = native + .root + .as_ref() + .into_iter() + .flat_map(|root| match root { + PaneNode::Leaf(leaf) => leaf.tabs.clone(), + PaneNode::Split { .. } => Vec::new(), + }) + .filter(|tab| { + matches!(tab.content, WorkspaceTabContent::Terminal(_)) + && !typed_tab_ids.contains(&tab.id) + }) + .map(|tab| (tab.id, tab)) + .collect::>(); + + fn refresh_node(node: &mut PaneNode, native_tabs: &mut BTreeMap) { + match node { + PaneNode::Leaf(leaf) => { + leaf.tabs.retain_mut(|tab| { + if !matches!(tab.content, WorkspaceTabContent::Terminal(_)) { + return true; + } + let Some(native) = native_tabs.remove(&tab.id) else { + return false; + }; + *tab = native; + true + }); + if leaf + .active_tab + .is_some_and(|active| !leaf.tabs.iter().any(|tab| tab.id == active)) + { + leaf.active_tab = leaf.tabs.first().map(|tab| tab.id); + } + } + PaneNode::Split { first, second, .. } => { + refresh_node(first, native_tabs); + refresh_node(second, native_tabs); + } + } + } + + fn first_leaf_mut(node: &mut PaneNode) -> Option<&mut PaneLeaf> { + match node { + PaneNode::Leaf(leaf) => Some(leaf), + PaneNode::Split { first, second, .. } => { + first_leaf_mut(first).or_else(|| first_leaf_mut(second)) + } + } + } + + refresh_node(&mut root, &mut native_tabs); + if !native_tabs.is_empty() { + if let Some(target) = first_leaf_mut(&mut root) { + target.tabs.extend(native_tabs.into_values()); + if target.active_tab.is_none() { + target.active_tab = target.tabs.first().map(|tab| tab.id); + } + } + } + let focus = retained.focus.filter(|focus| { + fn contains(node: &PaneNode, focus: WorkspaceFocus) -> bool { + match node { + PaneNode::Leaf(leaf) => { + leaf.id == focus.pane_id && leaf.tabs.iter().any(|tab| tab.id == focus.tab_id) + } + PaneNode::Split { first, second, .. } => { + contains(first, focus) || contains(second, focus) + } + } + } + contains(&root, *focus) + }); + WorkspaceSurfaceState { + root: Some(root), + focus, + } +} + +fn provider_focus_matches( + node: &shelldeck_core::workspace_navigation::PaneNode, + focus: shelldeck_core::workspace_navigation::WorkspaceFocus, + platform_user_workspace_id: &str, + session_id: &str, +) -> bool { + match node { + shelldeck_core::workspace_navigation::PaneNode::Leaf(leaf) => { + leaf.id == focus.pane_id + && leaf.tabs.iter().any(|tab| { + tab.id == focus.tab_id + && matches!( + &tab.content, + WorkspaceTabContent::ProviderSession(binding) + if binding.platform_user_workspace_id + == platform_user_workspace_id + && binding.session_id == session_id + ) + }) + } + shelldeck_core::workspace_navigation::PaneNode::Split { first, second, .. } => { + provider_focus_matches(first, focus, platform_user_workspace_id, session_id) + || provider_focus_matches(second, focus, platform_user_workspace_id, session_id) + } + } +} + pub(super) struct WorkspaceHubView { catalog: ProjectCatalog, navigation: WorkspaceNavigationState, + attention: BTreeMap, + platform_attention: BTreeMap>, cards: WorkspaceCardAggregator, creation: WorkspaceCreationReducer, retained: BTreeMap>, + retained_subscriptions: Vec, + agent_host: Option>, unclaimed_terminal: Option>, terminal_config: Option, connections: HashMap, @@ -562,10 +723,112 @@ pub(super) struct WorkspaceHubView { pub(super) enum WorkspaceHubEvent { ActiveTerminal(Entity), + OpenPlatformAttention(shelldeck_core::config::platform_attention::PlatformAttentionActivation), + /// Requests that the application attach/focus the runtime represented by + /// a typed workspace tab. Terminal and local editor tabs are hosted by the + /// retained surface itself; provider and browser runtimes cross this + /// explicit orchestration boundary. + OpenWorkspacePane(WorkspacePaneActivation), + /// The project/checkout/workspace tree changed and consumers should pull + /// a fresh snapshot through `catalog()`. + CatalogChanged, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(super) struct WorkspacePaneActivation { + pub workspace: CatalogWorkspaceId, + pub focus: WorkspaceFocus, + pub title: String, + pub content: WorkspaceTabContent, } impl EventEmitter for WorkspaceHubView {} +fn retained_surface_entity( + catalog: &ProjectCatalog, + workspace: CatalogWorkspaceId, + terminal: Entity, + agent_host: Option>, + surface: WorkspaceSurfaceState, + cx: &mut Context, +) -> (Entity, Subscription) { + let checkout = catalog + .workspace(workspace) + .ok() + .and_then(|record| { + catalog + .checkout_in_project(record.project_id(), record.checkout_id()) + .ok() + }) + .cloned() + .expect("a retained workspace must reference its catalog checkout"); + let editor = cx.new(FileEditorView::new); + let empty_root = shelldeck_core::config::workspace_catalog::WorkspaceRelativePath::new("") + .expect("empty workspace root is valid"); + if let Ok(root) = checkout.resolve_existing_local_path(&empty_root) { + editor.update(cx, |editor, _| { + editor.file_browser.set_root(root.as_path().to_path_buf()) + }); + } + let retained = cx.new({ + move |_| { + RetainedWorkspaceSurface::new( + workspace, terminal, editor, agent_host, checkout, surface, + ) + } + }); + let subscription = cx.subscribe( + &retained, + |hub, retained, event: &workspaces_panes::RetainedWorkspaceEvent, cx| { + match event { + workspaces_panes::RetainedWorkspaceEvent::Activate(activation) => { + let surface = retained.read(cx).surface.clone(); + if hub.navigation.active() != Some(activation.workspace) + || hub + .navigation + .reduce( + &hub.catalog, + WorkspaceNavigationAction::UpdateSurface { + id: activation.workspace, + surface, + }, + ) + .is_err() + { + return; + } + match &activation.content { + WorkspaceTabContent::Terminal(_) => { + cx.emit(WorkspaceHubEvent::ActiveTerminal( + retained.read(cx).terminal.clone(), + )); + } + _ => cx.emit(WorkspaceHubEvent::OpenWorkspacePane(activation.clone())), + } + } + workspaces_panes::RetainedWorkspaceEvent::LayoutChanged { workspace, surface } => { + if hub.navigation.active() != Some(*workspace) + || surface.validate_for(&hub.catalog, *workspace).is_err() + { + return; + } + if let Err(error) = hub.navigation.reduce( + &hub.catalog, + WorkspaceNavigationAction::UpdateSurface { + id: *workspace, + surface: surface.clone(), + }, + ) { + hub.error = Some(error.to_string()); + } + } + } + cx.notify(); + }, + ); + (retained, subscription) +} + impl WorkspaceHubView { /// Resolve the active workspace through the catalog's exact reconciliation. /// Provider session bindings are deliberately not considered authority. @@ -575,6 +838,612 @@ impl WorkspaceHubView { PlatformReviewTarget::from_exact_mapping(mapping).ok() } + pub(super) fn active_platform_attention_target( + &self, + ) -> Option<(CatalogWorkspaceId, PlatformAttentionTarget)> { + let (active, target) = self.active_platform_attention_context()?; + target.map(|target| (active, target)) + } + + /// The current local workspace remains meaningful even when its Platform + /// mapping was removed or became non-exact. Callers use that distinction + /// to retire a formerly authoritative board instead of silently retaining + /// it as if the old mapping were still current. + pub(super) fn active_platform_attention_context( + &self, + ) -> Option<(CatalogWorkspaceId, Option)> { + let active = self.navigation.active()?; + let target = self + .catalog + .workspace(active) + .ok() + .and_then(|workspace| workspace.platform_mapping()) + .and_then(|mapping| PlatformAttentionTarget::from_exact_mapping(mapping).ok()); + Some((active, target)) + } + + pub(super) const fn catalog(&self) -> &ProjectCatalog { + &self.catalog + } + + pub(super) const fn navigation(&self) -> &WorkspaceNavigationState { + &self.navigation + } + + /// Exact local-agent session currently occupying the focused workspace + /// tab. Selection alone is not visibility: unread accounting consumes + /// this only while the Workspaces surface itself is on screen. + pub(super) fn active_agent_session_id(&self) -> Option { + fn active_session(node: &shelldeck_core::workspace_navigation::PaneNode) -> Option { + match node { + shelldeck_core::workspace_navigation::PaneNode::Leaf(leaf) => { + let active = leaf.active_tab?; + leaf.tabs.iter().find_map(|tab| match &tab.content { + WorkspaceTabContent::AgentSession(binding) if tab.id == active => { + Some(binding.session_id) + } + _ => None, + }) + } + shelldeck_core::workspace_navigation::PaneNode::Split { first, second, .. } => { + active_session(first).or_else(|| active_session(second)) + } + } + } + + fn focused_session( + node: &shelldeck_core::workspace_navigation::PaneNode, + focus: WorkspaceFocus, + ) -> Option { + match node { + shelldeck_core::workspace_navigation::PaneNode::Leaf(leaf) + if leaf.id == focus.pane_id => + { + leaf.tabs.iter().find_map(|tab| { + (tab.id == focus.tab_id) + .then_some(&tab.content) + .and_then(|content| match content { + WorkspaceTabContent::AgentSession(binding) => { + Some(binding.session_id) + } + _ => None, + }) + }) + } + shelldeck_core::workspace_navigation::PaneNode::Leaf(_) => None, + shelldeck_core::workspace_navigation::PaneNode::Split { first, second, .. } => { + focused_session(first, focus).or_else(|| focused_session(second, focus)) + } + } + } + + let workspace = self.navigation.active()?; + let surface = &self.navigation.workspace(workspace)?.surface; + let root = surface.root.as_ref()?; + surface + .focus + .and_then(|focus| focused_session(root, focus)) + .or_else(|| active_session(root)) + } + + /// Installs the application-owned multi-session agent surface into every + /// retained workspace. The selected typed agent tab remains responsible + /// for telling that host which session to display via `OpenWorkspacePane`. + pub(super) fn attach_agent_host( + &mut self, + host: Entity, + cx: &mut Context, + ) { + self.agent_host = Some(host.clone()); + for retained in self.retained.values() { + retained.update(cx, |retained, cx| { + retained.agent_host = Some(host.clone()); + cx.notify(); + }); + } + cx.notify(); + } + + /// Remove all retained navigation references to a closed local agent + /// session, including duplicate/corrupt references in separate panes. + pub(super) fn remove_agent_session_tabs( + &mut self, + session_id: Uuid, + cx: &mut Context, + ) -> Result { + let workspace_ids = self + .catalog + .workspaces() + .map(UserWorkspaceRecord::id) + .collect::>(); + let mut changed = 0; + for workspace in workspace_ids { + let Some(mut surface) = self + .navigation + .workspace(workspace) + .map(|retained| retained.surface.clone()) + else { + continue; + }; + if !workspaces_panes::remove_agent_session_tabs(&mut surface, session_id) { + continue; + } + surface + .validate_for(&self.catalog, workspace) + .map_err(|error| error.to_string())?; + self.navigation + .reduce( + &self.catalog, + WorkspaceNavigationAction::UpdateSurface { + id: workspace, + surface: surface.clone(), + }, + ) + .map_err(|error| error.to_string())?; + if let Some(retained) = self.retained.get(&workspace) { + retained.update(cx, |retained, cx| { + retained.set_surface(surface); + cx.notify(); + }); + } + changed += 1; + } + cx.notify(); + Ok(changed) + } + + /// Adds a typed tab to the active pane (or focuses its stable id when it + /// is already retained) without flattening the pane tree. + pub(super) fn open_or_focus_tab( + &mut self, + workspace: CatalogWorkspaceId, + tab: WorkspaceTab, + cx: &mut Context, + ) -> Result { + use shelldeck_core::workspace_navigation::PaneNode; + + fn find_tab(node: &PaneNode, tab: WorkspaceTabId) -> Option { + match node { + PaneNode::Leaf(leaf) => leaf + .tabs + .iter() + .any(|candidate| candidate.id == tab) + .then_some(WorkspaceFocus { + pane_id: leaf.id, + tab_id: tab, + }), + PaneNode::Split { first, second, .. } => { + find_tab(first, tab).or_else(|| find_tab(second, tab)) + } + } + } + + fn append_tab( + node: &mut PaneNode, + preferred: Option, + tab: WorkspaceTab, + ) -> WorkspaceFocus { + match node { + PaneNode::Leaf(leaf) if preferred.is_none_or(|pane| pane == leaf.id) => { + let focus = WorkspaceFocus { + pane_id: leaf.id, + tab_id: tab.id, + }; + leaf.tabs.push(tab); + leaf.active_tab = Some(focus.tab_id); + focus + } + PaneNode::Leaf(_) => unreachable!("preferred pane was validated by retained focus"), + PaneNode::Split { first, second, .. } => { + if preferred.is_some_and(|pane| pane_in_node(first, pane)) { + append_tab(first, preferred, tab) + } else { + append_tab(second, preferred, tab) + } + } + } + } + + fn pane_in_node(node: &PaneNode, pane: PaneId) -> bool { + match node { + PaneNode::Leaf(leaf) => leaf.id == pane, + PaneNode::Split { first, second, .. } => { + pane_in_node(first, pane) || pane_in_node(second, pane) + } + } + } + + fn pane_holding_agent(node: &PaneNode) -> Option { + match node { + PaneNode::Leaf(leaf) + if leaf + .tabs + .iter() + .any(|tab| matches!(tab.content, WorkspaceTabContent::AgentSession(_))) => + { + Some(leaf.id) + } + PaneNode::Leaf(_) => None, + PaneNode::Split { first, second, .. } => { + pane_holding_agent(first).or_else(|| pane_holding_agent(second)) + } + } + } + + let retained = self + .navigation + .workspace(workspace) + .ok_or_else(|| format!("workspace {workspace} is not retained"))?; + let mut surface = retained.surface.clone(); + let focus = if let Some(root) = surface.root.as_ref() { + find_tab(root, tab.id) + } else { + None + }; + let focus = if let Some(focus) = focus { + if let Some(PaneNode::Leaf(leaf)) = surface.root.as_mut() { + if leaf.id == focus.pane_id { + leaf.active_tab = Some(focus.tab_id); + } + } else if let Some(root) = surface.root.as_mut() { + workspaces_panes::set_active_tab(root, focus); + } + focus + } else if let Some(root) = surface.root.as_mut() { + let preferred = if matches!(tab.content, WorkspaceTabContent::AgentSession(_)) { + pane_holding_agent(root).or_else(|| surface.focus.map(|focus| focus.pane_id)) + } else { + surface.focus.map(|focus| focus.pane_id) + }; + append_tab(root, preferred, tab) + } else { + let pane_id = PaneId::from_uuid(Uuid::new_v4()); + let focus = WorkspaceFocus { + pane_id, + tab_id: tab.id, + }; + surface.root = Some(PaneNode::Leaf(PaneLeaf { + id: pane_id, + tabs: vec![tab], + active_tab: Some(focus.tab_id), + })); + focus + }; + surface.focus = Some(focus); + surface + .validate_for(&self.catalog, workspace) + .map_err(|error| error.to_string())?; + if !self.switch_to_checked(workspace, cx) { + return Err(self + .error + .clone() + .unwrap_or_else(|| "workspace activation refused".into())); + } + self.navigation + .reduce( + &self.catalog, + WorkspaceNavigationAction::UpdateSurface { + id: workspace, + surface: surface.clone(), + }, + ) + .map_err(|error| error.to_string())?; + let retained = self + .retained + .get(&workspace) + .cloned() + .ok_or_else(|| format!("workspace {workspace} has no native surface"))?; + retained.update(cx, |retained, cx| { + retained.set_surface(surface); + retained.activate_tab(focus, cx); + }); + Ok(focus) + } + + pub(super) fn open_or_focus_agent_session( + &mut self, + workspace: CatalogWorkspaceId, + title: impl Into, + binding: AgentSessionBinding, + cx: &mut Context, + ) -> Result { + fn matching_tab( + node: &shelldeck_core::workspace_navigation::PaneNode, + session_id: Uuid, + ) -> Option { + match node { + shelldeck_core::workspace_navigation::PaneNode::Leaf(leaf) => { + leaf.tabs.iter().find_map(|tab| { + matches!(&tab.content, WorkspaceTabContent::AgentSession(candidate) + if candidate.session_id == session_id) + .then_some(tab.id) + }) + } + shelldeck_core::workspace_navigation::PaneNode::Split { first, second, .. } => { + matching_tab(first, session_id).or_else(|| matching_tab(second, session_id)) + } + } + } + let id = self + .navigation + .workspace(workspace) + .and_then(|retained| retained.surface.root.as_ref()) + .and_then(|root| matching_tab(root, binding.session_id)) + .unwrap_or_else(|| WorkspaceTabId::from_uuid(Uuid::new_v4())); + let checkout_id = binding.checkout_id; + let focus = self.open_or_focus_tab( + workspace, + WorkspaceTab { + id, + title: title.into(), + content: WorkspaceTabContent::AgentSession(binding), + }, + cx, + )?; + self.ensure_local_files_pane(workspace, checkout_id, focus, cx)?; + Ok(focus) + } + + fn ensure_local_files_pane( + &mut self, + workspace: CatalogWorkspaceId, + checkout_id: CatalogCheckoutId, + agent_focus: WorkspaceFocus, + cx: &mut Context, + ) -> Result<(), String> { + use shelldeck_core::workspace_navigation::{PaneNode, SplitAxis}; + + let record = self + .catalog + .workspace(workspace) + .map_err(|error| error.to_string())?; + let checkout = self + .catalog + .checkout_in_project(record.project_id(), checkout_id) + .map_err(|error| error.to_string())?; + if !matches!(checkout.host(), CheckoutHost::Local { .. }) { + return Ok(()); + } + let relative_root = WorkspaceRelativePath::new("").map_err(|error| error.to_string())?; + let mut surface = self + .navigation + .workspace(workspace) + .ok_or_else(|| format!("workspace {workspace} is not retained"))? + .surface + .clone(); + + fn existing_files(node: &PaneNode) -> Option { + match node { + PaneNode::Leaf(leaf) => leaf.tabs.iter().find_map(|tab| { + matches!(tab.content, WorkspaceTabContent::Files { .. }).then_some(tab.id) + }), + PaneNode::Split { first, second, .. } => { + existing_files(first).or_else(|| existing_files(second)) + } + } + } + + let existing = surface.root.as_ref().and_then(existing_files); + let files_tab = if let Some(tab_id) = existing { + tab_id + } else { + let tab_id = WorkspaceTabId::from_uuid(Uuid::new_v4()); + let files = WorkspaceTab { + id: tab_id, + title: t!("file_editor.browser.files").to_string(), + content: WorkspaceTabContent::Files { + checkout_id, + relative_root: relative_root.clone(), + }, + }; + fn split_agent_leaf( + node: &mut PaneNode, + focus: WorkspaceFocus, + files: WorkspaceTab, + ) -> bool { + match node { + PaneNode::Leaf(leaf) if leaf.id == focus.pane_id => { + let files_pane = PaneId::from_uuid(Uuid::new_v4()); + let first = leaf.clone(); + *node = PaneNode::Split { + axis: SplitAxis::Horizontal, + ratio_basis_points: 7_500, + first: Box::new(PaneNode::Leaf(first)), + second: Box::new(PaneNode::Leaf(PaneLeaf { + id: files_pane, + active_tab: Some(files.id), + tabs: vec![files], + })), + }; + true + } + PaneNode::Leaf(_) => false, + PaneNode::Split { first, second, .. } => { + split_agent_leaf(first, focus, files.clone()) + || split_agent_leaf(second, focus, files) + } + } + } + let root = surface + .root + .as_mut() + .ok_or_else(|| "agent workspace has no pane surface".to_string())?; + if !split_agent_leaf(root, agent_focus, files) { + return Err("agent workspace pane disappeared before files layout".to_string()); + } + tab_id + }; + surface.focus = Some(agent_focus); + surface + .validate_for(&self.catalog, workspace) + .map_err(|error| error.to_string())?; + self.navigation + .reduce( + &self.catalog, + WorkspaceNavigationAction::UpdateSurface { + id: workspace, + surface: surface.clone(), + }, + ) + .map_err(|error| error.to_string())?; + if let Some(retained) = self.retained.get(&workspace) { + retained.update(cx, |retained, cx| { + retained.set_surface(surface); + retained.prepare_files_tab(files_tab, &relative_root, cx); + }); + } + Ok(()) + } + + pub(super) fn set_platform_attention( + &mut self, + rows: BTreeMap>, + cx: &mut Context, + ) { + self.platform_attention = rows; + cx.notify(); + } + + pub(super) fn open_platform_attention_surface( + &mut self, + workspace: CatalogWorkspaceId, + cx: &mut Context, + ) -> bool { + self.switch_to_checked(workspace, cx) + } + + pub(super) fn platform_attention_surface_is_visible( + &self, + workspace: CatalogWorkspaceId, + ) -> bool { + self.navigation.active() == Some(workspace) + } + + pub(super) fn open_retained_provider_pane( + &mut self, + workspace: CatalogWorkspaceId, + session: &ResourceCoordinate, + focus: shelldeck_core::workspace_navigation::WorkspaceFocus, + cx: &mut Context, + ) -> bool { + let Some(platform_user_workspace_id) = self + .catalog + .workspace(workspace) + .ok() + .and_then(|workspace| workspace.platform_mapping()) + .filter(|mapping| mapping.is_exact()) + .map(|mapping| mapping.user_workspace.id.as_str()) + else { + return false; + }; + let Some(retained) = self.navigation.workspace(workspace) else { + return false; + }; + let mut surface = retained.surface.clone(); + let exact = surface.root.as_ref().is_some_and(|root| { + provider_focus_matches(root, focus, platform_user_workspace_id, session.id.as_str()) + }); + let Some(retained_entity) = self.retained.get(&workspace).cloned() else { + return false; + }; + if !exact || !self.switch_to_checked(workspace, cx) { + return false; + } + let Some(root) = surface.root.as_mut() else { + return false; + }; + let Some(tab) = workspaces_panes::set_active_tab(root, focus) else { + return false; + }; + surface.focus = Some(focus); + if self + .navigation + .reduce( + &self.catalog, + WorkspaceNavigationAction::UpdateSurface { + id: workspace, + surface: surface.clone(), + }, + ) + .is_err() + { + return false; + } + retained_entity.update(cx, |retained, entity_cx| { + if retained + .terminal + .read(entity_cx) + .tabs + .iter() + .any(|native| native.id == focus.tab_id.as_uuid()) + { + retained.terminal.update(entity_cx, |terminal, _| { + terminal.select_tab(focus.tab_id.as_uuid()) + }); + } + retained.set_surface(surface); + }); + cx.emit(WorkspaceHubEvent::OpenWorkspacePane( + WorkspacePaneActivation { + workspace, + focus, + title: tab.title, + content: tab.content, + }, + )); + cx.notify(); + true + } + + pub(super) fn retained_provider_pane_is_visible( + &self, + workspace: CatalogWorkspaceId, + session: &ResourceCoordinate, + focus: shelldeck_core::workspace_navigation::WorkspaceFocus, + cx: &App, + ) -> bool { + if self.navigation.active() != Some(workspace) { + return false; + } + let Some(platform_user_workspace_id) = self + .catalog + .workspace(workspace) + .ok() + .and_then(|workspace| workspace.platform_mapping()) + .filter(|mapping| mapping.is_exact()) + .map(|mapping| mapping.user_workspace.id.as_str()) + else { + return false; + }; + let surface_matches = self + .navigation + .workspace(workspace) + .is_some_and(|retained| { + retained.surface.focus == Some(focus) + && retained.surface.root.as_ref().is_some_and(|root| { + provider_focus_matches( + root, + focus, + platform_user_workspace_id, + session.id.as_str(), + ) + }) + }); + let native_matches = self.retained.get(&workspace).is_some_and(|retained| { + let retained = retained.read(cx); + retained.surface.focus == Some(focus) + && retained.surface.root.as_ref().is_some_and(|root| { + provider_focus_matches( + root, + focus, + platform_user_workspace_id, + session.id.as_str(), + ) + }) + }); + surface_matches && native_matches + } + pub(super) fn configure_terminals( &mut self, config: WorkspaceTerminalConfig, @@ -651,6 +1520,8 @@ impl WorkspaceHubView { }); let mut navigation = WorkspaceNavigationState::default(); let mut retained = BTreeMap::new(); + let mut retained_subscriptions = Vec::new(); + let mut attention = BTreeMap::new(); let mut initial_terminal = Some(initial_terminal); for workspace in catalog.workspaces() { let surface = WorkspaceSurfaceState::default(); @@ -680,10 +1551,17 @@ impl WorkspaceHubView { } } } - retained.insert( + let (surface, subscription) = retained_surface_entity( + &catalog, workspace.id(), - cx.new(move |_| RetainedWorkspaceSurface::new(workspace.id(), terminal)), + terminal, + None, + WorkspaceSurfaceState::default(), + cx, ); + retained.insert(workspace.id(), surface); + retained_subscriptions.push(subscription); + attention.insert(workspace.id(), AttentionBoard::new(workspace.id())); } let onboarding_open = catalog.projects().len() == 0; let onboarding_connection = connections.keys().next().copied(); @@ -716,9 +1594,13 @@ impl WorkspaceHubView { Self { catalog, navigation, + attention, + platform_attention: BTreeMap::new(), cards: WorkspaceCardAggregator::default(), creation: WorkspaceCreationReducer::default(), retained, + retained_subscriptions, + agent_host: None, unclaimed_terminal: initial_terminal, terminal_config: None, connections, @@ -752,18 +1634,28 @@ impl WorkspaceHubView { } fn switch_to(&mut self, id: CatalogWorkspaceId, cx: &mut Context) { + self.switch_to_checked(id, cx); + } + + fn switch_to_checked(&mut self, id: CatalogWorkspaceId, cx: &mut Context) -> bool { if self.recovery_pending { self.error = Some(t!("workspaces.launcher.folder_unavailable").to_string()); cx.notify(); - return; + return false; } if let Some(outgoing) = self.navigation.active() { if let Some(surface) = self.retained.get(&outgoing) { let state = { let retained = surface.read(cx); - terminal_surface(&self.catalog, outgoing, retained.terminal.read(cx)) + reconcile_terminal_surface( + &retained.surface, + terminal_surface(&self.catalog, outgoing, retained.terminal.read(cx)), + ) }; - surface.update(cx, |surface, cx| surface.capture(cx)); + surface.update(cx, |surface, cx| { + surface.set_surface(state.clone()); + surface.capture(cx); + }); if let Err(error) = self.navigation.reduce( &self.catalog, WorkspaceNavigationAction::UpdateSurface { @@ -773,7 +1665,7 @@ impl WorkspaceHubView { ) { self.error = Some(error.to_string()); cx.notify(); - return; + return false; } } } @@ -784,16 +1676,148 @@ impl WorkspaceHubView { Ok(()) => { self.error = None; if let Some(surface) = self.retained.get(&id) { - surface.update(cx, |surface, cx| surface.apply(cx)); + let retained_state = self + .navigation + .workspace(id) + .map(|workspace| workspace.surface.clone()) + .unwrap_or_default(); + let state = { + let retained = surface.read(cx); + reconcile_terminal_surface( + &retained_state, + terminal_surface(&self.catalog, id, retained.terminal.read(cx)), + ) + }; + surface.update(cx, |surface, cx| { + surface.set_surface(state.clone()); + surface.apply(cx); + }); + if let Err(error) = self.navigation.reduce( + &self.catalog, + WorkspaceNavigationAction::UpdateSurface { id, surface: state }, + ) { + self.error = Some(error.to_string()); + cx.notify(); + return false; + } cx.emit(WorkspaceHubEvent::ActiveTerminal( surface.read(cx).terminal.clone(), )); } self.observe_local_card(id, cx); + cx.notify(); + true } - Err(error) => self.error = Some(error.to_string()), + Err(error) => { + self.error = Some(error.to_string()); + cx.notify(); + false + } + } + } + + /// Admit one already-authoritative local attention item. Canonical review + /// events are deliberately not converted here because they do not carry + /// the exact retained pane/session coordinates this board requires. + #[allow(dead_code)] + pub(super) fn apply_attention_item( + &mut self, + item: AttentionItem, + cx: &mut Context, + ) -> Result { + let workspace = item.target.workspace; + let board = self + .attention + .get_mut(&workspace) + .ok_or(AttentionError::WrongWorkspace)?; + let notify = board.apply(item)?; + cx.notify(); + Ok(notify) + } + + fn attention_items(&self, workspace: CatalogWorkspaceId) -> Vec { + let Some(board) = self.attention.get(&workspace) else { + return Vec::new(); + }; + board + .ordered() + .into_iter() + .rev() + .map(|item| AttentionItemPresentation { + workspace, + id: item.id, + revision: item.revision, + state: item.state, + title: item.title.clone(), + unread: board.is_unread(item.id), + agent_path: item.agent_path.clone(), + }) + .collect() + } + + fn open_attention_item( + &mut self, + workspace: CatalogWorkspaceId, + id: AttentionItemId, + expected_revision: u64, + cx: &mut Context, + ) -> Result { + let focus = self + .attention + .get(&workspace) + .ok_or(AttentionError::WrongWorkspace)? + .resolve_target( + id, + expected_revision, + workspace, + &self.catalog, + &self.navigation, + )?; + + let native_target_exists = self.retained.get(&workspace).is_some_and(|surface| { + let terminal = surface.read(cx).terminal.clone(); + terminal + .read(cx) + .tabs + .iter() + .any(|tab| tab.id == focus.tab_id.as_uuid()) + }); + if !native_target_exists { + return Err(AttentionError::InvalidSurface); } + if !self.switch_to_checked(workspace, cx) { + return Err(AttentionError::InvalidSurface); + } + + let surface = self + .retained + .get(&workspace) + .cloned() + .ok_or(AttentionError::InvalidSurface)?; + let terminal = surface.read(cx).terminal.clone(); + terminal.update(cx, |terminal, _| { + terminal.select_tab(focus.tab_id.as_uuid()); + }); + let native_surface = terminal_surface(&self.catalog, workspace, terminal.read(cx)); + if native_surface.focus != Some(focus) { + return Err(AttentionError::InvalidSurface); + } + self.navigation + .reduce( + &self.catalog, + WorkspaceNavigationAction::UpdateSurface { + id: workspace, + surface: native_surface, + }, + ) + .map_err(|_| AttentionError::InvalidSurface)?; + self.attention + .get_mut(&workspace) + .ok_or(AttentionError::WrongWorkspace)? + .mark_read(id, expected_revision)?; + self.error = None; cx.notify(); + Ok(focus) } fn observe_local_card(&mut self, workspace: CatalogWorkspaceId, cx: &mut Context) { @@ -996,6 +2020,7 @@ impl WorkspaceHubView { self.refresh_checkout_select(Some(checkout_id), cx); self.onboarding_open = false; self.error = None; + cx.emit(WorkspaceHubEvent::CatalogChanged); } Err(error) => self.error = Some(error), } @@ -1395,10 +2420,19 @@ impl WorkspaceHubView { ) { self.error = Some(error.to_string()); } - self.retained.insert( + let (retained, subscription) = retained_surface_entity( + &self.catalog, workspace, - cx.new(move |_| RetainedWorkspaceSurface::new(workspace, terminal)), + terminal, + self.agent_host.clone(), + WorkspaceSurfaceState::default(), + cx, ); + self.retained.insert(workspace, retained); + self.retained_subscriptions.push(subscription); + self.attention + .insert(workspace, AttentionBoard::new(workspace)); + cx.emit(WorkspaceHubEvent::CatalogChanged); let executor = self.executor.clone(); let acknowledge = cx .background_executor() @@ -1578,10 +2612,19 @@ impl WorkspaceHubView { ) { self.error = Some(error.to_string()); } - self.retained.insert( + let (retained, subscription) = retained_surface_entity( + &self.catalog, workspace, - cx.new(move |_| RetainedWorkspaceSurface::new(workspace, terminal)), + terminal, + self.agent_host.clone(), + WorkspaceSurfaceState::default(), + cx, ); + self.retained.insert(workspace, retained); + self.retained_subscriptions.push(subscription); + self.attention + .insert(workspace, AttentionBoard::new(workspace)); + cx.emit(WorkspaceHubEvent::CatalogChanged); if let Err(error) = self.creation.reduce( request.catalog_revision, WorkspaceCreateEvent::Completed { @@ -1830,9 +2873,15 @@ impl WorkspaceHubView { if let Some(surface) = self.retained.get(&workspace) { let state = { let retained = surface.read(cx); - terminal_surface(&self.catalog, workspace, retained.terminal.read(cx)) + reconcile_terminal_surface( + &retained.surface, + terminal_surface(&self.catalog, workspace, retained.terminal.read(cx)), + ) }; - surface.update(cx, |surface, cx| surface.capture(cx)); + surface.update(cx, |surface, cx| { + surface.set_surface(state.clone()); + surface.capture(cx); + }); if let Err(error) = self.navigation.reduce( &self.catalog, WorkspaceNavigationAction::UpdateSurface { @@ -1858,6 +2907,7 @@ impl WorkspaceHubView { self.error = Some(error.to_string()); } else { self.navigation.reconcile_catalog(&self.catalog); + cx.emit(WorkspaceHubEvent::CatalogChanged); if archived { self.switch_to(workspace, cx); } else if was_active { @@ -2020,6 +3070,31 @@ fn agent_label(agent: WorkspaceAgentState) -> String { .to_string() } +fn attention_state_label(state: AttentionState) -> String { + match state { + AttentionState::NeedsYou => t!("workspaces.attention.needs_you"), + AttentionState::Working => t!("workspaces.attention.working"), + AttentionState::Blocked => t!("workspaces.attention.blocked"), + AttentionState::Done => t!("workspaces.attention.done"), + AttentionState::Idle => t!("workspaces.attention.idle"), + } + .to_string() +} + +fn attention_state_variant(state: AttentionState) -> BadgeVariant { + match state { + AttentionState::NeedsYou => BadgeVariant::Warning, + AttentionState::Blocked => BadgeVariant::Destructive, + AttentionState::Done => BadgeVariant::Default, + AttentionState::Working => BadgeVariant::Secondary, + AttentionState::Idle => BadgeVariant::Outline, + } +} + +fn attention_error_label(_error: AttentionError) -> String { + t!("workspaces.attention.open_refused").to_string() +} + fn freshness_label(freshness: WorkspaceFreshness) -> String { match freshness { WorkspaceFreshness::Fresh => t!("workspaces.freshness.fresh"), diff --git a/crates/shelldeck-ui/src/workspace/workspaces_panes.rs b/crates/shelldeck-ui/src/workspace/workspaces_panes.rs new file mode 100644 index 00000000..ce60de02 --- /dev/null +++ b/crates/shelldeck-ui/src/workspace/workspaces_panes.rs @@ -0,0 +1,607 @@ +//! Native presentation for the typed tabs retained by `WorkspaceSurfaceState`. +//! +//! The core DTO remains the source of pane/split/focus identity. This module +//! only binds those identities to GPUI surfaces and reports activations that +//! require an application-owned runtime (currently providers and browsers). + +use super::*; +use shelldeck_core::workspace_navigation::{PaneNode, SplitAxis}; + +pub(super) fn resolve_local_tab_path( + checkout: &ProjectCheckout, + relative: &shelldeck_core::config::workspace_catalog::WorkspaceRelativePath, +) -> Option { + checkout.resolve_existing_local_path(relative).ok() +} + +pub(super) fn validated_browser_location(raw: &str) -> Option { + let raw = raw.trim(); + if raw.is_empty() || raw.len() > 4_096 || raw.chars().any(char::is_control) { + return None; + } + let parsed = url::Url::parse(raw).ok()?; + if !matches!(parsed.scheme(), "http" | "https") + || !parsed.username().is_empty() + || parsed.password().is_some() + || parsed.host_str().is_none_or(str::is_empty) + { + return None; + } + Some(raw.to_owned()) +} + +#[derive(Clone, Debug)] +pub(super) enum RetainedWorkspaceEvent { + Activate(WorkspacePaneActivation), + LayoutChanged { + workspace: CatalogWorkspaceId, + surface: WorkspaceSurfaceState, + }, +} + +impl EventEmitter for RetainedWorkspaceSurface {} + +pub(super) fn set_active_tab(node: &mut PaneNode, focus: WorkspaceFocus) -> Option { + match node { + PaneNode::Leaf(leaf) if leaf.id == focus.pane_id => { + let tab = leaf.tabs.iter().find(|tab| tab.id == focus.tab_id)?.clone(); + leaf.active_tab = Some(focus.tab_id); + Some(tab) + } + PaneNode::Leaf(_) => None, + PaneNode::Split { first, second, .. } => { + set_active_tab(first, focus).or_else(|| set_active_tab(second, focus)) + } + } +} + +/// Remove every pane reference to one local agent runtime. Session UUIDs are +/// global, but malformed/restored state may contain duplicates, so this never +/// stops after the first match. Empty leaves remain valid split placeholders. +pub(super) fn remove_agent_session_tabs( + surface: &mut WorkspaceSurfaceState, + session_id: Uuid, +) -> bool { + fn remove(node: &mut PaneNode, session_id: Uuid) -> bool { + match node { + PaneNode::Leaf(leaf) => { + let before = leaf.tabs.len(); + leaf.tabs.retain(|tab| { + !matches!(&tab.content, WorkspaceTabContent::AgentSession(binding) + if binding.session_id == session_id) + }); + if leaf + .active_tab + .is_some_and(|active| !leaf.tabs.iter().any(|tab| tab.id == active)) + { + leaf.active_tab = leaf.tabs.first().map(|tab| tab.id); + } + before != leaf.tabs.len() + } + PaneNode::Split { first, second, .. } => { + let first_changed = remove(first, session_id); + remove(second, session_id) || first_changed + } + } + } + + fn contains(node: &PaneNode, focus: WorkspaceFocus) -> bool { + match node { + PaneNode::Leaf(leaf) => { + leaf.id == focus.pane_id && leaf.tabs.iter().any(|tab| tab.id == focus.tab_id) + } + PaneNode::Split { first, second, .. } => { + contains(first, focus) || contains(second, focus) + } + } + } + + fn first_focus(node: &PaneNode) -> Option { + match node { + PaneNode::Leaf(leaf) => leaf.active_tab.map(|tab_id| WorkspaceFocus { + pane_id: leaf.id, + tab_id, + }), + PaneNode::Split { first, second, .. } => { + first_focus(first).or_else(|| first_focus(second)) + } + } + } + + let Some(root) = surface.root.as_mut() else { + return false; + }; + let changed = remove(root, session_id); + if changed && surface.focus.is_some_and(|focus| !contains(root, focus)) { + surface.focus = first_focus(root); + } + changed +} + +pub(super) fn split_leaf_with_active_tab( + node: &mut PaneNode, + pane_id: PaneId, + axis: SplitAxis, +) -> Option { + match node { + PaneNode::Leaf(leaf) if leaf.id == pane_id && leaf.tabs.len() > 1 => { + let active = leaf.active_tab?; + let active_index = leaf.tabs.iter().position(|tab| tab.id == active)?; + let mut first = leaf.clone(); + let moved = first.tabs.remove(active_index); + first.active_tab = first.tabs.first().map(|tab| tab.id); + let second_id = PaneId::from_uuid(Uuid::new_v4()); + let focus = WorkspaceFocus { + pane_id: second_id, + tab_id: moved.id, + }; + *node = PaneNode::Split { + axis, + ratio_basis_points: 5_000, + first: Box::new(PaneNode::Leaf(first)), + second: Box::new(PaneNode::Leaf(PaneLeaf { + id: second_id, + tabs: vec![moved], + active_tab: Some(focus.tab_id), + })), + }; + Some(focus) + } + PaneNode::Leaf(_) => None, + PaneNode::Split { first, second, .. } => split_leaf_with_active_tab(first, pane_id, axis) + .or_else(|| split_leaf_with_active_tab(second, pane_id, axis)), + } +} + +fn first_pane_id(node: &PaneNode) -> PaneId { + match node { + PaneNode::Leaf(leaf) => leaf.id, + PaneNode::Split { first, .. } => first_pane_id(first), + } +} + +pub(super) fn adjust_split_ratio( + node: &mut PaneNode, + first_anchor: PaneId, + second_anchor: PaneId, + delta_basis_points: i16, +) -> bool { + match node { + PaneNode::Leaf(_) => false, + PaneNode::Split { + ratio_basis_points, + first, + second, + .. + } if first_pane_id(first) == first_anchor && first_pane_id(second) == second_anchor => { + *ratio_basis_points = + (*ratio_basis_points as i32 + delta_basis_points as i32).clamp(1_000, 9_000) as u16; + true + } + PaneNode::Split { first, second, .. } => { + adjust_split_ratio(first, first_anchor, second_anchor, delta_basis_points) + || adjust_split_ratio(second, first_anchor, second_anchor, delta_basis_points) + } + } +} + +impl RetainedWorkspaceSurface { + pub(super) fn set_surface(&mut self, surface: WorkspaceSurfaceState) { + self.surface = surface; + } + + pub(super) fn activate_tab(&mut self, focus: WorkspaceFocus, cx: &mut Context) { + let Some(root) = self.surface.root.as_mut() else { + return; + }; + let Some(tab) = set_active_tab(root, focus) else { + return; + }; + self.surface.focus = Some(focus); + self.resolved_local_tab = None; + match &tab.content { + WorkspaceTabContent::Terminal(_) => { + self.terminal + .update(cx, |terminal, _| terminal.select_tab(tab.id.as_uuid())); + } + WorkspaceTabContent::Editor { relative_path, .. } => { + if let Some(path) = resolve_local_tab_path(&self.checkout, relative_path) { + self.resolved_local_tab = Some(tab.id); + self.editor.update(cx, |editor, cx| { + editor.file_browser_visible = false; + editor.open_file(path.as_path().to_path_buf(), cx); + }); + } + } + WorkspaceTabContent::Files { relative_root, .. } => { + if let Some(path) = resolve_local_tab_path(&self.checkout, relative_root) { + self.resolved_local_tab = Some(tab.id); + self.editor.update(cx, |editor, _| { + editor.file_browser.set_root(path.as_path().to_path_buf()); + editor.file_browser_visible = true; + }); + } + } + WorkspaceTabContent::Browser { .. } + | WorkspaceTabContent::AgentSession(_) + | WorkspaceTabContent::ProviderSession(_) => {} + } + cx.emit(RetainedWorkspaceEvent::Activate(WorkspacePaneActivation { + workspace: self.workspace, + focus, + title: tab.title, + content: tab.content, + })); + cx.notify(); + } + + pub(super) fn prepare_files_tab( + &mut self, + tab_id: WorkspaceTabId, + relative_root: &shelldeck_core::config::workspace_catalog::WorkspaceRelativePath, + cx: &mut Context, + ) -> bool { + let Ok(path) = self.checkout.resolve_existing_local_path(relative_root) else { + return false; + }; + self.resolved_local_tab = Some(tab_id); + self.editor.update(cx, |editor, _| { + editor.file_browser.set_root(path.as_path().to_path_buf()); + editor.file_browser_visible = true; + }); + cx.notify(); + true + } + + fn split_pane(&mut self, pane_id: PaneId, axis: SplitAxis, cx: &mut Context) { + let Some(root) = self.surface.root.as_mut() else { + return; + }; + let Some(focus) = split_leaf_with_active_tab(root, pane_id, axis) else { + return; + }; + self.surface.focus = Some(focus); + cx.emit(RetainedWorkspaceEvent::LayoutChanged { + workspace: self.workspace, + surface: self.surface.clone(), + }); + cx.notify(); + } + + fn resize_split( + &mut self, + first_anchor: PaneId, + second_anchor: PaneId, + delta_basis_points: i16, + cx: &mut Context, + ) { + let Some(root) = self.surface.root.as_mut() else { + return; + }; + if !adjust_split_ratio(root, first_anchor, second_anchor, delta_basis_points) { + return; + } + cx.emit(RetainedWorkspaceEvent::LayoutChanged { + workspace: self.workspace, + surface: self.surface.clone(), + }); + cx.notify(); + } + + fn render_tab_strip(&self, leaf: &PaneLeaf, cx: &mut Context) -> AnyElement { + let entity = cx.entity(); + let mut strip = div() + .flex() + .items_center() + .h(px(34.0)) + .flex_shrink_0() + .overflow_x_hidden() + .border_b_1() + .border_color(ShellDeckColors::border()) + .bg(ShellDeckColors::bg_surface()); + for tab in &leaf.tabs { + let focus = WorkspaceFocus { + pane_id: leaf.id, + tab_id: tab.id, + }; + let active = leaf.active_tab == Some(tab.id); + let icon = match tab.content { + WorkspaceTabContent::Terminal(_) => "terminal", + WorkspaceTabContent::Editor { .. } => "file-code-2", + WorkspaceTabContent::Files { .. } => "files", + WorkspaceTabContent::Browser { .. } => "globe-2", + WorkspaceTabContent::AgentSession(_) => "bot", + WorkspaceTabContent::ProviderSession(_) => "bot", + }; + let tab_entity = entity.clone(); + strip = strip.child( + div() + .id(("workspace-pane-tab", tab.id.as_uuid().as_u128() as u64)) + .flex() + .items_center() + .gap(px(6.0)) + .h_full() + .px(px(10.0)) + .border_r_1() + .border_color(ShellDeckColors::border()) + .when(active, |view| view.bg(ShellDeckColors::bg_primary())) + .text_size(px(11.0)) + .text_color(if active { + ShellDeckColors::text_primary() + } else { + ShellDeckColors::text_muted() + }) + .cursor_pointer() + .on_click(move |_, _, cx| { + tab_entity.update(cx, |surface, cx| surface.activate_tab(focus, cx)); + }) + .child(lucide_icon(icon, 13.0, ShellDeckColors::text_muted())) + .child(tab.title.clone()), + ); + } + if leaf.tabs.len() > 1 { + let split_entity = entity; + let pane_id = leaf.id; + strip = strip.child( + div() + .id(("workspace-pane-split", leaf.id.as_uuid().as_u128() as u64)) + .ml_auto() + .flex() + .items_center() + .justify_center() + .w(px(32.0)) + .h_full() + .border_l_1() + .border_color(ShellDeckColors::border()) + .cursor_pointer() + .on_click(move |_, _, cx| { + split_entity.update(cx, |surface, cx| { + surface.split_pane(pane_id, SplitAxis::Horizontal, cx) + }); + }) + .child(lucide_icon( + "columns-2", + 13.0, + ShellDeckColors::text_muted(), + )), + ); + } + strip.into_any_element() + } + + fn render_unattached_runtime( + &self, + icon: &'static str, + title: String, + detail: String, + ) -> AnyElement { + div() + .flex() + .flex_col() + .items_center() + .justify_center() + .gap(px(8.0)) + .size_full() + .text_size(px(11.0)) + .text_color(ShellDeckColors::text_muted()) + .child(lucide_icon(icon, 24.0, ShellDeckColors::text_muted())) + .child( + div() + .font_weight(FontWeight::MEDIUM) + .text_color(ShellDeckColors::text_primary()) + .child(title), + ) + .child(detail) + .into_any_element() + } + + fn render_browser_card( + &self, + tab_id: WorkspaceTabId, + title: String, + location: String, + ) -> AnyElement { + let validated = validated_browser_location(&location); + let mut open = Button::new( + ("workspace-browser-open", tab_id.as_uuid().as_u128() as u64), + t!("workspaces.card.open").to_string(), + ) + .size(ButtonSize::Sm) + .variant(ButtonVariant::Default) + .disabled(validated.is_none()); + if let Some(url) = validated { + open = open.on_click(move |_, _, cx| cx.open_url(&url)); + } + div() + .flex() + .flex_col() + .items_center() + .justify_center() + .gap(px(10.0)) + .size_full() + .px(px(20.0)) + .text_size(px(11.0)) + .text_color(ShellDeckColors::text_muted()) + .child(lucide_icon("globe-2", 24.0, ShellDeckColors::text_muted())) + .child( + div() + .font_weight(FontWeight::MEDIUM) + .text_color(ShellDeckColors::text_primary()) + .child(title), + ) + .child(div().max_w(px(520.0)).child(location)) + .child(open) + .into_any_element() + } + + fn render_leaf(&self, leaf: &PaneLeaf, cx: &mut Context) -> AnyElement { + let active = leaf + .active_tab + .and_then(|id| leaf.tabs.iter().find(|tab| tab.id == id)); + let body = match active.map(|tab| &tab.content) { + Some(WorkspaceTabContent::Terminal(_)) => self.terminal.clone().into_any_element(), + Some(WorkspaceTabContent::Editor { .. } | WorkspaceTabContent::Files { .. }) + if active.is_some_and(|tab| self.resolved_local_tab == Some(tab.id)) => + { + self.editor.clone().into_any_element() + } + Some(WorkspaceTabContent::ProviderSession(binding)) => self.render_unattached_runtime( + "bot", + active.map(|tab| tab.title.clone()).unwrap_or_default(), + binding.session_id.clone(), + ), + Some(WorkspaceTabContent::AgentSession(binding)) + if self.agent_host.as_ref().is_some_and(|host| { + host.read(cx).selected_session_id() == Some(binding.session_id) + }) => + { + self.agent_host.clone().unwrap().into_any_element() + } + Some(WorkspaceTabContent::AgentSession(binding)) => self.render_unattached_runtime( + "bot", + active.map(|tab| tab.title.clone()).unwrap_or_default(), + binding.session_id.to_string(), + ), + Some(WorkspaceTabContent::Browser { location }) => self.render_browser_card( + active.expect("browser content has an active tab").id, + active.map(|tab| tab.title.clone()).unwrap_or_default(), + location.clone(), + ), + Some(WorkspaceTabContent::Editor { relative_path, .. }) => self + .render_unattached_runtime( + "file-code-2", + relative_path.as_str().to_owned(), + String::new(), + ), + Some(WorkspaceTabContent::Files { relative_root, .. }) => self + .render_unattached_runtime( + "files", + relative_root.as_str().to_owned(), + String::new(), + ), + None => self.render_unattached_runtime("panel-top", String::new(), String::new()), + }; + div() + .flex() + .flex_col() + .min_w_0() + .min_h_0() + .size_full() + .child(self.render_tab_strip(leaf, cx)) + .child(div().flex().flex_1().min_h_0().min_w_0().child(body)) + .into_any_element() + } + + fn render_node(&self, node: &PaneNode, cx: &mut Context) -> AnyElement { + match node { + PaneNode::Leaf(leaf) => self.render_leaf(leaf, cx), + PaneNode::Split { + axis, + ratio_basis_points, + first, + second, + } => { + let first_ratio = *ratio_basis_points as f32 / 10_000.0; + let second_ratio = 1.0 - first_ratio; + let horizontal = *axis == SplitAxis::Horizontal; + let first_anchor = first_pane_id(first); + let second_anchor = first_pane_id(second); + let shrink_entity = cx.entity(); + let grow_entity = shrink_entity.clone(); + let divider = div() + .flex() + .when(horizontal, |view| view.flex_col().w(px(24.0)).h_full()) + .when(!horizontal, |view| view.flex_row().h(px(24.0)).w_full()) + .items_center() + .justify_center() + .flex_shrink_0() + .bg(ShellDeckColors::bg_surface()) + .child( + Button::new( + ( + "workspace-split-shrink", + first_anchor.as_uuid().as_u128() as u64, + ), + "−", + ) + .size(ButtonSize::Sm) + .variant(ButtonVariant::Ghost) + .on_click(move |_, _, cx| { + shrink_entity.update(cx, |surface, cx| { + surface.resize_split(first_anchor, second_anchor, -500, cx) + }); + }), + ) + .child( + Button::new( + ( + "workspace-split-grow", + second_anchor.as_uuid().as_u128() as u64, + ), + "+", + ) + .size(ButtonSize::Sm) + .variant(ButtonVariant::Ghost) + .on_click(move |_, _, cx| { + grow_entity.update(cx, |surface, cx| { + surface.resize_split(first_anchor, second_anchor, 500, cx) + }); + }), + ); + div() + .flex() + .when(horizontal, |view| view.flex_row()) + .when(!horizontal, |view| view.flex_col()) + .size_full() + .min_w_0() + .min_h_0() + .child( + div() + .flex() + .min_w_0() + .min_h_0() + .flex_basis(relative(first_ratio)) + .when(horizontal, |view| { + view.border_r_1().border_color(ShellDeckColors::border()) + }) + .when(!horizontal, |view| { + view.border_b_1().border_color(ShellDeckColors::border()) + }) + .child(self.render_node(first, cx)), + ) + .child(divider) + .child( + div() + .flex() + .min_w_0() + .min_h_0() + .flex_basis(relative(second_ratio)) + .child(self.render_node(second, cx)), + ) + .into_any_element() + } + } + } +} + +impl Render for RetainedWorkspaceSurface { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let body = self + .surface + .root + .clone() + .map(|root| self.render_node(&root, cx)) + .unwrap_or_else(|| { + self.render_unattached_runtime("panel-top", String::new(), String::new()) + }); + div() + .id(( + "retained-workspace", + self.workspace.as_uuid().as_u128() as u64, + )) + .flex() + .flex_col() + .size_full() + .child(body) + } +} diff --git a/crates/shelldeck-ui/src/workspace/workspaces_render.rs b/crates/shelldeck-ui/src/workspace/workspaces_render.rs index 91b209d6..85695d35 100644 --- a/crates/shelldeck-ui/src/workspace/workspaces_render.rs +++ b/crates/shelldeck-ui/src/workspace/workspaces_render.rs @@ -275,6 +275,8 @@ impl WorkspaceHubView { presentation: WorkspaceCardPresentation, active: bool, creation: Option<&BackgroundWorkspaceCreateState>, + attention_items: Vec, + platform_attention_items: Vec, cx: &mut Context, ) -> AnyElement { let entity = cx.entity(); @@ -444,6 +446,156 @@ impl WorkspaceHubView { orchestration, )); } + if !attention_items.is_empty() || !platform_attention_items.is_empty() { + content = content.child( + div() + .pt(px(3.0)) + .text_size(px(10.0)) + .font_weight(FontWeight::SEMIBOLD) + .text_color(ShellDeckColors::text_primary()) + .child(t!("workspaces.attention.title").to_string()), + ); + } + for item in attention_items { + let attention_entity = entity.clone(); + let workspace = item.workspace; + let attention_id = item.id; + let revision = item.revision; + let path = if item.agent_path.is_empty() { + t!("workspaces.attention.agent_root").to_string() + } else { + item.agent_path.join(" › ") + }; + content = content.child( + Card::new().content( + div() + .flex() + .flex_col() + .gap(px(5.0)) + .child( + div() + .flex() + .items_center() + .gap(px(5.0)) + .child( + Badge::new(attention_state_label(item.state)) + .variant(attention_state_variant(item.state)), + ) + .child( + Badge::new(if item.unread { + t!("workspaces.attention.unread").to_string() + } else { + t!("workspaces.attention.read").to_string() + }) + .variant(if item.unread { + BadgeVariant::Destructive + } else { + BadgeVariant::Outline + }), + ) + .child( + div() + .min_w_0() + .truncate() + .text_size(px(10.0)) + .text_color(ShellDeckColors::text_muted()) + .child( + t!("workspaces.attention.agent_path", path = path) + .to_string(), + ), + ), + ) + .child( + Button::new( + SharedString::from(format!( + "workspace-attention-{}-{revision}", + attention_id.as_uuid() + )), + item.title, + ) + .size(ButtonSize::Sm) + .variant(ButtonVariant::Ghost) + .on_click(move |_, _, cx| { + attention_entity.update(cx, |this, cx| { + if let Err(error) = this.open_attention_item( + workspace, + attention_id, + revision, + cx, + ) { + this.error = Some(attention_error_label(error)); + cx.notify(); + } + }); + }), + ), + ), + ); + } + for item in platform_attention_items { + let activation = item.activation; + let attention_entity = entity.clone(); + let path = if item.nested_agent_path.is_empty() { + t!("workspaces.attention.agent_root").to_string() + } else { + item.nested_agent_path.join(" › ") + }; + content = content.child( + Card::new().content( + div() + .flex() + .flex_col() + .gap(px(5.0)) + .child( + div() + .flex() + .items_center() + .gap(px(5.0)) + .child( + Badge::new(platform_attention_state_label(item.state)) + .variant(BadgeVariant::Outline), + ) + .child( + Badge::new(if item.unread { + t!("workspaces.attention.unread").to_string() + } else { + t!("workspaces.attention.read").to_string() + }) + .variant(if item.unread { + BadgeVariant::Destructive + } else { + BadgeVariant::Outline + }), + ) + .child( + div() + .min_w_0() + .truncate() + .text_size(px(10.0)) + .text_color(ShellDeckColors::text_muted()) + .child(path), + ), + ) + .child( + Button::new( + SharedString::from(format!( + "platform-attention-{}-{}", + activation.item.uuid(), + activation.item_revision.get() + )), + attention_reason_label(item.reason), + ) + .size(ButtonSize::Sm) + .variant(ButtonVariant::Ghost) + .on_click(move |_, _, cx| { + attention_entity.update(cx, |_this, cx| { + cx.emit(WorkspaceHubEvent::OpenPlatformAttention(activation)); + }); + }), + ), + ), + ); + } if let Some(state) = creation { content = content.child(render_creation_state(state)); } @@ -745,10 +897,18 @@ impl Render for WorkspaceHubView { &self.connections, self.cards.sources.get(&workspace.id()), ) { + let attention_items = self.attention_items(workspace.id()); + let platform_attention_items = self + .platform_attention + .get(&workspace.id()) + .cloned() + .unwrap_or_default(); workspace_cards = workspace_cards.child(self.render_workspace_card( presentation, self.navigation.active() == Some(workspace.id()), self.creation.state(workspace.id()), + attention_items, + platform_attention_items, cx, )); } diff --git a/crates/shelldeck-ui/src/workspace/workspaces_tests.rs b/crates/shelldeck-ui/src/workspace/workspaces_tests.rs index 4de74e2b..832718a0 100644 --- a/crates/shelldeck-ui/src/workspace/workspaces_tests.rs +++ b/crates/shelldeck-ui/src/workspace/workspaces_tests.rs @@ -3,27 +3,42 @@ use super::*; #[cfg(test)] mod tests { use super::{ - mutate_and_persist, workspace_card_presentation, AuthorizedLaunchHost, GitWorktreeAdapter, - LauncherIntakeKind, NativeLaunchOutcome, NativeWorkspaceExecutor, ProviderCardObservation, - WorkspaceCardAggregator, WorkspaceExecutionRequest, WorkspaceHubView, - WorkspaceLaunchExecutor, WorkspaceLaunchMode, WorkspaceLauncherDraft, - WorkspaceTerminalConfig, + mutate_and_persist, reconcile_terminal_surface, terminal_surface, + workspace_card_presentation, + workspaces_panes::{ + adjust_split_ratio, remove_agent_session_tabs, resolve_local_tab_path, + split_leaf_with_active_tab, validated_browser_location, + }, + AuthorizedLaunchHost, GitWorktreeAdapter, LauncherIntakeKind, NativeLaunchOutcome, + NativeWorkspaceExecutor, ProviderCardObservation, WorkspaceCardAggregator, + WorkspaceExecutionRequest, WorkspaceHubView, WorkspaceLaunchExecutor, WorkspaceLaunchMode, + WorkspaceLauncherDraft, WorkspaceTerminalConfig, }; use crate::terminal_view::TerminalView; use gpui::{AppContext, TestAppContext}; + use shelldeck_core::config::platform::{ + ResourceAuthority, ResourceCoordinate, ResourceId, ResourceKind, + }; use shelldeck_core::config::themes::TerminalTheme; use shelldeck_core::config::workspace_catalog::{ CatalogCheckoutId, CatalogProjectId, CatalogWorkspaceId, CheckoutHost, ExternalWorkItem, ExternalWorkItemKind, PlatformContextRef, PlatformMappingReconciliation, PlatformV2Mapping, ProjectCatalog, ProjectCheckout, ProjectRecord, RepositoryIdentity, WorkspaceLaunchIntake, - WorkspaceLaunchRequest, + WorkspaceLaunchRequest, WorkspaceRelativePath, }; use shelldeck_core::workspace_navigation::{ - BackgroundWorkspaceCreateState, CreationOperationId, GitDirtyState, WorkspaceAgentState, + AgentSessionBinding, BackgroundWorkspaceCreateState, CreationOperationId, GitDirtyState, + PaneId, PaneLeaf, PaneNode, ProviderSessionBinding, SplitAxis, TerminalAuthority, + TerminalBinding, TerminalBindingId, TerminalSurface, TerminalViewport, WorkspaceAgentState, WorkspaceCardState, WorkspaceCreateEvent, WorkspaceCreateFailure, - WorkspaceCreateFailureKind, WorkspaceCreatePhase, WorkspaceCreateProgress, - WorkspaceFreshness, + WorkspaceCreateFailureKind, WorkspaceCreatePhase, WorkspaceCreateProgress, WorkspaceFocus, + WorkspaceFreshness, WorkspaceNavigationAction, WorkspaceSurfaceState, WorkspaceTab, + WorkspaceTabContent, WorkspaceTabId, + }; + use shelldeck_core::workspace_review::{ + AttentionError, AttentionItem, AttentionItemId, AttentionState, AttentionTarget, }; + use shelldeck_terminal::session::TerminalSession; use std::collections::HashMap; use std::path::PathBuf; use std::sync::Arc; @@ -163,6 +178,601 @@ mod tests { }); } + // SDTEST-1885 — SDUC-490/493 + #[test] + fn terminal_reconciliation_preserves_typed_tabs_splits_and_focus() { + let checkout = CatalogCheckoutId::from_uuid(Uuid::from_u128(2)); + let agent_pane = PaneId::from_uuid(Uuid::from_u128(80)); + let files_pane = PaneId::from_uuid(Uuid::from_u128(81)); + let agent_tab = WorkspaceTabId::from_uuid(Uuid::from_u128(82)); + let files_tab = WorkspaceTabId::from_uuid(Uuid::from_u128(83)); + let terminal_tab = WorkspaceTabId::from_uuid(Uuid::from_u128(84)); + let retained = WorkspaceSurfaceState { + root: Some(PaneNode::Split { + axis: SplitAxis::Horizontal, + ratio_basis_points: 6_250, + first: Box::new(PaneNode::Leaf(PaneLeaf { + id: agent_pane, + tabs: vec![WorkspaceTab { + id: agent_tab, + title: "Implement cockpit".into(), + content: WorkspaceTabContent::AgentSession(AgentSessionBinding { + checkout_id: checkout, + session_id: Uuid::from_u128(85), + }), + }], + active_tab: Some(agent_tab), + })), + second: Box::new(PaneNode::Leaf(PaneLeaf { + id: files_pane, + tabs: vec![WorkspaceTab { + id: files_tab, + title: "Files".into(), + content: WorkspaceTabContent::Files { + checkout_id: checkout, + relative_root: WorkspaceRelativePath::new("src").unwrap(), + }, + }], + active_tab: Some(files_tab), + })), + }), + focus: Some(WorkspaceFocus { + pane_id: agent_pane, + tab_id: agent_tab, + }), + }; + let native = WorkspaceSurfaceState { + root: Some(PaneNode::Leaf(PaneLeaf { + id: PaneId::from_uuid(Uuid::from_u128(86)), + tabs: vec![WorkspaceTab { + id: terminal_tab, + title: "Terminal".into(), + content: WorkspaceTabContent::Terminal(TerminalSurface { + binding: TerminalBinding { + id: TerminalBindingId::from_uuid(Uuid::from_u128(87)), + authority: TerminalAuthority::Local { + checkout_id: checkout, + }, + }, + viewport: TerminalViewport::default(), + draft: String::new(), + }), + }], + active_tab: Some(terminal_tab), + })), + focus: None, + }; + + let reconciled = reconcile_terminal_surface(&retained, native); + assert_eq!(reconciled.focus, retained.focus); + let PaneNode::Split { + ratio_basis_points, + first, + second, + .. + } = reconciled.root.unwrap() + else { + panic!("typed split was flattened"); + }; + assert_eq!(ratio_basis_points, 6_250); + let PaneNode::Leaf(first) = *first else { + panic!("first split leaf missing"); + }; + assert!(first.tabs.iter().any(|tab| tab.id == agent_tab)); + assert!(first.tabs.iter().any(|tab| tab.id == terminal_tab)); + let PaneNode::Leaf(second) = *second else { + panic!("second split leaf missing"); + }; + assert_eq!(second.tabs[0].id, files_tab); + } + + // SDTEST-1886 — SDUC-490 + #[test] + fn closing_agent_session_removes_every_duplicate_and_repairs_focus() { + let checkout = CatalogCheckoutId::from_uuid(Uuid::from_u128(2)); + let session_id = Uuid::from_u128(1886); + let first_pane = PaneId::from_uuid(Uuid::from_u128(90)); + let second_pane = PaneId::from_uuid(Uuid::from_u128(91)); + let terminal_tab = WorkspaceTabId::from_uuid(Uuid::from_u128(92)); + let first_agent = WorkspaceTabId::from_uuid(Uuid::from_u128(93)); + let second_agent = WorkspaceTabId::from_uuid(Uuid::from_u128(94)); + let agent = |id| WorkspaceTab { + id, + title: "Duplicate agent".into(), + content: WorkspaceTabContent::AgentSession(AgentSessionBinding { + checkout_id: checkout, + session_id, + }), + }; + let mut surface = WorkspaceSurfaceState { + root: Some(PaneNode::Split { + axis: SplitAxis::Horizontal, + ratio_basis_points: 5_000, + first: Box::new(PaneNode::Leaf(PaneLeaf { + id: first_pane, + tabs: vec![ + WorkspaceTab { + id: terminal_tab, + title: "Terminal".into(), + content: WorkspaceTabContent::Terminal(TerminalSurface { + binding: TerminalBinding { + id: TerminalBindingId::from_uuid(Uuid::from_u128(95)), + authority: TerminalAuthority::Local { + checkout_id: checkout, + }, + }, + viewport: TerminalViewport::default(), + draft: String::new(), + }), + }, + agent(first_agent), + ], + active_tab: Some(first_agent), + })), + second: Box::new(PaneNode::Leaf(PaneLeaf { + id: second_pane, + tabs: vec![agent(second_agent)], + active_tab: Some(second_agent), + })), + }), + focus: Some(WorkspaceFocus { + pane_id: second_pane, + tab_id: second_agent, + }), + }; + + assert!(remove_agent_session_tabs(&mut surface, session_id)); + assert_eq!( + surface.focus, + Some(WorkspaceFocus { + pane_id: first_pane, + tab_id: terminal_tab, + }) + ); + assert!(!remove_agent_session_tabs(&mut surface, session_id)); + let PaneNode::Split { first, second, .. } = surface.root.unwrap() else { + panic!("split disappeared"); + }; + let PaneNode::Leaf(first) = *first else { + panic!("first leaf disappeared"); + }; + let PaneNode::Leaf(second) = *second else { + panic!("second leaf disappeared"); + }; + assert_eq!(first.tabs.len(), 1); + assert_eq!(first.active_tab, Some(terminal_tab)); + assert!(second.tabs.is_empty()); + assert_eq!(second.active_tab, None); + } + + // SDTEST-1887 — SDUC-490 + #[test] + fn split_action_moves_the_active_tab_into_a_distinct_native_pane() { + let checkout = CatalogCheckoutId::from_uuid(Uuid::from_u128(2)); + let pane = PaneId::from_uuid(Uuid::from_u128(100)); + let terminal_tab = WorkspaceTabId::from_uuid(Uuid::from_u128(101)); + let agent_tab = WorkspaceTabId::from_uuid(Uuid::from_u128(102)); + let mut node = PaneNode::Leaf(PaneLeaf { + id: pane, + tabs: vec![ + WorkspaceTab { + id: terminal_tab, + title: "Terminal".into(), + content: WorkspaceTabContent::Terminal(TerminalSurface { + binding: TerminalBinding { + id: TerminalBindingId::from_uuid(Uuid::from_u128(103)), + authority: TerminalAuthority::Local { + checkout_id: checkout, + }, + }, + viewport: TerminalViewport::default(), + draft: String::new(), + }), + }, + WorkspaceTab { + id: agent_tab, + title: "Agent".into(), + content: WorkspaceTabContent::AgentSession(AgentSessionBinding { + checkout_id: checkout, + session_id: Uuid::from_u128(104), + }), + }, + ], + active_tab: Some(agent_tab), + }); + + let focus = split_leaf_with_active_tab(&mut node, pane, SplitAxis::Horizontal) + .expect("two tabs can be split"); + let PaneNode::Split { + axis, + ratio_basis_points, + first, + second, + } = node + else { + panic!("split action did not create a split"); + }; + assert_eq!(axis, SplitAxis::Horizontal); + assert_eq!(ratio_basis_points, 5_000); + let PaneNode::Leaf(first) = *first else { + panic!("first leaf missing"); + }; + let PaneNode::Leaf(second) = *second else { + panic!("second leaf missing"); + }; + assert_eq!(first.tabs[0].id, terminal_tab); + assert_eq!(first.active_tab, Some(terminal_tab)); + assert_eq!(second.tabs[0].id, agent_tab); + assert_eq!(second.active_tab, Some(agent_tab)); + assert_eq!(focus.pane_id, second.id); + assert_eq!(focus.tab_id, agent_tab); + } + + // SDTEST-1888 — SDUC-490 + #[test] + fn workspace_editor_path_resolution_requires_an_existing_authorized_entry() { + let root = tempfile::tempdir().unwrap(); + std::fs::create_dir(root.path().join("src")).unwrap(); + std::fs::write(root.path().join("src/lib.rs"), "fn main() {}").unwrap(); + let checkout = ProjectCheckout::new( + CatalogCheckoutId::from_uuid(Uuid::from_u128(110)), + "local", + CheckoutHost::Local { + device_label: "Local".into(), + root: std::fs::canonicalize(root.path()).unwrap(), + }, + RepositoryIdentity { + slug: "inklura/shelldeck".into(), + canonical_url: None, + }, + ); + let valid = WorkspaceRelativePath::new("src/lib.rs").unwrap(); + let missing = WorkspaceRelativePath::new("src/missing.rs").unwrap(); + + assert_eq!( + resolve_local_tab_path(&checkout, &valid).unwrap().as_path(), + std::fs::canonicalize(root.path().join("src/lib.rs")) + .unwrap() + .as_path() + ); + assert!(resolve_local_tab_path(&checkout, &missing).is_none()); + } + + // SDTEST-1889 — SDUC-490 + #[test] + fn split_resize_controls_adjust_the_exact_divider_and_stay_bounded() { + let first = PaneId::from_uuid(Uuid::from_u128(120)); + let second = PaneId::from_uuid(Uuid::from_u128(121)); + let mut node = PaneNode::Split { + axis: SplitAxis::Horizontal, + ratio_basis_points: 5_000, + first: Box::new(PaneNode::Leaf(PaneLeaf { + id: first, + tabs: Vec::new(), + active_tab: None, + })), + second: Box::new(PaneNode::Leaf(PaneLeaf { + id: second, + tabs: Vec::new(), + active_tab: None, + })), + }; + + assert!(adjust_split_ratio(&mut node, first, second, 500)); + let PaneNode::Split { + ratio_basis_points, .. + } = &node + else { + unreachable!(); + }; + assert_eq!(*ratio_basis_points, 5_500); + assert!(adjust_split_ratio(&mut node, first, second, 20_000)); + let PaneNode::Split { + ratio_basis_points, .. + } = &node + else { + unreachable!(); + }; + assert_eq!(*ratio_basis_points, 9_000); + assert!(!adjust_split_ratio( + &mut node, + PaneId::from_uuid(Uuid::from_u128(122)), + second, + 500, + )); + } + + // SDTEST-1890 — SDUC-490 + #[test] + fn browser_pane_open_action_admits_only_safe_http_locations() { + assert_eq!( + validated_browser_location(" https://127.0.0.1:3000/preview "), + Some("https://127.0.0.1:3000/preview".into()) + ); + assert!(validated_browser_location("javascript:alert(1)").is_none()); + assert!(validated_browser_location("https://user:secret@example.test").is_none()); + assert!(validated_browser_location("https://").is_none()); + assert!(validated_browser_location("https://example.test/\nnext").is_none()); + } + + // SDTEST-1811 + #[test] + fn sdtest_1811_retained_gpui_attention_opens_exact_workspace_pane_and_tab() { + let mut cx = TestAppContext::single(); + let (catalog, workspace_a, workspace_b, _checkout, _ssh_checkout, ssh_connection) = + fixture_catalog(); + let initial_terminal = cx.update(|cx| cx.new(TerminalView::new)); + let hub = cx.update(|cx| { + cx.new(|cx| WorkspaceHubView::new(Ok(catalog), &[], initial_terminal, cx)) + }); + cx.run_until_parked(); + + let (session_a1, _data_a1, _input_a1) = + TerminalSession::spawn_ssh("A first".into(), 24, 80).unwrap(); + let session_a1_id = session_a1.id; + let (session_a2, _data_a2, _input_a2) = + TerminalSession::spawn_ssh("A attention".into(), 24, 80).unwrap(); + let session_a2_id = session_a2.id; + let (session_b, _data_b, _input_b) = + TerminalSession::spawn_ssh("B active".into(), 24, 80).unwrap(); + + hub.update(&mut cx, |hub, cx| { + let terminal_a = hub + .retained + .get(&workspace_a) + .unwrap() + .read(cx) + .terminal + .clone(); + terminal_a.update(cx, |terminal, _| { + terminal.add_session(session_a1); + terminal.add_session(session_a2); + }); + let surface_a = terminal_surface(&hub.catalog, workspace_a, terminal_a.read(cx)); + hub.navigation + .reduce( + &hub.catalog, + WorkspaceNavigationAction::UpdateSurface { + id: workspace_a, + surface: surface_a, + }, + ) + .unwrap(); + + let terminal_b = hub + .retained + .get(&workspace_b) + .unwrap() + .read(cx) + .terminal + .clone(); + terminal_b.update(cx, |terminal, _| { + terminal.add_session_with_connection(session_b, Some(ssh_connection)); + }); + let surface_b = terminal_surface(&hub.catalog, workspace_b, terminal_b.read(cx)); + hub.navigation + .reduce( + &hub.catalog, + WorkspaceNavigationAction::UpdateSurface { + id: workspace_b, + surface: surface_b, + }, + ) + .unwrap(); + + let attention_id = AttentionItemId::from_uuid(Uuid::from_u128(70)); + hub.apply_attention_item( + AttentionItem { + id: attention_id, + revision: 7, + observed_at_millis: 700, + target: AttentionTarget { + workspace: workspace_a, + pane: PaneId::from_uuid(workspace_a.as_uuid()), + tab_id: Some(WorkspaceTabId::from_uuid(session_a2_id)), + session_id: None, + }, + state: AttentionState::NeedsYou, + title: "Review exact tab".into(), + unread: true, + agent_path: vec!["root".into(), "reviewer".into()], + }, + cx, + ) + .unwrap(); + let rows = hub.attention_items(workspace_a); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].state, AttentionState::NeedsYou); + assert!(rows[0].unread); + assert_eq!(rows[0].agent_path, ["root", "reviewer"]); + + hub.switch_to(workspace_a, cx); + hub.switch_to(workspace_b, cx); + assert_eq!(hub.navigation.active(), Some(workspace_b)); + + // The retained pane's mutable active tab changes after the + // observation. Activation must still use the captured tab ID. + terminal_a.update(cx, |terminal, _| { + terminal.select_tab(session_a1_id); + }); + let moved_surface = terminal_surface(&hub.catalog, workspace_a, terminal_a.read(cx)); + hub.navigation + .reduce( + &hub.catalog, + WorkspaceNavigationAction::UpdateSurface { + id: workspace_a, + surface: moved_surface, + }, + ) + .unwrap(); + assert_eq!( + terminal_a.read(cx).tabs[terminal_a.read(cx).active_tab_index()].id, + session_a1_id + ); + + let focus = hub + .open_attention_item(workspace_a, attention_id, 7, cx) + .unwrap(); + assert_eq!(hub.navigation.active(), Some(workspace_a)); + assert_eq!(focus.pane_id, PaneId::from_uuid(workspace_a.as_uuid())); + assert_eq!(focus.tab_id.as_uuid(), session_a2_id); + assert_eq!( + hub.navigation.workspace(workspace_a).unwrap().surface.focus, + Some(focus) + ); + assert_eq!( + terminal_a.read(cx).tabs[terminal_a.read(cx).active_tab_index()].id, + session_a2_id + ); + assert_ne!(session_a1_id, session_a2_id); + assert!(!hub + .attention + .get(&workspace_a) + .unwrap() + .is_unread(attention_id)); + + hub.apply_attention_item( + AttentionItem { + id: attention_id, + revision: 8, + observed_at_millis: 800, + target: AttentionTarget { + workspace: workspace_a, + pane: focus.pane_id, + tab_id: Some(focus.tab_id), + session_id: None, + }, + state: AttentionState::Blocked, + title: "Newer exact attention".into(), + unread: true, + agent_path: vec!["root".into(), "reviewer".into()], + }, + cx, + ) + .unwrap(); + assert_eq!( + hub.open_attention_item(workspace_a, attention_id, 7, cx), + Err(AttentionError::StaleObservation) + ); + assert!(hub + .attention + .get(&workspace_a) + .unwrap() + .is_unread(attention_id)); + }); + } + + // SDTEST-1832 + #[test] + fn retained_provider_activation_focuses_only_the_exact_native_tab_and_mapping() { + let mut cx = TestAppContext::single(); + let (mut catalog, workspace_a, workspace_b, _checkout, _ssh_checkout, _ssh_connection) = + fixture_catalog(); + catalog + .set_platform_mapping( + workspace_a, + None, + PlatformV2Mapping { + reconciliation_revision: 1, + project: PlatformContextRef { + id: "project-1".into(), + revision: 1, + }, + checkout: PlatformContextRef { + id: "checkout-1".into(), + revision: 1, + }, + user_workspace: PlatformContextRef { + id: "workspace-1".into(), + revision: 1, + }, + reconciliation: PlatformMappingReconciliation::Exact { + reconciled_at_millis: 1, + }, + }, + ) + .unwrap(); + let initial_terminal = cx.update(|cx| cx.new(TerminalView::new)); + let hub = cx.update(|cx| { + cx.new(|cx| WorkspaceHubView::new(Ok(catalog), &[], initial_terminal, cx)) + }); + cx.run_until_parked(); + + let (other, _other_data, _other_input) = + TerminalSession::spawn_ssh("Other".into(), 24, 80).unwrap(); + let other_id = other.id; + let (provider, _provider_data, _provider_input) = + TerminalSession::spawn_ssh("Provider".into(), 24, 80).unwrap(); + let provider_id = provider.id; + let coordinate = ResourceCoordinate::new( + ResourceAuthority::Automonique, + ResourceKind::Session, + ResourceId::new("provider-session-1").unwrap(), + ); + + hub.update(&mut cx, |hub, cx| { + let terminal = hub + .retained + .get(&workspace_a) + .unwrap() + .read(cx) + .terminal + .clone(); + terminal.update(cx, |terminal, _| { + terminal.add_session(other); + terminal.add_session(provider); + terminal.select_tab(other_id); + }); + hub.switch_to(workspace_b, cx); + let focus = WorkspaceFocus { + pane_id: PaneId::from_uuid(workspace_a.as_uuid()), + tab_id: WorkspaceTabId::from_uuid(provider_id), + }; + hub.navigation + .reduce( + &hub.catalog, + WorkspaceNavigationAction::UpdateSurface { + id: workspace_a, + surface: WorkspaceSurfaceState { + root: Some(shelldeck_core::workspace_navigation::PaneNode::Leaf( + PaneLeaf { + id: focus.pane_id, + tabs: vec![WorkspaceTab { + id: focus.tab_id, + title: "Provider".into(), + content: WorkspaceTabContent::ProviderSession( + ProviderSessionBinding { + platform_user_workspace_id: "workspace-1".into(), + session_id: coordinate.id.as_str().into(), + run_id: None, + }, + ), + }], + active_tab: Some(focus.tab_id), + }, + )), + focus: Some(focus), + }, + }, + ) + .unwrap(); + assert!(hub.open_retained_provider_pane(workspace_a, &coordinate, focus, cx)); + assert_eq!(hub.navigation.active(), Some(workspace_a)); + assert_eq!( + terminal.read(cx).tabs[terminal.read(cx).active_tab_index()].id, + provider_id + ); + assert_ne!(provider_id, other_id); + + let foreign = ResourceCoordinate::new( + ResourceAuthority::Automonique, + ResourceKind::Session, + ResourceId::new("foreign-session").unwrap(), + ); + assert!(!hub.open_retained_provider_pane(workspace_a, &foreign, focus, cx)); + }); + } + #[test] fn loading_existing_workspace_with_missing_root_is_fail_closed_and_localized() { let root = tempfile::tempdir().unwrap(); diff --git a/crates/shelldeck/Cargo.toml b/crates/shelldeck/Cargo.toml index e4e42f7d..d6c5ef6f 100644 --- a/crates/shelldeck/Cargo.toml +++ b/crates/shelldeck/Cargo.toml @@ -25,9 +25,7 @@ anyhow = { workspace = true } directories = { workspace = true } parking_lot = { workspace = true } uuid = { workspace = true } -tray-icon = { workspace = true } notify-rust = { workspace = true } -image = { workspace = true } -[target.'cfg(target_os = "linux")'.dependencies] -gtk = { workspace = true } +[dev-dependencies] +image = { workspace = true } diff --git a/crates/shelldeck/src/main.rs b/crates/shelldeck/src/main.rs index 179ff38e..68ad8b1c 100644 --- a/crates/shelldeck/src/main.rs +++ b/crates/shelldeck/src/main.rs @@ -16,7 +16,7 @@ use shelldeck_ui::theme::ShellDeckColors; use shelldeck_ui::{ settings::{CompanionShortcutStatuses, ShortcutRegistrationStatus}, AiCompanionController, AiCompanionEvent, AiDockView, AiDockVisibilityHandler, - CommandPaletteWindowView, Workspace, WorkspaceAiBindings, + CommandPaletteWindowView, PlatformAttentionNotification, Workspace, WorkspaceAiBindings, }; use std::{borrow::Cow, cell::RefCell, rc::Rc}; use tracing_subscriber::EnvFilter; @@ -480,6 +480,34 @@ fn show_tray_notification(n: shelldeck_ui::TrayNotification) -> anyhow::Result<( Ok(()) } +/// Show one actionable same-process attention notification and retain its +/// native handle until activation or dismissal. The returned activation is a +/// local tuple only; cold-launch deep links are intentionally out of scope. +fn show_platform_attention_notification( + notification: PlatformAttentionNotification, + activated: std::sync::mpsc::Sender< + shelldeck_core::config::platform_attention::PlatformAttentionActivation, + >, +) -> anyhow::Result<()> { + let handle = notify_rust::Notification::new() + .appname("ShellDeck") + .summary(¬ification.summary) + .body(¬ification.body) + .icon("shelldeck") + .action("open", ¬ification.action_label) + .show()?; + handle.wait_for_action(move |action| { + if platform_attention_action_opens(action) { + let _ = activated.send(notification.activation); + } + }); + Ok(()) +} + +fn platform_attention_action_opens(action: &str) -> bool { + matches!(action, "default" | "open") +} + #[derive(Clone, Default)] struct WorkspaceSlot(Rc>>>); @@ -736,6 +764,40 @@ impl CompanionRoot { }); } + let (attention_tx, attention_rx) = std::sync::mpsc::channel(); + workspace.update(cx, |ws, _cx| { + ws.set_platform_attention_notifier(Box::new(move |notification| { + let activated = attention_tx.clone(); + std::thread::spawn(move || { + if let Err(error) = + show_platform_attention_notification(notification, activated) + { + tracing::warn!("Platform attention notification failed: {error}"); + } + }); + })); + }); + let attention_workspace = workspace.downgrade(); + let attention_window = self.runtime.main_window; + cx.spawn(async move |_this, cx: &mut gpui::AsyncApp| loop { + cx.background_executor() + .timer(std::time::Duration::from_millis(100)) + .await; + let Some(workspace) = attention_workspace.upgrade() else { + break; + }; + while let Ok(activation) = attention_rx.try_recv() { + let _ = attention_window.update(cx, |_, window, cx| { + window.show_window(); + window.activate_window(); + workspace.update(cx, |workspace, cx| { + workspace.activate_platform_attention(activation, cx); + }); + }); + } + }) + .detach(); + self.workspace_slot.set(&workspace); self.workspace = Some(workspace.clone()); cx.notify(); @@ -1855,12 +1917,12 @@ fn main() -> Result<()> { // Show / Palette / Quit, live counters (phase B), OS notifs // (phase C). Best-effort — if the tray backend refuses (Flatpak // sandbox, headless container, minimal WM) the app still runs. - // Must be constructed on the main thread (GTK requirement on - // Linux); running inside the GPUI closure satisfies that. + // Must be constructed on GPUI's foreground thread because the native + // macOS/Windows tray owners and Linux callback bridge live there. // (cmd_rx, state_tx) — Some when the tray came up, None when // the backend refused. state_tx feeds the live-counter row // updates from the workspace side. - let tray_handles = match tray::TrayService::new() { + let tray_handles = match tray::TrayService::new(cx) { Ok(mut svc) => { svc.start_state_updates(cx); let cmd_rx = svc.take_receiver(); @@ -2303,10 +2365,11 @@ mod tests { use super::{ ai_dock_bounds, ai_dock_global_shortcut, ai_dock_window_action, character_asset_paths, command_palette_bounds, command_palette_global_shortcut, companion_main_window_visible, - companion_pointer, merge_workspace_connections, tray_command_requires_auth, - workspace_created_at_boot, AiDockRequest, AiDockWindowAction, Assets, CompanionCommand, - CompanionRuntime, GlobalHotkeyRegistry, GlobalShortcutRegistrationState, - ShortcutRegistrationStatus, AI_DOCK_GLOBAL_HOTKEY_ID, COMMAND_PALETTE_GLOBAL_HOTKEY_ID, + companion_pointer, merge_workspace_connections, platform_attention_action_opens, + tray_command_requires_auth, workspace_created_at_boot, AiDockRequest, AiDockWindowAction, + Assets, CompanionCommand, CompanionRuntime, GlobalHotkeyRegistry, + GlobalShortcutRegistrationState, ShortcutRegistrationStatus, AI_DOCK_GLOBAL_HOTKEY_ID, + COMMAND_PALETTE_GLOBAL_HOTKEY_ID, }; #[cfg(target_os = "linux")] use super::{parse_x11_workarea, parse_xrandr_monitor_geometry}; @@ -2744,4 +2807,13 @@ mod tests { ); } } + + // SDTEST-1831 + #[test] + fn same_process_attention_notifications_activate_only_explicit_open_actions() { + assert!(platform_attention_action_opens("default")); + assert!(platform_attention_action_opens("open")); + assert!(!platform_attention_action_opens("__closed")); + assert!(!platform_attention_action_opens("unknown")); + } } diff --git a/crates/shelldeck/src/tray/mod.rs b/crates/shelldeck/src/tray/mod.rs index ca5f5f58..e9a63103 100644 --- a/crates/shelldeck/src/tray/mod.rs +++ b/crates/shelldeck/src/tray/mod.rs @@ -1,112 +1,39 @@ -//! System tray integration. +//! Native system-tray integration through GPUI. //! -//! Cross-platform tray icon + menu + OS notifications. The tray keeps -//! ShellDeck present even when the main window is hidden or minimized, -//! surfaces live counters (SSH sessions, tunnels, unread tickets, Monique -//! confirmations), and pushes OS notifications on state deltas the user -//! opted into. -//! -//! # Architecture -//! -//! - Menu clicks fire on the tray thread and are marshalled back to -//! GPUI via a `Sender` that the workspace consumes on -//! the foreground executor. -//! - Live counter updates flow the other way: the workspace publishes a -//! [`TrayState`] snapshot via a `Sender`. Linux applies it on -//! the GTK tray thread; macOS and Windows apply it from GPUI's native -//! foreground executor, where `muda` menu handles are safe to mutate. -//! - Phase A: static menu (Show/Palette/Quit). Phase B (this file): -//! live counters. Phase C: OS notifications on deltas. Phase D: -//! opt-in per notification category. -//! -//! # Linux GTK requirement -//! -//! `tray-icon` depends on `libappindicator`, which itself sits on GTK3. -//! adabraka-gpui is Wayland/X11 native and does **not** initialise GTK, -//! so calling `TrayIconBuilder::build()` from the GPUI closure panics -//! with `"GTK has not been initialized. Call gtk::init first."`. -//! -//! The fix is a dedicated tray thread that: -//! -//! 1. Calls `gtk::init()`. -//! 2. Builds the `TrayIcon` (and its `MenuItem`s) inside the thread. -//! 3. Registers a `glib::timeout_add_local` that periodically drains -//! the state channel from within GTK's loop — `MenuItem::set_text` -//! is `!Send` and can only run on the GTK thread. -//! 4. Parks on `gtk::main()` so GTK's event loop keeps dispatching -//! both menu clicks (via the global `MenuEvent` channel) and our -//! state-drain timeout. -//! -//! # Platform notes -//! -//! - **Linux**: `libayatana-appindicator3` or `libappindicator3`, -//! typically pre-installed on GNOME/KDE. -//! - **macOS**: `NSStatusItem` with a dedicated monochrome template asset, -//! allowing AppKit to adapt it to light/dark/pressed menu-bar states. -//! - **Windows**: `Shell_NotifyIcon` with the colored application icon. -//! - **macOS + Windows**: GPUI's foreground executor is the native main-loop -//! bridge used for live menu mutations; no background thread touches the -//! non-`Send` `muda` handles. - -use anyhow::{Context, Result}; +//! ShellDeck uses GPUI's platform-native status item on every desktop. On +//! Linux that is the StatusNotifierItem (`ksni`) backend, so the application +//! does not initialise GTK or carry the unmaintained GTK3 tray dependency. + +use anyhow::{anyhow, Result}; +use gpui::{App, SharedString, TrayMenuItem}; use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender}; -use tray_icon::menu::{Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu}; use uuid::Uuid; pub use shelldeck_ui::ai_dock::TrayLabels; -/// Actions the tray can request from the running app. The workspace -/// polls a receiver on the foreground executor and dispatches these -/// onto the main GPUI thread. #[derive(Debug, Clone, Copy)] pub enum TrayCommand { - /// Bring the ShellDeck window to the front (or restore it if - /// minimized / hidden to tray). ShowWindow, - /// Show or hide the compact standalone AI assistant window. ToggleAiDock, - /// Open the Dock directly on the Clippy clipboard assistant. OpenClippy, - /// Open the Dock directly on its durable AI task center. OpenAiTasks, - /// Open the command palette. OpenPalette, - /// Open Settings directly on the desktop character cards. ChooseCharacter, - /// Pause or resume the optional desktop character runtime. PauseCharacter, - /// Ask the desktop character to return to a safe screen corner. ReturnCharacterToCorner, - /// Connect one of the persisted quick-access hosts. ConnectPinned(Uuid), - /// Quit the app. Quit, } -/// Snapshot of the counters the tray displays. Published by the -/// workspace whenever any tracked count changes; the tray thread -/// diffs against its last known state and only calls `MenuItem::set_text` -/// on the rows that actually moved, keeping the menu paint quiet. #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct TrayState { - /// Whether authenticated tray actions may execute. Defaults to false so - /// the native menu starts fail-closed before Workspace publishes state. pub signed_in: bool, - /// SSH connections in `Connected` status. pub active_ssh: usize, - /// Port forwards currently open. pub open_tunnels: usize, - /// Support tickets with `unread=true`. pub unread_tickets: usize, - /// Monique fleet jobs waiting for user confirmation before running. pub monique_pending: usize, - /// AI tasks currently generating or executing. pub ai_tasks_running: usize, - /// Persisted quick-access connections, in sidebar order. pub pinned_connections: Vec, - /// Localized copy used by the native menu thread. Keeping it in the - /// snapshot makes a live language change observable even when counters do - /// not move. pub labels: TrayLabels, } @@ -116,94 +43,66 @@ pub struct PinnedConnection { pub name: String, } -/// Public handle over the tray subsystem. Callers drop this once they have -/// started updates and taken the receiver + sender — the platform backend or -/// detached foreground task then owns the live `TrayIcon` and `MenuItem`s. pub struct TrayService { rx: Option>, state_tx: Option>, - #[cfg(not(target_os = "linux"))] - foreground_state: Option, + state_rx: Option>, } impl TrayService { - /// Build the tray icon + menu and wire the event routing. - /// - /// On Linux this spawns a dedicated GTK-owning thread and blocks - /// (~50 ms) on a ready signal so the tray is guaranteed visible - /// before `TrayService::new` returns. On other platforms the tray - /// is constructed on the calling thread (must be the main thread). - /// - /// Returns an error only if the tray truly can't come up (icon - /// decode failure, `libappindicator` absent, GTK init failure). - /// Callers should log the error and continue without a tray rather - /// than aborting the app. - pub fn new() -> Result { - let (cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel::(); - install_menu_router(cmd_tx); - - let (state_tx, state_rx) = tokio::sync::mpsc::unbounded_channel::(); - - // Icon bytes are cheap to decode on either thread. We do it - // here so a bad PNG surfaces as an early hard error rather - // than as a silent tray-thread failure. - let icon = load_icon().context("load tray icon")?; - - #[cfg(target_os = "linux")] - spawn_tray_backend(icon, state_rx)?; - #[cfg(not(target_os = "linux"))] - let foreground_state = Some(spawn_tray_backend(icon, state_rx)?); + /// Creates a platform-native tray and refuses startup if the desktop did + /// not actually accept it. Callers use that result to keep the main window + /// visible in headless sessions and minimal window managers. + pub fn new(cx: &App) -> Result { + let (cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel(); + let (state_tx, state_rx) = tokio::sync::mpsc::unbounded_channel(); + + cx.set_tray_icon(Some(tray_icon_bytes())); + if !cx.is_tray_available() { + cx.set_tray_icon(None); + return Err(anyhow!("native tray backend unavailable")); + } + cx.set_tray_tooltip("ShellDeck"); + cx.set_tray_menu(build_menu(&TrayState::default())); + cx.on_tray_menu_action(move |id, _cx| { + let Some(command) = command_for_menu_id(id.as_ref()) else { + return; + }; + if let Err(error) = cmd_tx.send(command) { + tracing::warn!(%error, "tray event dropped because its consumer stopped"); + } + }); Ok(Self { rx: Some(cmd_rx), state_tx: Some(state_tx), - #[cfg(not(target_os = "linux"))] - foreground_state, + state_rx: Some(state_rx), }) } - /// Start applying workspace snapshots to the native menu. - /// - /// Linux owns its menu on the dedicated GTK thread and starts draining - /// during [`Self::new`]. macOS and Windows keep `muda`'s non-`Send` - /// handles on GPUI's foreground executor and await snapshots there. - pub fn start_state_updates(&mut self, cx: &gpui::App) { - #[cfg(target_os = "linux")] - let _ = cx; - - #[cfg(not(target_os = "linux"))] - { - let foreground = self - .foreground_state - .take() - .expect("TrayService::start_state_updates called twice"); - let ForegroundTrayState { - state_rx, - mut items, - } = foreground; - cx.spawn(async move |_| { - let mut prev_state = TrayState::default(); - consume_tray_states(state_rx, |next| { - apply_state(&mut items, &mut prev_state, next); - }) - .await; - }) - .detach(); - } + /// Rebuilds GPUI's immutable native menu for each workspace snapshot. + pub fn start_state_updates(&mut self, cx: &App) { + let mut state_rx = self + .state_rx + .take() + .expect("TrayService::start_state_updates called twice"); + cx.spawn(async move |cx| { + while let Some(state) = state_rx.recv().await { + let menu = build_menu(&state); + if cx.update(|cx| cx.set_tray_menu(menu)).is_err() { + break; + } + } + }) + .detach(); } - /// Hand off the command receiver to the caller. Panics if called - /// twice — the workspace should consume this exactly once at - /// startup and await it without periodic wakeups. pub fn take_receiver(&mut self) -> UnboundedReceiver { self.rx .take() .expect("TrayService::take_receiver called twice") } - /// Hand off the state sender to the caller. The workspace keeps this and - /// pushes a fresh [`TrayState`] every time its counters change; the menu's - /// owner thread applies each snapshot. pub fn take_state_sender(&mut self) -> UnboundedSender { self.state_tx .take() @@ -211,21 +110,99 @@ impl TrayService { } } -/// Install the global menu-event handler that routes menu clicks into -/// our channel. Called once per process; a second call replaces the -/// first (documented `tray_icon` behaviour). -/// -/// The item ids are stable strings set inside [`build_menu`] so this -/// handler doesn't need to see the `MenuItem`s directly. -fn install_menu_router(cmd_tx: UnboundedSender) { - MenuEvent::set_event_handler(Some(move |event: MenuEvent| { - let Some(cmd) = command_for_menu_id(event.id.0.as_str()) else { - return; - }; - if let Err(e) = cmd_tx.send(cmd) { - tracing::warn!("tray event dropped (no consumer?): {e}"); - } - })); +fn action(label: impl Into, id: &'static str) -> TrayMenuItem { + TrayMenuItem::Action { + label: label.into(), + id: id.into(), + } +} + +fn label(text: impl Into) -> TrayMenuItem { + TrayMenuItem::Label { label: text.into() } +} + +fn protected_action( + items: &mut Vec, + signed_in: bool, + text: impl Into, + id: &'static str, +) { + let text = text.into(); + if signed_in { + items.push(action(text, id)); + } else { + items.push(label(text)); + } +} + +fn build_menu(state: &TrayState) -> Vec { + let labels = &state.labels; + let mut items = Vec::new(); + protected_action( + &mut items, + state.signed_in, + labels.assistant.clone(), + ASSISTANT_ID, + ); + protected_action( + &mut items, + state.signed_in, + labels.clippy.clone(), + CLIPPY_ID, + ); + items.push(action(labels.show.clone(), SHOW_ID)); + items.push(action(labels.palette.clone(), PALETTE_ID)); + protected_action( + &mut items, + state.signed_in, + labels.choose_character.clone(), + CHOOSE_CHARACTER_ID, + ); + protected_action( + &mut items, + state.signed_in, + labels.pause_character.clone(), + PAUSE_CHARACTER_ID, + ); + protected_action( + &mut items, + state.signed_in, + labels.return_character.clone(), + RETURN_CHARACTER_ID, + ); + + let pinned = if state.signed_in && !state.pinned_connections.is_empty() { + state + .pinned_connections + .iter() + .map(|connection| TrayMenuItem::Action { + label: connection.name.clone().into(), + id: format!("{PINNED_ID_PREFIX}{}", connection.id).into(), + }) + .collect() + } else { + vec![label(labels.no_pinned.clone())] + }; + items.push(TrayMenuItem::Submenu { + label: labels.pinned.clone().into(), + items: pinned, + }); + items.push(TrayMenuItem::Separator); + items.push(label(counter_label_ssh(state.active_ssh))); + items.push(label(counter_label_tunnels(state.open_tunnels))); + items.push(label(counter_label_tickets(state.unread_tickets))); + items.push(label(counter_label_monique(state.monique_pending))); + if state.signed_in { + items.push(action( + counter_label_ai_tasks(state.ai_tasks_running), + AI_TASKS_ID, + )); + } else { + items.push(label(counter_label_ai_tasks(state.ai_tasks_running))); + } + items.push(TrayMenuItem::Separator); + items.push(action(labels.quit.clone(), QUIT_ID)); + items } fn command_for_menu_id(id: &str) -> Option { @@ -257,340 +234,41 @@ const RETURN_CHARACTER_ID: &str = "shelldeck.tray.character.return_to_dock"; const QUIT_ID: &str = "shelldeck.tray.quit"; const PINNED_ID_PREFIX: &str = "shelldeck.tray.pinned."; -// Ids for the disabled counter rows. They still need ids so the same -// widget can be found later for `set_text` (though on GTK we hold the -// MenuItem handles directly). -const COUNTER_SSH_ID: &str = "shelldeck.tray.counter.ssh"; -const COUNTER_TUNNELS_ID: &str = "shelldeck.tray.counter.tunnels"; -const COUNTER_TICKETS_ID: &str = "shelldeck.tray.counter.tickets"; -const COUNTER_MONIQUE_ID: &str = "shelldeck.tray.counter.monique"; - -/// The counter `MenuItem`s live here, produced by [`build_menu`] -/// alongside their parent menu. Kept together so the tray-thread's -/// state-drain closure can reach them via a single move-capture. -struct CounterItems { - ssh: MenuItem, - tunnels: MenuItem, - tickets: MenuItem, - monique: MenuItem, - ai_tasks: MenuItem, -} - -struct MenuItems { - assistant: MenuItem, - clippy: MenuItem, - show: MenuItem, - palette: MenuItem, - choose_character: MenuItem, - pause_character: MenuItem, - return_character: MenuItem, - quit: MenuItem, - counters: CounterItems, - pinned_menu: Submenu, - pinned_items: Vec, -} - -#[cfg(not(target_os = "linux"))] -struct ForegroundTrayState { - state_rx: UnboundedReceiver, - items: MenuItems, -} - -/// Build the tray menu — click actions on top, live counters in the middle, -/// then Quit. Informational counters are disabled; the AI task row remains -/// enabled because it opens the task center. -fn build_menu() -> Result<(Menu, MenuItems)> { - let menu = Menu::new(); - let labels = TrayLabels::localized(); - - let assistant_item = MenuItem::with_id(ASSISTANT_ID, &labels.assistant, false, None); - let clippy_item = MenuItem::with_id(CLIPPY_ID, &labels.clippy, false, None); - let show_item = MenuItem::with_id(SHOW_ID, &labels.show, true, None); - let palette_item = MenuItem::with_id(PALETTE_ID, &labels.palette, true, None); - let choose_character_item = - MenuItem::with_id(CHOOSE_CHARACTER_ID, &labels.choose_character, false, None); - let pause_character_item = - MenuItem::with_id(PAUSE_CHARACTER_ID, &labels.pause_character, false, None); - let return_character_item = - MenuItem::with_id(RETURN_CHARACTER_ID, &labels.return_character, false, None); - let quit_item = MenuItem::with_id(QUIT_ID, &labels.quit, true, None); - let pinned_menu = Submenu::new(&labels.pinned, false); - let no_pinned = MenuItem::new(&labels.no_pinned, false, None); - pinned_menu - .append(&no_pinned) - .context("append empty pinned row")?; - - // Counter rows: `enabled = false` so the tray marks them as - // dimmed / unclickable — they exist for information only. - let counters = CounterItems { - ssh: MenuItem::with_id(COUNTER_SSH_ID, counter_label_ssh(0), false, None), - tunnels: MenuItem::with_id(COUNTER_TUNNELS_ID, counter_label_tunnels(0), false, None), - tickets: MenuItem::with_id(COUNTER_TICKETS_ID, counter_label_tickets(0), false, None), - monique: MenuItem::with_id(COUNTER_MONIQUE_ID, counter_label_monique(0), false, None), - ai_tasks: MenuItem::with_id(AI_TASKS_ID, counter_label_ai_tasks(0), false, None), - }; - - menu.append(&assistant_item) - .context("append Assistant item")?; - menu.append(&clippy_item).context("append Clippy item")?; - menu.append(&show_item).context("append Show item")?; - menu.append(&palette_item).context("append Palette item")?; - menu.append(&choose_character_item) - .context("append Choose character item")?; - menu.append(&pause_character_item) - .context("append Pause character item")?; - menu.append(&return_character_item) - .context("append Return character item")?; - menu.append(&pinned_menu) - .context("append pinned connections menu")?; - menu.append(&PredefinedMenuItem::separator()) - .context("append separator counters-top")?; - menu.append(&counters.ssh).context("append SSH counter")?; - menu.append(&counters.tunnels) - .context("append tunnels counter")?; - menu.append(&counters.tickets) - .context("append tickets counter")?; - menu.append(&counters.monique) - .context("append Monique counter")?; - menu.append(&counters.ai_tasks) - .context("append AI tasks counter")?; - menu.append(&PredefinedMenuItem::separator()) - .context("append separator quit-top")?; - menu.append(&quit_item).context("append Quit item")?; - - Ok(( - menu, - MenuItems { - assistant: assistant_item, - clippy: clippy_item, - show: show_item, - palette: palette_item, - choose_character: choose_character_item, - pause_character: pause_character_item, - return_character: return_character_item, - quit: quit_item, - counters, - pinned_menu, - pinned_items: vec![no_pinned], - }, - )) -} - -/// Apply a fresh state to the counter items. Only rewrites labels that -/// actually changed so the tray menu stays quiet under repeated -/// identical publishes. Must run on the GTK thread on Linux. -fn apply_state(items: &mut MenuItems, prev: &mut TrayState, next: TrayState) { - let counters = &items.counters; - let language_changed = prev.labels != next.labels; - if prev.signed_in != next.signed_in { - items.assistant.set_enabled(next.signed_in); - items.clippy.set_enabled(next.signed_in); - items.choose_character.set_enabled(next.signed_in); - items.pause_character.set_enabled(next.signed_in); - items.return_character.set_enabled(next.signed_in); - items.pinned_menu.set_enabled(next.signed_in); - counters.ai_tasks.set_enabled(next.signed_in); - } - if language_changed { - items.assistant.set_text(&next.labels.assistant); - items.clippy.set_text(&next.labels.clippy); - items.show.set_text(&next.labels.show); - items.palette.set_text(&next.labels.palette); - items - .choose_character - .set_text(&next.labels.choose_character); - items.pause_character.set_text(&next.labels.pause_character); - items - .return_character - .set_text(&next.labels.return_character); - items.quit.set_text(&next.labels.quit); - items.pinned_menu.set_text(&next.labels.pinned); - } - if language_changed || prev.active_ssh != next.active_ssh { - counters.ssh.set_text(counter_label_ssh(next.active_ssh)); - } - if language_changed || prev.open_tunnels != next.open_tunnels { - counters - .tunnels - .set_text(counter_label_tunnels(next.open_tunnels)); - } - if language_changed || prev.unread_tickets != next.unread_tickets { - counters - .tickets - .set_text(counter_label_tickets(next.unread_tickets)); - } - if language_changed || prev.monique_pending != next.monique_pending { - counters - .monique - .set_text(counter_label_monique(next.monique_pending)); - } - if language_changed || prev.ai_tasks_running != next.ai_tasks_running { - counters - .ai_tasks - .set_text(counter_label_ai_tasks(next.ai_tasks_running)); - } - if language_changed || prev.pinned_connections != next.pinned_connections { - for item in items.pinned_items.drain(..) { - if let Err(error) = items.pinned_menu.remove(&item) { - tracing::warn!("failed to remove pinned tray item: {error}"); - } - } - - if next.pinned_connections.is_empty() { - let item = MenuItem::new(&next.labels.no_pinned, false, None); - if let Err(error) = items.pinned_menu.append(&item) { - tracing::warn!("failed to append empty pinned tray row: {error}"); - } - items.pinned_items.push(item); - } else { - for connection in &next.pinned_connections { - let item = MenuItem::with_id( - format!("{PINNED_ID_PREFIX}{}", connection.id), - &connection.name, - true, - None, - ); - if let Err(error) = items.pinned_menu.append(&item) { - tracing::warn!("failed to append pinned tray item: {error}"); - } - items.pinned_items.push(item); - } - } - } - *prev = next; -} - -/// Forward every published snapshot to the owner-thread menu mutator until -/// all senders disappear during shutdown. -#[cfg(any(not(target_os = "linux"), test))] -async fn consume_tray_states( - mut state_rx: UnboundedReceiver, - mut apply: impl FnMut(TrayState), -) { - while let Some(next) = state_rx.recv().await { - apply(next); - } -} - -// Label formatters use explicit zero/one/many keys because rust-i18n does not -// infer the app's desired wording for these compact native-menu rows. - fn counter_label_ssh(n: usize) -> String { shelldeck_ui::ai_dock::tray_counter_ssh(n) } - fn counter_label_tunnels(n: usize) -> String { shelldeck_ui::ai_dock::tray_counter_tunnels(n) } - fn counter_label_tickets(n: usize) -> String { shelldeck_ui::ai_dock::tray_counter_tickets(n) } - fn counter_label_monique(n: usize) -> String { shelldeck_ui::ai_dock::tray_counter_monique(n) } - fn counter_label_ai_tasks(n: usize) -> String { shelldeck_ui::ai_dock::tray_counter_ai_tasks(n) } -/// Load the tray PNG. 32 px is the sweet spot for tray display across -/// DEs; the platform scales it to whatever the tray area needs. -fn load_icon() -> Result { - // Embedded at compile time so the binary is self-contained. macOS gets a - // 36 px monochrome alpha mask (18 pt @2x); Linux and Windows keep the - // colored 32 px app icon. +fn tray_icon_bytes() -> &'static [u8] { #[cfg(target_os = "macos")] - let bytes = include_bytes!("../../../../packaging/icons/shelldeck-tray-template-macos.png"); - #[cfg(not(target_os = "macos"))] - let bytes = include_bytes!("../../../../packaging/icons/shelldeck-32.png"); - let img = image::load_from_memory(bytes) - .context("decode embedded tray PNG")? - .to_rgba8(); - let (w, h) = img.dimensions(); - tray_icon::Icon::from_rgba(img.into_raw(), w, h).context("build tray_icon::Icon from RGBA") -} - -#[cfg(target_os = "linux")] -fn spawn_tray_backend(icon: tray_icon::Icon, state_rx: UnboundedReceiver) -> Result<()> { - // Oneshot to synchronise "tray is live" with the main thread. If - // GTK init or tray build fails, the error bubbles up before the - // app opens its main window. On success the thread parks on - // `gtk::main()` for the rest of the process's life. - let (ready_tx, ready_rx) = std::sync::mpsc::channel::>(); - - std::thread::Builder::new() - .name("shelldeck-tray".to_string()) - .spawn(move || { - if let Err(e) = gtk::init() { - let _ = ready_tx.send(Err(anyhow::anyhow!("gtk::init failed: {e}"))); - return; - } - - let (menu, mut items) = match build_menu() { - Ok(pair) => pair, - Err(e) => { - let _ = ready_tx.send(Err(e)); - return; - } - }; - - let build = tray_icon::TrayIconBuilder::new() - .with_menu(Box::new(menu)) - .with_tooltip("ShellDeck") - .with_icon(icon) - .build(); - - let _tray = match build { - Ok(t) => t, - Err(e) => { - let _ = ready_tx.send(Err(anyhow::anyhow!("tray build failed: {e}"))); - return; - } - }; - - // Register the state-drain inside GTK's main context. - // Runs every 200 ms — snappy enough for the human, cheap - // enough to run forever. `timeout_add_local` requires - // being called from the main context (we are, we're the - // GTK thread). - // - // The closure owns the menu handles, - // `prev_state` for diffing, and `state_rx` for draining - // publishes from the workspace. - let mut prev_state = TrayState::default(); - let mut state_rx = state_rx; - gtk::glib::timeout_add_local(std::time::Duration::from_millis(200), move || { - while let Ok(next) = state_rx.try_recv() { - apply_state(&mut items, &mut prev_state, next); - } - gtk::glib::ControlFlow::Continue - }); - - let _ = ready_tx.send(Ok(())); - - // Park on GTK's main loop — never returns. - gtk::main(); - }) - .context("spawn shelldeck-tray thread")?; - - ready_rx - .recv() - .context("tray thread died before signalling")? + return include_bytes!("../../../../packaging/icons/shelldeck-tray-template-macos.png"); + #[cfg(target_os = "windows")] + return include_bytes!("../../../../packaging/icons/shelldeck.ico"); + #[cfg(not(any(target_os = "macos", target_os = "windows")))] + return include_bytes!("../../../../packaging/icons/shelldeck-32.png"); } #[cfg(test)] mod tests { use super::*; + // SDTEST-1336 #[test] fn pinned_menu_id_routes_to_connection() { let id = Uuid::new_v4(); - let menu_id = format!("{PINNED_ID_PREFIX}{id}"); - assert!(matches!( - command_for_menu_id(&menu_id), + command_for_menu_id(&format!("{PINNED_ID_PREFIX}{id}")), Some(TrayCommand::ConnectPinned(routed)) if routed == id )); } @@ -632,23 +310,44 @@ mod tests { } // SDTEST-1411 - #[tokio::test(flavor = "current_thread")] - async fn tray_state_pump_forwards_every_snapshot_until_shutdown() { - let (state_tx, state_rx) = tokio::sync::mpsc::unbounded_channel(); - for active_ssh in [1, 2, 0] { - state_tx - .send(TrayState { - active_ssh, - ..TrayState::default() - }) - .expect("live tray receiver"); - } - drop(state_tx); - - let mut observed = Vec::new(); - consume_tray_states(state_rx, |state| observed.push(state.active_ssh)).await; + #[test] + fn native_menu_snapshot_preserves_fail_closed_actions_counters_and_pins() { + let signed_out = build_menu(&TrayState::default()); + assert!(matches!( + signed_out.first(), + Some(TrayMenuItem::Label { .. }) + )); + let pinned_id = Uuid::new_v4(); + let signed_in = build_menu(&TrayState { + signed_in: true, + active_ssh: 2, + pinned_connections: vec![PinnedConnection { + id: pinned_id, + name: "Production".into(), + }], + ..TrayState::default() + }); + assert!( + matches!(signed_in.first(), Some(TrayMenuItem::Action { id, .. }) if id.as_ref() == ASSISTANT_ID) + ); + assert!(signed_in.iter().any(|item| { + matches!(item, TrayMenuItem::Label { label } if label.as_ref() == counter_label_ssh(2)) + })); + assert!(signed_in.iter().any(|item| { + matches!(item, TrayMenuItem::Submenu { items, .. } if matches!( + items.as_slice(), + [TrayMenuItem::Action { label, id }] + if label.as_ref() == "Production" + && id.as_ref() == format!("{PINNED_ID_PREFIX}{pinned_id}") + )) + })); + } - assert_eq!(observed, vec![1, 2, 0]); + // SDTEST-1337 + #[test] + fn unknown_or_malformed_menu_id_is_ignored() { + assert!(command_for_menu_id("shelldeck.tray.counter.ssh").is_none()); + assert!(command_for_menu_id("shelldeck.tray.pinned.invalid").is_none()); } // SDTEST-1410 @@ -665,49 +364,25 @@ mod tests { for pixel in image.pixels() { if pixel.0[3] > 0 { visible += 1; - assert_eq!( - &pixel.0[..3], - &[0, 0, 0], - "template RGB must stay black; AppKit colors the alpha mask" - ); + assert_eq!(&pixel.0[..3], &[0, 0, 0]); } } - assert!(visible > 36 * 36 / 8, "template mark is unexpectedly empty"); - assert!( - visible < 36 * 36 * 3 / 4, - "template background is not transparent" - ); + assert!(visible > 36 * 36 / 8); + assert!(visible < 36 * 36 * 3 / 4); for (x, y) in [(0, 0), (35, 0), (0, 35), (35, 35)] { assert_eq!(image.get_pixel(x, y).0[3], 0); } } + // SDTEST-1812 + #[cfg(target_os = "windows")] #[test] - fn unknown_or_malformed_menu_id_is_ignored() { - assert!(command_for_menu_id("shelldeck.tray.counter.ssh").is_none()); - assert!(command_for_menu_id("shelldeck.tray.pinned.invalid").is_none()); + fn sdtest_1812_windows_tray_asset_is_a_decodable_ico() { + let bytes = tray_icon_bytes(); + assert_eq!(&bytes[..6], &[0, 0, 1, 0, 6, 0]); + let icon = image::load_from_memory_with_format(bytes, image::ImageFormat::Ico) + .expect("decode Windows tray ICO"); + assert_eq!(icon.width(), icon.height()); + assert!(icon.width() >= 32); } } - -#[cfg(not(target_os = "linux"))] -fn spawn_tray_backend( - icon: tray_icon::Icon, - state_rx: UnboundedReceiver, -) -> Result { - // macOS + Windows: no separate event loop needed, the platform - // run-loop drives the tray directly. The `TrayIcon` must be built - // on the main thread but doesn't need to be kept as a named - // binding — we intentionally leak it so it lives for the whole - // process (dropping the icon removes the tray entry). - let (menu, items) = build_menu()?; - let builder = tray_icon::TrayIconBuilder::new() - .with_menu(Box::new(menu)) - .with_tooltip("ShellDeck") - .with_icon(icon); - #[cfg(target_os = "macos")] - let builder = builder.with_icon_as_template(true); - let tray = builder.build().context("build tray icon")?; - Box::leak(Box::new(tray)); - - Ok(ForegroundTrayState { state_rx, items }) -} diff --git a/deny.toml b/deny.toml index 483b12ca..bea74793 100644 --- a/deny.toml +++ b/deny.toml @@ -10,22 +10,12 @@ all-features = true unmaintained = "workspace" unsound = "all" ignore = [ - { id = "RUSTSEC-2023-0071", reason = "rsa is transitive through russh and RustSec has no patched release; retained temporarily for RSA SSH key compatibility" }, - { id = "RUSTSEC-2024-0370", reason = "proc-macro-error is transitive through GTK3 macros in the Linux tray stack" }, - { id = "RUSTSEC-2024-0384", reason = "instant is transitive through the vendored GPUI futures-lite 1 stack" }, - { id = "RUSTSEC-2024-0412", reason = "GTK3 is required by the current Linux tray integration; migration requires replacing that desktop backend" }, - { id = "RUSTSEC-2024-0413", reason = "GTK3 is required by the current Linux tray integration; migration requires replacing that desktop backend" }, - { id = "RUSTSEC-2024-0415", reason = "GTK3 is required by the current Linux tray integration; migration requires replacing that desktop backend" }, - { id = "RUSTSEC-2024-0416", reason = "GTK3 is required by the current Linux tray integration; migration requires replacing that desktop backend" }, - { id = "RUSTSEC-2024-0418", reason = "GTK3 is required by the current Linux tray integration; migration requires replacing that desktop backend" }, - { id = "RUSTSEC-2024-0419", reason = "GTK3 is required by the current Linux tray integration; migration requires replacing that desktop backend" }, - { id = "RUSTSEC-2024-0420", reason = "GTK3 is required by the current Linux tray integration; migration requires replacing that desktop backend" }, - { id = "RUSTSEC-2024-0429", reason = "glib 0.18 is transitive through the Linux GTK tray stack; ShellDeck does not use VariantStrIter" }, - { id = "RUSTSEC-2024-0436", reason = "paste is transitive through image codecs used by GPUI and has no in-place maintained release" }, - { id = "RUSTSEC-2025-0052", reason = "async-std is transitive through the vendored GPUI HTTP and async-tar stack" }, - { id = "RUSTSEC-2025-0134", reason = "rustls-pemfile is transitive through zed-reqwest in the vendored GPUI HTTP client" }, - { id = "RUSTSEC-2026-0173", reason = "proc-macro-error2 is transitive through stacksafe in the vendored GPUI renderer" }, - { id = "RUSTSEC-2026-0192", reason = "ttf-parser 0.25 is transitive through cosmic-text/fontdb and lopdf; both are already on their latest compatible release" }, + { id = "RUSTSEC-2023-0071", reason = "rsa is transitive through russh's default rsa feature; the advisory records no patched release at all, and dropping the feature would remove RSA SSH key support" }, + { id = "RUSTSEC-2024-0384", reason = "instant is transitive through futures-lite 1 in adabraka_util 0.5.1, a crates.io sibling of the vendored GPUI that we do not fork" }, + { id = "RUSTSEC-2024-0436", reason = "paste reaches the lockfile through three independent chains (metal on macOS, pulp/exr, rav1e/ravif), all still on paste 1 at their latest releases" }, + { id = "RUSTSEC-2025-0052", reason = "async-std is a non-optional dependency of async-tar 0.5, pinned by adabraka_http_client 0.5.1; async-tar 0.6 gates it but the requirement cannot resolve there" }, + { id = "RUSTSEC-2025-0134", reason = "rustls-pemfile is transitive through zed-reqwest 0.12.15-zed, a Zed fork of reqwest 0.12.15 that stopped publishing; upstream reqwest already dropped it" }, + { id = "RUSTSEC-2026-0192", reason = "ttf-parser 0.25 is transitive through cosmic-text 0.19 -> fontdb 0.23; fontdb 0.24 dropped it and pop-os/cosmic-text#526 is the open bump" }, ] [bans] diff --git a/docs/security-advisory-baseline.md b/docs/security-advisory-baseline.md index 9432c538..2495bf9f 100644 --- a/docs/security-advisory-baseline.md +++ b/docs/security-advisory-baseline.md @@ -29,17 +29,226 @@ three then-installed `ttf-parser` versions and the two then-installed | RUSTSEC-2021-0139 (`ansi_term`), RUSTSEC-2021-0145 and RUSTSEC-2024-0375 (`atty`) | Vendored GPUI migrated its Linux status item from `ksni` 0.2 to 0.3, removing the `dbus-codegen` -> Clap 2 dependency chain. | | RUSTSEC-2026-0194 and RUSTSEC-2026-0195 (`quick-xml` 0.30) | `xcb` updated from 1.7.0 to 1.7.1, which uses `quick-xml` 0.41. | | RUSTSEC-2026-0206 (`rustybuzz`) | Vendored GPUI moved to `cosmic-text` 0.19 and `resvg`/`usvg` 0.48, replacing both `rustybuzz` branches with `harfrust`. | -| Two obsolete RUSTSEC-2026-0192 (`ttf-parser`) occurrences | The rendering upgrades removed versions 0.20 and 0.21. Version 0.25 remains through current `fontdb` and `lopdf`. | +| Two obsolete RUSTSEC-2026-0192 (`ttf-parser`) occurrences | The rendering upgrades removed versions 0.20 and 0.21. Version 0.25 remains through current `cosmic-text` -> `fontdb`; `lopdf` only declares it behind an unused optional feature. | | Yanked `chacha20` 0.10.1 | Lockfile updated to the compatible 0.10.2 release. | +| RUSTSEC-2026-0173 (`proc-macro-error2`) | Vendored GPUI moved from `stacksafe` 0.1 to 1.0, whose procedural macro uses maintained Syn diagnostics. | + +## Resolved during the 2026-08-30 review + +Nothing. This pass re-tested all six remaining entries against the live registry +and the live upstream trackers rather than against the previous write-up, on the +assumption that a blocker recorded days ago may have quietly cleared. None had. +No advisory ID was removed, none was added, and no dependency was upgraded. + +The one substantive change is a correction, not progress: the claim that the +`adabraka_*` advisories had "no upstream tracker to file against" was wrong, and +is fixed under RUSTSEC-2024-0384 below. The real blocker there is stronger than +the one previously recorded. ## Reviewed transitive baseline | Owner / migration boundary | Advisory IDs | Current dependency path | | --- | --- | --- | -| Linux GTK3 tray backend | RUSTSEC-2024-0370, RUSTSEC-2024-0412, RUSTSEC-2024-0413, RUSTSEC-2024-0415, RUSTSEC-2024-0416, RUSTSEC-2024-0418, RUSTSEC-2024-0419, RUSTSEC-2024-0420, RUSTSEC-2024-0429 | `tray-icon` / `libappindicator` still use GTK3. ShellDeck does not call the affected `glib::VariantStrIter`; removing the family requires a tray-backend migration rather than piecemeal crate bumps. | -| Vendored GPUI runtime and HTTP stack | RUSTSEC-2024-0384, RUSTSEC-2025-0052, RUSTSEC-2025-0134, RUSTSEC-2026-0173 | `adabraka_util`, `adabraka_http_client`, `zed-reqwest`, and `stacksafe`. These require a coordinated GPUI fork sync or upstream dependency migration. | -| Rendering and font stack | RUSTSEC-2024-0436, RUSTSEC-2026-0192 | `image` codecs still pull `paste`; current `cosmic-text`/`fontdb` and `lopdf` both use `ttf-parser` 0.25. The text and SVG shapers have already migrated to `skrifa` / `harfrust`. | -| SSH compatibility | RUSTSEC-2023-0071 | `rsa` is transitive through `russh`; no patched RustSec release currently preserves the required RSA SSH-key path. | +| Vendored GPUI runtime and HTTP stack | RUSTSEC-2024-0384, RUSTSEC-2025-0052, RUSTSEC-2025-0134 | `adabraka_util` and `adabraka_http_client` 0.5.1, the two sibling crates of the vendored GPUI that we do **not** vendor. They pin `futures-lite` 1, `async-tar` 0.5 and `zed-reqwest`. | +| Rendering and font stack | RUSTSEC-2024-0436, RUSTSEC-2026-0192 | `paste` arrives through three independent chains (`metal`, `pulp`/`exr`, `rav1e`/`ravif`); `ttf-parser` 0.25 arrives only through `cosmic-text` 0.19 -> `fontdb` 0.23. | +| SSH compatibility | RUSTSEC-2023-0071 | `rsa` is transitive through `russh`'s default `rsa` feature; the advisory records no patched release at all. | + +## Why each remaining ID is still blocked + +Re-verified on **2026-08-30** against the committed `Cargo.lock`. `cargo audit` +run without this repository's ignore list reports exactly these six IDs and +nothing else, so no baseline entry is stale and none was widened. Every claim +below was checked against the crates.io sparse index, not from memory. + +Two whole-graph checks back that up, and both are cheap to repeat: + +- `cargo tree -i --target all` still resolves every path recorded + below, unchanged. +- `cargo update --dry-run` over the whole workspace moves **nothing** in any of + the six chains. The single chain crate it touches at all is `exr` + 1.74.1 -> 1.74.2, which still requires `pulp ^0.22.3` and therefore still + reaches `paste`. No semver-compatible release exists that removes any of the + six. Do not act on that dry run: it also proposes `pathfinder_simd` + 0.5.5 -> 0.5.6, which breaks the macOS release build against our pinned + nightly (see the pin note in `AGENTS.md`). + +### RUSTSEC-2024-0384: `instant` 0.1.13 (unmaintained) + +``` +instant 0.1.13 <- fastrand 1.9.0 <- futures-lite 1.13.0 <- adabraka_util 0.5.1 +``` + +`adabraka_util` declares `futures-lite = "^1.13"` as a **non-optional** +dependency, so no feature switch removes it. `futures-lite` 1.13.0 (2023-04-07) +is the last 1.x and pins `fastrand ^1.9`; `fastrand` 1.9.0 (2023-02-14) is the +last 1.x and the version that still carries `instant`. `futures-lite` 2 dropped +it, so the whole fix is "`adabraka_util` moves to `futures-lite` 2". + +**Blocked because we do not own that crate.** Only `adabraka-gpui` and +`adabraka-ui` are vendored under `patches/`; `adabraka_util` is a plain +crates.io dependency, and in the vendored fork's manifest it is the +**non-optional** `[dependencies.util]` entry, so no feature switch on our side +reaches it either. GPUI uses it across 46 files (`util::ResultExt`, +`util::defer`, `util::maybe`, ...), so it is not droppable in our code. +0.5.1 (2026-02-17) is still the newest release of every `adabraka_*` crate. + +**Upstream is abandoned, not merely unreachable.** The 2026-08-29 revision of +this note said there was "no upstream tracker to file against". That is +overstated and is corrected here. `https://github.com/Augani/adabraka-gpui` +does still return 404 (re-checked 2026-08-30, over HTTP and via `gh api`), but +the same author's `Augani/adabraka-ui` is live, unarchived and has issues +enabled. [`Augani/adabraka-ui#10`](https://github.com/Augani/adabraka-ui/issues/10) +("[BUG] adabraka-gpui source is unavailable", open since 2026-06-04) already +tracks exactly the missing-source problem, and a commenter states that "the +kael project supersedes this". `kael` is not published on crates.io, and the +repository's last push was 2026-02-19. + +So a tracker does exist, but filing there would not help: the crate line is +declared superseded and has shipped nothing for six months. That is a stronger +reason to keep the exception than the "no tracker" claim it replaces. The only +public source snapshot (`philippremy/adabraka-gpui`) has issues disabled and +mirrors 0.5.1 with the same `futures-lite = "1.13"` constraint, confirmed by +reading its `crates/util/Cargo.toml`, so no unpublished fix is waiting either. + +**Why no workaround.** Changing the constraint means vendoring a third crate or +adding a `[patch.crates-io]` entry. Those are steps 4 and 5 of the decision +ladder in [`.agents/patches.md`](../.agents/patches.md), and both require +explicit human sign-off. Taking on a fourth long-lived fork to silence one +*unmaintained* informational warning on a crate that only provides a WASM +`Instant` shim is a far worse trade than the documented exception. + +### RUSTSEC-2025-0052: `async-std` 1.13.2 (discontinued) + +``` +async-std 1.13.2 <- async-tar 0.5.1 <- adabraka_http_client 0.5.1 +``` + +`adabraka_http_client` declares `async-tar = "^0.5.1"` non-optional, and +`async-tar` 0.5.1 declares `async-std` non-optional with no feature gate at all. +`adabraka_http_client` is itself the non-optional `[dependencies.http_client]` +entry of the vendored fork, and GPUI uses it directly (`http_client::AsyncBody`, +`Request`, `Response`, `Uri`, ...), so there is no feature to switch off on our +side of the boundary either. The same holds for RUSTSEC-2025-0134 below, which +enters through the same crate. + +**The upstream migration exists but is out of reach.** `async-tar` 0.6.0 / +0.6.1 (2026-01 / 2026-06) made `async-std` optional behind `runtime-async-std` +and added a `runtime-tokio` alternative. Adopting it requires +`adabraka_http_client` to move to `async-tar` 0.6 with +`default-features = false, features = ["runtime-tokio"]`; a `^0.5.1` +requirement cannot resolve to 0.6. Same ownership problem as above. + +### RUSTSEC-2025-0134: `rustls-pemfile` 2.2.0 (unmaintained) + +``` +rustls-pemfile 2.2.0 <- zed-reqwest 0.12.15-zed <- adabraka_http_client 0.5.1 +``` + +Upstream `reqwest` already solved this: the workspace's own `reqwest` 0.12.28 +does **not** pull `rustls-pemfile`, which is why `cargo tree -i rustls-pemfile` +shows a single branch. In `zed-reqwest` the dependency is optional but enabled +by `__tls`, which every TLS feature turns on, so it is present in practice. + +**Blocked on a fork that stopped publishing.** `zed-reqwest` has exactly two +releases, both from 2025-10-05; `0.12.15-zed` is a snapshot of reqwest 0.12.15 +and nothing newer exists. This clears when `adabraka_http_client` depends on +upstream `reqwest` instead of the Zed fork, or when Zed publishes a rebased +`zed-reqwest`. + +### RUSTSEC-2024-0436: `paste` 1.0.15 (unmaintained) + +Three independent chains, each still declaring `paste` at its newest upstream +release: + +| Chain | Latest checked | Still declares `paste`? | +| --- | --- | --- | +| `metal` <- GPUI macOS renderer (also `adabraka_media`, `core-video`) | 0.33.0 (2025-12-17) | yes, `paste ^1`, non-optional, in 0.29 through 0.33 | +| `pulp` <- `exr` <- `image` 0.25.10 | `pulp` 0.22.3 (2026-06-20) | yes, `paste ^1`; `exr` 1.74.2 still requires `pulp ^0.22.3` | +| `rav1e` <- `ravif` <- `image` 0.25.10 | 0.8.1 (2025-06-16) | yes, `paste ^1.0` | + +Our lockfile sits *behind* two of those, for two different reasons, and neither +gap matters here: + +- `metal` 0.29.0, because the vendored fork's manifest requires `metal = "0.29"`. + Reaching 0.33 is a manifest edit across a semver-major renderer boundary, and + `paste ^1` is non-optional in 0.29 and 0.33 alike, so it would buy nothing. +- `exr` 1.74.1, which is plain lockfile lag. 1.74.2 is semver-compatible and a + `cargo update` would take it, but it still requires `pulp ^0.22.3`. + +`paste` itself is frozen at 1.0.15 with no successor release. + +The advisory suggests `pastey` as a drop-in fork, but adopting it is `metal`'s, +`pulp`'s and `rav1e`'s decision, not ours. + +**Why no workaround.** The vendored GPUI takes `image` with default features, +so setting `default-features = false` there would drop the EXR and AVIF +chains, but not the `metal` one. `cargo audit` reads `Cargo.lock`, which is +target-agnostic, so the ID would keep firing while GPUI silently lost image +formats it advertises. Making `metal` optional, or moving macOS to the +`macos-blade` feature, is a renderer swap rather than a dependency bump. Both +are strictly worse than the documented exception. + +### RUSTSEC-2026-0192: `ttf-parser` 0.25.1 (unmaintained) + +``` +ttf-parser 0.25.1 <- fontdb 0.23.0 <- cosmic-text 0.19.0 <- vendored adabraka-gpui +``` + +`cosmic-text` is a Linux/FreeBSD target dependency of the fork, but the +lockfile that `cargo audit` reads is target-agnostic, so the ID fires on +every platform. It is the only remaining `ttf-parser` occurrence: the shapers +already moved to `harfrust` + `skrifa`, and `lopdf` 0.44 declares `ttf-parser` +only behind an optional feature we do not enable. + +**This is the closest ID to clearing, and it is one upstream merge away.** +`fontdb` 0.24.0 (2026-07-29) removed `ttf-parser` entirely (its index entry +declares no `ttf-parser` dependency at all), while `fontdb` 0.23.0 declares +`ttf-parser ^0.25` **non-optional**, so no feature switch removes it. +`cosmic-text` 0.19.0 (2026-04-22) is the newest release and pins `fontdb ^0.23`, +also non-optional, so neither a lockfile move nor `default-features = false` +can reach 0.24 today. + +Re-checked on 2026-08-30, and the blocker is not stale: +[pop-os/cosmic-text#526](https://github.com/pop-os/cosmic-text/pull/526) +("chore: bump fontdb to 0.24") is **still open**, `merged: false`, last touched +2026-07-30. The bump has not landed by any other route either: `Cargo.toml` on +`cosmic-text` `main` still reads `fontdb = { version = "0.23", … }`, and the most +recent commit touching that file is the 0.19.0 release of 2026-04-22. There is +no 0.20 release, prerelease included. + +**When it lands**: bump the `cosmic-text` constraint in +`patches/adabraka-gpui/Cargo.toml`, a manifest-only fork patch of the same +shape as SDPATCH-121; confirm with `cargo tree -i ttf-parser`, then remove the +ID from all three configuration locations and from this document. + +### RUSTSEC-2023-0071: `rsa` 0.10.0-rc.16 (Marvin attack) + +``` +rsa 0.10.0-rc.16 <- russh 0.60.3 (default feature `rsa`) + <- internal-russh-forked-ssh-key 0.6.18 (via `ssh-key/rsa`) +``` + +**No fixed version exists.** Re-read from a freshly fetched advisory database on +2026-08-30, the advisory still records `patched = []`, and `cargo audit` prints +"No fixed upgrade is available!" for it. +[RustCrypto/RSA#626](https://github.com/RustCrypto/RSA/issues/626) ("Padding +implementation is not constant-time") is still open, last updated 2026-06-02, +and the constant-time rewrite has not shipped. `rsa` has never published a +stable 0.10: the registry holds only `0.10.0-rc.13` through `0.10.0-rc.18`. +Upgrading `russh` does not help either: 0.63.1 (2026-08-23) moved to +`rsa =0.10.0-rc.18`, which the advisory covers just the same. This is the one +baseline entry that cannot clear by any action of ours, ever, until upstream +ships the rewrite. + +**Why no workaround.** The only way to drop `rsa` is to build `russh` with +`default-features = false` and leave the `rsa` feature off, which removes RSA +public-key authentication and `ssh-rsa` host keys. `shelldeck-ssh` implements +that path deliberately (`client.rs` negotiates `best_supported_rsa_hash()` for +`Algorithm::Rsa` and probes `~/.ssh/id_rsa`), and many production hosts still +accept only RSA user keys. Breaking them is a functional regression in the +app's core use case. The advisory's own workaround section scopes the risk to +settings where an attacker can observe signing timings; ShellDeck signs locally +with a user key rather than serving attacker-driven timing oracles. ## Maintenance rule @@ -48,3 +257,41 @@ its full `cargo tree -i @ --target all` path and document why an in-place update or a ShellDeck-side change cannot remove it. Remove an ID from all three configuration locations as soon as the dependency chain no longer contains it. + +## Re-running the review + +`cargo audit` in the repository root reads `.cargo/audit.toml` and therefore +reports nothing while the baseline holds. To see the raw list, copy the +lockfile somewhere without that config and audit there: + +```bash +export PKG_CONFIG_PATH=/usr/lib/x86_64-linux-gnu/pkgconfig +mkdir -p /tmp/sd-audit && cp Cargo.lock /tmp/sd-audit/ +(cd /tmp/sd-audit && cargo audit) +``` + +Anything beyond the six IDs above is a genuinely new finding and must be +resolved, not appended here. `cargo update --dry-run --verbose` shows, per +crate, whether a newer compatible release exists. That is the fastest way to +confirm a baseline entry is still pinned by a requirement we do not control. + +> **Dry run only.** Never let that command talk you into an actual +> `cargo update`. It proposes `pathfinder_simd` 0.5.5 -> 0.5.6, which breaks the +> macOS release build against our pinned nightly and which `cargo check` will +> not catch. If you ever do run it, follow immediately with +> `cargo update -p pathfinder_simd --precise 0.5.5`. See `AGENTS.md`. + +Do not re-derive a blocker from this document alone. Each entry names an upstream +release, pull request or issue: re-check those at their source before repeating +the conclusion. A blocker that was real last week may have merged since, and the +whole point of this file is to make that cheap to test rather than cheap to +assume. Checking the six takes about ten minutes: + +```bash +# still reachable, and by which path? +cargo tree -i --target all +# does a newer release exist that changes the requirement? +curl -s https://index.crates.io/// | jq -r '.vers' +# did the upstream PR/issue that gates it move? +gh api repos///pulls/ --jq '{state,merged,updated_at}' +``` diff --git a/docs/testing/USE_CASES.md b/docs/testing/USE_CASES.md index cac06acd..221e9987 100644 --- a/docs/testing/USE_CASES.md +++ b/docs/testing/USE_CASES.md @@ -871,9 +871,12 @@ at a short cadence; background runtime refreshes never erase an in-flight chat. ### SDUC-475 — Coding agents run on an explicit local or SSH target Dev mode exposes one provider-neutral agent console for Claude Code, Codex, -DeepSeek through Jcode, and Jcode's configured/default provider. Every run names an absolute working directory, an -explicit local machine or existing ShellDeck SSH connection, a model override, -and a closed access level. Read-only is the default. Workspace-write and full +Automonique ACP, DeepSeek through Jcode, and Jcode's configured/default provider. +Every run names a target-valid working directory (a native absolute path locally, +an absolute POSIX path or the exact remote-home sentinel over SSH), an explicit +local machine or existing ShellDeck SSH connection, an optional model override +when the provider supports one, and a closed access level. Read-only is the +default. Workspace-write and full access require a separate confirmation that repeats the provider, target, working directory, and permission level. Output streams into the console and Stop terminates the local process or closes the remote SSH channel. The @@ -912,6 +915,48 @@ the normal Send action, with an explicit tooltip rather than a competing text button. The Markdown renderer receives that conversation measure as a definite width, so structured output such as tables wraps inside it instead of being laid out intrinsically and clipped at the right edge. +When a session changes from the local machine to an SSH target without an +authorized catalog checkout, its canonical working-directory default is `~`; +the remote shell expands that exact sentinel to its own `$HOME`. ShellDeck +never sends a quoted literal tilde and never treats an inherited client-only +absolute path as the remote default. An explicit catalog checkout root remains +authoritative when one is available. + +### SDUC-499 — Agent work remains independently identifiable and observable + +The Dev cockpit retains multiple named provider-neutral agent sessions rather +than replacing one global transcript. Up to four run concurrently by default, +with a hard bound of eight, and every session retains its own exact execution +context, lifecycle timestamps, transcript, structured technical trace, unread +count and attention state. Selecting a session clears ordinary unread state; +failures remain attention-worthy until explicitly acknowledged. Commands, +file reads, diffs, tests and other tool activity are typed independently from +conversation prose. Provider output and retained histories have byte and item +bounds, secret-shaped values are redacted from technical trace fields, and +malformed control payloads are never rendered verbatim. Durable cockpit state +never serializes a provider resume identifier, and an active status recovered +after application restart becomes an interrupted failure rather than claiming +that an absent process is still running. Messages and technical events share a +stable monotonic session sequence, so equal wall-clock timestamps and later +delta/status updates cannot reorder an already-presented timeline row. Local +and SSH byte streams retain at most one admitted provider line; a newline-free +oversized record emits one bounded omission, discards through its newline, and +then resumes framing later records. A run mapped to a catalog checkout opens +its exact retained Workspace cockpit; local checkouts place an +authority-resolved file pane beside the agent, while typed split controls can +keep the retained terminal visible concurrently. A session-to-checkout binding +is immutable until that session closes, and closing it removes every retained +pane reference. On wide desktops the session navigator is the single session +selector rather than being repeated as a second tab strip. A persistent +inspector shows the actual local working-directory tree and opens selected +local files through a typed event only after canonical containment beneath the +selected session root. SSH trees are requested through the exact +selected session, connection, and requested path; asynchronous results must +match all three before their separately resolved canonical directory may +replace the current listing. The `~` default becomes a remote path only after +that host returns one bounded, valid absolute home directory. The Changes tab projects changed files, diff +counts, and bounded previews from structured trace events, and selecting one +highlights that exact event in the ordered timeline. --- @@ -1934,8 +1979,13 @@ off, and the main ShellDeck window remains hidden. Every visible tray label, including zero/one/many counter forms and the empty pinned-connections row, follows the selected French or English UI locale. A live language change republishes the tray snapshot so every desktop backend updates the native menu -immediately. Counters and pinned connections follow the same owner-thread -snapshot path. The Dock header and rail toolbox use keyboard-focusable controls +immediately. Counters and pinned connections rebuild the same immutable GPUI +native-menu snapshot on its foreground executor. Informational counters and +signed-out session actions are native disabled labels, while command dispatch +independently rechecks authentication. Linux uses GPUI's +StatusNotifierItem/`ksni` backend without GTK or AppIndicator; its +service-thread events are marshalled back to the GPUI foreground executor +before application dispatch. The Dock header and rail toolbox use keyboard-focusable controls with visible localized names or tooltips; Escape remains an explicit hide action. On macOS, the tray uses a dedicated 36 px black-and-alpha Monolith mark as an AppKit template image, so the system @@ -1948,7 +1998,10 @@ Windows retain the colored app icon. when the system tray was created successfully. The default remains a visible start for old and fresh configurations. If the tray backend is unavailable, ShellDeck ignores the hidden-start preference and opens its main window so the -process is always recoverable. Tray and deep-link show actions explicitly show +process is always recoverable. On Windows, an Explorer restart authoritatively +re-adds the retained icon; a failed re-add makes tray availability false rather +than claiming a notification-area entry that no longer exists. Tray and +deep-link show actions explicitly show the hidden window before activating it. A hidden start initially owns only a lightweight `CompanionRoot`: it does not construct `Workspace`, its views or its pollers until a tray, deep-link, palette, Dock or task-target command needs @@ -2574,7 +2627,15 @@ Permission requests reach an injected user-decision broker and only an option the agent actually offered can be returned. With no broker the request is cancelled. ShellDeck advertises no filesystem or terminal service, so ACP cannot bypass its typed confirmation paths or Automonique's execution -authority. Automonique is the built-in launch profile (`automonique acp`). +authority. Automonique is the built-in launch profile (`automonique acp`) and +is a first-class coding-agent provider: its message chunks and tool lifecycle +stream into the shared Agent timeline, its durable session ID resumes only in +the exact execution context, and Stop sends `session/cancel` before the guarded +ACP process tree is dropped. The local ACP launch never substitutes for an SSH +selection; remote Automonique work remains reachable only through an exact AI +Operations Platform workspace/session mapping. Read-only cancels every ACP +permission request, while a separately confirmed mutating run may select only +an exact one-shot allow option the agent offered and never persists that grant. ### SDUC-483 — Successful sign-in progress completes exactly once @@ -2810,13 +2871,62 @@ Forced-process and real-adapter no-replay evidence remains a Red release gate. ### SDUC-493 — Agent attention always returns to its authoritative work context Needs You, Working, Blocked, Done, and Idle are typed observations with unread -state and a nested-agent path. Each item names one local user workspace and -pane; provider-session items additionally name the exact session in that pane. -Older or conflicting same-revision observations are refused. Opening an item in -the core resolves only through retained state keyed by the authoritative local -workspace and validates all coordinates against that workspace surface, -refuses duplicate pane/session coordinates, returns only that exact tab, and -records local read state separately from the authoritative observation revision. +state and a nested-agent path. The legacy local board names a retained local +workspace/pane/session surface and validates all coordinates before returning +one exact tab. Shared Platform v2 attention instead retains the authoritative +project, user workspace, source-scoped opaque item identity, item revision, and +optional authority-qualified provider session exactly as supplied. It never +invents a pane, tab, terminal, path, or session from chronology or labels. +Platform sources are derived only from a bounded duplicate-free work-context +inventory and typed review presence. A complete source snapshot atomically +replaces its predecessor; exact replay is inert, stale/conflicting successors +are refused, valid absence removes an item, and refusal or transport failure +hides but does not discard predecessor custody. A documented source-scoped +UUIDv5 is presentation-only and collisions fail the entire replacement. A +fresh authenticated complete read may explicitly establish a baseline above +revision one; after a missed predecessor the same distinct resync operation may +bridge the gap while refusing source, observation, or surviving-item rollback. +Normal reads remain exact-predecessor fenced, and restart or source re-add never +silently treats a discontinuous snapshot as a successor. +Local read and notification custody is stored separately under exact +`(source,item,item_revision)` tuples. It is bounded and atomically persisted; +capacity, parse, or write failure suppresses new custody instead of evicting an +older tuple. A private no-follow/reparse-fenced storage boundary and an +exclusive sidecar lock serialize reload/compare/persist across independent +processes, so only one process may reserve a notification tuple. Version-two +overlay tuples also bind the exact Platform target; unqualified version-one +tuples are discarded during migration rather than inherited after a remap. +Target/source retirement fences are written to the same document before a +board disappears, and successful cleanup atomically removes both old overlay +and fence. A surviving fence blocks replacement custody after restart. The serialized +document must fit its read bound before replacement. The native Workspace and +Fleet surfaces now consume the same authoritative board. Every activation +re-resolves the current exact catalog mapping, full authority-qualified session +directory, and retained provider pane: missing, duplicate, foreign, or stale +coordinates refuse without consuming unread state. Review and orchestration +items open only their exact workspace attention surface; provider items open +only the fresh session bound to their authoritative WorkContext source, either +in Fleet or its unique retained pane. Whole-poll failures hide retained +projections. Loss of all active context retires orphan boards; an exact context +removal/remap retires only that workspace, so visiting a different retained +workspace preserves the first board and overlay. A failed retirement-fence +write leaves the old board unavailable and blocks replacement. Every destination +queues its exact activation until the real Dev transition finishes, opens the +required surface, then re-resolves authority and rendered visibility on a later +UI turn before durable read custody. User, Support, Settings, or an unrelated +in-flight transition cannot consume unread while the destination is hidden. Same-process +OS notification handles remain alive through activation/dismissal and route the +same tuple back through that current-catalog resolver. A failed durable +notification reservation suppresses the toast, whose body names the destination +workspace and degrades to the reason alone when the catalog no longer names it. +Attention is also presented as one chronological activity list ordered on the +authoritative `observed_at_ms`, never on poll arrival or source order; equal +observations fall back to the item revision and then to the authoritative +`(source,item)` key, so the sequence is identical in every process and stable +across workspaces. A hidden source contributes no chronology. Each row carries +only the activation token and re-resolves its workspace, session and pane +through the same resolver when opened. Native cold-launch/OS launch +protocol remains intentionally outside this milestone. Delivery checks, review status, merge readiness, and delivery state carry their observed authority and freshness. Once Fresh, they cannot be overwritten by a Stale or Unknown projection even if that projection claims a higher revision. @@ -2843,16 +2953,113 @@ The persisted local workspace-review schema remains independent and unmigrated. For an exactly reconciled Platform project/workspace, ShellDeck expands the canonical review snapshot into bounded files, hunks, conflicts, safe preview metadata, attributed comments, and attention chronology. A user may select an -exact hunk line and prepare one typed comment or review approval. Comments in -`not_sent`/`refused` state can be selected into one batch-to-agent action. -Server-projected Git proposals, terminal CI checks, and a fresh ready pull -request can prepare their corresponding stage/unstage/commit, rerun, or merge -action. Every confirmation names the exact workspace and captured snapshot or -target revision before dispatch. - -The client dispatches that canonical SDK `ReviewAction` at most once. Accepted, -unknown, or transport-ambiguous outcomes retain the original idempotency key and -use receipt lookup only. Async results are admitted only if both the complete +exact hunk line and either persist a durable line-anchored note or prepare one +typed comment or review approval. A note retains the file, hunk, side, line and +captured snapshot revision it was written against, plus a durable +batch-delivery selection; a note captured on a superseded snapshot is retained +but reported non-actionable rather than re-anchored, and it is stored under the +same bounded, no-follow, cross-process-locked private boundary as review +custody. A note is a local draft and carries no server comment id, so its +durable selection marks which drafts to prepare, never what is delivered. + +Batch delivery to the authorized session is exposed for exactly the comments +the server advertised in `agent_deliverable_comments` for this project, +workspace and snapshot revision. Each comment's id and expected revision are +sent verbatim from that capability, at the capability's own snapshot revision, +in the strictly ordered form the contract requires; the snapshot is consulted +only to prove the two reads agree and supplies no value that crosses the +network. A capability list for another coordinate, an entry the snapshot +contradicts or does not carry, a comment already delivered, a review that is +not fresh, and an empty advertised list each withhold the control with their +own stated reason. + +This lane carries no confirmation digest and no receipt correlation, and that +is deliberate. The target session is registry-owned and never on the wire, the +snapshot revision is fenced twice server-side, and the note set is fenced by +the server's own batch arm, so exactly-once falls out of the domain state +machine; a minted correlation would additionally make the client's own receipt +unfindable, because the host's receipt lookup skips a retained action carrying +one. What replaces the digest is the advertisement, so ShellDeck re-reads it +after every settlement rather than reusing it: a batch preview is revalidated +against the current advertisement before it crosses the dispatch fence, and the +persisted selection is retained against that advertisement rather than the +snapshot, so a settled delivery empties it instead of allowing a stale list to +be posted under a fresh idempotency key. + +A terminal CI check exposes rerun only when a separately +fetched server capability matches the exact project, workspace, snapshot +revision, check revision, authority, confirmation digest, and +receipt-correlation digest. Every exposed confirmation names the exact +workspace and captured snapshot or target revision before dispatch. + +File-level staging, unstaging, committing and conflict resolution are exposed +on the same terms, against `ReviewCapabilities`' `staging` and +`conflict_resolutions` lists. Each entry names the commit `HEAD` the server's +preflight resolved to and a digest over the whole index it read, because a +worktree is shared substrate: another process running as the daemon uid can +move either between the advertisement and the action, and the snapshot revision +tracks only what the projection observed. The four actions require a +server-minted confirmation, so their unconfirmed spelling cannot be encoded on +either side of the wire, and their receipts are recovered through the +correlation digest rather than by re-sending an action whose confirmation the +write itself invalidated. A capability whose observation disagrees with a +fresher document stops matching, so the control withdraws locally rather than +being refused by the daemon. + +A control exists only where a slot exists for that exact proposal. The three +staging kinds share one capability type but not one grant — an operator +installs index writes, committing and conflict resolution independently — so a +deployment that grants stage and unstage but not commit produces two controls +and not three, and the withheld one is *absent* rather than disabled. Absence +of the whole lane is reported with its own stated reason: no capability +response attributed to this coordinate, an exact response that advertised +nothing, or no durable custody lane. `conflict_resolutions` lists one entry per +side git actually recorded, because the side decides which blob lands and is +inside the confirmation digest, so a file holding both sides renders two +controls and a delete/modify pair renders one; nothing but a side ever crosses +that wire, so no caller-supplied content can be written. + +Hunk-level staging remains unrepresentable: a proposal names file ids, and +`ReviewAnchor`'s hunk id serves comments only. The three pull-request slots +still ship empty because no provider adapter can preflight one, and the merge +preview is now a confirmed lane too, so it refuses at construction rather than +minting a request the transport cannot encode. + +The read side presents those files as one combined worktree: conflicted first, +then staged, unstaged, and untracked, with empty lanes omitted. A partially +staged file appears on both sides and says so; an unresolved conflict is listed +only as conflicted, because git reports it unmerged and refuses to stage it, +while a resolved conflict returns to its own lane. Anchor identities bind lane, +file, and hunk so a twice-listed file cannot route one lane's click to the +other. The server's own stage, unstage, commit, and conflict-resolution +proposals are shown as per-file observations with their admissibility; a +control joins one only where the server advertised a slot for that exact +proposal, and the surface otherwise states which fence is missing. Hunk-level +staging is not representable at all — a proposal names files, never hunks. + +Previews are decided from declared metadata, never from trust. Text is painted +only from bounded hunk previews, with control characters and invisible +reordering scalars replaced and line count and length capped; the protocol's +own control-free bound still admits a bidirectional override that would repaint +a diff line. A sanitized image is described beside an aspect-preserving +placeholder box that can never exceed its edge cap, and no pixels are decoded or +fetched. Sanitized HTML is described only: never interpreted as markup, never +re-emitted as source. An unsanitized payload, opaque binary, an absent preview, +a declared size or raster above the client budget, and a kind disagreeing with +its media type or content are each withheld with a distinct localized reason +present in both shipped locales. + +Before any exposed review mutation crosses the network — comment, approval, +confirmed rerun or unconfirmed batch delivery — ShellDeck persists the inert +preview and then a dispatched +marker under a bounded, no-follow, cross-process-locked custody store. A +workspace owns at most one non-terminal effect at a time, and preparation is +refused outright when that store is unavailable. Accepted, unknown, or +transport-ambiguous outcomes retain the original idempotency key and use +correlated receipt lookup +only; restart never reposts a dispatched action, while an unconfirmed prepared +preview is reported as never started. Terminal receipts retain the server actor +for presentation. Async results are admitted only if both the complete captured authenticated connection and `PlatformReviewTarget` still match the active context. A missing proposal, wrong authority kind, unresolved-conflict proposal, stale check, duplicate comment selection, or non-ready pull request @@ -2863,6 +3070,214 @@ an unavailable adapter refuses before any effect. ## Change log +- **2026-09-01** — Amended SDUC-499 with SDTEST-1902/1903/1909: the wide cockpit + removes its duplicate session tab strip, adds a real local tree plus a + session/host/path-fenced SSH browsing contract, and projects trace-backed + changes into a persistent inspector. Manual Local↔SSH target switches now + choose target-valid working-directory defaults instead of submitting a + client-only path or rejecting the remote-home sentinel. + +- **2026-09-01** — Amended SDUC-475 with SDTEST-1904/1905/1908: manual SSH + targets use an exact remote-home sentinel instead of inheriting a local + absolute path, while catalog-backed runs translate the matching checkout + root and preserve their validated relative suffix. Remote browsing admits + only one bounded absolute home returned by the selected SSH host. +- **2026-09-01** — Hardened SDUC-499 with SDTEST-1895..1898: a session-wide + monotonic sequence now orders equal-time conversation and trace rows, while + the shared local/SSH stream framer bounds newline-free provider output and + recovers after one omission. Explicit surface visibility, not selected + navigation identity, now consumes session unread state. + +- **2026-09-01** — Added SDUC-499 and SDTEST-1879..1881 for named concurrent + agent sessions, lifecycle/attention state, typed provider traces, bounded + untrusted output, and restart-safe durable state that excludes provider + resume identifiers. + +- **2026-08-30** — Settled the hunk half of `benfavre/shelldeck#128` as a + design record rather than a feature. No behaviour changes and no new SDUC or + SDTEST: the outcome of `benfavre/shelldeck#163` is that hunk-level staging is + not representable, and would not become representable by adding a field. + SDUC-495 is already correct as written, because it describes the hunk as what + it is: the anchor a comment resolves a line against. + + Three findings, each sufficient on its own. **A hunk has no identity to close + a write over.** It is not a property of the repository but of a rendering: + the same pair of blobs decomposes into three hunks at `-U2` and two at `-U3`, + the boundary and the added lines both move when `diff.indentHeuristic` is + toggled, `-w` yields a different set from the default, and a `.gitattributes` + textconv driver makes the same blob pair produce none at all. Two hunks in + one file can carry byte-identical bodies with their full context, so content + addressing alone is ambiguous inside a single file. **The hunk on the wire is + not an observation of the worktree a write would touch.** At the pinned + revision, `DiffHunk` and `ReviewFile` are constructed in exactly one + non-test place, the decoder, so nothing in `bext-stack/automonique` builds a + hunk from a repository, and the git worktree adapter that PR #225 added + contains no occurrence of "hunk" at all. A path is both server-owned and + observed; a hunk id can only ever be the first. **And git offers no fenced + mechanism.** `git add -p` is `git diff-files -p` followed by `git apply + --cached`, which matches context at any line offset: it exits 0 against an + index the patch was not minted from, and when the reviewed region has been + removed it writes the change into a different, similar region instead, with + no error. + + What *is* representable is a partial-file index write closed on the resulting + blob object id, with the selection re-derived server-side from a pinned blob + pair rather than named by the client. That keeps the file as the unit the + server closes over, but it needs an upstream family and it inverts the + invariant PR #225 was built on, where every write names a path and lets git + supply the content. It is deliberately not built. The dormant `StageHunks` / + `UnstageHunks` reducer in `workspace_review.rs` now carries the reason at the + site, because its `MutationTargetFence::LocalReview` pins this crate's own + review revision and would fence a git write against nothing. + +- **2026-08-29** — Amended SDUC-495 with capability-fenced file-level staging, + unstaging, committing and conflict resolution, and added SDTEST-1867..1873. + This was the last open item of `benfavre/shelldeck#128`, and it was blocked + on the same kind of contract gap as the batch delivery before it: a proposal + id plus a snapshot revision does not pin a worktree, so a staging + confirmation digest would have had to commit to the `HEAD` object id and the + index state, and no projection carried either. `bext-stack/automonique` + PR #225 earned that field set from a mutation-free preflight of the + repository, and ShellDeck now consumes it. + + The controls follow `delivery.rs` rather than a second shape: a read-side + projection reporting what the server proved, plus the exact missing fence + when it proved nothing. Absence *is* the withholding — the three staging + kinds share one capability type but not one grant, so a deployment that + installs index writes without committing advertises no `Commit` entry and + that proposal keeps its observation badge and grows no button. SDTEST-1867 + demonstrates that rather than asserting it: the same run drives stage and + unstage end to end to a dispatchable confirmed preview, then grants the + commit and watches the third control appear from unchanged projection code. + + `conflict_resolutions` lists one entry per side git actually recorded, + because the side decides which blob lands and is therefore inside the + confirmation digest; a file holding both sides renders two controls + (SDTEST-1869). A stale worktree withdraws the control locally: the server + mints the digest over the commit `HEAD`, the whole index and every named + path, so comparing it against a fresher capability document is the + head-and-index comparison, and the observation is exposed so a reader can see + the fence rather than trust one exists (SDTEST-1868). + + Two findings worth recording. The confirmation guard in `review_action` was a + local `matches!` on the rerun rather than the contract's own + `requires_confirmation()`; the confirmed set grew from one member to eight + with this protocol revision, and the stale spelling was already letting + `merge_pull_request` mint previews the transport could no longer encode. And + the custody disk format did not understand any of the four staging families, + so preparation would have returned "unsupported action" and the dispatch + would have died at the fence — the same defect PR #161 found for the batch + delivery after PR #160 asserted the format already knew "these actions". + SDTEST-1870/1871 drive the whole store rather than only the enum. + + Hunk-level staging is still not representable and was not invented: proposals + list file ids, and `ReviewAnchor`'s hunk id serves comments only. The #128 + item therefore ticks for the file half and needs the hunk half called out as + a protocol follow-up. + +- **2026-08-29** — Expanded SDUC-495 with capability-fenced batch delivery of + review comments to the authorized session, and added SDTEST-1863..1866. The + item was blocked on a contract gap, not a client one: `ReviewCapabilities` + advertised only `rerunnable_checks`, so nothing could fence the control. + `bext-stack/automonique` PR #221 lands `agent_deliverable_comments`, and the + send control now exists only for the comments it names, with every coordinate + taken verbatim from it. + + This is the unconfirmed lane, deliberately. A rerun needs a digest because + the client names the target and the effect fires in a system the daemon does + not own; delivery reaches a registry-owned session that is never on the wire, + at a snapshot revision fenced twice server-side, over a note set fenced by + the server's batch arm. Minting a receipt correlation would have been worse + than redundant — the host's receipt lookup skips a retained action carrying + one, so the client would have made its own receipt unfindable. + + The advertisement is what replaces the digest, so it must be re-read after a + receipt settles rather than reused; a reused list is stale by construction, + because settling moves the comment out of `not_sent`/`refused` and bumps both + the snapshot revision and the comment's own. SDTEST-1863 demonstrates that + rather than asserting it: it replays the server's own eligibility rule, shows + the advertisement retracting exactly the delivered comment, and shows the + reused list refused even under a fresh idempotency key. + + The custody fence was not weakened to fit the lane. Batch delivery needs no + confirmation digest but is still an exposed mutation, and the custody disk + format knew only `AddComment`, `ApproveReview` and `RerunCheck` — so it could + not be recorded at all and would have refused at the fence. + +- **2026-08-29** — Expanded SDUC-495 with durable line-anchored review notes + and extended the custody fence to every exposed mutation; added + SDTEST-1853..1855, SDTEST-1858 and SDTEST-1859. Comments and approvals + previously dispatched with no durable record at all: only the confirmed + rerun crossed the fence, so a restart mid-dispatch left a posted comment + indistinguishable from an unstarted one and its receipt actor was never + retained. Notes are drafts bound to the snapshot revision they were written + on, so a newer snapshot makes one explicitly stale instead of quietly moving + it to a different line. Batch delivery of a selected note set to the + authorized session stays unexposed: + `automonique_protocol::platform_v2_transport::ReviewCapabilities` carries + only `rerunnable_checks`, so the server can advertise no authority for that + action and no receipt-correlation digest exists to make an ambiguous outcome + recoverable. + +- **2026-08-29** — Expanded SDUC-493 with a chronological attention activity + surface and added SDTEST-1856/1857. The board carried the authoritative + `observed_at_ms` but never used it: rows reached both surfaces in + source-key order, so what looked like a feed was arbitrary, and a + cross-workspace list kept every row of the first board ahead of the second. + The desktop toast also named no destination, which made an actionable + notification say only why it fired and never where it went. + +- **2026-08-29** — Expanded SDUC-495 and added SDTEST-1843..1852: the review + snapshot now projects into one combined conflicted/staged/unstaged/untracked + worktree with per-file conflict state, and every preview is decided from + declared metadata rather than trusted. Staging stays read-only and says why: + Platform v2 `ReviewCapabilities` advertises rerunnable checks only, so no + staging capability, confirmation digest, or receipt-correlation digest exists + to fence a Git mutation, and a proposal's `git` authority inside the read + snapshot is an observation rather than a capability. Hunk-level staging is + not representable in the contract at all. The preview projection replaces + invisible reordering scalars the protocol's control-free bound still admits, + caps text shape, clamps declared rasters into a bounded placeholder box + without decoding pixels, and describes sanitized HTML without ever + interpreting it as markup or re-emitting it as source. + +- **2026-08-29** — Added SDTEST-1842: the shared cross-client attention + succession corpus (`platform-v2-attention-conformance-v1.json`, copied + byte-for-byte from Automonique) replays against the authoritative board. + `automonique.platform/attention/v1` is `atomic_replace`, so no single + snapshot says what a client must conclude after a sequence of reads; the + corpus fixes that sequence and ShellDeck now proves it reaches the same + outcome the contract records, including the cases where the honest answer + is that the source is hidden rather than empty. + +- **2026-08-28** — Narrowed SDUC-495 to the controls that have explicit server + authority and added SDTEST-1822..1828 for confirmed-rerun capability fencing, + durable pre-dispatch custody, restart lookup-only recovery, correlated-only + receipt routing, actor retention, storage refusal, and absent unsupported UI. + +- **2026-08-28** — Expanded SDUC-493 and added SDTEST-1816..1821 for the + canonical Platform v2 attention source inventory, source-atomic revision + reducer, source-scoped deterministic UI identities, and bounded durable local + read/notification custody. Follow-up hardening added authenticated complete + baseline/gap resync, cross-process notification reservation, size admission, + and no-follow/reparse-fenced storage. SDTEST-1829..1841 then activate the + authenticated board in Workspace/Fleet: provider coordinates stay bound to + their fresh WorkContext session, polling/remapping hides or retires stale + boards, visible native surface transitions precede read custody, and + same-process notifications retain exact revision-bound activation. Native + cold-launch/OS-launch routing remains a separate integration milestone. + +- **2026-08-28** — Hardened SDUC-435 and added SDTEST-1813..1815: Windows now + handles Explorer's `TaskbarCreated` broadcast through an invisible top-level + tray owner, re-adds the retained HICON with fail-closed availability, and + exercises GPUI's ICO selector plus `CreateIconFromResourceEx` on native CI. + +- **2026-08-28** — Amended SDUC-434/435 after moving every desktop tray to + GPUI's native backend: Linux no longer initializes GTK/AppIndicator, native + availability remains authoritative for hidden start, and counters/signed-out + actions use portable disabled labels with foreground-thread event delivery. + Added SDTEST-1812 for Windows ICO decoding and visible-icon registration. + - **2026-08-28** — Amended SDUC-443 and added SDTEST-1808: status counters now use semantic icons, numbers, 20 px hover targets, and localized tooltips; the wide Git branch is anchored to the geometric center outside the flex @@ -2919,6 +3334,9 @@ an unavailable adapter refuses before any effect. the fixed workspace subsystem: exact prepare/release plus prepare/resume into the descriptor-retained clean repository, using the production client and no path-bearing SSH exec request. +- **2026-08-28** — Expanded SDUC-493 and added SDTEST-1810/1811 for + revision-bound retained attention rows and exact native workspace/pane/tab + activation without inferring coordinates from review events. - **2026-08-28** — Added SDTEST-1792 so line and comment selections cannot cross a project/workspace switch or review-snapshot revision boundary. - **2026-08-28** — Expanded SDUC-495 and added SDTEST-1791 for exact diff --git a/docs/testing/tests-core.md b/docs/testing/tests-core.md index fb7b1fd3..0793ce1e 100644 --- a/docs/testing/tests-core.md +++ b/docs/testing/tests-core.md @@ -272,6 +272,14 @@ Existing: **0 tests**. | SDTEST-1674 | `agent_runtime.rs::sdtest_1674_remote_command_quotes_workspace_and_encodes_stdin` | SDUC-475 | Green | SSH commands quote hostile working-directory characters and base64-encode multiline stdin so prompt text cannot become shell syntax. | | SDTEST-1675 | `agent_runtime.rs::sdtest_1675_provider_streams_normalize_visible_text_and_errors` | SDUC-475 | Green | Claude, Codex, and Jcode JSONL fixtures normalize into provider-neutral text/error events; only Claude's `system/init` becomes Ready, later hook/status control records stay out of the visible trace, and `isApiErrorMessage` remains an error instead of becoming a synthetic Agent reply. | | SDTEST-1680 | `agent_runtime.rs::sdtest_1680_remote_shell_transport_preserves_hostile_multiline_stdin` (`#[cfg(unix)]`) | SDUC-475 | Green | The generated SSH shell command is executed against a fake agent; hostile multiline prompt text arrives byte-for-byte on stdin and cannot become shell syntax. | +| SDTEST-1879 | `agent_session.rs::sdtest_1879_collection_runs_bounded_sessions_concurrently_and_tracks_attention` | SDUC-499 | Green | Two named sessions run concurrently while a third is capacity-fenced; background trace/text becomes unread, a visibly selected session clears ordinary unread state, and a background failure retains explicit attention. | +| SDTEST-1880 | `agent_runtime.rs::sdtest_1880_provider_tools_become_bounded_redacted_structured_trace` | SDUC-499 | Green | Claude and Codex tool records project to typed file/test trace entries, credential-shaped values are redacted, malformed JSON is not echoed, and oversized provider lines become a bounded omission label. | +| SDTEST-1881 | `agent_session.rs::sdtest_1881_durable_state_excludes_resume_ids_recovers_runs_and_bounds_streams` | SDUC-499 | Green | Repeated deltas and trace strings remain UTF-8 byte-bounded, provider resume IDs never enter serialized state, and a restored active session recovers as an attention-worthy interruption. | +| SDTEST-1895 | `agent_session.rs::sdtest_1895_equal_timestamps_and_incremental_updates_keep_one_monotonic_timeline` | SDUC-499 | Green | Equal-time messages and traces receive one session-wide monotonic sequence; later text deltas and correlated trace completion update their existing row without changing its timestamp or sequence. | +| SDTEST-1896 | `agent_runtime.rs::sdtest_1896_stream_framer_discards_one_oversized_line_and_recovers` | SDUC-499 | Green | Incremental newline-free input never grows the retained buffer past the parser bound, emits exactly one omission for an oversized record, discards its remainder, recovers at the next line, and still admits an exactly-at-limit line. | +| SDTEST-1898 | `agent_session.rs::sdtest_1898_selected_but_hidden_session_accumulates_unread_until_visible` | SDUC-499 | Green | Selection remains stable navigation identity while explicit surface visibility alone consumes unread state; hidden output increments unread even for the selected session, and visible output does not. | +| SDTEST-1904 | `agent_runtime.rs::sdtest_1904_ssh_home_workdir_expands_once_and_never_becomes_literal_tilde` (`#[cfg(unix)]`) | SDUC-475 | Green | A manual SSH target admits the exact `~` home sentinel, expands it to the remote `$HOME` before launch, keeps Jcode relative to that expanded directory, and rejects ambiguous `~/…` spellings. | +| SDTEST-1906 | `agent_runtime.rs::sdtest_1906_automonique_acp_updates_and_permissions_keep_typed_boundaries` | SDUC-475, SDUC-480 | Green | Automonique ACP message/tool updates become bounded provider-neutral session/text/trace events without exposing raw tool inputs; read-only grants nothing, mutating access chooses only an offered one-shot allow, the provider serializes durably, and the command-stream path refuses it. | --- @@ -514,10 +522,10 @@ Existing: **0 tests**. | SDTEST-1752 | `workspace_review_tests.rs::sdtest_1752_drafts_use_workspace_paths_monotone_cas_and_expected_identity` | SDUC-492 | Yellow | The core uses a workspace-keyed private-root path, expected-workspace admission, monotone disk revision and stale-writer CAS. The pre-anchor schema is explicitly refused because section/hunk identity cannot be reconstructed. A release-runner multiprocess/native-filesystem exercise remains required. | | SDTEST-1753 | `workspace_review_tests.rs::sdtest_1753_only_registered_unexpired_previews_can_submit` | SDUC-492 | Green | Provider authority cannot prepare repository work; a modified/deserialized preview, expired grant, and revoked/superseded current repository grant cannot cross durable submit admission. Grant minting remains crate-private. | | SDTEST-1754 | `workspace_review_tests.rs::sdtest_1754_dispatched_ledger_recovers_only_by_original_receipt` | SDUC-492 | Yellow | Drop/reload turns a durably dispatched operation into reconciliation-only work with the original key and exact receipt fences. A completed record remains enumerable after operation-ID loss until explicit terminal acknowledgement. Forced process termination plus the real receipt adapter remains SDTEST-1758. | -| SDTEST-1755 | `workspace_review_tests.rs::sdtest_1755_attention_read_state_replays_and_duplicate_coordinates_fail_closed` | SDUC-493 | Yellow | Core local-read state survives an unchanged authoritative replay; attention resolves only through the retained state keyed by local workspace, so a foreign Browser surface sharing checkout/pane coordinates cannot substitute, and duplicate session coordinates fail closed. Retained GPUI entity/deep-link integration remains SDTEST-1758. | +| SDTEST-1755 | `workspace_review_tests.rs::sdtest_1755_attention_read_state_replays_and_duplicate_coordinates_fail_closed` | SDUC-493 | Yellow | Core local-read state survives an unchanged authoritative replay; attention resolves only through the retained state keyed by local workspace, so a foreign Browser surface sharing checkout/pane coordinates cannot substitute, and duplicate session coordinates fail closed. Native retained-entity activation is SDTEST-1811; OS notification/cold-launch deep-link integration remains SDTEST-1758. | | SDTEST-1756 | `workspace_review_tests.rs::sdtest_1756_delivery_mutations_require_exact_fresh_projection_revision` | SDUC-492, SDUC-493 | Green | Merge submission and its receipt require the exact fresh delivery projection, target and authority revision; advanced or stale evidence is refused, and a higher-revision Stale/Unknown projection cannot replace Fresh board state. | | SDTEST-1757 | `workspace_review_tests.rs::sdtest_1757_approval_is_bound_to_pending_id_and_session_revision` | SDUC-492 | Green | Approval preparation requires the exact pending approval, actor, authority revision and fresh provider-session revision; advanced/removed approval evidence cannot submit. | -| SDTEST-1758 | Release-runner + adapter + retained-GPUI integration | SDUC-492, SDUC-493 | Red | Exercise Linux/Windows-native component replacement and multiprocess draft locking, forced termination between durable dispatch and response, real receipt lookup without replay, actual bounded image decoding, and exact retained-pane focus. | +| SDTEST-1758 | Release-runner + adapter + retained-GPUI integration | SDUC-492, SDUC-493 | Red | Exercise Linux/Windows-native component replacement and multiprocess draft locking, forced termination between durable dispatch and response, real receipt lookup without replay, actual bounded image decoding, and OS-notification cold-launch focus of an exact retained pane. | | SDTEST-1759 | `workspace_review_tests.rs::sdtest_1759_draft_persistence_refuses_links_and_reads_one_bounded_descriptor` | SDUC-492 | Yellow | Unix core coverage tightens an owned legacy root to private mode, refuses linked workspace/final/lock paths, proves replacement cannot redirect an already-open descriptor, and detects same-inode growth with maximum-plus-one reading. The Windows reparse branch compile-checks; Windows-native component replacement remains SDTEST-1758. | | SDTEST-1760 | `workspace_review_tests.rs::sdtest_1760_comment_batches_require_exact_workspace_surface_and_unique_anchors` | SDUC-492 | Green | Comment preparation/submission fences the exact Platform user-workspace ID and reconciliation revision through grant, projection, target and receipt; a mismatched receipt and P1→P2 remap are refused, as is matching-ID/actor/revision evidence crossing review, session, grant, or surface authority. Duplicate comment IDs and section/hunk aliases fail closed. | | SDTEST-1761 | `workspace_review_tests.rs::sdtest_1761_prepared_and_refused_records_remain_until_explicit_ack` | SDUC-492 | Yellow | Prepared records survive reload and can be explicitly abandoned; terminal refusals remain enumerable and continue occupying their bounded slot until explicit acknowledgement. Forced-process evidence remains SDTEST-1758. | @@ -540,6 +548,49 @@ Existing: **0 tests**. | SDTEST-1781 | `config/platform_review.rs::native_review_previews_admit_only_exact_comment_and_approval_authority` | SDUC-495 | Green | The native seam constructs canonical SDK `AddComment` and `ApproveReview` actions only for the exact typed workspace, snapshot revision, hunk line, and fresh review revision; foreign workspaces and out-of-hunk lines fail closed, and each preview owns a distinct idempotency key. | | SDTEST-1784 | `config/platform.rs::typed_review_client_separates_single_dispatch_from_key_only_lookup` | SDUC-495 | Green | Deterministic shared-SDK transports prove the dispatch lane emits exactly one typed `ExecuteReviewAction`, while reconciliation emits only `GetReviewReceipt` with the original project, workspace, and idempotency key. | | SDTEST-1791 | `config/platform_review.rs::exact_snapshot_coordinates_prepare_each_supported_external_action_family` | SDUC-495 | Green | Exact comment revisions, a server-projected Git proposal, fresh CI check revision, and ready pull-request head construct canonical batch-send, proposal, rerun, and merge actions. Duplicate comment IDs, unresolved-conflict proposals, and stale CI or pull-request evidence fail before dispatch. | +| SDTEST-1810 | `workspace_review_tests.rs::sdtest_1810_attention_activation_requires_exact_revision_and_coordinates` | SDUC-493 | Green | Target resolution and local-read admission require the same exact item revision. Stale clicks, foreign workspaces, missing panes/sessions/items, and duplicate provider-session coordinates fail closed without consuming unread state. | +| SDTEST-1822 | `config/platform_review/custody.rs::prepared_restart_is_never_started_and_dispatched_restart_is_lookup_only` | SDUC-495 | Green | An inert prepared preview survives restart as explicitly never started, while the durable dispatched boundary recovers lookup-only with the original exact key and never authorizes another POST. | +| SDTEST-1823 | `config/platform_review/custody.rs::accepted_restart_then_completed_retains_actor_and_never_reopens_dispatch` | SDUC-495 | Green | Accepted custody survives restart, advances only through receipt reconciliation to Completed, retains server actor attribution, and remains non-dispatchable throughout. | +| SDTEST-1824 | `config/platform_review/custody.rs::concurrent_stores_reserve_only_one_effect_for_an_exact_target` | SDUC-495 | Green | Two independent store handles serialize with an OS lock; only one nonterminal effect can reserve an exact project/workspace target. | +| SDTEST-1825 | `config/platform_review/custody.rs::oversized_document_is_rejected_without_replacement` | SDUC-495 | Green | Oversized custody state fails closed before replacement and leaves the last admitted document unchanged. | +| SDTEST-1826 | `config/platform_review/custody.rs::linked_custody_path_is_rejected` | SDUC-495 | Green | Linked custody components are refused without following them or modifying the outside target. Windows reparse refusal shares the repository storage primitive and remains covered by its native matrix. | +| SDTEST-1827 | `config/platform.rs::confirmed_rerun_uses_authoritative_capability_and_correlated_lookup_only` | SDUC-495 | Green | The deterministic SDK transport proves separate capability loading, exact confirmation/workspace/correlation fields on the single confirmed dispatch, and correlation-bound receipt lookup with no generic fallback. | +| SDTEST-1843 | `config/platform_review/worktree.rs::sdtest_1843_combined_lanes_place_every_file_where_git_actually_has_it` | SDUC-495 | Green | One snapshot projects into conflicted/staged/unstaged/untracked lanes in that blocking-first order. A partially staged file is listed on both sides and flagged partial; an unresolved conflict is listed only as conflicted because git reports it unmerged; a resolved conflict returns to its own worktree lane. The distinct file count counts a twice-listed file once. | +| SDTEST-1844 | `config/platform_review/worktree.rs::sdtest_1844_every_projected_hunk_anchor_is_admitted_by_the_comment_constructor` | SDUC-495 | Green | The lane projection derives each hunk's side and line. Every derived anchor is proved against the real `add_comment` constructor rather than a restatement of the same arithmetic, so a drift would offer a line the snapshot refuses. New-side and old-side-only hunks are both covered. | +| SDTEST-1845 | `config/platform_review/worktree.rs::sdtest_1845_safe_text_neutralizes_invisible_reordering_and_bounds_the_shape` | SDUC-495 | Green | The protocol bound (512 control-free bytes) still admits bidirectional overrides and zero-width scalars that repaint a diff line. The last stop before pixels replaces them with U+FFFD, splits on both line terminators, and caps line count and line length with an explicit truncation marker. | +| SDTEST-1846 | `config/platform_review/worktree.rs::sdtest_1846_image_previews_are_clamped_and_absurd_rasters_are_refused` | SDUC-495 | Green | Declared raster dimensions are a claim, not a budget. A sanitized bounded image yields an aspect-preserving placeholder box never above the edge cap (including an extreme 4000×1 ratio); zero, over-edge, over-area and incomplete dimensions are withheld as oversized raster, and a missing or over-budget byte size is withheld as oversized. | +| SDTEST-1847 | `config/platform_review/worktree.rs::sdtest_1847_html_and_opaque_previews_are_described_but_never_rendered` | SDUC-495 | Green | Sanitized HTML yields a description carrying no markup and no source text. Unsanitized payloads (HTML or text), a media type disagreeing with the kind, hunks on a non-text kind, opaque binary, an absent preview, and a text preview with no hunk content are each withheld with their own reason. | +| SDTEST-1848 | *(retired 2026-08-29)* | SDUC-495 | Retired | Pinned the reason staging had no control: `advertised_staging_capability` returned false for every capability load because Platform v2 defined no staging capability. `bext-stack/automonique` PR #225 closed that gap, so the pin no longer describes anything. SDTEST-1867 replaces it and fails closed the other way — it proves a *withheld* grant renders no control while its granted siblings go end to end. | +| SDTEST-1849 | `config/platform_review/worktree.rs::sdtest_1849_canonical_snapshot_projects_to_lanes_previews_and_observations` | SDUC-495 | Green | The shipped canonical v2 fixture — not a hand-built snapshot — reaches the combined lanes (its partially staged file on both sides, no conflicts, no untracked), the bounded safe text preview, the new-side anchor, the commit proposal observation, and the withheld staging fence. | +| SDTEST-1853 | `config/platform_review/notes.rs::note_and_selection_survive_restart_with_exact_anchor` | SDUC-495 | Green | A line-anchored note and its batch-delivery selection survive restart with the exact file/hunk/side/line coordinates and the snapshot revision they were captured against. | +| SDTEST-1854 | `config/platform_review/notes.rs::superseded_revision_and_foreign_anchor_are_never_actionable` | SDUC-495 | Green | A note captured on a superseded snapshot is retained but reported non-actionable rather than silently re-anchored; a line outside the exact hunk and an empty body are refused before persistence. | +| SDTEST-1855 | `config/platform_review/notes.rs::oversized_note_document_is_rejected_without_replacement` | SDUC-495 | Green | An oversized note document fails closed on open and leaves the bytes on disk untouched. | +| SDTEST-1858 | `config/platform_review/custody.rs::unconfirmed_comment_is_fenced_restart_safe_and_actor_attributed` | SDUC-495 | Green | An unconfirmed `AddComment` traverses the same durable custody lane as a confirmed rerun: a second comment cannot open a parallel effect, restart recovers lookup-only instead of reposting, and the terminal receipt retains the server actor. | +| SDTEST-1863 | `config/platform_review.rs::advertised_delivery_is_sent_verbatim_and_a_reused_advertisement_is_refused` | SDUC-495 | Green | Batch delivery sends exactly what `agent_deliverable_comments` advertised, at the capability's own snapshot revision and in the wire order the protocol requires, then demonstrates the retraction rather than asserting it: settling one delivery is replayed through the server's own eligibility rule, the re-read advertisement drops only that comment, and the preview built from the superseded list is refused locally. A fresh preview over the same batch and the same advertised revisions carries a new idempotency key and is refused identically, which is what makes a confirmation digest unnecessary here. A capability minted at another snapshot revision, and a sendable comment that was simply never advertised, both refuse at construction. | +| SDTEST-1864 | `config/platform_review/delivery.rs::every_delivery_fence_fails_closed_with_its_own_distinct_reason` | SDUC-495 | Green | Each delivery fence fails closed with its own reason: an absent capability response, one attributed to another project or a superseded snapshot revision, an exact response advertising nothing, and a missing custody lane are four distinct answers rather than one. Torn reads between the capability list and the snapshot — a disagreeing comment revision, an unknown comment, a foreign review authority, an already-sent comment, a stale review — drop the entry. `advertised_agent_deliveries` is asserted directly and not only through the projection, because the action preview calls it on its own. | +| SDTEST-1865 | `config/platform_review/custody.rs::unconfirmed_batch_delivery_is_fenced_and_survives_the_disk_round_trip` | SDUC-495 | Green | The unconfirmed batch delivery takes the same durable at-most-once lane as every other exposed mutation despite carrying no confirmation digest: a second delivery cannot open a parallel effect, a restart before dispatch reports it never started and after dispatch is lookup-only, the exact comment set and revisions survive the disk round trip, and the terminal receipt retains the server actor. Before this slice the custody disk format knew only `AddComment`, `ApproveReview` and `RerunCheck`, so a batch delivery could not be recorded at all. | +| SDTEST-1867 | `config/platform_review/worktree.rs::sdtest_1867_withheld_commit_grant_renders_no_control_while_its_siblings_go_end_to_end` | SDUC-495 | Green | The withholding demonstration, not an assertion about it. A deployment that installs index writes but not committing advertises `Stage` and `Unstage` and no `Commit`: the commit proposal keeps its observation badge, `advertises_staging` answers false, and the preview constructor refuses it, while both granted kinds go all the way to a confirmed preview carrying the server's own confirmation and correlation digests verbatim and still admitted by a re-read of the capability. Granting the commit then makes the third control appear from unchanged projection code. Mutation-checked: forcing `advertises_staging` true fails the test. | +| SDTEST-1868 | `config/platform_review/worktree.rs::sdtest_1868_staging_lane_names_its_missing_fence_and_refuses_a_moved_worktree` | SDUC-495 | Green | Each staging fence fails closed with its own reason: no capability response, no exactly reconciled target, an exact response with two empty git lists, and a missing custody lane are four distinct answers. The lanes still render in every one of them — only the control is withheld. The observed `HEAD` and index digest are exposed so a reader can see the fence, and a preview minted against one worktree read stops matching once a fresher document reports a different `HEAD` *or* a different index, so the control withdraws locally instead of being refused by the daemon. A capability whose kind disagrees with the snapshot's proposal is dropped as a torn read. | +| SDTEST-1869 | `config/platform_review/worktree.rs::sdtest_1869_both_recorded_sides_render_two_controls_and_a_single_side_renders_one` | SDUC-495 | Green | `conflict_resolutions` lists one entry per admissible side, so a file with both stage 2 and stage 3 recorded yields two controls and each carries the digest minted over the blob that side would write — proved by asserting the two previews carry *different* confirmations, which is what makes them unswappable. A delete/modify pair advertises one side and the other refuses locally. A file the snapshot no longer reports unmerged advertises nothing, whatever an older capability read said. | +| SDTEST-1870 | `config/platform_review/custody.rs::confirmed_staging_is_fenced_and_survives_the_disk_round_trip` | SDUC-495 | Green | A confirmed staging preview takes the same durable at-most-once lane as every other exposed mutation: a second staging cannot open a parallel effect, a restart before dispatch reports it never started and after dispatch is lookup-only, and both server digests survive the disk round trip. The disk format did not know these four families before this slice, so `prepare` would have returned `UnsupportedAction` and the dispatch would have died at the fence — the same defect PR #161 found for the batch delivery, which is why this drives the whole store rather than only the enum. | +| SDTEST-1871 | `config/platform_review/custody.rs::every_staging_family_round_trips_and_a_resolution_keeps_its_side` | SDUC-495 | Green | Each of `Stage`, `Unstage`, `Commit` and both sides of `ResolveConflict` serializes and re-parses as exactly itself through the protocol's own grammar. The three staging kinds are separate disk variants because they are separately granted, so a recovered plan cannot be re-read as a wider one, and a resolution persists its side because the side decides which blob lands. A record whose confirmation no longer matches its family is rejected as invalid rather than recovered into a preview the transport cannot encode. | +| SDTEST-1884 | `workspace_navigation.rs::local_agent_session_is_bound_to_the_workspace_checkout` | SDUC-490 | Green | A retained local agent tab validates only under the checkout that owns its CLI runtime; reusing the same session identity through a foreign checkout fails at the typed surface authority boundary. | + +--- + +## 29. `config/platform_attention.rs` + +| ID | Location | SDUC | Status | Notes | +|---|---|---|---|---| +| SDTEST-1816 | `config/platform_attention.rs::sdtest_1816_inventory_is_exact_bounded_and_authority_derived` | SDUC-493 | Green | Exact project/workspace relations, typed review presence, and attempt-to-session lineage derive only the bounded authoritative source set; missing/foreign, duplicate, and over-limit inventories fail closed. | +| SDTEST-1817 | `config/platform_attention.rs::sdtest_1817_snapshot_replacement_is_atomic_and_retains_revision_custody` | SDUC-493 | Green | Exact replay is inert and discontinuous normal successors make zero mutation. An explicit authenticated complete baseline admits a fresh/restarted consumer above revision one and bridges a missed predecessor while rejecting surviving-item rollback; valid empty replacement, refusal custody, source removal, and remove/re-add resync remain distinct. | +| SDTEST-1818 | `config/platform_attention.rs::sdtest_1818_ui_identity_is_source_scoped_and_collisions_fail_atomically` | SDUC-493 | Green | Length-delimited raw source/item tuples produce deterministic presentation-only UUIDv5 values without delimiter aliasing, while an injected collision across different raw tuples rejects the entire candidate board. | +| SDTEST-1819 | `config/platform_attention.rs::sdtest_1819_local_overlay_is_revision_bound_durable_and_fail_closed` | SDUC-493 | Green | Read and notification custody survives restart only under exact Platform target/source/item/revision tuples. Independent stale handles reload before mutation; revision advance is independently eligible; valid replacement prunes only that target/source custody; capacity, write, unknown-field, and maximum serialized-size failures do not mutate the accepted store. | +| SDTEST-1820 | `config/platform_attention.rs::sdtest_1820_notification_reservation_is_locked_across_processes` | SDUC-493 | Green | Two separately spawned processes open the same initially empty custody document before a coordinated race; the exclusive secure sidecar lock makes exactly one return `Reserved`, the other returns `AlreadyReserved`, and restart retains the winning tuple. | +| SDTEST-1821 | `config/platform_attention.rs::sdtest_1821_attention_storage_refuses_links_and_reads_one_descriptor` / `sdtest_1821_attention_storage_refuses_windows_reparse_points` | SDUC-493 | Green | The private storage boundary rejects final, dangling, lock-file, and parent links; a replacement after descriptor validation cannot redirect the accepted read. Windows compiles and runs the equivalent native reparse-point refusal through `FILE_FLAG_OPEN_REPARSE_POINT`. | +| SDTEST-1829 | `config/platform_attention.rs::sdtest_1829_activation_re_resolves_exact_current_catalogues_and_refuses_ambiguity` | SDUC-493 | Green | A captured authoritative item is resolved again against the current exact workspace mapping, its source's authoritative Platform-session relation, the fresh authority-qualified session directory, and retained provider panes. Exact Fleet and unique pane destinations succeed; redirected, stale/unknown, missing/duplicate sessions, duplicate panes, and stale item revisions refuse without first-match fallback. | +| SDTEST-1842 | `config/platform_attention.rs::sdtest_1842_shared_attention_corpus_replays_to_the_recorded_outcomes` | SDUC-493 | Green | The shared cross-client succession corpus replays case by case against the authoritative board. Every recorded outcome is reached exactly — first-read revision fence, exact-predecessor succession, identical replay, conflicting replay, retention gap, authenticated baseline bridge, baseline rollback, baseline built behind the retained revision, and item-revision regression. Final availability and rendered item identities match the corpus, and hiding a source never discards its revision chain. | +| SDTEST-1856 | `config/platform_attention.rs::sdtest_1856_chronology_follows_authoritative_observation_not_source_order` | SDUC-493 | Green | The chronology projection orders visible items on the authoritative `observed_at_ms`, contradicting the source-keyed iteration order that `visible_items` produces; equal observations fall back to the item revision then the authoritative key, and a hidden source contributes nothing without reordering what survives. | --- diff --git a/docs/testing/tests-ui-and-app.md b/docs/testing/tests-ui-and-app.md index b03c4e11..46354e9e 100644 --- a/docs/testing/tests-ui-and-app.md +++ b/docs/testing/tests-ui-and-app.md @@ -333,7 +333,11 @@ parallel `cargo test`. | SDTEST-1408 | `tray::tests::ai_tasks_menu_id_routes_to_task_center` | SDUC-429, SDUC-434 | Green | The stable clickable AI-task row routes to the task center command rather than toggling the Dock or revealing the main window. | | SDTEST-1409 | `main::tests::task_center_request_always_shows_the_existing_dock` | SDUC-429, SDUC-434 | Green | A missing Dock is created and a hidden or visible Dock is shown idempotently, so selecting the tray task indicator never hides it. | | SDTEST-1410 | `tray::tests::macos_template_asset_is_retina_monochrome_with_transparent_background` | SDUC-434 | Green | The dedicated 36×36 Retina asset decodes, contains only black visible pixels, keeps transparent corners/background, and has non-trivial bounded mark coverage. macOS alone enables AppKit template rendering. | -| SDTEST-1411 | `tray::tests::tray_state_pump_forwards_every_snapshot_until_shutdown` | SDUC-429, SDUC-434 | Green | The shared async pump forwards every live snapshot until all publishers close. Linux consumes it on the GTK owner thread; macOS/Windows retain `muda` handles on GPUI's foreground executor. Native visual smoke remains a release check. | +| SDTEST-1411 | `tray::tests::native_menu_snapshot_preserves_fail_closed_actions_counters_and_pins` | SDUC-429, SDUC-412, SDUC-434 | Green | One immutable GPUI menu snapshot keeps signed-out session actions non-interactive, retains exact live counters, and binds a pinned label to its exact UUID command. Native visual/event smoke remains a release check. | +| SDTEST-1812 | `tray::tests::sdtest_1812_windows_tray_asset_is_a_decodable_ico` | SDUC-434, SDUC-435 | Superseded | The generic Windows `image` decoder rejects an embedded PNG color format that Win32 accepts, so this smoke is no longer a CI gate. SDTEST-1814 exercises GPUI's actual ICO selector and SDTEST-1815 proves the shipped resource crosses the native HICON boundary. | +| SDTEST-1813 | `platform::windows::{platform::tests::sdtest_1813_taskbar_created_message_registration_is_live, tray::tests::sdtest_1813_explorer_restart_readd_is_authoritative_and_retains_icon}` | SDUC-435 | Green | Native Windows registers Explorer's recovery broadcast. A restart clears stale availability before the retry, sends the retained HICON through an exact icon-bearing `NIM_ADD`, preserves custody on refusal, and marks the tray available only on acceptance. | +| SDTEST-1814 | `platform::windows::tray::tests::sdtest_1814_ico_resource_selects_closest_then_highest_depth` | SDUC-434, SDUC-435 | Green | GPUI's actual ICONDIR parser selects the closest requested dimensions, breaks equal-size ties toward higher depth, and rejects overlapping or invalid directory ranges. | +| SDTEST-1815 | `platform::windows::tray::tests::sdtest_1815_shell_deck_ico_creates_a_native_hicon` | SDUC-434, SDUC-435 | Green | Native Windows CI passes ShellDeck's shipped ICO through GPUI's selector and the real `CreateIconFromResourceEx` boundary, then destroys the returned HICON. | | SDTEST-1412 | `workspace::ssh::tests::only_unexpected_ssh_transport_loss_notifies_with_exact_identity` | SDUC-439 | Green | The session-end reducer keeps explicit tab closes and clean remote exits silent, while unexpected transport loss emits one notification carrying the exact connection display name. | | SDTEST-1414 | *to write* — User/Support home dashboards route to their operational tabs | SDUC-440 | **Red / P1** | GPUI integration: both modes start on Accueil; every Support counter clears stale constraints and opens the exact advertised queue; the greeting addresses « équipe Support » directly; the action banner and priority-list column keep distinct headings; priority-ticket and recent-request rows open their real detail; User exposes Sites/Requests only as primary tabs, keeps New Request as its sole quick action, and gives each site distinct public/Manage/select actions; sync acts on the current Manage account; onboarding omits Dev cards/media/shortcuts for non-Dev roles. Manually validated on X11: the User quick action opened creation on 2026-08-25, and the Support greeting rendered exactly « Bonjour, équipe Support » on 2026-08-26. | | SDTEST-1614 | `support_view::home::tests::support_home_targets_route_to_the_expected_section_and_ticket_filter` | SDUC-440 | Green | The five home destinations map exhaustively to Requests or to the exact All/Open/SLA/Unassigned ticket filter, preventing a visually correct card from opening the wrong queue. | @@ -467,10 +471,12 @@ parallel `cargo test`. | SDTEST-1214 | `sidebar::tests::rail_lists_activities_not_destinations` | SDUC-443 | Green | Monique, Fleet, bext Cloud and Settings never take a rail slot, and every rail entry either has a panel or is a spelled-out main-view entry (Server Sync or Agents) — the guard against re-adding a rail icon with nothing behind it. | | SDTEST-1215 | *to write* — panel content follows the selected activity | SDUC-443 | **Red / P1** | GPUI integration: selecting each rail activity swaps the panel to that activity's rows, a row click performs its open/focus action, empty activities show their localized hint, and a panel-less activity collapses the panel. Regression guard for the 2026-07-25 mislabelled-panel defect. | | SDTEST-1676 | `workspace::agents::tests::sdtest_1676_local_runner_streams_and_stops_the_process_group` (`#[cfg(unix)]`) | SDUC-475 | Green | A fake streaming agent crosses the real local runner, then Stop kills its shell plus sleeping child and returns promptly instead of hanging on inherited output pipes. | -| SDTEST-1677 | *to write* — local and SSH agent console end-to-end smoke | SDUC-475 | **Red / P1** | GPUI + fake SSH harness: select each provider and target, stream remote output, close the remote channel, cover Windows process-tree Stop, and prove a removed connection cannot launch. The release-critical access gate is covered separately by SDTEST-1679 without requiring the missing GPUI harness. | +| SDTEST-1677 | *to write* — local and SSH agent console end-to-end smoke | SDUC-475 | **Red / P1** | GPUI + fake SSH harness: select each provider and target, stream remote output, browse a remote directory with stale-response reordering, close the remote channel, cover Windows process-tree Stop, and prove a removed connection cannot launch. The release-critical access gate is covered separately by SDTEST-1679 without requiring the missing GPUI harness. | | SDTEST-1678 | `agent_console_view::tests::sdtest_1678_session_resume_requires_the_exact_execution_context` | SDUC-475 | Green | A provider session token is reused only when provider, target, access, workdir, and model still match; changing permissions or hosts necessarily starts a new session. | | SDTEST-1679 | `agent_console_view::tests::sdtest_1679_mutating_access_always_requires_confirmation` | SDUC-475 | Green | Read-only runs may start directly; workspace-write and full-access runs must stop at the separate confirmation disposition before Workspace receives a launch event. | | SDTEST-1681 | `workspace::agents::tests::sdtest_1681_remote_stream_reassembles_split_utf8_and_json_lines` | SDUC-475 | Green | SSH packet boundaries may split a multibyte glyph or JSON line; the byte accumulator reconstructs the complete UTF-8 line before provider parsing. | +| SDTEST-1883 | `workspace::agents::tests::sdtest_1883_parallel_agent_runs_are_bounded_without_a_singleton_gate` | SDUC-475 | Green | The native runner admits four independently stoppable local/SSH agents by default instead of treating the first run as a global lock, while refusing launches at the configured desktop capacity boundary. | +| SDTEST-1897 | `workspace::agents::tests::sdtest_1897_local_stream_discards_newline_free_oversize_then_recovers` | SDUC-499 | Green | The real local reader consumes fixed-size chunks, emits one bounded omission for a newline-free oversized record, and resumes provider parsing at the next valid JSONL record. The SSH path uses the same bounded framer. | | SDTEST-1687 | `follow_scroll.rs::tests::detects_bottom_with_small_layout_rounding_slop` | SDUC-477 | Green | The shared pre-append decision follows an empty or bottom-aligned viewport, tolerates GPUI's rounded layout offset, and refuses to repin a reader who is above the end. | | SDTEST-1690 | `motion.rs::tests::repeating_phase_wraps_at_the_shared_period` | SDUC-479 | Green | Recurring indicators derive stable quarter-cycle phases from the common epoch and wrap exactly at their period. | | SDTEST-1691 | SDPATCH-115 reduced-motion element policy — compile-check plus manual platform preference smoke | SDUC-479 | Yellow | The vendored GPUI path compiles on Linux; CI must compile macOS/Windows, and a platform smoke confirms one-shots snap final, loops stay static, animated WebP uses the static badge, and no recurring clock remains leased. | @@ -480,6 +486,16 @@ parallel `cargo test`. | SDTEST-1712 | `agent_console_view::tests::sdtest_1712_agent_turns_use_spacing_without_a_markdown_rule` | SDUC-475 | Green | Consecutive agent turns are separated by whitespace only; the stored Markdown cannot recreate the unexplained full-column horizontal rule. | | SDTEST-1713 | *to write* — the Agents thread scrolls independently and run controls keep one footprint | SDUC-475 | **Red / P1** | GPUI integration: stream a transcript taller than the viewport, wheel away from the end without being repinned, return to the latest response while the Composer remains fixed, and verify Send/Stop each occupy the same round 28 px slot with an accessible label. | | SDTEST-1714 | *to write* — structured Agents Markdown stays inside the conversation measure | SDUC-475 | **Red / P1** | GPUI integration: render a two-column table with multiple long subjects at wide and compact window widths; cells wrap to the definite Markdown width, every suffix remains visible, and neither the table nor ordinary prose crosses or gets clipped by the 860 px conversation cap. | +| SDTEST-1882 | `agent_console_view::tests::sdtest_1882_cockpit_trace_presentations_cover_structured_events` | SDUC-499 | Green | The dense cockpit assigns distinct icon/status/stat presentation to commands and diffs, keeps exit/diff summaries visible, and derives a bounded task-tab name from a long first prompt. | +| SDTEST-1899 | `agent_console_view::tests::sdtest_1899_terminal_errors_always_fail_and_only_clean_stop_cancels` | SDUC-499 | Green | Transport and spawn errors fail the exact routed session even without an exit code; only a clean user stop without an error becomes cancelled. | +| SDTEST-1900 | `agent_console_view::tests::sdtest_1900_worktree_selection_reuses_bound_session_or_clones_context` | SDUC-499 | Green | Worktree selection reuses an exact target-and-path session or creates a new session from the current provider/model context; identical SSH paths on different connections never alias. | +| SDTEST-1901 | `agent_console_view::tests::sdtest_1901_stop_request_enters_stopping_before_runtime_completion` | SDUC-499 | Green | Stop immediately transitions the canonical session to Stopping before the asynchronous local process tree or SSH channel reports completion. | +| SDTEST-1902 | `agent_console_view::tests::sdtest_1902_inspector_projects_latest_files_and_diff_focus_from_trace` | SDUC-499 | Green | The inspector deduplicates read and changed paths by their latest structured event while retaining the exact newest diff identity, counts and preview source used for timeline focus. | +| SDTEST-1903 | `agent_console_view::tests::sdtest_1903_target_switch_defaults_and_validation_are_target_aware` | SDUC-475, SDUC-499 | Green | Local→SSH replaces only the inherited local default with the exact remote-home sentinel, SSH→Local restores the native default, explicit worktree paths survive, and validation accepts only target-appropriate absolute paths. | +| SDTEST-1905 | `workspace::agents::tests::sdtest_1905_remote_launch_maps_an_inherited_local_checkout_path` | SDUC-475 | Green | A stale Local→SSH run maps the longest matching local checkout authority to the one unambiguous same-project SSH checkout on the selected connection, preserving a validated relative suffix; unrelated and explicit remote paths are untouched. | +| SDTEST-1907 | `workspace::agents::tests::sdtest_1907_automonique_never_falls_back_from_ssh_to_local_acp` | SDUC-475, SDUC-480 | Green | Automonique ACP admits the local target with its provider-owned model, rejects an SSH target instead of executing locally on the wrong host, and rejects a CLI model override that the ACP contract cannot honor. | +| SDTEST-1908 | `workspace::agents::tests::sdtest_1908_remote_home_probe_accepts_one_absolute_path_only` | SDUC-475 | Green | The remote-files adapter accepts one UTF-8 absolute home (including spaces and CRLF transport) and rejects relative, multiline, NUL-bearing, or non-UTF-8 probe output before using it in a listing command. | +| SDTEST-1909 | `workspace::agents::tests::sdtest_1909_agent_file_open_stays_inside_the_selected_local_session_root` | SDUC-499 | Green | Local inspector opens require the exact local session plus a canonical regular file beneath its canonical working root; unrelated paths, stale session IDs, and symlink escapes are refused. | | SDTEST-1708 | `workspace::user_home::tests::sdtest_1708_welcome_height_breakpoint_tracks_ui_scale` | SDUC-441 | Green | The welcome screen centers its authentication task and pins the publisher at normal heights, then switches to one scrollable column below 560 logical pixels; the same boundary holds at 1× and 2× App Font Size. | | SDTEST-1710 | *to write* — the wrapped welcome promise stays centered with the authentication task | SDUC-152, SDUC-441 | **Red / P2** | GPUI integration: in French and English, at wide and compact widths, every wrapped tagline line remains centered beneath the title and above the two centered actions. | | SDTEST-1212 | *to write* — Workspace surfaces re-layout at non-default App Font Size | SDUC-441 | **Red / P2** | GPUI integration: at 10px and 22px the User home, welcome screen and account/site/mode titlebar dropdowns scale proportionally while the client inset, shadow geometry and window-resize border stay in device pixels. Needs a `TestAppContext` harness we do not have yet. | @@ -512,6 +528,12 @@ parallel `cargo test`. | ID | Location | SDUC | Status | Notes | |---|---|---|---|---| | SDTEST-1736 | `workspace::workspaces::tests_file::tests::{keyed_gpui_workspace_entity_retention_preserves_hidden_terminal_state,retained_terminal_activation_keeps_the_complete_runtime_config,uncorroborated_completion_cannot_spawn_a_second_authorized_terminal,uncorroborated_completion_for_vanished_folder_has_no_side_effect,loading_existing_workspace_with_missing_root_is_fail_closed_and_localized}` and `terminal_view::tests::{canonical_workspace_cwd_reaches_tabs_splits_and_cli_created_terminals,missing_required_workspace_cwd_blocks_open_and_cli_without_home_fallback,disappearing_required_workspace_cwd_blocks_split_of_a_live_local_session,native_workspace_snapshot_reapplies_split_focus_and_ratio}` | SDUC-490 | Yellow | Tests prove stable GPUI workspace/terminal entity identity across A→B→A, native terminal snapshot round-tripping, canonical checkout-cwd inheritance, fail-closed localized behavior for a disappearing or already-missing catalog root, rejection of uncorroborated completion, a real guarded secondary spawn attempt from an existing live local tab, and retained-terminal runtime configuration hydration. Editor/files/browser pane restoration and a live multi-workspace PTY archive/resume journey are not covered and remain unsupported. | +| SDTEST-1885 | `workspace/workspaces_tests.rs::terminal_reconciliation_preserves_typed_tabs_splits_and_focus` | SDUC-490, SDUC-493 | Green | Refreshing live terminal bindings inside a retained surface preserves its local-agent and files tabs, split axis and ratio, and exact focused agent tab instead of flattening the cockpit back into a terminal-only leaf. | +| SDTEST-1886 | `workspace/workspaces_tests.rs::closing_agent_session_removes_every_duplicate_and_repairs_focus` | SDUC-490 | Green | Closing one local agent runtime removes every duplicate retained reference across split leaves, repairs each active tab, moves workspace focus to the first surviving active tab, and leaves an empty split leaf valid. | +| SDTEST-1887 | `workspace/workspaces_tests.rs::split_action_moves_the_active_tab_into_a_distinct_native_pane` | SDUC-490 | Green | The pane-header split action moves the active typed tab into a new 50/50 native pane while keeping the surviving terminal tab active in the original pane. | +| SDTEST-1888 | `workspace/workspaces_tests.rs::workspace_editor_path_resolution_requires_an_existing_authorized_entry` | SDUC-490 | Green | Editor/files activation returns only an existing canonical `AuthorizedLocalPath` admitted by `ProjectCheckout`; a missing relative entry yields no host path instead of being formed with `root.join`. | +| SDTEST-1889 | `workspace/workspaces_tests.rs::split_resize_controls_adjust_the_exact_divider_and_stay_bounded` | SDUC-490 | Green | Clickable divider controls adjust the targeted split by five percentage points and clamp its ratio to the usable 10–90% range; a foreign divider identity cannot mutate it. | +| SDTEST-1890 | `workspace/workspaces_tests.rs::browser_pane_open_action_admits_only_safe_http_locations` | SDUC-490 | Green | The explicit in-pane Open action admits bounded HTTP(S) locations only; script schemes, credential-bearing URLs, missing hosts and control characters remain disabled. | | SDTEST-1737 | `workspace::workspaces::tests_file::tests::{existing_folder_executor_streams_intermediate_progress,native_worktree_executor_uses_exact_typed_arguments_and_completes_all_phases,launcher_prefills_all_external_kinds_through_one_validated_model,failed_multi_step_catalog_mutation_rolls_back_before_persistence,uncorroborated_completion_after_catalog_change_prevents_native_attach,late_completion_from_prior_retry_has_no_terminal_side_effect,completion_during_cancellation_has_no_terminal_side_effect,premature_completion_has_no_terminal_side_effect}` | SDUC-488, SDUC-489, SDUC-491 | Yellow | The production adapter is wired for exact existing folders, ShellDeck-owned Git worktrees, and SSH checkouts backed by the fixed trusted helper. The SSH path retains the connection and prepared receipt on a dedicated runtime, releases before resume on cancellation, and publishes the resumed terminal only after the catalog commit; protocol and remote authority tests are catalogued as SDTEST-1786..1790. Reducer/UI tests pin manual/external intake, cancellation, revision conflicts, retry fencing, receipt ownership and transactional catalog rollback. A live installed-helper archive/resume journey remains a release gap. | | SDTEST-1738 | `workspace::workspaces::tests_file::tests::{workspace_card_keeps_external_and_provider_authorities_distinct,git_source_never_erases_provider_or_conflict_evidence,provider_only_observation_never_presents_retained_git_as_current}` | SDUC-489, SDUC-490 | Yellow | Pure aggregation/projection tests prove external-task versus orchestration separation and that unavailable or unobserved Git cannot borrow provider freshness or present retained Git facts as current. A live provider observation subscription and rendered interaction coverage are not implemented. Exact provider-to-workspace admission remains covered separately by core SDTEST-1734. | | SDTEST-1763 | `workspace::workspaces::native_lifecycle::tests::sdtest_1763_durable_journal_restarts_only_an_exact_clean_worktree` | SDUC-491 | Green | A real temporary worktree survives the crash window between effect and acknowledgement, is adopted only after exact restart validation, keeps its committed journal, and refuses cleanup once untracked user data appears. | @@ -535,6 +557,28 @@ parallel `cargo test`. | SDTEST-1783 | `fleet_view.rs::review_render_tests::review_dispatch_reducer_switches_permanently_from_execute_to_lookup` | SDUC-495 | Green | The pure native review reducer emits one execute directive only before dispatch; once an unresolved original key exists, lookup-only dominates even if a later draft also exists, preventing blind replay. | | SDTEST-1785 | `fleet_view.rs::review_render_tests::unresolved_review_keys_remain_bound_to_their_exact_workspace` | SDUC-495 | Green | An ambiguous review key blocks and reconciles only its exact project/workspace target; switching to another workspace neither replays the old action nor disables the new context, and returning selects the original key. | | SDTEST-1792 | `fleet_view.rs::review_render_tests::review_selections_survive_only_the_same_exact_target_and_revision` | SDUC-495 | Green | Line and comment selections may survive a refresh only when the complete project/workspace target and snapshot revision are unchanged; a workspace switch, revision advance, or unavailable review clears them before another action can be prepared. | +| SDTEST-1828 | `fleet_view.rs::review_render_tests::rerun_control_is_absent_without_capability_or_custody` | SDUC-495 | Green | Fleet renders no rerun control unless both an exact server-advertised rerun capability and an available durable custody store are present; remote review observation alone remains inert. | +| SDTEST-1850 | `fleet_view.rs::review_render_tests::sdtest_1850_only_populated_lanes_render_and_conflicts_lead` | SDUC-495 | Green | The combined review paints only the lanes holding a file, most blocking first, so a clean index does not render four zero-count headers. Only the conflicted lane reads as destructive; staged, unstaged and untracked stay secondary. | +| SDTEST-1851 | `fleet_view.rs::review_render_tests::sdtest_1851_every_withheld_reason_has_distinct_copy_in_both_locales` | SDUC-495 | Green | Every declared lane, preview-withheld and staging-withheld state resolves to a distinct non-empty localized label present in both `fr.toml` and `en.toml`. A new protocol variant therefore cannot reach the user as a raw key or silently reuse another state's copy. | +| SDTEST-1852 | `fleet_view.rs::review_render_tests::sdtest_1852_anchor_identity_survives_a_file_listed_in_two_lanes` | SDUC-495 | Green | A partially staged file renders in two lanes, so the anchor button identity binds lane, file and hunk. GPUI routes a click by element id: colliding ids would select the other lane's anchor. The identity is also stable across renders. | +| SDTEST-1859 | `fleet_view.rs::review_render_tests::preparation_requires_custody_for_every_action_family` | SDUC-495 | Green | Preparation of any review mutation now requires the durable custody store, not merely an idle non-ambiguous surface; the rerun gate stays strictly narrower, and a custody refusal is reported with copy distinct from a moved snapshot so the user's next step differs. | +| SDTEST-1866 | `fleet_view.rs::review_render_tests::every_delivery_withheld_reason_has_its_own_key_in_both_shipped_locales` | SDUC-495 | Green | Each batch-delivery withheld reason resolves to its own non-empty localized explanation in both `fr.toml` and `en.toml`, never reuses another delivery reason's copy, and never borrows the staging lane's copy — the two are withheld for different reasons and fixed differently. A retracted delivery advertisement is also reported with copy distinct from a changed rerun digest. | +| SDTEST-1872 | `fleet_view.rs::review_render_tests::sdtest_1872_every_staging_control_has_its_own_label_in_both_shipped_locales` | SDUC-495 | Green | Stage, unstage, commit and the two conflict sides each resolve to their own non-empty localized label in both `fr.toml` and `en.toml`, and none reuses another's copy. They are separately granted, so a shared label would let a reader believe the control in front of them is the one their deployment denied; and a file with both conflict sides recorded renders both buttons side by side, where only the label says which blob would land. | +| SDTEST-1873 | `fleet_view.rs::review_render_tests::sdtest_1873_staging_withheld_reasons_stay_distinct_from_the_delivery_lane` | SDUC-495 | Green | The three staging withheld reasons resolve to distinct localized copy in both locales, never reuse a delivery-lane explanation, and no longer name the contract gap that PR #225 closed — a surface still telling the user to wait for a capability that already shipped would send them to a fix that does not exist. | +| SDTEST-1811 | `workspace/workspaces_tests.rs::sdtest_1811_retained_gpui_attention_opens_exact_workspace_pane_and_tab` | SDUC-493 | Green | A retained GPUI workspace renders the typed state, unread state, and nested-agent path. After A→B, activating A's captured attention revision returns to its exact retained pane/tab and marks only that revision read; a newer replay makes the old activation stale and stays unread. | +| SDTEST-1830 | `workspace/fleet.rs::orchestration_attention_remains_discoverable_without_a_review_snapshot` | SDUC-493 | Green | The exact typed review-not-found refusal is treated as authoritative review absence, so orchestration/provider attention inventory loading continues; transport/refusal ambiguity remains fail-closed and retains prior board custody. | +| SDTEST-1831 | `main.rs::same_process_attention_notifications_activate_only_explicit_open_actions` | SDUC-493 | Green | The pure callback admission policy accepts only the native default/open action strings; dismissal and unknown actions cannot enqueue navigation. Production wiring retains the handle in its blocking same-process worker, while native delivery/activation remains a release acceptance check rather than something this classifier unit test simulates. Cold-launch deep links remain deliberately out of scope. | +| SDTEST-1832 | `workspace/workspaces_tests.rs::retained_provider_activation_focuses_only_the_exact_native_tab_and_mapping` | SDUC-493 | Green | A retained provider binding activates only when its exact mapped Platform workspace, provider session, pane, and native tab still agree; successful activation switches workspace and selects that native tab, while a foreign session refuses. | +| SDTEST-1833 | `workspace/platform_attention.rs::workspace_attention_gap_hides_the_source_until_the_next_poll_baseline` | SDUC-493 | Green | A discontinuous source successor is hidden and records an explicit resync requirement; only the following authenticated complete snapshot may restore that source and clear the resync marker. | +| SDTEST-1834 | `workspace/platform_attention.rs::whole_poll_failure_hides_every_board_and_mapping_replacement_recovers_cleanly` | SDUC-493 | Green | The board reducer hides every retained projection after a whole-poll failure, recognizes missing/remapped exact context as retirement, removes old board/resync state, and admits a distinct replacement in memory. Durable cleanup/restart and inactive-workspace preservation are SDTEST-1837..1832. | +| SDTEST-1835 | `workspace/platform_attention.rs::activation_routes_each_destination_to_a_visible_surface_before_read_custody` | SDUC-493 | Green | Destination classification requires Workspaces for workspace/retained panes and Fleet for sessions. The custody predicate refuses a failed open, Settings, non-Dev mode, an in-flight transition, or the wrong active surface; exact native pane/session opening remains SDTEST-1832. | +| SDTEST-1836 | `workspace/platform_attention.rs::attention_mode_admission_closes_settings_and_stages_dev_mode` | SDUC-493 | Green | The real Workspace admission transition from authenticated User mode with Settings open closes Settings and stages the permitted Dev transition before any attention destination may open. | +| SDTEST-1837 | `config/platform_attention.rs::sdtest_1837_retirement_fence_survives_failed_cleanup_and_restart` | SDUC-493 | Green | A target/source retirement fence blocks overlay mutation, survives an injected cleanup-write failure and restart, and only a successful exact finish removes overlay and fence. The same test proves unqualified v1 custody is discarded and subsequent v2 custody is target-qualified. | +| SDTEST-1838 | `workspace/platform_attention.rs::workspace_context_removal_and_restart_flush_durable_retirement_custody` | SDUC-493 | Green | The production Workspace reconciler keeps a board unavailable when initial fence persistence fails, retries before removal, and flushes a durable retirement from a fresh store handle even when no board survived restart. | +| SDTEST-1839 | `workspace/platform_attention.rs::inactive_workspace_revisit_preserves_distinct_board_and_overlay_custody` | SDUC-493 | Green | The production context-retirement reducer preserves all non-active retained workspace boards when one exact active context is reconciled; only a truly absent global context selects every orphan for retirement. | +| SDTEST-1840 | `workspace/platform_attention.rs::real_workspace_waits_for_dev_visibility_across_modes_settings_and_inflight_transition` | SDUC-493 | Green | A real Workspace with an exact catalog, authoritative board, local store and injected Platform snapshot keeps unread custody through User and Support transitions, Settings, and an unrelated in-flight transition; only completed Dev/Workspaces visibility on a later UI turn records read. | +| SDTEST-1841 | `workspace/platform_attention.rs::authority_change_between_open_and_visible_confirmation_stays_unread` | SDUC-493 | Green | If the authoritative source becomes unavailable between exact destination opening and the later visible-surface confirmation, current re-resolution fails and the activation remains unread. | +| SDTEST-1857 | `workspace/platform_attention.rs::sdtest_1857_cross_workspace_activity_is_ordered_by_observation_not_by_board` | SDUC-493 | Green | Flattening per-workspace boards into one activity list re-orders on the authoritative observation instant instead of keeping every row of the first board ahead of the second; equal observations fall back to the item revision, and the desktop toast body names the destination workspace, degrading to the reason alone when the catalogue no longer names it. | --- @@ -546,6 +590,9 @@ targets (not just Linux) are cross-linked here for the release checklist: - SDTEST-121, SDTEST-122 (keychain macOS/Windows) +- SDTEST-1410, SDTEST-1411, SDTEST-1812..1815 (native tray template/menu + snapshot, Explorer-restart custody, Windows ICO-to-HICON smoke, plus the + Linux/macOS/Windows CI compile matrix) - SDTEST-960..968 (PTY spawn on all three) - SDTEST-1579..1581 (shell resolution — pure fn asserts both platform branches from any target, no cfg gate) diff --git a/marketing/shelldeck-promo/.gitignore b/marketing/shelldeck-promo/.gitignore new file mode 100644 index 00000000..b62197ba --- /dev/null +++ b/marketing/shelldeck-promo/.gitignore @@ -0,0 +1,7 @@ +node_modules +dist +.DS_Store +.env + +# Ignore the output video from Git but not videos you import into src/. +out diff --git a/marketing/shelldeck-promo/.prettierrc b/marketing/shelldeck-promo/.prettierrc new file mode 100644 index 00000000..37d50717 --- /dev/null +++ b/marketing/shelldeck-promo/.prettierrc @@ -0,0 +1,5 @@ +{ + "useTabs": false, + "bracketSpacing": true, + "tabWidth": 2 +} diff --git a/marketing/shelldeck-promo/README.md b/marketing/shelldeck-promo/README.md new file mode 100644 index 00000000..7d66d144 --- /dev/null +++ b/marketing/shelldeck-promo/README.md @@ -0,0 +1,18 @@ +# ShellDeck light-mode promo + +A 16.8-second, silent ShellDeck marketing example built with Remotion. It uses +real ShellDeck light-mode captures and reusable scene components. + +The project also includes a 21.1-second User → Support journey composition, +focused on request creation, support triage, contextual AI and resolution. + +```bash +npm install +npm run dev +npm run still +npm run render +npm run still:user-support +npm run render:user-support +``` + +The rendered files are written to `out/`. diff --git a/marketing/shelldeck-promo/eslint.config.mjs b/marketing/shelldeck-promo/eslint.config.mjs new file mode 100644 index 00000000..13b44a0d --- /dev/null +++ b/marketing/shelldeck-promo/eslint.config.mjs @@ -0,0 +1,3 @@ +import { config } from "@remotion/eslint-config-flat"; + +export default config; diff --git a/marketing/shelldeck-promo/package-lock.json b/marketing/shelldeck-promo/package-lock.json new file mode 100644 index 00000000..5cae2992 --- /dev/null +++ b/marketing/shelldeck-promo/package-lock.json @@ -0,0 +1,5818 @@ +{ + "name": "shelldeck-promo", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "shelldeck-promo", + "version": "1.0.0", + "license": "UNLICENSED", + "dependencies": { + "@remotion/cli": "4.0.518", + "@remotion/tailwind-v4": "4.0.518", + "@remotion/transitions": "4.0.518", + "react": "19.2.3", + "react-dom": "19.2.3", + "remotion": "4.0.518", + "tailwindcss": "4.0.0" + }, + "devDependencies": { + "@remotion/eslint-config-flat": "4.0.518", + "@types/react": "19.2.7", + "@types/web": "0.0.166", + "eslint": "9.19.0", + "prettier": "3.8.1", + "typescript": "5.9.3" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/core/node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/generator/node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/generator/node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports/node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers/node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.24.1.tgz", + "integrity": "sha512-Zo9c7N3xdOIQrNip7Lc9wvRPzlRtovHVE4lkz8WEDr7uYh/GMQhSiIgFxGIArRHYdJE5kxtZjAf8rT0xhdLCzg==", + "license": "MIT", + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template/node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/template/node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse/node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/traverse/node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.24.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.24.0.tgz", + "integrity": "sha512-+j7a5c253RfKh8iABBhywc8NSfP5LURe7Uh4qpsh6jc+aLJguvmIUBdjSdEMQv2bENrCR5MfRdjGo7vzS/ob7w==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.23.4", + "@babel/helper-validator-identifier": "^7.22.20", + "to-fast-properties": "^2.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.3.tgz", + "integrity": "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==", + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.3", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz", + "integrity": "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.19.2.tgz", + "integrity": "sha512-GNKqxfHG2ySmJOBSHg7LxeUx4xpuCoFjacmlCoYWEbaPXLwvfIjixRI12xCQZeULksQb23uiA8F40w5TojpV7w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.6", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.10.0.tgz", + "integrity": "sha512-gFHJ+xBOo4G3WRlR1e/3G8A6/KZAH6zcE/hkLRCZTi/B9avAG365QhFA8uOGzTMqgTghpn7/fSnscW++dpMSAw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.6.tgz", + "integrity": "sha512-l2Ul9PrHsPCKcEY/ac7VgFj9D80C7S68sOKc618SyHDPK36s1XcFebXY0iTzUVn4Yq+YbwvSnDmCz9yxjX+QrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.3.0", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/js": { + "version": "9.19.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.19.0.tgz", + "integrity": "sha512-rbq9/g38qjfqFLOVPvwjIvFFdNziEC5S65jmjPw5r6A//QH+W91akh9irMwjDN8zKUTak6W9EsAv4m/7Wnw0UQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.2.8", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.2.8.tgz", + "integrity": "sha512-ZAoA40rNMPwSm+AeHpCq8STiNAwzWLJuP8Xv4CHIc9wv/PSuExjMrmjfYNj682vW0OOiZ1HKxzvjQr9XZIisQA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.13.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit/node_modules/@eslint/core": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.13.0.tgz", + "integrity": "sha512-yfkgDw1KR66rkT5A8ci4irzDysN7FRpq3ttJolR88OqQikAWqwA8j5VZyas+vjyBNFIJ7MfybJ9plMILI2UrCw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", + "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@mediabunny/aac-encoder": { + "version": "1.55.1", + "resolved": "https://registry.npmjs.org/@mediabunny/aac-encoder/-/aac-encoder-1.55.1.tgz", + "integrity": "sha512-NkLRZzfZoCmMZpMosj+O9gpl1tgHgKR85G8QrtUSUGJO1HaI/NqZznwSEdGWZKT4rSiRRaYtYXecLCAIdKWDFQ==", + "license": "MPL-2.0", + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/Vanilagy" + }, + "peerDependencies": { + "mediabunny": "^1.0.0" + } + }, + "node_modules/@mediabunny/flac-encoder": { + "version": "1.55.1", + "resolved": "https://registry.npmjs.org/@mediabunny/flac-encoder/-/flac-encoder-1.55.1.tgz", + "integrity": "sha512-tWmL2Fv6MtAIkjSVebZ8YgSj7YR/xN6Jn37F+JpSA2WdZLDXLxxH0wyw/kqsKv9aWCCUcYhc/GpDo/KUr9SUHw==", + "license": "MPL-2.0", + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/Vanilagy" + }, + "peerDependencies": { + "mediabunny": "^1.0.0" + } + }, + "node_modules/@mediabunny/mp3-encoder": { + "version": "1.55.1", + "resolved": "https://registry.npmjs.org/@mediabunny/mp3-encoder/-/mp3-encoder-1.55.1.tgz", + "integrity": "sha512-5l9o9R+Ptb0MzAdysZ7fG5GxCpq+MxtJxy+ywlm32HFhvifutKJTw1ORP6pz2Bu0m8aD5OPITgdANvmwkXYU/g==", + "license": "MPL-2.0", + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/Vanilagy" + }, + "peerDependencies": { + "mediabunny": "^1.0.0" + } + }, + "node_modules/@module-federation/error-codes": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/@module-federation/error-codes/-/error-codes-0.22.0.tgz", + "integrity": "sha512-xF9SjnEy7vTdx+xekjPCV5cIHOGCkdn3pIxo9vU7gEZMIw0SvAEdsy6Uh17xaCpm8V0FWvR0SZoK9Ik6jGOaug==", + "license": "MIT" + }, + "node_modules/@module-federation/runtime": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/@module-federation/runtime/-/runtime-0.22.0.tgz", + "integrity": "sha512-38g5iPju2tPC3KHMPxRKmy4k4onNp6ypFPS1eKGsNLUkXgHsPMBFqAjDw96iEcjri91BrahG4XcdyKi97xZzlA==", + "license": "MIT", + "dependencies": { + "@module-federation/error-codes": "0.22.0", + "@module-federation/runtime-core": "0.22.0", + "@module-federation/sdk": "0.22.0" + } + }, + "node_modules/@module-federation/runtime-core": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/@module-federation/runtime-core/-/runtime-core-0.22.0.tgz", + "integrity": "sha512-GR1TcD6/s7zqItfhC87zAp30PqzvceoeDGYTgF3Vx2TXvsfDrhP6Qw9T4vudDQL3uJRne6t7CzdT29YyVxlgIA==", + "license": "MIT", + "dependencies": { + "@module-federation/error-codes": "0.22.0", + "@module-federation/sdk": "0.22.0" + } + }, + "node_modules/@module-federation/runtime-tools": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/@module-federation/runtime-tools/-/runtime-tools-0.22.0.tgz", + "integrity": "sha512-4ScUJ/aUfEernb+4PbLdhM/c60VHl698Gn1gY21m9vyC1Ucn69fPCA1y2EwcCB7IItseRMoNhdcWQnzt/OPCNA==", + "license": "MIT", + "dependencies": { + "@module-federation/runtime": "0.22.0", + "@module-federation/webpack-bundler-runtime": "0.22.0" + } + }, + "node_modules/@module-federation/sdk": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/@module-federation/sdk/-/sdk-0.22.0.tgz", + "integrity": "sha512-x4aFNBKn2KVQRuNVC5A7SnrSCSqyfIWmm1DvubjbO9iKFe7ith5niw8dqSFBekYBg2Fwy+eMg4sEFNVvCAdo6g==", + "license": "MIT" + }, + "node_modules/@module-federation/webpack-bundler-runtime": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/@module-federation/webpack-bundler-runtime/-/webpack-bundler-runtime-0.22.0.tgz", + "integrity": "sha512-aM8gCqXu+/4wBmJtVeMeeMN5guw3chf+2i6HajKtQv7SJfxV/f4IyNQJUeUQu9HfiAZHjqtMV5Lvq/Lvh8LdyA==", + "license": "MIT", + "dependencies": { + "@module-federation/runtime": "0.22.0", + "@module-federation/sdk": "0.22.0" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.0.7.tgz", + "integrity": "sha512-SeDnOO0Tk7Okiq6DbXmmBODgOAb9dp9gjlphokTUxmt8U3liIP1ZsozBahH69j/RJv+Rfs6IwUKHTgQYJ/HBAw==", + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.5.0", + "@emnapi/runtime": "^1.5.0", + "@tybys/wasm-util": "^0.10.1" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@remotion/bundler": { + "version": "4.0.518", + "resolved": "https://registry.npmjs.org/@remotion/bundler/-/bundler-4.0.518.tgz", + "integrity": "sha512-1LNpYAoVHjK51PaRQBAG8gQQREdID9ZyD2WUfD63Hu1e9ByIu1aWpOD7OcWJQ8+tvY1lObSmx+jw8+uzsiJmeg==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "@remotion/media-parser": "4.0.518", + "@remotion/studio": "4.0.518", + "@remotion/studio-shared": "4.0.518", + "@remotion/timeline-utils": "4.0.518", + "@rspack/core": "1.7.11", + "@rspack/plugin-react-refresh": "1.6.1", + "css-loader": "7.1.4", + "esbuild": "0.28.1", + "react-refresh": "0.18.0", + "remotion": "4.0.518", + "style-loader": "4.0.0", + "webpack": "5.105.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@remotion/canvas": { + "version": "4.0.518", + "resolved": "https://registry.npmjs.org/@remotion/canvas/-/canvas-4.0.518.tgz", + "integrity": "sha512-yVwTo7vlBgF6Un23yObEt8ZOhAlircjHlRPa6sGh4trqhI1n/daMdmtOKz8BFohirK7MEzCdHoUj75zBv/P4Pg==", + "license": "Remotion License", + "dependencies": { + "@remotion/player": "4.0.518", + "remotion": "4.0.518" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@remotion/captions": { + "version": "4.0.518", + "resolved": "https://registry.npmjs.org/@remotion/captions/-/captions-4.0.518.tgz", + "integrity": "sha512-S5ZM9zSvC3K+y47CU5nliQSrO689BWr3feYLHihGjLm2MhVQn1tq6dRxZ1kLArJPdgJlptI3yzMltrX5XjHQRA==", + "license": "MIT" + }, + "node_modules/@remotion/cli": { + "version": "4.0.518", + "resolved": "https://registry.npmjs.org/@remotion/cli/-/cli-4.0.518.tgz", + "integrity": "sha512-83VkRyHHAp4X+IpQXtZVTwKeOZRzTMXNIeD70rzvhJYkn8/wlN8Hc3quvSI2p2aZ2ZU0CnvdN/HFqjHnlao1HQ==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "@babel/parser": "7.24.1", + "@remotion/bundler": "4.0.518", + "@remotion/media-utils": "4.0.518", + "@remotion/player": "4.0.518", + "@remotion/renderer": "4.0.518", + "@remotion/studio": "4.0.518", + "@remotion/studio-server": "4.0.518", + "@remotion/studio-shared": "4.0.518", + "dotenv": "17.3.1", + "minimist": "1.2.6", + "prompts": "2.4.2", + "remotion": "4.0.518", + "semver": "7.5.3" + }, + "bin": { + "remotion": "remotion-cli.js", + "remotionb": "remotionb-cli.js", + "remotiond": "remotiond-cli.js" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@remotion/compositor-darwin-arm64": { + "version": "4.0.518", + "resolved": "https://registry.npmjs.org/@remotion/compositor-darwin-arm64/-/compositor-darwin-arm64-4.0.518.tgz", + "integrity": "sha512-iGO42MLLJsowNXiMFdfxINMGaH+O/P/hupSinegT+0IDhflvcHTTzbM+mVm2pE6eFU4s6WUjkoueKDuaioM/WQ==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@remotion/compositor-darwin-x64": { + "version": "4.0.518", + "resolved": "https://registry.npmjs.org/@remotion/compositor-darwin-x64/-/compositor-darwin-x64-4.0.518.tgz", + "integrity": "sha512-mBvjNtNlmUB082iJSh625FCsB2wUZA1H4lM2xFPuNEhe3bzK0nb5UTxYO7kMLoFdJgmpRpuJKOyFwqTxaSKXFw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@remotion/compositor-linux-arm64-gnu": { + "version": "4.0.518", + "resolved": "https://registry.npmjs.org/@remotion/compositor-linux-arm64-gnu/-/compositor-linux-arm64-gnu-4.0.518.tgz", + "integrity": "sha512-wnAA7FvLvnz11QBS/uJ3GeuSZlRotNZYwCF0MPSC70LrPgtZLgGYjRqozUFSFDAYIlWM7cqrFb8fm9xISrY00w==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@remotion/compositor-linux-arm64-musl": { + "version": "4.0.518", + "resolved": "https://registry.npmjs.org/@remotion/compositor-linux-arm64-musl/-/compositor-linux-arm64-musl-4.0.518.tgz", + "integrity": "sha512-S/lsSnWgDvfx1mBjOl4UtyIUBUjgnbGC3ufwjwdpZaawa52yLKgdwWMNqRmG56lhQKpkw0yExW68FHPJVLbD9Q==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@remotion/compositor-linux-x64-gnu": { + "version": "4.0.518", + "resolved": "https://registry.npmjs.org/@remotion/compositor-linux-x64-gnu/-/compositor-linux-x64-gnu-4.0.518.tgz", + "integrity": "sha512-aYB6ZSX9kqxiXNPXhDZmFzOrz3GFJ9C66FVcYlsQ2A2Iil8vQANDdYztyhrHlLPC4VoR50hY+FnHJiwYMSAi6g==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@remotion/compositor-linux-x64-musl": { + "version": "4.0.518", + "resolved": "https://registry.npmjs.org/@remotion/compositor-linux-x64-musl/-/compositor-linux-x64-musl-4.0.518.tgz", + "integrity": "sha512-9b8P4XP49ls+yuNZG6EVyvA105RE6Y6akCgw3Afwbhvto6vdybrOMySAt4Kp0tiDRTk+j/yKefROKyDmQV6/2A==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@remotion/compositor-win32-x64-msvc": { + "version": "4.0.518", + "resolved": "https://registry.npmjs.org/@remotion/compositor-win32-x64-msvc/-/compositor-win32-x64-msvc-4.0.518.tgz", + "integrity": "sha512-m9gJuCeI7oUz57slFVk1YBJqXVUUlBwaWalptkiBsoh7EQZbUbzjGotsDaoVCJ0arwNoMJK5cN5BE5asUsEHYg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@remotion/eslint-config-flat": { + "version": "4.0.518", + "resolved": "https://registry.npmjs.org/@remotion/eslint-config-flat/-/eslint-config-flat-4.0.518.tgz", + "integrity": "sha512-B+i8xyY/Ld7EIT8nLZSVpPFT/XzDhwK43M1lFiuA+MUHYjpWTi8PIYp0Jm3axt/yd+AWXEMu1K8datF5m8HbZA==", + "dev": true, + "license": "ISC", + "dependencies": { + "typescript-eslint": "8.21.0" + }, + "peerDependencies": { + "eslint": ">=9" + } + }, + "node_modules/@remotion/eslint-config-flat/node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.21.0.tgz", + "integrity": "sha512-eTH+UOR4I7WbdQnG4Z48ebIA6Bgi7WO8HvFEneeYBxG8qCOYgTOFPSg6ek9ITIDvGjDQzWHcoWHCDO2biByNzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "8.21.0", + "@typescript-eslint/type-utils": "8.21.0", + "@typescript-eslint/utils": "8.21.0", + "@typescript-eslint/visitor-keys": "8.21.0", + "graphemer": "^1.4.0", + "ignore": "^5.3.1", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.0.0 || ^8.0.0-alpha.0", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.8.0" + } + }, + "node_modules/@remotion/eslint-config-flat/node_modules/@typescript-eslint/parser": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.21.0.tgz", + "integrity": "sha512-Wy+/sdEH9kI3w9civgACwabHbKl+qIOu0uFZ9IMKzX3Jpv9og0ZBJrZExGrPpFAY7rWsXuxs5e7CPPP17A4eYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.21.0", + "@typescript-eslint/types": "8.21.0", + "@typescript-eslint/typescript-estree": "8.21.0", + "@typescript-eslint/visitor-keys": "8.21.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.8.0" + } + }, + "node_modules/@remotion/eslint-config-flat/node_modules/@typescript-eslint/type-utils": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.21.0.tgz", + "integrity": "sha512-95OsL6J2BtzoBxHicoXHxgk3z+9P3BEcQTpBKriqiYzLKnM2DeSqs+sndMKdamU8FosiadQFT3D+BSL9EKnAJQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/typescript-estree": "8.21.0", + "@typescript-eslint/utils": "8.21.0", + "debug": "^4.3.4", + "ts-api-utils": "^2.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.8.0" + } + }, + "node_modules/@remotion/eslint-config-flat/node_modules/@typescript-eslint/typescript-estree": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.21.0.tgz", + "integrity": "sha512-x+aeKh/AjAArSauz0GiQZsjT8ciadNMHdkUSwBB9Z6PrKc/4knM4g3UfHml6oDJmKC88a6//cdxnO/+P2LkMcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.21.0", + "@typescript-eslint/visitor-keys": "8.21.0", + "debug": "^4.3.4", + "fast-glob": "^3.3.2", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^2.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <5.8.0" + } + }, + "node_modules/@remotion/eslint-config-flat/node_modules/@typescript-eslint/utils": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.21.0.tgz", + "integrity": "sha512-xcXBfcq0Kaxgj7dwejMbFyq7IOHgpNMtVuDveK7w3ZGwG9owKzhALVwKpTF2yrZmEwl9SWdetf3fxNzJQaVuxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.4.0", + "@typescript-eslint/scope-manager": "8.21.0", + "@typescript-eslint/types": "8.21.0", + "@typescript-eslint/typescript-estree": "8.21.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.8.0" + } + }, + "node_modules/@remotion/eslint-config-flat/node_modules/brace-expansion": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@remotion/eslint-config-flat/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@remotion/eslint-config-flat/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@remotion/eslint-config-flat/node_modules/typescript": { + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz", + "integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/@remotion/eslint-config-flat/node_modules/typescript-eslint": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.21.0.tgz", + "integrity": "sha512-txEKYY4XMKwPXxNkN8+AxAdX6iIJAPiJbHE/FpQccs/sxw8Lf26kqwC3cn0xkHlW8kEbLhkhCsjWuMveaY9Rxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.21.0", + "@typescript-eslint/parser": "8.21.0", + "@typescript-eslint/utils": "8.21.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.8.0" + } + }, + "node_modules/@remotion/licensing": { + "version": "4.0.518", + "resolved": "https://registry.npmjs.org/@remotion/licensing/-/licensing-4.0.518.tgz", + "integrity": "sha512-YoGKkBRlRRQrBvhgckJnUhfoEROTYkTxyPFSNnGMNtgrSgHI+3LM+Fvm538GtwOrBGyD6b/u3CCJt0Xt/Mm54Q==", + "license": "MIT" + }, + "node_modules/@remotion/media": { + "version": "4.0.518", + "resolved": "https://registry.npmjs.org/@remotion/media/-/media-4.0.518.tgz", + "integrity": "sha512-L5H4xk48jRePmHJe9SiL5DW8g0rARoPOmgiCgSa5Of4I7vNPEgM3qzrQPwflwdUeko8ZLKc0+bVZXZFmjB6nRA==", + "dependencies": { + "mediabunny": "1.55.1", + "remotion": "4.0.518", + "zod": "4.4.3" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@remotion/media-parser": { + "version": "4.0.518", + "resolved": "https://registry.npmjs.org/@remotion/media-parser/-/media-parser-4.0.518.tgz", + "integrity": "sha512-Qa5lkxS06Tl7eeO4fN/2+lYsQbCuRclsDq09ucUhcyzDlNKc2N3k/jv1eG+3P7xOGLbuym3HqGCvIEXLbY+ShQ==", + "license": "Remotion License https://remotion.dev/license" + }, + "node_modules/@remotion/media-utils": { + "version": "4.0.518", + "resolved": "https://registry.npmjs.org/@remotion/media-utils/-/media-utils-4.0.518.tgz", + "integrity": "sha512-XWyG4QALox1y4w5oInL4rsytllbQ6GMiN3z9Wbzrc3+cLzcGeWr7apMWF3kddj78TJP1QYOA/t32pbb1Dl3Jow==", + "license": "MIT", + "dependencies": { + "mediabunny": "1.55.1", + "remotion": "4.0.518" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@remotion/paths": { + "version": "4.0.518", + "resolved": "https://registry.npmjs.org/@remotion/paths/-/paths-4.0.518.tgz", + "integrity": "sha512-EAp+yvlaLBbfOk0zloAnRnynfhK87VYdTxLRb+tMjhbrCjkvR6dgUANQM7c5NK5OvmuEINDZSfYvTUXqo8NbzQ==", + "license": "MIT" + }, + "node_modules/@remotion/player": { + "version": "4.0.518", + "resolved": "https://registry.npmjs.org/@remotion/player/-/player-4.0.518.tgz", + "integrity": "sha512-HjbMhH6Z5zPM6KCXin4HV+56lYyB9cuQoDylfW4NUtnN4dM/JCyFbpJ7O6ubi8hbTzOUguj3MCLcpF3DtVKbLA==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "remotion": "4.0.518" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@remotion/renderer": { + "version": "4.0.518", + "resolved": "https://registry.npmjs.org/@remotion/renderer/-/renderer-4.0.518.tgz", + "integrity": "sha512-yTUhwOlR+7hS14lDL+eAzoK1rD5G5ClTNowuQUokpsLwfQxdBU+2mkzbzR9cGNqNA/72jn1DDHOKE5hBA5M0UA==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "@remotion/licensing": "4.0.518", + "@remotion/streaming": "4.0.518", + "execa": "5.1.1", + "remotion": "4.0.518", + "source-map": "0.8.0", + "ws": "8.21.0" + }, + "optionalDependencies": { + "@remotion/compositor-darwin-arm64": "4.0.518", + "@remotion/compositor-darwin-x64": "4.0.518", + "@remotion/compositor-linux-arm64-gnu": "4.0.518", + "@remotion/compositor-linux-arm64-musl": "4.0.518", + "@remotion/compositor-linux-x64-gnu": "4.0.518", + "@remotion/compositor-linux-x64-musl": "4.0.518", + "@remotion/compositor-win32-x64-msvc": "4.0.518" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@remotion/shapes": { + "version": "4.0.518", + "resolved": "https://registry.npmjs.org/@remotion/shapes/-/shapes-4.0.518.tgz", + "integrity": "sha512-M5mqEvclV1oH9wc+R+fve5FY/6qcpsSkkqnlGmITXPsSw2HrDNPH1X4A77wHB9Ut0pxOfNWA3WNqSZin3kO1Fw==", + "license": "MIT", + "dependencies": { + "@remotion/paths": "4.0.518", + "remotion": "4.0.518" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@remotion/streaming": { + "version": "4.0.518", + "resolved": "https://registry.npmjs.org/@remotion/streaming/-/streaming-4.0.518.tgz", + "integrity": "sha512-A4U5lu29LLM9GdqfTlu744S2PmEztzi4Yi5GAIOlNElyL1sPA8s0sR2keoHNE7npEGMCvFwwP+kPKBXRvfmxNA==", + "license": "MIT" + }, + "node_modules/@remotion/studio": { + "version": "4.0.518", + "resolved": "https://registry.npmjs.org/@remotion/studio/-/studio-4.0.518.tgz", + "integrity": "sha512-ZDuXxBbjQ+OJquH4+DIRyo0QTzUyuygX+1K5b08iTnmc20Vgs+ixp4PcSihA/CL38YM0fsvy5LggOjFOY2hQAQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.31", + "@remotion/canvas": "4.0.518", + "@remotion/captions": "4.0.518", + "@remotion/media": "4.0.518", + "@remotion/media-utils": "4.0.518", + "@remotion/player": "4.0.518", + "@remotion/renderer": "4.0.518", + "@remotion/studio-protocol": "4.0.518", + "@remotion/studio-shared": "4.0.518", + "@remotion/timeline-utils": "4.0.518", + "@remotion/web-renderer": "4.0.518", + "@remotion/zod-types": "4.0.518", + "@tanstack/react-virtual": "3.14.9", + "mediabunny": "1.55.1", + "memfs": "3.4.3", + "open": "8.4.2", + "remotion": "4.0.518", + "semver": "7.5.3", + "zod": "4.4.3" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@remotion/studio-codemods": { + "version": "4.0.518", + "resolved": "https://registry.npmjs.org/@remotion/studio-codemods/-/studio-codemods-4.0.518.tgz", + "integrity": "sha512-N9eDTLkOYpV7iMxrXd8dTpBt68vhjVg2cTzOPGUMO98gILgTZ+jwAfxCrctzP39NyGgMMPo20alMUNKPDKsrDA==", + "license": "MIT", + "dependencies": { + "@babel/parser": "7.24.1", + "@babel/types": "7.24.0", + "@remotion/studio-shared": "4.0.518", + "ast-types": "0.16.1", + "recast": "0.23.21", + "remotion": "4.0.518" + } + }, + "node_modules/@remotion/studio-protocol": { + "version": "4.0.518", + "resolved": "https://registry.npmjs.org/@remotion/studio-protocol/-/studio-protocol-4.0.518.tgz", + "integrity": "sha512-KJzf6NWyYoqUCXV6uOR7toltxsW2wYFtLWTv5LmGrWXwEPzUOysK/gmswNGDpVwsEAMMwuQmR5Ivxy1TnEQszg==", + "license": "Remotion License" + }, + "node_modules/@remotion/studio-server": { + "version": "4.0.518", + "resolved": "https://registry.npmjs.org/@remotion/studio-server/-/studio-server-4.0.518.tgz", + "integrity": "sha512-8eHrNyZCrJhv1jYJ4WEIDV065kjqiuBBWwGtbLCA03R5kYNWaaQCZskK9wU492U/m/O58UkpQ2imiJky+Y5d0w==", + "license": "MIT", + "dependencies": { + "@babel/parser": "7.24.1", + "@babel/types": "7.24.0", + "@remotion/bundler": "4.0.518", + "@remotion/renderer": "4.0.518", + "@remotion/studio-codemods": "4.0.518", + "@remotion/studio-protocol": "4.0.518", + "@remotion/studio-shared": "4.0.518", + "@svgr/core": "8.1.0", + "@svgr/plugin-jsx": "8.1.0", + "kiwi-schema": "0.5.0", + "memfs": "3.4.3", + "open": "8.4.2", + "prettier": "3.8.1", + "recast": "0.23.21", + "remotion": "4.0.518", + "semver": "7.5.3", + "zod": "4.4.3" + } + }, + "node_modules/@remotion/studio-shared": { + "version": "4.0.518", + "resolved": "https://registry.npmjs.org/@remotion/studio-shared/-/studio-shared-4.0.518.tgz", + "integrity": "sha512-VzRz77JZ9gmB37PwIq/5errTZ/R/0/KdvS+MY4WRVkQ5mhnNgTuctAyPWi7jGmkuF4Ll9MP30x7QwJqS2COslA==", + "license": "MIT", + "dependencies": { + "@remotion/studio-protocol": "4.0.518", + "remotion": "4.0.518" + } + }, + "node_modules/@remotion/tailwind-v4": { + "version": "4.0.518", + "resolved": "https://registry.npmjs.org/@remotion/tailwind-v4/-/tailwind-v4-4.0.518.tgz", + "integrity": "sha512-4u6zoaOntQiGYyfoYBqghBF8+xZXg8t3CN73WHz/s3UyDiIKMKnRTlp37QKwHB3YhcjK8HpIAKWNtmGwMBwuQQ==", + "license": "MIT", + "dependencies": { + "@tailwindcss/webpack": "4.2.0", + "css-loader": "7.1.4", + "style-loader": "4.0.0", + "tailwindcss": "4.2.0" + }, + "peerDependencies": { + "@remotion/bundler": "4.0.518" + } + }, + "node_modules/@remotion/tailwind-v4/node_modules/tailwindcss": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.0.tgz", + "integrity": "sha512-yYzTZ4++b7fNYxFfpnberEEKu43w44aqDMNM9MHMmcKuCH7lL8jJ4yJ7LGHv7rSwiqM0nkiobF9I6cLlpS2P7Q==", + "license": "MIT" + }, + "node_modules/@remotion/timeline-utils": { + "version": "4.0.518", + "resolved": "https://registry.npmjs.org/@remotion/timeline-utils/-/timeline-utils-4.0.518.tgz", + "integrity": "sha512-qyz3VQ7JjBipURhhOsqrPfYpy6L2xqavpc9EZVBM/t0YjFMUuocqUYj5vPly8pTZsdA/18hqNy80aw744/2GqQ==", + "license": "MIT", + "dependencies": { + "mediabunny": "1.55.1", + "remotion": "4.0.518" + } + }, + "node_modules/@remotion/transitions": { + "version": "4.0.518", + "resolved": "https://registry.npmjs.org/@remotion/transitions/-/transitions-4.0.518.tgz", + "integrity": "sha512-jgYKDU+Mvlb9GDq4QUNYNmS/HRjHGbb4XswD/9PeV/TkoRp4DCAQx3CdWc/84Mcrkp3FxlBy34lrll6glQj+kQ==", + "license": "UNLICENSED", + "dependencies": { + "@remotion/paths": "4.0.518", + "@remotion/shapes": "4.0.518", + "remotion": "4.0.518" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@remotion/web-renderer": { + "version": "4.0.518", + "resolved": "https://registry.npmjs.org/@remotion/web-renderer/-/web-renderer-4.0.518.tgz", + "integrity": "sha512-YJLOPYwg/i5OIACYYV7IYEVQnTazLRQK12s4PPHFzdEGaxsyyvN4qSZfRJaHX26D/UDKiiuUCVE/SM+T5r2EOA==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "@mediabunny/aac-encoder": "1.55.1", + "@mediabunny/flac-encoder": "1.55.1", + "@mediabunny/mp3-encoder": "1.55.1", + "@remotion/licensing": "4.0.518", + "mediabunny": "1.55.1", + "remotion": "4.0.518" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@remotion/zod-types": { + "version": "4.0.518", + "resolved": "https://registry.npmjs.org/@remotion/zod-types/-/zod-types-4.0.518.tgz", + "integrity": "sha512-nybXA5tWJN5AOJWVv7313ivVyvDviDbziaUqt1S0UMoA0Ezbp8MOIV1hfBPdesEEiYzw9QCgdSeUKQ91Dcz7WA==", + "license": "MIT", + "dependencies": { + "remotion": "4.0.518" + } + }, + "node_modules/@rspack/binding": { + "version": "1.7.11", + "resolved": "https://registry.npmjs.org/@rspack/binding/-/binding-1.7.11.tgz", + "integrity": "sha512-2MGdy2s2HimsDT444Bp5XnALzNRxuBNc7y0JzyuqKbHBywd4x2NeXyhWXXoxufaCFu5PBc9Qq9jyfjW2Aeh06Q==", + "license": "MIT", + "optionalDependencies": { + "@rspack/binding-darwin-arm64": "1.7.11", + "@rspack/binding-darwin-x64": "1.7.11", + "@rspack/binding-linux-arm64-gnu": "1.7.11", + "@rspack/binding-linux-arm64-musl": "1.7.11", + "@rspack/binding-linux-x64-gnu": "1.7.11", + "@rspack/binding-linux-x64-musl": "1.7.11", + "@rspack/binding-wasm32-wasi": "1.7.11", + "@rspack/binding-win32-arm64-msvc": "1.7.11", + "@rspack/binding-win32-ia32-msvc": "1.7.11", + "@rspack/binding-win32-x64-msvc": "1.7.11" + } + }, + "node_modules/@rspack/binding-darwin-arm64": { + "version": "1.7.11", + "resolved": "https://registry.npmjs.org/@rspack/binding-darwin-arm64/-/binding-darwin-arm64-1.7.11.tgz", + "integrity": "sha512-oduECiZVqbO5zlVw+q7Vy65sJFth99fWPTyucwvLJJtJkPL5n17Uiql2cYP6Ijn0pkqtf1SXgK8WjiKLG5bIig==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rspack/binding-darwin-x64": { + "version": "1.7.11", + "resolved": "https://registry.npmjs.org/@rspack/binding-darwin-x64/-/binding-darwin-x64-1.7.11.tgz", + "integrity": "sha512-a1+TtTE9ap6RalgFi7FGIgkJP6O4Vy6ctv+9WGJy53E4kuqHR0RygzaiVxCI/GMc/vBT9vY23hyrpWb3d1vtXA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rspack/binding-linux-arm64-gnu": { + "version": "1.7.11", + "resolved": "https://registry.npmjs.org/@rspack/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.7.11.tgz", + "integrity": "sha512-P0QrGRPbTWu6RKWfN0bDtbnEps3rXH0MWIMreZABoUrVmNQKtXR6e73J3ub6a+di5s2+K0M2LJ9Bh2/H4UsDUA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rspack/binding-linux-arm64-musl": { + "version": "1.7.11", + "resolved": "https://registry.npmjs.org/@rspack/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.7.11.tgz", + "integrity": "sha512-6ky7R43VMjWwmx3Yx7Jl7faLBBMAgMDt+/bN35RgwjiPgsIByz65EwytUVuW9rikB43BGHvA/eqlnjLrUzNBqw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rspack/binding-linux-x64-gnu": { + "version": "1.7.11", + "resolved": "https://registry.npmjs.org/@rspack/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.7.11.tgz", + "integrity": "sha512-cuOJMfCOvb2Wgsry5enXJ3iT1FGUjdPqtGUBVupQlEG4ntSYsQ2PtF4wIDVasR3wdxC5nQbipOrDiN/u6fYsdQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rspack/binding-linux-x64-musl": { + "version": "1.7.11", + "resolved": "https://registry.npmjs.org/@rspack/binding-linux-x64-musl/-/binding-linux-x64-musl-1.7.11.tgz", + "integrity": "sha512-CoK37hva4AmHGh3VCsQXmGr40L36m1/AdnN5LEjUX6kx5rEH7/1nEBN6Ii72pejqDVvk9anEROmPDiPw10tpFg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rspack/binding-wasm32-wasi": { + "version": "1.7.11", + "resolved": "https://registry.npmjs.org/@rspack/binding-wasm32-wasi/-/binding-wasm32-wasi-1.7.11.tgz", + "integrity": "sha512-OtrmnPUVJMxjNa3eDMfHyPdtlLRmmp/aIm0fQHlAOATbZvlGm12q7rhPW5BXTu1yh+1rQ1/uqvz+SzKEZXuJaQ==", + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "1.0.7" + } + }, + "node_modules/@rspack/binding-win32-arm64-msvc": { + "version": "1.7.11", + "resolved": "https://registry.npmjs.org/@rspack/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.7.11.tgz", + "integrity": "sha512-lObFW6e5lCWNgTBNwT//yiEDbsxm9QG4BYUojqeXxothuzJ/L6ibXz6+gLMvbOvLGV3nKgkXmx8GvT9WDKR0mA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rspack/binding-win32-ia32-msvc": { + "version": "1.7.11", + "resolved": "https://registry.npmjs.org/@rspack/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.7.11.tgz", + "integrity": "sha512-0pYGnZd8PPqNR68zQ8skamqNAXEA1sUfXuAdYcknIIRq2wsbiwFzIc0Pov1cIfHYab37G7sSIPBiOUdOWF5Ivw==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rspack/binding-win32-x64-msvc": { + "version": "1.7.11", + "resolved": "https://registry.npmjs.org/@rspack/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.7.11.tgz", + "integrity": "sha512-EeQXayoQk/uBkI3pdoXfQBXNIUrADq56L3s/DFyM2pJeUDrWmhfIw2UFIGkYPTMSCo8F2JcdcGM32FGJrSnU0Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rspack/core": { + "version": "1.7.11", + "resolved": "https://registry.npmjs.org/@rspack/core/-/core-1.7.11.tgz", + "integrity": "sha512-rsD9b+Khmot5DwCMiB3cqTQo53ioPG3M/A7BySu8+0+RS7GCxKm+Z+mtsjtG/vsu4Tn2tcqCdZtA3pgLoJB+ew==", + "license": "MIT", + "dependencies": { + "@module-federation/runtime-tools": "0.22.0", + "@rspack/binding": "1.7.11", + "@rspack/lite-tapable": "1.1.0" + }, + "engines": { + "node": ">=18.12.0" + }, + "peerDependencies": { + "@swc/helpers": ">=0.5.1" + }, + "peerDependenciesMeta": { + "@swc/helpers": { + "optional": true + } + } + }, + "node_modules/@rspack/lite-tapable": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rspack/lite-tapable/-/lite-tapable-1.1.0.tgz", + "integrity": "sha512-E2B0JhYFmVAwdDiG14+DW0Di4Ze4Jg10Pc4/lILUrd5DRCaklduz2OvJ5HYQ6G+hd+WTzqQb3QnDNfK4yvAFYw==", + "license": "MIT" + }, + "node_modules/@rspack/plugin-react-refresh": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@rspack/plugin-react-refresh/-/plugin-react-refresh-1.6.1.tgz", + "integrity": "sha512-eqqW5645VG3CzGzFgNg5HqNdHVXY+567PGjtDhhrM8t67caxmsSzRmT5qfoEIfBcGgFkH9vEg7kzXwmCYQdQDw==", + "license": "MIT", + "dependencies": { + "error-stack-parser": "^2.1.4", + "html-entities": "^2.6.0" + }, + "peerDependencies": { + "react-refresh": ">=0.10.0 <1.0.0", + "webpack-hot-middleware": "2.x" + }, + "peerDependenciesMeta": { + "webpack-hot-middleware": { + "optional": true + } + } + }, + "node_modules/@svgr/babel-plugin-add-jsx-attribute": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-8.0.0.tgz", + "integrity": "sha512-b9MIk7yhdS1pMCZM8VeNfUlSKVRhsHZNMl5O9SfaX0l0t5wjdgu4IDzGB8bpnGBBOjGST3rRFVsaaEtI4W6f7g==", + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-remove-jsx-attribute": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-remove-jsx-attribute/-/babel-plugin-remove-jsx-attribute-8.0.0.tgz", + "integrity": "sha512-BcCkm/STipKvbCl6b7QFrMh/vx00vIP63k2eM66MfHJzPr6O2U0jYEViXkHJWqXqQYjdeA9cuCl5KWmlwjDvbA==", + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-remove-jsx-empty-expression": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-remove-jsx-empty-expression/-/babel-plugin-remove-jsx-empty-expression-8.0.0.tgz", + "integrity": "sha512-5BcGCBfBxB5+XSDSWnhTThfI9jcO5f0Ai2V24gZpG+wXF14BzwxxdDb4g6trdOux0rhibGs385BeFMSmxtS3uA==", + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-replace-jsx-attribute-value": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-replace-jsx-attribute-value/-/babel-plugin-replace-jsx-attribute-value-8.0.0.tgz", + "integrity": "sha512-KVQ+PtIjb1BuYT3ht8M5KbzWBhdAjjUPdlMtpuw/VjT8coTrItWX6Qafl9+ji831JaJcu6PJNKCV0bp01lBNzQ==", + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-svg-dynamic-title": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-svg-dynamic-title/-/babel-plugin-svg-dynamic-title-8.0.0.tgz", + "integrity": "sha512-omNiKqwjNmOQJ2v6ge4SErBbkooV2aAWwaPFs2vUY7p7GhVkzRkJ00kILXQvRhA6miHnNpXv7MRnnSjdRjK8og==", + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-svg-em-dimensions": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-svg-em-dimensions/-/babel-plugin-svg-em-dimensions-8.0.0.tgz", + "integrity": "sha512-mURHYnu6Iw3UBTbhGwE/vsngtCIbHE43xCRK7kCw4t01xyGqb2Pd+WXekRRoFOBIY29ZoOhUCTEweDMdrjfi9g==", + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-transform-react-native-svg": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-transform-react-native-svg/-/babel-plugin-transform-react-native-svg-8.1.0.tgz", + "integrity": "sha512-Tx8T58CHo+7nwJ+EhUwx3LfdNSG9R2OKfaIXXs5soiy5HtgoAEkDay9LIimLOcG8dJQH1wPZp/cnAv6S9CrR1Q==", + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-transform-svg-component": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-transform-svg-component/-/babel-plugin-transform-svg-component-8.0.0.tgz", + "integrity": "sha512-DFx8xa3cZXTdb/k3kfPeaixecQLgKh5NVBMwD0AQxOzcZawK4oo1Jh9LbrcACUivsCA7TLG8eeWgrDXjTMhRmw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-preset": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-preset/-/babel-preset-8.1.0.tgz", + "integrity": "sha512-7EYDbHE7MxHpv4sxvnVPngw5fuR6pw79SkcrILHJ/iMpuKySNCl5W1qcwPEpU+LgyRXOaAFgH0KhwD18wwg6ug==", + "license": "MIT", + "dependencies": { + "@svgr/babel-plugin-add-jsx-attribute": "8.0.0", + "@svgr/babel-plugin-remove-jsx-attribute": "8.0.0", + "@svgr/babel-plugin-remove-jsx-empty-expression": "8.0.0", + "@svgr/babel-plugin-replace-jsx-attribute-value": "8.0.0", + "@svgr/babel-plugin-svg-dynamic-title": "8.0.0", + "@svgr/babel-plugin-svg-em-dimensions": "8.0.0", + "@svgr/babel-plugin-transform-react-native-svg": "8.1.0", + "@svgr/babel-plugin-transform-svg-component": "8.0.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/core": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@svgr/core/-/core-8.1.0.tgz", + "integrity": "sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.21.3", + "@svgr/babel-preset": "8.1.0", + "camelcase": "^6.2.0", + "cosmiconfig": "^8.1.3", + "snake-case": "^3.0.4" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + } + }, + "node_modules/@svgr/hast-util-to-babel-ast": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/hast-util-to-babel-ast/-/hast-util-to-babel-ast-8.0.0.tgz", + "integrity": "sha512-EbDKwO9GpfWP4jN9sGdYwPBU0kdomaPIL2Eu4YwmgP+sJeXT+L7bMwJUBnhzfH8Q2qMBqZ4fJwpCyYsAN3mt2Q==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.21.3", + "entities": "^4.4.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + } + }, + "node_modules/@svgr/plugin-jsx": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@svgr/plugin-jsx/-/plugin-jsx-8.1.0.tgz", + "integrity": "sha512-0xiIyBsLlr8quN+WyuxooNW9RJ0Dpr8uOnH/xrCVO8GLUcwHISwj1AG0k+LFzteTkAA0GbX0kj9q6Dk70PTiPA==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.21.3", + "@svgr/babel-preset": "8.1.0", + "@svgr/hast-util-to-babel-ast": "8.0.0", + "svg-parser": "^2.0.4" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@svgr/core": "*" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.0.tgz", + "integrity": "sha512-Yv+fn/o2OmL5fh/Ir62VXItdShnUxfpkMA4Y7jdeC8O81WPB8Kf6TT6GSHvnqgSwDzlB5iT7kDpeXxLsUS0T6Q==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.19.0", + "jiti": "^2.6.1", + "lightningcss": "1.31.1", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.2.0" + } + }, + "node_modules/@tailwindcss/node/node_modules/tailwindcss": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.0.tgz", + "integrity": "sha512-yYzTZ4++b7fNYxFfpnberEEKu43w44aqDMNM9MHMmcKuCH7lL8jJ4yJ7LGHv7rSwiqM0nkiobF9I6cLlpS2P7Q==", + "license": "MIT" + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.0.tgz", + "integrity": "sha512-AZqQzADaj742oqn2xjl5JbIOzZB/DGCYF/7bpvhA8KvjUj9HJkag6bBuwZvH1ps6dfgxNHyuJVlzSr2VpMgdTQ==", + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.2.0", + "@tailwindcss/oxide-darwin-arm64": "4.2.0", + "@tailwindcss/oxide-darwin-x64": "4.2.0", + "@tailwindcss/oxide-freebsd-x64": "4.2.0", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.0", + "@tailwindcss/oxide-linux-arm64-gnu": "4.2.0", + "@tailwindcss/oxide-linux-arm64-musl": "4.2.0", + "@tailwindcss/oxide-linux-x64-gnu": "4.2.0", + "@tailwindcss/oxide-linux-x64-musl": "4.2.0", + "@tailwindcss/oxide-wasm32-wasi": "4.2.0", + "@tailwindcss/oxide-win32-arm64-msvc": "4.2.0", + "@tailwindcss/oxide-win32-x64-msvc": "4.2.0" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.0.tgz", + "integrity": "sha512-F0QkHAVaW/JNBWl4CEKWdZ9PMb0khw5DCELAOnu+RtjAfx5Zgw+gqCHFvqg3AirU1IAd181fwOtJQ5I8Yx5wtw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.0.tgz", + "integrity": "sha512-I0QylkXsBsJMZ4nkUNSR04p6+UptjcwhcVo3Zu828ikiEqHjVmQL9RuQ6uT/cVIiKpvtVA25msu/eRV97JeNSA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.0.tgz", + "integrity": "sha512-6TmQIn4p09PBrmnkvbYQ0wbZhLtbaksCDx7Y7R3FYYx0yxNA7xg5KP7dowmQ3d2JVdabIHvs3Hx4K3d5uCf8xg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.0.tgz", + "integrity": "sha512-qBudxDvAa2QwGlq9y7VIzhTvp2mLJ6nD/G8/tI70DCDoneaUeLWBJaPcbfzqRIWraj+o969aDQKvKW9dvkUizw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.0.tgz", + "integrity": "sha512-7XKkitpy5NIjFZNUQPeUyNJNJn1CJeV7rmMR+exHfTuOsg8rxIO9eNV5TSEnqRcaOK77zQpsyUkBWmPy8FgdSg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.0.tgz", + "integrity": "sha512-Mff5a5Q3WoQR01pGU1gr29hHM1N93xYrKkGXfPw/aRtK4bOc331Ho4Tgfsm5WDGvpevqMpdlkCojT3qlCQbCpA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.0.tgz", + "integrity": "sha512-XKcSStleEVnbH6W/9DHzZv1YhjE4eSS6zOu2eRtYAIh7aV4o3vIBs+t/B15xlqoxt6ef/0uiqJVB6hkHjWD/0A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.0.tgz", + "integrity": "sha512-/hlXCBqn9K6fi7eAM0RsobHwJYa5V/xzWspVTzxnX+Ft9v6n+30Pz8+RxCn7sQL/vRHHLS30iQPrHQunu6/vJA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.0.tgz", + "integrity": "sha512-lKUaygq4G7sWkhQbfdRRBkaq4LY39IriqBQ+Gk6l5nKq6Ay2M2ZZb1tlIyRNgZKS8cbErTwuYSor0IIULC0SHw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.0.tgz", + "integrity": "sha512-xuDjhAsFdUuFP5W9Ze4k/o4AskUtI8bcAGU4puTYprr89QaYFmhYOPfP+d1pH+k9ets6RoE23BXZM1X1jJqoyw==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.8.1", + "@emnapi/runtime": "^1.8.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.1", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.0.tgz", + "integrity": "sha512-2UU/15y1sWDEDNJXxEIrfWKC2Yb4YgIW5Xz2fKFqGzFWfoMHWFlfa1EJlGO2Xzjkq/tvSarh9ZTjvbxqWvLLXA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.0.tgz", + "integrity": "sha512-CrFadmFoc+z76EV6LPG1jx6XceDsaCG3lFhyLNo/bV9ByPrE+FnBPckXQVP4XRkN76h3Fjt/a+5Er/oA/nCBvQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/webpack": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/webpack/-/webpack-4.2.0.tgz", + "integrity": "sha512-TohlILOzF/3l+Yolll9iD2b5bsLG/1TuxAXPTxJcA77YAgPyKKc5RIwj4vIjg4l9DFMidekx2Z0BWyPAle9TUA==", + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "@tailwindcss/node": "4.2.0", + "@tailwindcss/oxide": "4.2.0", + "tailwindcss": "4.2.0" + }, + "peerDependencies": { + "webpack": "^5" + } + }, + "node_modules/@tailwindcss/webpack/node_modules/tailwindcss": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.0.tgz", + "integrity": "sha512-yYzTZ4++b7fNYxFfpnberEEKu43w44aqDMNM9MHMmcKuCH7lL8jJ4yJ7LGHv7rSwiqM0nkiobF9I6cLlpS2P7Q==", + "license": "MIT" + }, + "node_modules/@tanstack/react-virtual": { + "version": "3.14.9", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.14.9.tgz", + "integrity": "sha512-qZyr0FZDP8rDC4WBhsryIZmAd9bveJvFGUJJtskWaew6/0dTRS6wZxnR6VQ5bY2KwL3LjerrHqQLk3a0GKcPXQ==", + "license": "MIT", + "dependencies": { + "@tanstack/virtual-core": "3.17.7" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/virtual-core": { + "version": "3.17.7", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.17.7.tgz", + "integrity": "sha512-bp+v10y65sp2H7WpWfIMyxTNfl8ZVfxFTLRjPIFRryi6FV/J33z4IS53WO4pTk36KlvJ4iLiQz+oaydDC1xbcA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/dom-mediacapture-transform": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/@types/dom-mediacapture-transform/-/dom-mediacapture-transform-0.1.12.tgz", + "integrity": "sha512-d7/QsLRwF864A5mgIM/YrfiglHoYn7zgCcAoJgW404r+2DwnNr7EBbLnCWpmOMgH8y0te73L1AV6H1bmauaWFw==", + "license": "MIT", + "dependencies": { + "@types/dom-webcodecs": "*" + } + }, + "node_modules/@types/dom-webcodecs": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/@types/dom-webcodecs/-/dom-webcodecs-0.1.13.tgz", + "integrity": "sha512-O5hkiFIcjjszPIYyUSyvScyvrBoV3NOEEZx/pMlsu44TKzWNkLVBBxnxJz42in5n3QIolYOcBYFCPZZ0h8SkwQ==", + "license": "MIT" + }, + "node_modules/@types/eslint": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", + "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", + "license": "MIT", + "dependencies": { + "@types/estree": "*", + "@types/json-schema": "*" + } + }, + "node_modules/@types/eslint-scope": { + "version": "3.7.7", + "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz", + "integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==", + "license": "MIT", + "dependencies": { + "@types/eslint": "*", + "@types/estree": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "26.4.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.4.0.tgz", + "integrity": "sha512-faiGnoIrLH/V8cibOMEAZ8pMw6oXqSukl29ra4mN8GdaB2ZewzeaLj+INpV5N+Z1eKWzY+IzaIZH2EIR6YZRNQ==", + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", + "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/web": { + "version": "0.0.166", + "resolved": "https://registry.npmjs.org/@types/web/-/web-0.0.166.tgz", + "integrity": "sha512-qvY/TzK1WuxfeACL3Zzw+gMivGiIynRKH98nLET7ACzTRTX8CWMA6LQJ9WayIHvTBU1JeFCBRIBjsxhGz4TfHQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.21.0.tgz", + "integrity": "sha512-G3IBKz0/0IPfdeGRMbp+4rbjfSSdnGkXsM/pFZA8zM9t9klXDnB/YnKOBQ0GoPmoROa4bCq2NeHgJa5ydsQ4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.21.0", + "@typescript-eslint/visitor-keys": "8.21.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.21.0.tgz", + "integrity": "sha512-PAL6LUuQwotLW2a8VsySDBwYMm129vFm4tMVlylzdoTybTHaAi0oBp7Ac6LhSrHHOdLM3efH+nAR6hAWoMF89A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.21.0.tgz", + "integrity": "sha512-BkLMNpdV6prozk8LlyK/SOoWLmUFi+ZD+pcqti9ILCbVvHGk1ui1g4jJOc2WDLaeExz2qWwojxlPce5PljcT3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.21.0", + "eslint-visitor-keys": "^4.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@webassemblyjs/ast": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", + "integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==", + "license": "MIT", + "dependencies": { + "@webassemblyjs/helper-numbers": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2" + } + }, + "node_modules/@webassemblyjs/floating-point-hex-parser": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz", + "integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==", + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-api-error": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz", + "integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==", + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-buffer": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz", + "integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==", + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-numbers": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.13.2.tgz", + "integrity": "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==", + "license": "MIT", + "dependencies": { + "@webassemblyjs/floating-point-hex-parser": "1.13.2", + "@webassemblyjs/helper-api-error": "1.13.2", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/helper-wasm-bytecode": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz", + "integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==", + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-wasm-section": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.14.1.tgz", + "integrity": "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==", + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/wasm-gen": "1.14.1" + } + }, + "node_modules/@webassemblyjs/ieee754": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.13.2.tgz", + "integrity": "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==", + "license": "MIT", + "dependencies": { + "@xtuc/ieee754": "^1.2.0" + } + }, + "node_modules/@webassemblyjs/leb128": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.13.2.tgz", + "integrity": "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==", + "license": "Apache-2.0", + "dependencies": { + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/utf8": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.13.2.tgz", + "integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==", + "license": "MIT" + }, + "node_modules/@webassemblyjs/wasm-edit": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.14.1.tgz", + "integrity": "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==", + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/helper-wasm-section": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-opt": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1", + "@webassemblyjs/wast-printer": "1.14.1" + } + }, + "node_modules/@webassemblyjs/wasm-gen": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.14.1.tgz", + "integrity": "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==", + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" + } + }, + "node_modules/@webassemblyjs/wasm-opt": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.14.1.tgz", + "integrity": "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==", + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1" + } + }, + "node_modules/@webassemblyjs/wasm-parser": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.14.1.tgz", + "integrity": "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==", + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-api-error": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" + } + }, + "node_modules/@webassemblyjs/wast-printer": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.14.1.tgz", + "integrity": "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==", + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@xtuc/ieee754": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", + "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", + "license": "BSD-3-Clause" + }, + "node_modules/@xtuc/long": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", + "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", + "license": "Apache-2.0" + }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-import-phases": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz", + "integrity": "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + }, + "peerDependencies": { + "acorn": "^8.14.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-formats/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/ast-types": { + "version": "0.16.1", + "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.16.1.tgz", + "integrity": "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.19", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.19.tgz", + "integrity": "sha512-Grytf1xOxOEMTGRwx6rLGKkTabd4vMg3VrKdj/7joCmV0qgh4QwMMO6xh34YEXQqirAuUdgQGa5orJQQ+69RBw==", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.8", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", + "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.12", + "caniuse-lite": "^1.0.30001809", + "electron-to-chromium": "^1.5.402", + "node-releases": "^2.0.53", + "update-browserslist-db": "^1.3.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001810", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chrome-trace-event": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", + "integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==", + "license": "MIT", + "engines": { + "node": ">=6.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "license": "MIT" + }, + "node_modules/cosmiconfig": { + "version": "8.3.6", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz", + "integrity": "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==", + "license": "MIT", + "dependencies": { + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0", + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css-loader": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-7.1.4.tgz", + "integrity": "sha512-vv3J9tlOl04WjiMvHQI/9tmIrCxVrj6PFbHemBB1iihpeRbi/I4h033eoFIhwxBBqLhI0KYFS7yvynBFhIZfTw==", + "license": "MIT", + "dependencies": { + "icss-utils": "^5.1.0", + "postcss": "^8.4.40", + "postcss-modules-extract-imports": "^3.1.0", + "postcss-modules-local-by-default": "^4.0.5", + "postcss-modules-scope": "^3.2.0", + "postcss-modules-values": "^4.0.0", + "postcss-value-parser": "^4.2.0", + "semver": "^7.6.3" + }, + "engines": { + "node": ">= 18.12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "@rspack/core": "0.x || ^1.0.0 || ^2.0.0-0", + "webpack": "^5.27.0" + }, + "peerDependenciesMeta": { + "@rspack/core": { + "optional": true + }, + "webpack": { + "optional": true + } + } + }, + "node_modules/css-loader/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/define-lazy-prop": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", + "integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dot-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/dot-case/-/dot-case-3.0.4.tgz", + "integrity": "sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==", + "license": "MIT", + "dependencies": { + "no-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, + "node_modules/dotenv": { + "version": "17.3.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz", + "integrity": "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.415", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.415.tgz", + "integrity": "sha512-958V+Kbhtgz+SxXeEVKBjrlKRBIDAYvUJfwhjxMZ5S6ut9jAl7l9ZKBkBrvjyjZE36PabLUo2L8kEeV5O4vgJg==", + "license": "ISC" + }, + "node_modules/enhanced-resolve": { + "version": "5.24.5", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz", + "integrity": "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/error-stack-parser": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/error-stack-parser/-/error-stack-parser-2.1.4.tgz", + "integrity": "sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ==", + "license": "MIT", + "dependencies": { + "stackframe": "^1.3.4" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.19.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.19.0.tgz", + "integrity": "sha512-ug92j0LepKlbbEv6hD911THhoRHmbdXt2gX+VDABAW/Ir7D3nqKdv5Pf5vtlyY6HQMTEP2skXY43ueqTCWssEA==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.19.0", + "@eslint/core": "^0.10.0", + "@eslint/eslintrc": "^3.2.0", + "@eslint/js": "9.19.0", + "@eslint/plugin-kit": "^0.2.5", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.1", + "@types/estree": "^1.0.6", + "@types/json-schema": "^7.0.15", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.2.0", + "eslint-visitor-keys": "^4.2.0", + "espree": "^10.3.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.6.tgz", + "integrity": "sha512-7Ical1vFEMr0onbVzEDIreM22I4khW+fzyQPwvAFWBp1iwdshSZRsL4jjRvPG9JP1uiqMHRto+YU6R2/CzDz5Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/fs-monkey": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/fs-monkey/-/fs-monkey-1.0.3.tgz", + "integrity": "sha512-cybjIfiiE+pTWicSCLFHSrXZ6EilF30oh91FDP9S2B051prEa7QWfrVTQm10/dDpswBDXZugPa1Ogu8Yh+HV0Q==", + "license": "Unlicense" + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/glob-to-regexp": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", + "license": "BSD-2-Clause" + }, + "node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true, + "license": "MIT" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/html-entities": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz", + "integrity": "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/mdevils" + }, + { + "type": "patreon", + "url": "https://patreon.com/mdevils" + } + ], + "license": "MIT" + }, + "node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "license": "Apache-2.0", + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/icss-utils": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/icss-utils/-/icss-utils-5.1.0.tgz", + "integrity": "sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==", + "license": "ISC", + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "license": "MIT" + }, + "node_modules/is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jest-worker": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", + "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/kiwi-schema": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/kiwi-schema/-/kiwi-schema-0.5.0.tgz", + "integrity": "sha512-X+FpfU0yTEtc6aTHS7VwbOpvQwRt70+pXXWRI5fd6CvWhe7pSVC854TVo4Zo0x5/wwcWj+/9KUlXpdcP0dY9AA==", + "license": "MIT", + "bin": { + "kiwic": "cli.js" + } + }, + "node_modules/kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz", + "integrity": "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.31.1", + "lightningcss-darwin-arm64": "1.31.1", + "lightningcss-darwin-x64": "1.31.1", + "lightningcss-freebsd-x64": "1.31.1", + "lightningcss-linux-arm-gnueabihf": "1.31.1", + "lightningcss-linux-arm64-gnu": "1.31.1", + "lightningcss-linux-arm64-musl": "1.31.1", + "lightningcss-linux-x64-gnu": "1.31.1", + "lightningcss-linux-x64-musl": "1.31.1", + "lightningcss-win32-arm64-msvc": "1.31.1", + "lightningcss-win32-x64-msvc": "1.31.1" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.31.1.tgz", + "integrity": "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.31.1.tgz", + "integrity": "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.31.1.tgz", + "integrity": "sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.31.1.tgz", + "integrity": "sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.31.1.tgz", + "integrity": "sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.31.1.tgz", + "integrity": "sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.31.1.tgz", + "integrity": "sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.31.1.tgz", + "integrity": "sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.31.1.tgz", + "integrity": "sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.31.1.tgz", + "integrity": "sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.31.1.tgz", + "integrity": "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "license": "MIT" + }, + "node_modules/loader-runner": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.2.tgz", + "integrity": "sha512-DFEqQ3ihfS9blba08cLfYf1NRAIEm+dDjic073DRDc3/JspI/8wYmtDsHwd3+4hwvdxSK7PGaElfTmm0awWJ4w==", + "license": "MIT", + "engines": { + "node": ">=6.11.5" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lower-case": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz", + "integrity": "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.3" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mediabunny": { + "version": "1.55.1", + "resolved": "https://registry.npmjs.org/mediabunny/-/mediabunny-1.55.1.tgz", + "integrity": "sha512-JDdTEUOw9g6Ey8eWc2Ei63GmRGtGTJoE56SE7CHikwzS8i9xpsWcve4bEiUw47d4j49L3r7MXDFsH/5oZIhjBw==", + "license": "MPL-2.0", + "workspaces": [ + ".", + "packages/*" + ], + "dependencies": { + "@types/dom-mediacapture-transform": "^0.1.11", + "@types/dom-webcodecs": "0.1.13" + }, + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/Vanilagy" + } + }, + "node_modules/memfs": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/memfs/-/memfs-3.4.3.tgz", + "integrity": "sha512-eivjfi7Ahr6eQTn44nvTnR60e4a1Fs1Via2kCR5lHo/kyNoiMWaXCNJ/GpSd0ilXas2JSOl9B5FTIhflXu0hlg==", + "license": "Unlicense", + "dependencies": { + "fs-monkey": "1.0.3" + }, + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "license": "MIT" + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.6.tgz", + "integrity": "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "license": "MIT" + }, + "node_modules/no-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz", + "integrity": "sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==", + "license": "MIT", + "dependencies": { + "lower-case": "^2.0.2", + "tslib": "^2.0.3" + } + }, + "node_modules/node-releases": { + "version": "2.0.53", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.53.tgz", + "integrity": "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "license": "MIT", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/open": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz", + "integrity": "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==", + "license": "MIT", + "dependencies": { + "define-lazy-prop": "^2.0.0", + "is-docker": "^2.1.1", + "is-wsl": "^2.2.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-modules-extract-imports": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/postcss-modules-extract-imports/-/postcss-modules-extract-imports-3.1.0.tgz", + "integrity": "sha512-k3kNe0aNFQDAZGbin48pL2VNidTF0w4/eASDsxlyspobzU3wZQLOGj7L9gfRe0Jo9/4uud09DsjFNH7winGv8Q==", + "license": "ISC", + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-modules-local-by-default": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-modules-local-by-default/-/postcss-modules-local-by-default-4.2.0.tgz", + "integrity": "sha512-5kcJm/zk+GJDSfw+V/42fJ5fhjL5YbFDl8nVdXkJPLLW+Vf9mTD5Xe0wqIaDnLuL2U6cDNpTr+UQ+v2HWIBhzw==", + "license": "MIT", + "dependencies": { + "icss-utils": "^5.0.0", + "postcss-selector-parser": "^7.0.0", + "postcss-value-parser": "^4.1.0" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-modules-scope": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/postcss-modules-scope/-/postcss-modules-scope-3.2.1.tgz", + "integrity": "sha512-m9jZstCVaqGjTAuny8MdgE88scJnCiQSlSrOWcTQgM2t32UBe+MUmFSO5t7VMSfAf/FJKImAxBav8ooCHJXCJA==", + "license": "ISC", + "dependencies": { + "postcss-selector-parser": "^7.0.0" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-modules-values": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/postcss-modules-values/-/postcss-modules-values-4.0.0.tgz", + "integrity": "sha512-RDxHkAiEGI78gS2ofyvCsu7iycRv7oqw5xMWn9iMoR0N/7mf9D50ecQqUo5BZ9Zh2vH4bCUR/ktCqbB9m8vJjQ==", + "license": "ISC", + "dependencies": { + "icss-utils": "^5.0.0" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-selector-parser": { + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.5.tgz", + "integrity": "sha512-KvvtD7SrlBP7dlgkBghEE3r84CABm5SmV2aNcG4oCA+qDnJ/tvKonFVvwWAyyWUEwxuNawdfEAZKP9zM3oZ2Uw==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "license": "MIT" + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", + "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prompts": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "license": "MIT", + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", + "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.3" + } + }, + "node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/recast": { + "version": "0.23.21", + "resolved": "https://registry.npmjs.org/recast/-/recast-0.23.21.tgz", + "integrity": "sha512-mFAyJq9vUbSTARLZUvAEf1z3YxlvAwswbmxMx2mPA/MSm4KmpwvwvhsH/NIrZhyOuwD60Lzyw2qh83uCbgTPYw==", + "license": "MIT", + "dependencies": { + "ast-types": "^0.16.1", + "esprima": "~4.0.0", + "source-map": "~0.6.1", + "tiny-invariant": "^1.3.3", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">= 4" + } + }, + "node_modules/recast/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/remotion": { + "version": "4.0.518", + "resolved": "https://registry.npmjs.org/remotion/-/remotion-4.0.518.tgz", + "integrity": "sha512-EV3i85Mhcw8zxb3uSaHfACtCe3Bq3Mk3Oica8Iex0FkymX6bgzphHiL4+w7N/OE56MiFLj5JGs3KF29kCJwxuQ==", + "license": "SEE LICENSE IN LICENSE.md", + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/schema-utils": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", + "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/schema-utils/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/schema-utils/node_modules/ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, + "node_modules/schema-utils/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.5.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.3.tgz", + "integrity": "sha512-QBlUtyVk/5EeHbi7X0fw6liDZc7BBmEaSYn01fMU1OUYbf6GPsbTtd8WmnqbI20SeycoHSeiybkE/q1Q+qlThQ==", + "license": "ISC", + "dependencies": { + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/semver/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/semver/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "license": "MIT" + }, + "node_modules/snake-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/snake-case/-/snake-case-3.0.4.tgz", + "integrity": "sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==", + "license": "MIT", + "dependencies": { + "dot-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, + "node_modules/source-map": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.8.0.tgz", + "integrity": "sha512-d8EqvL+k/SOXCreS/SUzg2ciyHqBBLcN/yuRjFsbvVhHTE2pgei7oAhmPM7kWFbkX6OSMQfUq4KbkF3au9lhYQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">= 12" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/source-map-support/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackframe": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/stackframe/-/stackframe-1.3.4.tgz", + "integrity": "sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==", + "license": "MIT" + }, + "node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/style-loader": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/style-loader/-/style-loader-4.0.0.tgz", + "integrity": "sha512-1V4WqhhZZgjVAVJyt7TdDPZoPBPNHbekX4fWnCJL1yQukhCeZhJySUL+gL9y6sNdN95uEOS83Y55SqHcP7MzLA==", + "license": "MIT", + "engines": { + "node": ">= 18.12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.27.0" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/svg-parser": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/svg-parser/-/svg-parser-2.1.0.tgz", + "integrity": "sha512-bwLf38YmY+TDYHJw1Ex0Co8c4yeXuJAo8YnXGZrscxrvYoVVIvLeniEkV1Ks/54VteMnL4FtyN1+P+TZJdUPmQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/tailwindcss": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.0.0.tgz", + "integrity": "sha512-ULRPI3A+e39T7pSaf1xoi58AqqJxVCLg8F/uM5A3FadUbnyDTgltVnXJvdkTjwCOGA6NazqHVcwPJC5h2vRYVQ==", + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/terser": { + "version": "5.51.1", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.51.1.tgz", + "integrity": "sha512-jxJxk3OtMbMeoDTtrSezoFRPDFleMDzBl+mFj4gB04HGQUfKnIMllFV1TVhWdsj9o54AlaTvLuNP9yWHzGD1BA==", + "license": "BSD-2-Clause", + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.15.0", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/terser-webpack-plugin": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.6.1.tgz", + "integrity": "sha512-201R5j+sJpK8nFWwKVyNfZot8FaJbLZDq5evriVzbV1wDtSXDjRUDRfJzHpAaxFDMEhsZL1QkeqM61wgsS3KaQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "jest-worker": "^27.4.5", + "schema-utils": "^4.3.0", + "terser": "^5.31.1" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.1.0" + }, + "peerDependenciesMeta": { + "@minify-html/node": { + "optional": true + }, + "@swc/core": { + "optional": true + }, + "@swc/css": { + "optional": true + }, + "@swc/html": { + "optional": true + }, + "clean-css": { + "optional": true + }, + "cssnano": { + "optional": true + }, + "csso": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "html-minifier-terser": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "postcss": { + "optional": true + }, + "uglify-js": { + "optional": true + } + } + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, + "node_modules/to-fast-properties": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", + "integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.1.tgz", + "integrity": "sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/watchpack": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.2.tgz", + "integrity": "sha512-6i/00NBjP4yGPs+caKSyRfpTF/8Torsu0MOW3mMzIbhgISFder8i7xbqgHlLMwJrdiN8ndBV3UA1/AfzPSr+jg==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.1.2" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/webpack": { + "version": "5.105.0", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.105.0.tgz", + "integrity": "sha512-gX/dMkRQc7QOMzgTe6KsYFM7DxeIONQSui1s0n/0xht36HvrgbxtM1xBlgx596NbpHuQU8P7QpKwrZYwUX48nw==", + "license": "MIT", + "dependencies": { + "@types/eslint-scope": "^3.7.7", + "@types/estree": "^1.0.8", + "@types/json-schema": "^7.0.15", + "@webassemblyjs/ast": "^1.14.1", + "@webassemblyjs/wasm-edit": "^1.14.1", + "@webassemblyjs/wasm-parser": "^1.14.1", + "acorn": "^8.15.0", + "acorn-import-phases": "^1.0.3", + "browserslist": "^4.28.1", + "chrome-trace-event": "^1.0.2", + "enhanced-resolve": "^5.19.0", + "es-module-lexer": "^2.0.0", + "eslint-scope": "5.1.1", + "events": "^3.2.0", + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.2.11", + "json-parse-even-better-errors": "^2.3.1", + "loader-runner": "^4.3.1", + "mime-types": "^2.1.27", + "neo-async": "^2.6.2", + "schema-utils": "^4.3.3", + "tapable": "^2.3.0", + "terser-webpack-plugin": "^5.3.16", + "watchpack": "^2.5.1", + "webpack-sources": "^3.3.3" + }, + "bin": { + "webpack": "bin/webpack.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependenciesMeta": { + "webpack-cli": { + "optional": true + } + } + }, + "node_modules/webpack-sources": { + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.5.1.tgz", + "integrity": "sha512-jyuiGJdtvY434z5bUZrjz67v76/ePNvFZTp9Mdz29IlH4+GPsgyGjiv0fKI+M7BdkU6ADjulUcKAd3tUK3WlEw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/webpack/node_modules/eslint-scope": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/webpack/node_modules/estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/marketing/shelldeck-promo/package.json b/marketing/shelldeck-promo/package.json new file mode 100644 index 00000000..ee4ad71b --- /dev/null +++ b/marketing/shelldeck-promo/package.json @@ -0,0 +1,41 @@ +{ + "name": "shelldeck-promo", + "version": "1.0.0", + "description": "Light-mode ShellDeck marketing video built with Remotion", + "repository": {}, + "license": "UNLICENSED", + "private": true, + "dependencies": { + "@remotion/cli": "4.0.518", + "@remotion/tailwind-v4": "4.0.518", + "@remotion/transitions": "4.0.518", + "react": "19.2.3", + "react-dom": "19.2.3", + "remotion": "4.0.518", + "tailwindcss": "4.0.0" + }, + "devDependencies": { + "@remotion/eslint-config-flat": "4.0.518", + "@types/react": "19.2.7", + "@types/web": "0.0.166", + "eslint": "9.19.0", + "prettier": "3.8.1", + "typescript": "5.9.3" + }, + "scripts": { + "dev": "remotion studio", + "build": "remotion bundle", + "render": "remotion render ShellDeck-Light-Promo out/shelldeck-light-promo.mp4", + "render:user-support": "remotion render ShellDeck-User-Support-Promo out/shelldeck-user-support-promo.mp4", + "render:user-support:webm": "remotion render ShellDeck-User-Support-Promo out/shelldeck-user-support-promo.webm --codec=vp9", + "render:user-support:mobile": "remotion render ShellDeck-User-Support-Promo-Mobile out/shelldeck-user-support-promo-mobile.mp4", + "still": "remotion still ShellDeck-Light-Promo out/shelldeck-light-promo.png --frame=90", + "still:user-support": "remotion still ShellDeck-User-Support-Promo out/shelldeck-user-support-promo.png --frame=320", + "still:user-support:mobile": "remotion still ShellDeck-User-Support-Promo-Mobile out/shelldeck-user-support-promo-mobile.png --frame=317", + "upgrade": "remotion upgrade", + "lint": "eslint src && tsc" + }, + "sideEffects": [ + "*.css" + ] +} diff --git a/marketing/shelldeck-promo/public/assets/ai-support.webp b/marketing/shelldeck-promo/public/assets/ai-support.webp new file mode 100644 index 00000000..5b46b956 Binary files /dev/null and b/marketing/shelldeck-promo/public/assets/ai-support.webp differ diff --git a/marketing/shelldeck-promo/public/assets/dev-scripts.webp b/marketing/shelldeck-promo/public/assets/dev-scripts.webp new file mode 100644 index 00000000..f5466425 Binary files /dev/null and b/marketing/shelldeck-promo/public/assets/dev-scripts.webp differ diff --git a/marketing/shelldeck-promo/public/assets/dev-terminal.webp b/marketing/shelldeck-promo/public/assets/dev-terminal.webp new file mode 100644 index 00000000..50e6d69d Binary files /dev/null and b/marketing/shelldeck-promo/public/assets/dev-terminal.webp differ diff --git a/marketing/shelldeck-promo/public/assets/dev-tunnels.webp b/marketing/shelldeck-promo/public/assets/dev-tunnels.webp new file mode 100644 index 00000000..d87907d2 Binary files /dev/null and b/marketing/shelldeck-promo/public/assets/dev-tunnels.webp differ diff --git a/marketing/shelldeck-promo/public/assets/natural-light.png b/marketing/shelldeck-promo/public/assets/natural-light.png new file mode 100644 index 00000000..ac7e8a82 Binary files /dev/null and b/marketing/shelldeck-promo/public/assets/natural-light.png differ diff --git a/marketing/shelldeck-promo/public/assets/shelldeck-hero.png b/marketing/shelldeck-promo/public/assets/shelldeck-hero.png new file mode 100644 index 00000000..4978e587 Binary files /dev/null and b/marketing/shelldeck-promo/public/assets/shelldeck-hero.png differ diff --git a/marketing/shelldeck-promo/public/assets/shelldeck-light.png b/marketing/shelldeck-promo/public/assets/shelldeck-light.png new file mode 100644 index 00000000..3b224924 Binary files /dev/null and b/marketing/shelldeck-promo/public/assets/shelldeck-light.png differ diff --git a/marketing/shelldeck-promo/public/assets/support-home.png b/marketing/shelldeck-promo/public/assets/support-home.png new file mode 100644 index 00000000..8d2f68bf Binary files /dev/null and b/marketing/shelldeck-promo/public/assets/support-home.png differ diff --git a/marketing/shelldeck-promo/public/assets/support-request.webp b/marketing/shelldeck-promo/public/assets/support-request.webp new file mode 100644 index 00000000..d23db2b8 Binary files /dev/null and b/marketing/shelldeck-promo/public/assets/support-request.webp differ diff --git a/marketing/shelldeck-promo/public/assets/user-home.png b/marketing/shelldeck-promo/public/assets/user-home.png new file mode 100644 index 00000000..843f5142 Binary files /dev/null and b/marketing/shelldeck-promo/public/assets/user-home.png differ diff --git a/marketing/shelldeck-promo/public/assets/user-request.webp b/marketing/shelldeck-promo/public/assets/user-request.webp new file mode 100644 index 00000000..f59d09d2 Binary files /dev/null and b/marketing/shelldeck-promo/public/assets/user-request.webp differ diff --git a/marketing/shelldeck-promo/remotion.config.ts b/marketing/shelldeck-promo/remotion.config.ts new file mode 100644 index 00000000..694eafbf --- /dev/null +++ b/marketing/shelldeck-promo/remotion.config.ts @@ -0,0 +1,14 @@ +/** + * Note: When using the Node.JS APIs, the config file + * doesn't apply. Instead, pass options directly to the APIs. + * + * All configuration options: https://remotion.dev/docs/config + */ + +import { Config } from "@remotion/cli/config"; +import { enableTailwind } from '@remotion/tailwind-v4'; + +Config.setRspack(true); +Config.setVideoImageFormat("jpeg"); +Config.setOverwriteOutput(true); +Config.overrideBundlerConfig(enableTailwind); diff --git a/marketing/shelldeck-promo/src/Composition.tsx b/marketing/shelldeck-promo/src/Composition.tsx new file mode 100644 index 00000000..24fa62c5 --- /dev/null +++ b/marketing/shelldeck-promo/src/Composition.tsx @@ -0,0 +1,129 @@ +import { Composition, Folder } from "remotion"; +import { ClosingScene } from "./scenes/ClosingScene"; +import { HeroScene } from "./scenes/HeroScene"; +import { TerminalScene } from "./scenes/TerminalScene"; +import { WorkflowScene } from "./scenes/WorkflowScene"; +import { ShellDeckPromo } from "./ShellDeckPromo"; +import { UserSupportPromo } from "./user-support/UserSupportPromo"; +import { MobileUserSupportPromo } from "./user-support/mobile/MobileUserSupportPromo"; +import { AssistResolveScene } from "./user-support/scenes/AssistResolveScene"; +import { JourneyIntroScene } from "./user-support/scenes/JourneyIntroScene"; +import { JourneyOutcomeScene } from "./user-support/scenes/JourneyOutcomeScene"; +import { ModeSwitchScene } from "./user-support/scenes/ModeSwitchScene"; +import { SupportTriageScene } from "./user-support/scenes/SupportTriageScene"; +import { UserRequestScene } from "./user-support/scenes/UserRequestScene"; + +export const ShellDeckCompositions: React.FC = () => { + return ( + <> + + + + + + + + + + + + + + + + + + + ); +}; diff --git a/marketing/shelldeck-promo/src/Root.tsx b/marketing/shelldeck-promo/src/Root.tsx new file mode 100644 index 00000000..17a4ccfe --- /dev/null +++ b/marketing/shelldeck-promo/src/Root.tsx @@ -0,0 +1,6 @@ +import "./index.css"; +import { ShellDeckCompositions } from "./Composition"; + +export const RemotionRoot: React.FC = () => { + return ; +}; diff --git a/marketing/shelldeck-promo/src/ShellDeckPromo.tsx b/marketing/shelldeck-promo/src/ShellDeckPromo.tsx new file mode 100644 index 00000000..d3b49dad --- /dev/null +++ b/marketing/shelldeck-promo/src/ShellDeckPromo.tsx @@ -0,0 +1,38 @@ +import { TransitionSeries, linearTiming } from "@remotion/transitions"; +import { fade } from "@remotion/transitions/fade"; +import { slide } from "@remotion/transitions/slide"; +import { ClosingScene } from "./scenes/ClosingScene"; +import { HeroScene } from "./scenes/HeroScene"; +import { TerminalScene } from "./scenes/TerminalScene"; +import { WorkflowScene } from "./scenes/WorkflowScene"; + +export const ShellDeckPromo: React.FC = () => { + return ( + + + + + + + + + + + + + + + + + + ); +}; diff --git a/marketing/shelldeck-promo/src/components/Brand.tsx b/marketing/shelldeck-promo/src/components/Brand.tsx new file mode 100644 index 00000000..d02d44e0 --- /dev/null +++ b/marketing/shelldeck-promo/src/components/Brand.tsx @@ -0,0 +1,34 @@ +import { CanvasImage, Interactive, staticFile } from "remotion"; +import { palette } from "../theme"; + +export const Brand: React.FC<{ compact?: boolean }> = ({ compact = false }) => { + const iconSize = compact ? 52 : 76; + + return ( + + + + ShellDeck + + + ); +}; diff --git a/marketing/shelldeck-promo/src/components/LightBackdrop.tsx b/marketing/shelldeck-promo/src/components/LightBackdrop.tsx new file mode 100644 index 00000000..e18341ae --- /dev/null +++ b/marketing/shelldeck-promo/src/components/LightBackdrop.tsx @@ -0,0 +1,44 @@ +import { AbsoluteFill, CanvasImage, staticFile } from "remotion"; +import { palette } from "../theme"; + +export const LightBackdrop: React.FC<{ imageOpacity?: number }> = ({ + imageOpacity = 0.12, +}) => { + return ( + + + + + + ); +}; diff --git a/marketing/shelldeck-promo/src/components/Pill.tsx b/marketing/shelldeck-promo/src/components/Pill.tsx new file mode 100644 index 00000000..98867f62 --- /dev/null +++ b/marketing/shelldeck-promo/src/components/Pill.tsx @@ -0,0 +1,45 @@ +import { Interactive } from "remotion"; +import { palette } from "../theme"; + +export const Pill: React.FC<{ + children: React.ReactNode; + accent?: "blue" | "teal" | "amber"; +}> = ({ children, accent = "blue" }) => { + const color = + accent === "teal" + ? palette.teal + : accent === "amber" + ? palette.amber + : palette.blue; + + return ( + + + {children} + + ); +}; diff --git a/marketing/shelldeck-promo/src/components/ScreenshotCard.tsx b/marketing/shelldeck-promo/src/components/ScreenshotCard.tsx new file mode 100644 index 00000000..69a38e8a --- /dev/null +++ b/marketing/shelldeck-promo/src/components/ScreenshotCard.tsx @@ -0,0 +1,32 @@ +import { CanvasImage, Interactive, staticFile } from "remotion"; +import { palette, softShadow } from "../theme"; + +export const ScreenshotCard: React.FC<{ + src: string; + name: string; + style?: React.CSSProperties; +}> = ({ src, name, style }) => { + return ( + + + + ); +}; diff --git a/marketing/shelldeck-promo/src/index.css b/marketing/shelldeck-promo/src/index.css new file mode 100644 index 00000000..b844a296 --- /dev/null +++ b/marketing/shelldeck-promo/src/index.css @@ -0,0 +1,74 @@ +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + background: #f7fafc; + font-family: + Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", + sans-serif; +} + +.us-glass { + border: 1px solid rgba(255, 255, 255, 0.82); + background: linear-gradient( + 145deg, + rgba(255, 255, 255, 0.9), + rgba(255, 255, 255, 0.65) + ); + box-shadow: + 0 40px 100px rgba(31, 55, 86, 0.15), + inset 0 1px 0 rgba(255, 255, 255, 0.94); + backdrop-filter: blur(26px) saturate(1.25); +} + +.us-stage { + overflow: hidden; + border: 1px solid rgba(147, 164, 184, 0.28); + background: rgba(255, 255, 255, 0.92); + box-shadow: + 0 46px 120px rgba(34, 57, 85, 0.2), + 0 12px 34px rgba(34, 57, 85, 0.1), + inset 0 1px 0 rgba(255, 255, 255, 0.92); +} + +.us-focus-frame { + display: flex; + flex-direction: column; + overflow: hidden; + border: 1px solid rgba(123, 145, 171, 0.24); + background: rgba(255, 255, 255, 0.96); + box-shadow: + 0 34px 90px rgba(42, 66, 95, 0.16), + 0 10px 24px rgba(42, 66, 95, 0.08); +} + +.us-focus-chrome { + flex: 0 0 auto; + border-bottom: 1px solid rgba(123, 145, 171, 0.18); + background: linear-gradient(180deg, #ffffff, #f7f9fc); +} + +.us-window-dot { + width: 12px; + height: 12px; + border-radius: 999px; + box-shadow: inset 0 0 0 1px rgba(45, 55, 72, 0.08); +} + +.us-flow-line { + height: 4px; + border-radius: 999px; + background: linear-gradient(90deg, #168ee0, #6d5ce7); + box-shadow: 0 0 24px rgba(78, 100, 220, 0.28); +} + +.us-micro-grid { + background-image: + linear-gradient(rgba(31, 111, 235, 0.055) 1px, transparent 1px), + linear-gradient(90deg, rgba(31, 111, 235, 0.055) 1px, transparent 1px); + background-size: 34px 34px; + mask-image: radial-gradient(circle at center, black, transparent 76%); +} diff --git a/marketing/shelldeck-promo/src/index.ts b/marketing/shelldeck-promo/src/index.ts new file mode 100644 index 00000000..f31c790e --- /dev/null +++ b/marketing/shelldeck-promo/src/index.ts @@ -0,0 +1,4 @@ +import { registerRoot } from "remotion"; +import { RemotionRoot } from "./Root"; + +registerRoot(RemotionRoot); diff --git a/marketing/shelldeck-promo/src/scenes/ClosingScene.tsx b/marketing/shelldeck-promo/src/scenes/ClosingScene.tsx new file mode 100644 index 00000000..9e42db83 --- /dev/null +++ b/marketing/shelldeck-promo/src/scenes/ClosingScene.tsx @@ -0,0 +1,113 @@ +import { + AbsoluteFill, + Easing, + Interactive, + interpolate, + useCurrentFrame, + useVideoConfig, +} from "remotion"; +import { Brand } from "../components/Brand"; +import { LightBackdrop } from "../components/LightBackdrop"; +import { palette } from "../theme"; + +export const ClosingScene: React.FC = () => { + const frame = useCurrentFrame(); + const { fps } = useVideoConfig(); + + return ( + + + + + + Votre infrastructure, +
+ au même endroit. +
+ + Travaillez plus vite. Gardez le contrôle. + + + shelldeck.1clic.pro + +
+
+ ); +}; diff --git a/marketing/shelldeck-promo/src/scenes/HeroScene.tsx b/marketing/shelldeck-promo/src/scenes/HeroScene.tsx new file mode 100644 index 00000000..bc6dbc4f --- /dev/null +++ b/marketing/shelldeck-promo/src/scenes/HeroScene.tsx @@ -0,0 +1,125 @@ +import { + AbsoluteFill, + Easing, + Interactive, + interpolate, + useCurrentFrame, + useVideoConfig, +} from "remotion"; +import { Brand } from "../components/Brand"; +import { LightBackdrop } from "../components/LightBackdrop"; +import { Pill } from "../components/Pill"; +import { ScreenshotCard } from "../components/ScreenshotCard"; +import { palette } from "../theme"; + +export const HeroScene: React.FC = () => { + const frame = useCurrentFrame(); + const { fps } = useVideoConfig(); + + return ( + + + + + + Tous vos serveurs. +
+ Un seul cockpit. +
+ + SSH, terminaux, scripts et tunnels réunis dans une application native. + +
+ + + + + SSH natif + Sessions persistantes + Multi-sites + +
+ ); +}; diff --git a/marketing/shelldeck-promo/src/scenes/TerminalScene.tsx b/marketing/shelldeck-promo/src/scenes/TerminalScene.tsx new file mode 100644 index 00000000..ec0d1881 --- /dev/null +++ b/marketing/shelldeck-promo/src/scenes/TerminalScene.tsx @@ -0,0 +1,154 @@ +import { + AbsoluteFill, + Easing, + Interactive, + interpolate, + useCurrentFrame, + useVideoConfig, +} from "remotion"; +import { Brand } from "../components/Brand"; +import { LightBackdrop } from "../components/LightBackdrop"; +import { ScreenshotCard } from "../components/ScreenshotCard"; +import { palette } from "../theme"; + +export const TerminalScene: React.FC = () => { + const frame = useCurrentFrame(); + const { fps } = useVideoConfig(); + + return ( + + + + + + 01 · Terminal + + + + + Connectez-vous. +
+ Gardez le contexte. +
+ + + + + + Session active · prod-eu-west + +
+ ); +}; diff --git a/marketing/shelldeck-promo/src/scenes/WorkflowScene.tsx b/marketing/shelldeck-promo/src/scenes/WorkflowScene.tsx new file mode 100644 index 00000000..e223cc05 --- /dev/null +++ b/marketing/shelldeck-promo/src/scenes/WorkflowScene.tsx @@ -0,0 +1,170 @@ +import { + AbsoluteFill, + Easing, + Interactive, + interpolate, + useCurrentFrame, + useVideoConfig, +} from "remotion"; +import { Brand } from "../components/Brand"; +import { LightBackdrop } from "../components/LightBackdrop"; +import { ScreenshotCard } from "../components/ScreenshotCard"; +import { palette } from "../theme"; + +export const WorkflowScene: React.FC = () => { + const frame = useCurrentFrame(); + const { fps } = useVideoConfig(); + + return ( + + + + + + 02 · Automatisation + + + + + Des tunnels aux scripts, +
+ tout reste à portée de main. +
+ + + + + + + {[ + ["2", "tunnels prêts", palette.blue], + ["3", "scripts disponibles", palette.teal], + ].map(([value, label, color]) => ( +
+
{value}
+
{label}
+
+ ))} +
+
+ ); +}; diff --git a/marketing/shelldeck-promo/src/theme.ts b/marketing/shelldeck-promo/src/theme.ts new file mode 100644 index 00000000..6edd1291 --- /dev/null +++ b/marketing/shelldeck-promo/src/theme.ts @@ -0,0 +1,17 @@ +export const palette = { + canvas: "#f7fafc", + warmCanvas: "#fbfaf5", + surface: "#ffffff", + ink: "#132337", + muted: "#65788b", + subtle: "#93a3b3", + border: "#dce5ec", + blue: "#188bd6", + blueDeep: "#1166b1", + teal: "#22bfa0", + green: "#22a66f", + amber: "#f3a73f", +}; + +export const softShadow = + "0 34px 90px rgba(31, 60, 86, 0.16), 0 8px 26px rgba(31, 60, 86, 0.09)"; diff --git a/marketing/shelldeck-promo/src/user-support/ActionCursor.tsx b/marketing/shelldeck-promo/src/user-support/ActionCursor.tsx new file mode 100644 index 00000000..c76b8f2d --- /dev/null +++ b/marketing/shelldeck-promo/src/user-support/ActionCursor.tsx @@ -0,0 +1,84 @@ +import { Easing, Interactive, interpolate, useCurrentFrame } from "remotion"; + +export const ActionCursor: React.FC<{ + name: string; + appearAt: number; + clickAt: number; + from: [number, number]; + to: [number, number]; + color?: string; + relativeToParentCenter?: boolean; +}> = ({ name, appearAt, clickAt, from, to, color = "#168ee0", relativeToParentCenter = false }) => { + const frame = useCurrentFrame(); + + return ( + + + + + + + ); +}; diff --git a/marketing/shelldeck-promo/src/user-support/FocusedCapture.tsx b/marketing/shelldeck-promo/src/user-support/FocusedCapture.tsx new file mode 100644 index 00000000..b40996ed --- /dev/null +++ b/marketing/shelldeck-promo/src/user-support/FocusedCapture.tsx @@ -0,0 +1,64 @@ +import { Img, Interactive, staticFile } from "remotion"; + +export const FocusedCapture: React.FC<{ + src: string; + name: string; + label: string; + sourceWidth?: number; + sourceHeight?: number; + zoom?: number; + imagePosition?: "left" | "center" | "right"; + imageTop?: number; + style?: React.CSSProperties; + children?: React.ReactNode; +}> = ({ + src, + name, + label, + sourceWidth = 1800, + sourceHeight = 1000, + zoom = 1, + imagePosition = "center", + imageTop = 0, + style, + children, +}) => { + return ( + + + + + + + {label} + + +
+ + {children} +
+
+ ); +}; diff --git a/marketing/shelldeck-promo/src/user-support/JourneyBackdrop.tsx b/marketing/shelldeck-promo/src/user-support/JourneyBackdrop.tsx new file mode 100644 index 00000000..f9ec5bc6 --- /dev/null +++ b/marketing/shelldeck-promo/src/user-support/JourneyBackdrop.tsx @@ -0,0 +1,96 @@ +import { + AbsoluteFill, + Easing, + Interactive, + interpolate, + useCurrentFrame, + useVideoConfig, +} from "remotion"; + +type JourneyTone = "user" | "support" | "blend"; + +const tones: Record = { + user: { primary: "#168ee0", secondary: "#35c8ad" }, + support: { primary: "#6d5ce7", secondary: "#2f9cea" }, + blend: { primary: "#168ee0", secondary: "#8065ec" }, +}; + +export const JourneyBackdrop: React.FC<{ tone: JourneyTone }> = ({ tone }) => { + const frame = useCurrentFrame(); + const { durationInFrames } = useVideoConfig(); + const colors = tones[tone]; + + return ( + + + + + + + + ); +}; diff --git a/marketing/shelldeck-promo/src/user-support/MediaStage.tsx b/marketing/shelldeck-promo/src/user-support/MediaStage.tsx new file mode 100644 index 00000000..e24270d0 --- /dev/null +++ b/marketing/shelldeck-promo/src/user-support/MediaStage.tsx @@ -0,0 +1,37 @@ +import { CanvasImage, Interactive, staticFile } from "remotion"; + +export const MediaStage: React.FC<{ + src: string; + name: string; + width?: number; + height?: number; + style?: React.CSSProperties; + imageStyle?: React.CSSProperties; + children?: React.ReactNode; +}> = ({ + src, + name, + width = 1920, + height = 1080, + style, + imageStyle, + children, +}) => { + return ( + + + {children} + + ); +}; diff --git a/marketing/shelldeck-promo/src/user-support/RoleBadge.tsx b/marketing/shelldeck-promo/src/user-support/RoleBadge.tsx new file mode 100644 index 00000000..0bba2c1f --- /dev/null +++ b/marketing/shelldeck-promo/src/user-support/RoleBadge.tsx @@ -0,0 +1,48 @@ +import { Interactive } from "remotion"; + +export const RoleBadge: React.FC<{ + role: "Utilisateur" | "Support"; + compact?: boolean; +}> = ({ role, compact = false }) => { + const user = role === "Utilisateur"; + const accent = user ? "#168ee0" : "#6d5ce7"; + + return ( + + + {user ? "U" : "S"} + + Mode {role} + + ); +}; diff --git a/marketing/shelldeck-promo/src/user-support/UserSupportPromo.tsx b/marketing/shelldeck-promo/src/user-support/UserSupportPromo.tsx new file mode 100644 index 00000000..f8fb7a03 --- /dev/null +++ b/marketing/shelldeck-promo/src/user-support/UserSupportPromo.tsx @@ -0,0 +1,58 @@ +import { TransitionSeries, springTiming } from "@remotion/transitions"; +import { fade } from "@remotion/transitions/fade"; +import { slide } from "@remotion/transitions/slide"; +import { AssistResolveScene } from "./scenes/AssistResolveScene"; +import { JourneyIntroScene } from "./scenes/JourneyIntroScene"; +import { JourneyOutcomeScene } from "./scenes/JourneyOutcomeScene"; +import { ModeSwitchScene } from "./scenes/ModeSwitchScene"; +import { SupportTriageScene } from "./scenes/SupportTriageScene"; +import { UserRequestScene } from "./scenes/UserRequestScene"; + +export const UserSupportPromo: React.FC = () => { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}; diff --git a/marketing/shelldeck-promo/src/user-support/mobile/MobileAssistScene.tsx b/marketing/shelldeck-promo/src/user-support/mobile/MobileAssistScene.tsx new file mode 100644 index 00000000..e15e69e7 --- /dev/null +++ b/marketing/shelldeck-promo/src/user-support/mobile/MobileAssistScene.tsx @@ -0,0 +1,243 @@ +import { + AbsoluteFill, + Easing, + Interactive, + interpolate, + useCurrentFrame, +} from "remotion"; +import { palette } from "../../theme"; +import { ActionCursor } from "../ActionCursor"; +import { JourneyBackdrop } from "../JourneyBackdrop"; +import { MobileHeader, MobileWindowChrome } from "./MobileShared"; + +export const MobileAssistScene: React.FC = () => { + const frame = useCurrentFrame(); + + return ( + + + + + + Le contexte est déjà là. +
+ + L’IA aide à agir. + +
+
+ + + +
+
+ Bonjour +
+
+ Sur quoi on travaille ? +
+
+ Le ticket sélectionné fournit déjà le contexte utile. +
+ + +
+ ↩   Rédiger +
+
+ ▣   Résumer + +
+
+ + +
+ ✦ Résumé généré + Prêt ✓ +
+
+ Accès temporaire demandé pour Atelier Nord. Priorité normale, mise + en ligne imminente. +
+
+ Utiliser dans la réponse → +
+
+ + + {["Ticket sélectionné", "Résumé prêt", "Réponse proposée"].map( + (item) => ( + + ✦ {item} + + ), + )} + +
+
+
+ ); +}; diff --git a/marketing/shelldeck-promo/src/user-support/mobile/MobileIntroScene.tsx b/marketing/shelldeck-promo/src/user-support/mobile/MobileIntroScene.tsx new file mode 100644 index 00000000..11dc7f6f --- /dev/null +++ b/marketing/shelldeck-promo/src/user-support/mobile/MobileIntroScene.tsx @@ -0,0 +1,213 @@ +import { + AbsoluteFill, + Easing, + Interactive, + interpolate, + useCurrentFrame, + useVideoConfig, +} from "remotion"; +import { palette } from "../../theme"; +import { JourneyBackdrop } from "../JourneyBackdrop"; +import { RoleBadge } from "../RoleBadge"; +import { MobileHeader } from "./MobileShared"; + +export const MobileIntroScene: React.FC = () => { + const frame = useCurrentFrame(); + const { fps } = useVideoConfig(); + + return ( + + + + + +
+ Un parcours partagé +
+ + Une demande. +
+ Deux perspectives. +
+

+ L’utilisateur explique. Le support agit. +
+ ShellDeck garde le fil. +

+
+ + + + + +
+ +
+ DEMANDE #248 +
+
+ Accès au serveur de préproduction +
+
+ Atelier Nord · contexte joint +
+
+
+ + + + + + ); +}; diff --git a/marketing/shelldeck-promo/src/user-support/mobile/MobileModeSwitchScene.tsx b/marketing/shelldeck-promo/src/user-support/mobile/MobileModeSwitchScene.tsx new file mode 100644 index 00000000..2ffba6e9 --- /dev/null +++ b/marketing/shelldeck-promo/src/user-support/mobile/MobileModeSwitchScene.tsx @@ -0,0 +1,243 @@ +import { + AbsoluteFill, + Easing, + Interactive, + interpolate, + useCurrentFrame, +} from "remotion"; +import { Brand } from "../../components/Brand"; +import { palette } from "../../theme"; +import { ActionCursor } from "../ActionCursor"; +import { JourneyBackdrop } from "../JourneyBackdrop"; + +export const MobileModeSwitchScene: React.FC = () => { + const frame = useCurrentFrame(); + const supportActive = frame >= 51; + + return ( + + + + + + + +
+ Changer de perspective +
+ + La demande reste. +
+ Le mode change. +
+
+ + +
+ + ShellDeck + + + Atelier Nord + +
+ + + +
+ Utilisateur +
+
+ Support +
+ +
+ + +
+ DEMANDE #248 + {supportActive ? "À traiter" : "Envoyée"} +
+
+ Accès au serveur de préproduction +
+
+ Atelier Nord · Camille · maintenant +
+
+ Le même contexte arrive côté Support. +
+
+
+
+ ); +}; diff --git a/marketing/shelldeck-promo/src/user-support/mobile/MobileOutcomeScene.tsx b/marketing/shelldeck-promo/src/user-support/mobile/MobileOutcomeScene.tsx new file mode 100644 index 00000000..3d9de5a5 --- /dev/null +++ b/marketing/shelldeck-promo/src/user-support/mobile/MobileOutcomeScene.tsx @@ -0,0 +1,183 @@ +import { + AbsoluteFill, + Easing, + Interactive, + interpolate, + useCurrentFrame, + useVideoConfig, +} from "remotion"; +import { Brand } from "../../components/Brand"; +import { palette } from "../../theme"; +import { JourneyBackdrop } from "../JourneyBackdrop"; +import { RoleBadge } from "../RoleBadge"; + +export const MobileOutcomeScene: React.FC = () => { + const frame = useCurrentFrame(); + const { fps } = useVideoConfig(); + + return ( + + + + + + + +
+ + ✓ + +
+ + + + + Du besoin à la résolution. +
+ + Sans perdre le fil. + +
+ + ShellDeck réunit vos utilisateurs et votre équipe support. + + + shelldeck.1clic.pro + + + + ); +}; diff --git a/marketing/shelldeck-promo/src/user-support/mobile/MobileShared.tsx b/marketing/shelldeck-promo/src/user-support/mobile/MobileShared.tsx new file mode 100644 index 00000000..882f23e5 --- /dev/null +++ b/marketing/shelldeck-promo/src/user-support/mobile/MobileShared.tsx @@ -0,0 +1,74 @@ +import { Interactive } from "remotion"; +import { Brand } from "../../components/Brand"; +import { RoleBadge } from "../RoleBadge"; + +export const MobileHeader: React.FC<{ + role?: "Utilisateur" | "Support"; +}> = ({ role }) => ( + + + {role ? : null} + +); + +export const MobileWindowChrome: React.FC<{ title: string }> = ({ title }) => ( + + + + + + {title} + + +); + +export const MobilePill: React.FC<{ + children: React.ReactNode; + color?: string; +}> = ({ children, color = "#168ee0" }) => ( + + {children} + +); diff --git a/marketing/shelldeck-promo/src/user-support/mobile/MobileSupportScene.tsx b/marketing/shelldeck-promo/src/user-support/mobile/MobileSupportScene.tsx new file mode 100644 index 00000000..e0cd94e4 --- /dev/null +++ b/marketing/shelldeck-promo/src/user-support/mobile/MobileSupportScene.tsx @@ -0,0 +1,223 @@ +import { + AbsoluteFill, + Easing, + Interactive, + interpolate, + useCurrentFrame, +} from "remotion"; +import { palette } from "../../theme"; +import { ActionCursor } from "../ActionCursor"; +import { JourneyBackdrop } from "../JourneyBackdrop"; +import { MobileHeader, MobilePill, MobileWindowChrome } from "./MobileShared"; + +export const MobileSupportScene: React.FC = () => { + const frame = useCurrentFrame(); + const reply = "Je vérifie les accès et le service. Je reviens vers vous ici."; + const typedReply = reply.slice( + 0, + Math.floor( + interpolate(frame, [72, 125], [0, reply.length], { + extrapolateLeft: "clamp", + extrapolateRight: "clamp", + }), + ), + ); + + return ( + + + + + + Le Support reprend. +
+ Sans perdre le contexte. +
+
+ + + +
+ +
+ ● À traiter + + il y a 1 min + +
+
+ Accès au serveur de préproduction +
+
+ Camille · Atelier Nord +
+
+ +
+ Bonjour, pouvez-vous préparer un accès temporaire avant la mise en + ligne ? +
+
+ {typedReply || "Réponse…"} + {frame >= 72 && frame < 127 ? ( + │ + ) : null} +
+ +
+ + {typedReply ? "Réponse prête" : "Écrire une réponse…"} + +
+ = 131 ? "#20a873" : "#6d5ce7", + fontSize: 17, + fontWeight: 780, + scale: interpolate(frame, [124, 128, 132], [1, 0.88, 1], { + extrapolateLeft: "clamp", + extrapolateRight: "clamp", + output: "perceptual-scale", + }), + }} + > + {frame >= 131 ? "Envoyé ✓" : "Envoyer ↑"} + + +
+
+
+
+
+ ); +}; diff --git a/marketing/shelldeck-promo/src/user-support/mobile/MobileUserRequestScene.tsx b/marketing/shelldeck-promo/src/user-support/mobile/MobileUserRequestScene.tsx new file mode 100644 index 00000000..cc61d9b8 --- /dev/null +++ b/marketing/shelldeck-promo/src/user-support/mobile/MobileUserRequestScene.tsx @@ -0,0 +1,229 @@ +import { + AbsoluteFill, + Easing, + Interactive, + interpolate, + useCurrentFrame, +} from "remotion"; +import { palette } from "../../theme"; +import { ActionCursor } from "../ActionCursor"; +import { JourneyBackdrop } from "../JourneyBackdrop"; +import { MobileHeader, MobilePill, MobileWindowChrome } from "./MobileShared"; + +export const MobileUserRequestScene: React.FC = () => { + const frame = useCurrentFrame(); + const title = "Accès au serveur de préproduction"; + const details = "Préparer un accès temporaire pour la mise en ligne."; + const typedTitle = title.slice( + 0, + Math.floor( + interpolate(frame, [34, 76], [0, title.length], { + extrapolateLeft: "clamp", + extrapolateRight: "clamp", + }), + ), + ); + const typedDetails = details.slice( + 0, + Math.floor( + interpolate(frame, [70, 115], [0, details.length], { + extrapolateLeft: "clamp", + extrapolateRight: "clamp", + }), + ), + ); + + return ( + + + + + + + Décrivez le besoin. +
+ Gardez le suivi. +
+
+ + + +
+
+ ✦ Préparer avec l’IA + + Codex › + +
+
+
+ ◎ Atelier Nord + ● Normale +
+
+ {typedTitle || "Titre de la demande"} + {frame >= 34 && frame < 78 ? ( + │ + ) : null} +
+
+ {typedDetails || "Détails de la demande…"} + {frame >= 70 && frame < 118 ? ( + │ + ) : null} +
+
+ + +   ✦ + +
+ = 128 + ? "linear-gradient(110deg,#20a873,#2abb8d)" + : "linear-gradient(110deg,#168ee0,#315bd6)", + boxShadow: "0 12px 28px rgba(22,142,224,.25)", + fontSize: 19, + fontWeight: 780, + scale: interpolate(frame, [121, 125, 129], [1, 0.88, 1], { + extrapolateLeft: "clamp", + extrapolateRight: "clamp", + output: "perceptual-scale", + }), + }} + > + {frame >= 128 ? "Envoyée ✓" : "Créer"} + + +
+
+
+
+ PNG, JPEG, WebP · 9 Mo max +
+
+
+
+ ); +}; diff --git a/marketing/shelldeck-promo/src/user-support/mobile/MobileUserSupportPromo.tsx b/marketing/shelldeck-promo/src/user-support/mobile/MobileUserSupportPromo.tsx new file mode 100644 index 00000000..81edf358 --- /dev/null +++ b/marketing/shelldeck-promo/src/user-support/mobile/MobileUserSupportPromo.tsx @@ -0,0 +1,61 @@ +import { TransitionSeries, springTiming } from "@remotion/transitions"; +import { fade } from "@remotion/transitions/fade"; +import { slide } from "@remotion/transitions/slide"; +import { MobileAssistScene } from "./MobileAssistScene"; +import { MobileIntroScene } from "./MobileIntroScene"; +import { MobileModeSwitchScene } from "./MobileModeSwitchScene"; +import { MobileOutcomeScene } from "./MobileOutcomeScene"; +import { MobileSupportScene } from "./MobileSupportScene"; +import { MobileUserRequestScene } from "./MobileUserRequestScene"; + +export const MobileUserSupportPromo: React.FC = () => ( + + + + + + + + + + + + + + + + + + + + + + + + + +); diff --git a/marketing/shelldeck-promo/src/user-support/scenes/AssistResolveScene.tsx b/marketing/shelldeck-promo/src/user-support/scenes/AssistResolveScene.tsx new file mode 100644 index 00000000..c1c22b50 --- /dev/null +++ b/marketing/shelldeck-promo/src/user-support/scenes/AssistResolveScene.tsx @@ -0,0 +1,217 @@ +import { + AbsoluteFill, + Easing, + Interactive, + interpolate, + useCurrentFrame, + useVideoConfig, +} from "remotion"; +import { Brand } from "../../components/Brand"; +import { palette } from "../../theme"; +import { ActionCursor } from "../ActionCursor"; +import { FocusedCapture } from "../FocusedCapture"; +import { JourneyBackdrop } from "../JourneyBackdrop"; +import { RoleBadge } from "../RoleBadge"; + +const contextItems = ["Ticket sélectionné", "Résumé prêt", "Réponse proposée"]; + +export const AssistResolveScene: React.FC = () => { + const frame = useCurrentFrame(); + const { fps } = useVideoConfig(); + + return ( + + + + + + + + + + + + Le contexte est déjà là. +
+ + L’IA aide à agir. + +
+
+ + + + +
+ ✦ Résumé généré + Prêt ✓ +
+
+ Accès temporaire demandé pour Atelier Nord. Priorité normale, mise en ligne imminente. +
+
+ Utiliser dans la réponse → +
+
+
+ + + {contextItems.map((item, index) => ( + + ✦ + {item} + + ))} + + +
+ ); +}; diff --git a/marketing/shelldeck-promo/src/user-support/scenes/JourneyIntroScene.tsx b/marketing/shelldeck-promo/src/user-support/scenes/JourneyIntroScene.tsx new file mode 100644 index 00000000..8b529b46 --- /dev/null +++ b/marketing/shelldeck-promo/src/user-support/scenes/JourneyIntroScene.tsx @@ -0,0 +1,226 @@ +import { + AbsoluteFill, + Easing, + Interactive, + interpolate, + useCurrentFrame, + useVideoConfig, +} from "remotion"; +import { Brand } from "../../components/Brand"; +import { palette } from "../../theme"; +import { JourneyBackdrop } from "../JourneyBackdrop"; +import { RoleBadge } from "../RoleBadge"; + +const STAGE_WIDTH = 1320; +const NODE_SIZE = 144; +const NODE_CENTER_Y = 112; +const USER_CENTER_X = 132; +const SUPPORT_CENTER_X = STAGE_WIDTH - USER_CENTER_X; +const LINE_START_X = USER_CENTER_X + NODE_SIZE / 2 - 1; +const LINE_END_X = SUPPORT_CENTER_X - NODE_SIZE / 2 + 1; + +const Person: React.FC<{ + role: "Utilisateur" | "Support"; + color: string; + symbol: string; + centerX: number; + opacity: number; + offsetX: number; +}> = ({ + role, + color, + symbol, + centerX, + opacity, + offsetX, +}) => ( +
+
+ {symbol} +
+ +
+); + +export const JourneyIntroScene: React.FC = () => { + const frame = useCurrentFrame(); + const { fps } = useVideoConfig(); + const lineProgress = interpolate(frame, [1.02 * fps, 1.92 * fps], [0, 1], { + extrapolateLeft: "clamp", + extrapolateRight: "clamp", + easing: Easing.bezier(0.16, 1, 0.3, 1), + }); + const cardProgress = interpolate(frame, [1.28 * fps, 1.82 * fps], [0, 1], { + extrapolateLeft: "clamp", + extrapolateRight: "clamp", + easing: Easing.bezier(0.16, 1, 0.3, 1), + }); + + return ( + + + + + + + +
+ Un parcours partagé +
+ + Une demande. Deux perspectives. + +

+ L’utilisateur explique. Le support agit. ShellDeck garde le fil. +

+
+ + + + + + +
+ + +
+
+ DEMANDE #248 + Normale +
+
Accès au serveur de préproduction
+
Atelier Nord · contexte joint
+
+
+ + + + + + + ); +}; diff --git a/marketing/shelldeck-promo/src/user-support/scenes/JourneyOutcomeScene.tsx b/marketing/shelldeck-promo/src/user-support/scenes/JourneyOutcomeScene.tsx new file mode 100644 index 00000000..a5e1329f --- /dev/null +++ b/marketing/shelldeck-promo/src/user-support/scenes/JourneyOutcomeScene.tsx @@ -0,0 +1,185 @@ +import { + AbsoluteFill, + Easing, + Interactive, + interpolate, + useCurrentFrame, + useVideoConfig, +} from "remotion"; +import { Brand } from "../../components/Brand"; +import { palette } from "../../theme"; +import { JourneyBackdrop } from "../JourneyBackdrop"; +import { RoleBadge } from "../RoleBadge"; + +export const JourneyOutcomeScene: React.FC = () => { + const frame = useCurrentFrame(); + const { fps } = useVideoConfig(); + + return ( + + + + + + + + + + ✓ + + + + + + + Du besoin à la résolution. +
+ + Sans perdre le fil. + +
+ + ShellDeck réunit vos utilisateurs et votre équipe support. + + + shelldeck.1clic.pro + +
+
+ ); +}; diff --git a/marketing/shelldeck-promo/src/user-support/scenes/ModeSwitchScene.tsx b/marketing/shelldeck-promo/src/user-support/scenes/ModeSwitchScene.tsx new file mode 100644 index 00000000..302de882 --- /dev/null +++ b/marketing/shelldeck-promo/src/user-support/scenes/ModeSwitchScene.tsx @@ -0,0 +1,150 @@ +import { + AbsoluteFill, + Easing, + Interactive, + interpolate, + useCurrentFrame, +} from "remotion"; +import { Brand } from "../../components/Brand"; +import { palette } from "../../theme"; +import { ActionCursor } from "../ActionCursor"; +import { JourneyBackdrop } from "../JourneyBackdrop"; + +export const ModeSwitchScene: React.FC = () => { + const frame = useCurrentFrame(); + const supportActive = frame >= 52; + + return ( + + + + + + +
Changer de perspective
+

+ La demande reste. Le mode change. +

+
+ + +
+ ShellDeck + + + {[ + { label: "Utilisateur", active: !supportActive }, + { label: "Support", active: supportActive }, + ].map((mode) => ( +
{mode.label}
+ ))} +
+
Atelier Nord
+
+ + +
Bonjour, Camille
+
Suivez vos sites et vos demandes au même endroit.
+
+ {[{ value: "3", label: "Sites disponibles" }, { value: "1", label: "Demande en cours" }].map((item) => ( +
+
{item.value}
+
{item.label}
+
+ ))} +
+
✓ Demande #248 envoyée au support
+
+ + +
+
+
Bonjour, l’équipe Support
+
Une nouvelle demande requiert votre attention.
+
+ 1 nouvelle +
+
+
+ Accès au serveur de préproduction + À traiter +
+
Atelier Nord · Camille · maintenant
+
Préparer un accès temporaire pour la mise en ligne.
+
+
+ + + +
+ ); +}; diff --git a/marketing/shelldeck-promo/src/user-support/scenes/SupportTriageScene.tsx b/marketing/shelldeck-promo/src/user-support/scenes/SupportTriageScene.tsx new file mode 100644 index 00000000..389edaad --- /dev/null +++ b/marketing/shelldeck-promo/src/user-support/scenes/SupportTriageScene.tsx @@ -0,0 +1,199 @@ +import { + AbsoluteFill, + Easing, + Interactive, + interpolate, + useCurrentFrame, + useVideoConfig, +} from "remotion"; +import { Brand } from "../../components/Brand"; +import { palette } from "../../theme"; +import { ActionCursor } from "../ActionCursor"; +import { JourneyBackdrop } from "../JourneyBackdrop"; +import { RoleBadge } from "../RoleBadge"; + +const requests = [ + { title: "Accès à la préproduction", meta: "Atelier Nord · maintenant", active: true }, + { title: "Certificat à renouveler", meta: "Studio Cobalt · 12 min", active: false }, + { title: "Domaine à connecter", meta: "Maison Lune · 24 min", active: false }, +]; + +export const SupportTriageScene: React.FC = () => { + const frame = useCurrentFrame(); + const { fps } = useVideoConfig(); + const reply = "Accès préparé. Je vous envoie les identifiants sécurisés."; + const typedReply = reply.slice( + 0, + Math.floor( + interpolate(frame, [70, 116], [0, reply.length], { + extrapolateLeft: "clamp", + extrapolateRight: "clamp", + }), + ), + ); + + return ( + + + + + + + + + + + + Trier moins. Résoudre plus vite. + +

+ Chaque demande arrive avec son site, sa priorité et son historique. +

+
+ + +
+ Demandes + {frame < 28 ? "2" : "3"} à traiter +
+
+ Rechercher une demande… +
+
+ {requests.map((request, index) => ( + +
{request.title}
+
+ + {request.meta} +
+
+ ))} +
+
+ + +
+
+
Accès au serveur de préproduction
+
+ {["À traiter", "Normale", "Atelier Nord"].map((item, index) => ( + {index === 0 && frame >= 54 ? "En cours" : item} + ))} +
+
+ ✦ Résumer +
+
+
Camille Bernard · il y a 1 h
+
Préparer un accès temporaire pour la mise en ligne.
+
+ +
+ {frame >= 131 ? "Votre réponse…" : typedReply || "Répondre à Studio Cobalt…"} + {frame >= 70 && frame < 118 ? │ : null} +
+
+ +   ✦ Proposer une réponse + = 131 ? "#20a873" : "#6d5ce7", + scale: interpolate(frame, [125, 128, 132], [1, 0.82, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: Easing.spring({ damping: 180 }), output: "perceptual-scale" }), + }} + > + {frame >= 131 ? "Envoyé ✓" : "Envoyer ↑"} + +
+
+ + {reply} + +
+ +
+ ); +}; diff --git a/marketing/shelldeck-promo/src/user-support/scenes/UserRequestScene.tsx b/marketing/shelldeck-promo/src/user-support/scenes/UserRequestScene.tsx new file mode 100644 index 00000000..a7a94346 --- /dev/null +++ b/marketing/shelldeck-promo/src/user-support/scenes/UserRequestScene.tsx @@ -0,0 +1,286 @@ +import { + AbsoluteFill, + Easing, + Interactive, + interpolate, + useCurrentFrame, + useVideoConfig, +} from "remotion"; +import { Brand } from "../../components/Brand"; +import { palette } from "../../theme"; +import { ActionCursor } from "../ActionCursor"; +import { JourneyBackdrop } from "../JourneyBackdrop"; +import { RoleBadge } from "../RoleBadge"; + +const steps = ["Décrire", "Joindre", "Envoyer"]; + +export const UserRequestScene: React.FC = () => { + const frame = useCurrentFrame(); + const { fps } = useVideoConfig(); + const requestTitle = "Accès au serveur de préproduction"; + const requestDetails = "Préparer un accès temporaire pour la mise en ligne de l’Atelier Nord."; + const typedTitle = requestTitle.slice( + 0, + Math.floor( + interpolate(frame, [36, 76], [0, requestTitle.length], { + extrapolateLeft: "clamp", + extrapolateRight: "clamp", + }), + ), + ); + const typedDetails = requestDetails.slice( + 0, + Math.floor( + interpolate(frame, [70, 116], [0, requestDetails.length], { + extrapolateLeft: "clamp", + extrapolateRight: "clamp", + }), + ), + ); + + return ( + + + + + + + + + + + + Décrivez le besoin. +
+ Gardez le suivi. +
+ + Une demande claire, son site, sa priorité et ses pièces jointes. + +
+ + + {steps.map((step, index) => ( + + = 72 + index * 22 ? "linear-gradient(135deg, #20a873, #35c99b)" : "linear-gradient(135deg, #168ee0, #39b9e5)", + boxShadow: "0 8px 18px rgba(22,142,224,0.26)", + fontSize: 16, + fontWeight: 800, + }} + > + {frame >= 72 + index * 22 ? "✓" : index + 1} + + {step} + + ))} + + + +
+ + + + Nouvelle demande +
+
+
+ ✦ Préparer avec l’IA + Codex › +
+
+
+ ◎ Aucun site précis + ● Normale +
+
+ {typedTitle || "Titre de la demande"} + {frame >= 36 && frame < 78 ? │ : null} +
+
+ {typedDetails || "Détails de la demande…"} + {frame >= 70 && frame < 118 ? │ : null} +
+
+ +   ✦ +
+ = 128 ? "linear-gradient(110deg, #20a873, #2abb8d)" : "linear-gradient(110deg, #168ee0, #315bd6)", + boxShadow: "0 12px 28px rgba(22,142,224,.25)", + fontSize: 17, + fontWeight: 780, + opacity: interpolate(frame, [42, 58], [0.55, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" }), + scale: interpolate(frame, [121, 125, 129], [1, 0.87, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: Easing.spring({ damping: 170 }), output: "perceptual-scale" }), + }} + > + {frame >= 128 ? "Envoyée ✓" : "Créer"} + + +
+
+
+
PNG, JPEG, WebP · 9 Mo max
+
+
+ + ✓ + Demande transmise au Support + +
+ ); +}; diff --git a/marketing/shelldeck-promo/tsconfig.json b/marketing/shelldeck-promo/tsconfig.json new file mode 100644 index 00000000..7bfaf1ab --- /dev/null +++ b/marketing/shelldeck-promo/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2018", + "module": "Preserve", + "moduleResolution": "Bundler", + "jsx": "react-jsx", + "strict": true, + "noEmit": true, + "lib": ["es2015"], + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "noUnusedLocals": true + }, + "exclude": ["remotion.config.ts"] +} diff --git a/patches/adabraka-gpui/Cargo.toml b/patches/adabraka-gpui/Cargo.toml index a2f9898e..eb5fb408 100644 --- a/patches/adabraka-gpui/Cargo.toml +++ b/patches/adabraka-gpui/Cargo.toml @@ -59,7 +59,9 @@ test-support = [ "leak-detection", "collections/test-support", "rand", - "util/test-support", + # ShellDeck patch: SDPATCH-119 — GPUI's test harness does not use + # util::test; the full feature needlessly links git2 into every consumer. + "util/rand", "http_client/test-support", "wayland", "x11", @@ -396,7 +398,7 @@ features = [ version = "2.0" [dependencies.stacksafe] -version = "0.1" +version = "1" [dependencies.strum] version = "0.27.0" diff --git a/patches/adabraka-gpui/Cargo.toml.orig b/patches/adabraka-gpui/Cargo.toml.orig index 0c2babf9..fa78724f 100644 --- a/patches/adabraka-gpui/Cargo.toml.orig +++ b/patches/adabraka-gpui/Cargo.toml.orig @@ -362,7 +362,7 @@ features = ["union", "serde"] version = "2.0" [dependencies.stacksafe] -version = "0.1" +version = "1" [dependencies.strum] version = "0.27.0" diff --git a/patches/adabraka-gpui/PATCHES.md b/patches/adabraka-gpui/PATCHES.md index 57aa5f2c..47482c16 100644 --- a/patches/adabraka-gpui/PATCHES.md +++ b/patches/adabraka-gpui/PATCHES.md @@ -8,12 +8,92 @@ tarball. If GitHub ever comes back, prefer that per `.agents/patches.md` step 3.)* **Last synced**: 2026-07-07 (v0.3.0 → v0.5.1) -Total marker occurrences in code: **139** -(`rg "ShellDeck patch:" src/`; SDPATCH-103 is Cargo.toml-only and outside -the src-scoped marker convention.) +Total marker occurrences in code: **166** +(`rg "ShellDeck patch:" src/`; SDPATCH-103, SDPATCH-119, and SDPATCH-121 are +Cargo.toml-only and outside the src-scoped marker convention.) ## Patches +### SDPATCH-121 — Move stack-safety macros off an unmaintained diagnostic dependency + +- **Files / symbols**: + - `Cargo.toml` and `Cargo.toml.orig` — `stacksafe` dependency +- **Markers**: none — the manifests are outside the + `patches//src/` marker scope. The entry exists so the sync knows to + re-apply the dependency constraint after each overlay. +- **Why**: `stacksafe` 0.1's procedural macro retained the unmaintained + `proc-macro-error2` crate. Version 1 keeps the `StackSafe` wrapper and + `#[stacksafe]` attribute used by GPUI while moving the macro to maintained + Syn diagnostics, removing that transitive RustSec warning without changing + GPUI's recursive layout contract. +- **Upstream status**: not filed yet; suitable as a dependency update in the + vendored GPUI upstream. + +### SDPATCH-120 — Portable native tray availability, labels, and Linux callbacks + +- **Files / symbols**: + - `src/app.rs` — `App::is_tray_available` + - `src/platform.rs` — `Platform::is_tray_available`, `TrayMenuItem::Label` + - `src/platform/linux/platform.rs` — Linux tray availability and callback bridge + - `src/platform/linux/tray.rs` — `LinuxTray::is_available`, label conversion + - `src/platform/linux/{wayland,x11}/client.rs` — native tray delegation + - `src/platform/mac/{platform,tray}.rs` — availability and label rendering + - `src/platform/windows/{platform,tray}.rs` — availability, Explorer restart recovery, ICO decoding, and label rendering +- **Markers** (27): + - `src/app.rs` — `/// ShellDeck patch: SDPATCH-120 — expose native tray creation success so` + - `src/platform.rs` — `/// ShellDeck patch: SDPATCH-120 — make tray availability part of the` + - `src/platform.rs` — `/// ShellDeck patch: SDPATCH-120 — native tray counters need a portable,` + - `src/platform/linux/platform.rs` — `// ShellDeck patch: SDPATCH-120 — Linux clients report tray startup and` + - `src/platform/linux/platform.rs` — `// ShellDeck patch: SDPATCH-120 — accept thread-safe service callbacks in` + - `src/platform/linux/platform.rs` — `// ShellDeck patch: SDPATCH-120 — tray callbacks live in foreground` + - `src/platform/linux/platform.rs` — `// ShellDeck patch: SDPATCH-120 — preserve the backend's actual startup` + - `src/platform/linux/platform.rs` — `// ShellDeck patch: SDPATCH-120 — marshal ksni callbacks back onto GPUI's` + - `src/platform/linux/tray.rs` — `// ShellDeck patch: SDPATCH-120 — render informational rows disabled.` + - `src/platform/linux/tray.rs` — `// ShellDeck patch: SDPATCH-120 — a successful ksni handle is the native` + - `src/platform/linux/wayland/client.rs` — `// ShellDeck patch: SDPATCH-120 — thread tray availability through Wayland.` + - `src/platform/linux/wayland/client.rs` — `// ShellDeck patch: SDPATCH-120 — install thread-safe ksni event bridges.` + - `src/platform/linux/x11/client.rs` — `// ShellDeck patch: SDPATCH-120 — thread tray availability through X11.` + - `src/platform/linux/x11/client.rs` — `// ShellDeck patch: SDPATCH-120 — install thread-safe ksni event bridges.` + - `src/platform/mac/platform.rs` — `// ShellDeck patch: SDPATCH-120 — report the retained NSStatusItem.` + - `src/platform/mac/tray.rs` — `// ShellDeck patch: SDPATCH-120 — retain the actual NSStatusItem result for` + - `src/platform/mac/tray.rs` — `// ShellDeck patch: SDPATCH-120 — render informational rows disabled.` + - `src/platform/windows/platform.rs` — `// ShellDeck patch: SDPATCH-120 — the tray owner must be an invisible` + - `src/platform/windows/platform.rs` — `// ShellDeck patch: SDPATCH-120 — report the retained Win32 tray owner only` + - `src/platform/windows/platform.rs` — `// ShellDeck patch: SDPATCH-120 — restore the exact retained tray icon` + - `src/platform/windows/platform.rs` — `// ShellDeck patch: SDPATCH-120 — use Windows' registered broadcast ID as the` + - `src/platform/windows/platform.rs` — `// ShellDeck patch: SDPATCH-120 — the native Windows test runner must prove` + - `src/platform/windows/tray.rs` — `// ShellDeck patch: SDPATCH-120 — create the shell entry only after a real` + - `src/platform/windows/tray.rs` — `// ShellDeck patch: SDPATCH-120 — Explorer discards notification-area` + - `src/platform/windows/tray.rs` — `// ShellDeck patch: SDPATCH-120 — render informational rows disabled.` + - `src/platform/windows/tray.rs` — `// ShellDeck patch: SDPATCH-120 — decode a complete .ico file as an ICONDIR,` + - `src/platform/windows/tray.rs` — `// ShellDeck patch: SDPATCH-120 — pin Explorer-restart custody, ICO selection,` +- **Why**: ShellDeck now uses GPUI's native tray on every desktop instead of + carrying a second `tray-icon`/GTK stack. The application must know whether + tray creation actually succeeded before honoring start-hidden, needs + disabled native rows for counters and signed-out actions, and must marshal + Linux `ksni` service-thread callbacks onto GPUI's foreground executor before + invoking application code. Windows uses an invisible top-level owner so it + receives Explorer's registered `TaskbarCreated` broadcast, then re-adds the + retained HICON and derives availability from that exact result. +- **Upstream status**: not filed yet; the generic availability and label APIs + are suitable for upstream, while the callback bridge fixes an existing + Linux backend integration gap. + +### SDPATCH-119 — Keep GPUI test support free of unused Git fixtures + +- **Files / symbols**: + - `Cargo.toml` — `test-support` feature (uses `util/rand` instead of + `util/test-support`) +- **Markers**: none — `Cargo.toml` is outside the `patches//src/` + marker scope. The entry exists so the sync knows to re-apply it after each + overlay. +- **Why**: GPUI's test dispatcher needs `rand`, but no GPUI source or test uses + `adabraka_util::test`. Enabling the broader feature pulled `git2` and its + native library into all downstream test-support builds solely for an unused + temporary Git-tree helper. Selecting the exact required feature preserves + GPUI's test API while removing that unnecessary dependency path. +- **Upstream status**: not filed yet — suitable as a small feature-hygiene PR. + ### SDPATCH-101 — `PathPromptOptions::starting_directory` - **Files / symbols**: @@ -572,6 +652,26 @@ the src-scoped marker convention.) ## Sync log +- **2026-08-28** — Added SDPATCH-121: upgraded `stacksafe` from 0.1 to 1, + removing the `proc-macro-error2` dependency path. No source marker was added + because this is a Cargo.toml-only patch. + +- **2026-08-28** — Hardened SDPATCH-120 after independent review: the Windows + tray owner now receives `TaskbarCreated`, restores its retained HICON with + fail-closed availability, and carries native parser/HICON custody tests. + Added 6 markers, bringing the source total from 160 to 166. + +- **2026-08-28** — Added SDPATCH-120: made GPUI's native tray API sufficient + for ShellDeck's cross-platform menu, fail-safe hidden-start decision, and + Linux event routing. The Windows path decodes ShellDeck's ICO resource and + admits hidden start only after `NIM_ADD` accepts a real `HICON`. 21 new + markers, bringing the source total from 139 to 160. + +- **2026-08-28** — Added SDPATCH-119: narrowed GPUI's downstream + `test-support` feature to the `adabraka_util` capability it actually uses, + removing the unused `git2`/`libgit2-sys` test-fixture dependency path. No + source marker was added because this is a Cargo.toml-only patch. + - **2026-08-28** — Added SDPATCH-118: upgraded Linux text/SVG rendering to `cosmic-text` 0.19 and `resvg`/`usvg` 0.48, removing both `rustybuzz` branches and two obsolete `ttf-parser` branches. 4 new markers, bringing diff --git a/patches/adabraka-gpui/src/app.rs b/patches/adabraka-gpui/src/app.rs index 324b3d55..bc4e3d6c 100644 --- a/patches/adabraka-gpui/src/app.rs +++ b/patches/adabraka-gpui/src/app.rs @@ -1004,9 +1004,18 @@ impl App { } /// Set the system tray icon. - pub fn set_tray_icon(&self, icon: Option<&[u8]>) { - self.platform.set_tray_icon(icon); - } + pub fn set_tray_icon(&self, icon: Option<&[u8]>) { + self.platform.set_tray_icon(icon); + } + + /// ShellDeck patch: SDPATCH-120 — expose native tray creation success so + /// hidden-start applications cannot strand an invisible process. + /// Returns whether the platform accepted the tray icon and has a live + /// status-item backend. This lets applications avoid starting hidden when + /// a headless session or desktop environment has no tray service. + pub fn is_tray_available(&self) -> bool { + self.platform.is_tray_available() + } /// Set the system tray menu items. pub fn set_tray_menu(&self, menu: Vec) { diff --git a/patches/adabraka-gpui/src/platform.rs b/patches/adabraka-gpui/src/platform.rs index 7a730008..4c1f6b67 100644 --- a/patches/adabraka-gpui/src/platform.rs +++ b/patches/adabraka-gpui/src/platform.rs @@ -341,8 +341,14 @@ pub(crate) trait Platform: 'static { fn keyboard_mapper(&self) -> Rc; fn on_keyboard_layout_change(&self, callback: Box); - fn set_tray_icon(&self, _icon: Option<&[u8]>) {} - fn set_tray_menu(&self, _menu: Vec) {} + fn set_tray_icon(&self, _icon: Option<&[u8]>) {} + /// ShellDeck patch: SDPATCH-120 — make tray availability part of the + /// portable platform contract. + /// Whether the native tray backend successfully created a live status item. + fn is_tray_available(&self) -> bool { + false + } + fn set_tray_menu(&self, _menu: Vec) {} fn set_tray_tooltip(&self, _tooltip: &str) {} fn set_tray_panel_mode(&self, _enabled: bool) {} fn get_tray_icon_bounds(&self) -> Option> { @@ -1604,14 +1610,21 @@ pub enum TrayIconEvent { /// A menu item for a system tray context menu. #[derive(Debug, Clone)] -pub enum TrayMenuItem { +pub enum TrayMenuItem { /// A clickable action item. - Action { + Action { /// The display label. label: SharedString, /// A unique identifier for this action. - id: SharedString, - }, + id: SharedString, + }, + /// ShellDeck patch: SDPATCH-120 — native tray counters need a portable, + /// non-interactive menu row instead of a fake action. + /// A non-interactive informational row. + Label { + /// The display label. + label: SharedString, + }, /// A visual separator between menu items. Separator, /// A submenu containing nested items. diff --git a/patches/adabraka-gpui/src/platform/linux/platform.rs b/patches/adabraka-gpui/src/platform/linux/platform.rs index 213d2dff..2ae713df 100644 --- a/patches/adabraka-gpui/src/platform/linux/platform.rs +++ b/patches/adabraka-gpui/src/platform/linux/platform.rs @@ -99,9 +99,18 @@ pub trait LinuxClient { None } - fn set_tray_icon(&self, _icon: Option<&[u8]>) {} - fn set_tray_menu(&self, _menu: Vec) {} - fn set_tray_tooltip(&self, _tooltip: &str) {} + fn set_tray_icon(&self, _icon: Option<&[u8]>) {} + // ShellDeck patch: SDPATCH-120 — Linux clients report tray startup and + // accept callbacks that are safe to invoke from the ksni service thread. + fn is_tray_available(&self) -> bool { + false + } + fn set_tray_menu(&self, _menu: Vec) {} + fn set_tray_tooltip(&self, _tooltip: &str) {} + // ShellDeck patch: SDPATCH-120 — accept thread-safe service callbacks in + // addition to GPUI's foreground-only application callbacks. + fn set_tray_icon_event_handler(&self, _callback: Box) {} + fn set_tray_menu_action_handler(&self, _callback: Box) {} fn register_global_hotkey(&self, _id: u32, _keystroke: &Keystroke) -> Result<()> { Err(anyhow::anyhow!( "Global hotkeys not supported on this platform" @@ -132,10 +141,10 @@ pub(crate) struct PlatformHandlers { pub(crate) reopen: Option>, pub(crate) app_menu_action: Option>, pub(crate) will_open_app_menu: Option>, - pub(crate) validate_app_menu_command: Option bool>>, - pub(crate) keyboard_layout_change: Option>, - pub(crate) tray_icon_event: Option>, - pub(crate) tray_menu_action: Option>, + pub(crate) validate_app_menu_command: Option bool>>, + pub(crate) keyboard_layout_change: Option>, + // ShellDeck patch: SDPATCH-120 — tray callbacks live in foreground + // receiver tasks instead of this cross-platform callback bag. pub(crate) global_hotkey: Option>, // ShellDeck patch: retain the Wayland portal registration-result callback. pub(crate) global_hotkey_registration: @@ -733,9 +742,15 @@ impl Platform for P { self.with_common(|common| common.keep_alive_without_windows = keep_alive); } - fn set_tray_icon(&self, icon: Option<&[u8]>) { - LinuxClient::set_tray_icon(self, icon); - } + fn set_tray_icon(&self, icon: Option<&[u8]>) { + LinuxClient::set_tray_icon(self, icon); + } + + // ShellDeck patch: SDPATCH-120 — preserve the backend's actual startup + // result through the platform facade. + fn is_tray_available(&self) -> bool { + LinuxClient::is_tray_available(self) + } fn set_tray_menu(&self, menu: Vec) { LinuxClient::set_tray_menu(self, menu); @@ -745,13 +760,45 @@ impl Platform for P { LinuxClient::set_tray_tooltip(self, tooltip); } - fn on_tray_icon_event(&self, callback: Box) { - self.with_common(|common| common.callbacks.tray_icon_event = Some(callback)); - } - - fn on_tray_menu_action(&self, callback: Box) { - self.with_common(|common| common.callbacks.tray_menu_action = Some(callback)); - } + // ShellDeck patch: SDPATCH-120 — marshal ksni callbacks back onto GPUI's + // foreground executor before invoking non-Send application callbacks. + fn on_tray_icon_event(&self, callback: Box) { + let (tx, rx) = flume::unbounded(); + LinuxClient::set_tray_icon_event_handler( + self, + Box::new(move |event| { + let _ = tx.send(event); + }), + ); + let executor = self.with_common(|common| common.foreground_executor.clone()); + executor + .spawn(async move { + let mut callback = callback; + while let Ok(event) = rx.recv_async().await { + callback(event); + } + }) + .detach(); + } + + fn on_tray_menu_action(&self, callback: Box) { + let (tx, rx) = flume::unbounded(); + LinuxClient::set_tray_menu_action_handler( + self, + Box::new(move |id| { + let _ = tx.send(id); + }), + ); + let executor = self.with_common(|common| common.foreground_executor.clone()); + executor + .spawn(async move { + let mut callback = callback; + while let Ok(id) = rx.recv_async().await { + callback(id); + } + }) + .detach(); + } fn register_global_hotkey(&self, id: u32, keystroke: &Keystroke) -> Result<()> { LinuxClient::register_global_hotkey(self, id, keystroke) diff --git a/patches/adabraka-gpui/src/platform/linux/tray.rs b/patches/adabraka-gpui/src/platform/linux/tray.rs index d2777585..af093edc 100644 --- a/patches/adabraka-gpui/src/platform/linux/tray.rs +++ b/patches/adabraka-gpui/src/platform/linux/tray.rs @@ -93,7 +93,7 @@ fn convert_menu_item( action_callback: &TrayActionCallback, ) -> ksni::MenuItem { match item { - TrayMenuItem::Action { label, id } => { + TrayMenuItem::Action { label, id } => { let id_clone = id.clone(); let cb = action_callback.clone(); ksni::MenuItem::Standard(ksni::menu::StandardItem { @@ -106,9 +106,15 @@ fn convert_menu_item( } }), ..Default::default() - }) - } - TrayMenuItem::Separator => ksni::MenuItem::Separator, + }) + } + // ShellDeck patch: SDPATCH-120 — render informational rows disabled. + TrayMenuItem::Label { label } => ksni::MenuItem::Standard(ksni::menu::StandardItem { + label: label.to_string(), + enabled: false, + ..Default::default() + }), + TrayMenuItem::Separator => ksni::MenuItem::Separator, TrayMenuItem::Submenu { label, items } => ksni::MenuItem::SubMenu(ksni::menu::SubMenu { label: label.to_string(), submenu: items @@ -172,7 +178,7 @@ impl LinuxTray { self.handle = tray.spawn().ok(); } - pub fn set_icon(&mut self, icon_data: Option<&[u8]>) { + pub fn set_icon(&mut self, icon_data: Option<&[u8]>) { self.ensure_started(); if let Some(handle) = &self.handle { let data = icon_data.unwrap_or(&[]).to_vec(); @@ -180,7 +186,13 @@ impl LinuxTray { tray.icon_data = data.clone(); }); } - } + } + + // ShellDeck patch: SDPATCH-120 — a successful ksni handle is the native + // availability authority used by hidden-start policy. + pub fn is_available(&self) -> bool { + self.handle.is_some() + } pub fn set_tooltip(&mut self, tooltip: &str) { self.ensure_started(); diff --git a/patches/adabraka-gpui/src/platform/linux/wayland/client.rs b/patches/adabraka-gpui/src/platform/linux/wayland/client.rs index 190722bb..96e750e0 100644 --- a/patches/adabraka-gpui/src/platform/linux/wayland/client.rs +++ b/patches/adabraka-gpui/src/platform/linux/wayland/client.rs @@ -938,17 +938,31 @@ impl LinuxClient for WaylandClient { inner(active_window.map(|aw| aw.surface())) } - fn set_tray_icon(&self, icon: Option<&[u8]>) { - self.0.borrow_mut().tray.set_icon(icon); - } + fn set_tray_icon(&self, icon: Option<&[u8]>) { + self.0.borrow_mut().tray.set_icon(icon); + } + + // ShellDeck patch: SDPATCH-120 — thread tray availability through Wayland. + fn is_tray_available(&self) -> bool { + self.0.borrow().tray.is_available() + } fn set_tray_menu(&self, menu: Vec) { self.0.borrow_mut().tray.set_menu(menu); } - fn set_tray_tooltip(&self, tooltip: &str) { - self.0.borrow_mut().tray.set_tooltip(tooltip); - } + fn set_tray_tooltip(&self, tooltip: &str) { + self.0.borrow_mut().tray.set_tooltip(tooltip); + } + + // ShellDeck patch: SDPATCH-120 — install thread-safe ksni event bridges. + fn set_tray_icon_event_handler(&self, callback: Box) { + self.0.borrow().tray.set_on_click(callback); + } + + fn set_tray_menu_action_handler(&self, callback: Box) { + self.0.borrow().tray.set_on_menu_action(callback); + } // ShellDeck patch: expose the Wayland portal manager through LinuxClient. fn register_global_hotkey(&self, id: u32, keystroke: &Keystroke) -> crate::Result<()> { diff --git a/patches/adabraka-gpui/src/platform/linux/x11/client.rs b/patches/adabraka-gpui/src/platform/linux/x11/client.rs index 4c1004c5..ed575e96 100644 --- a/patches/adabraka-gpui/src/platform/linux/x11/client.rs +++ b/patches/adabraka-gpui/src/platform/linux/x11/client.rs @@ -2103,16 +2103,30 @@ impl LinuxClient for X11Client { } fn set_tray_icon(&self, icon: Option<&[u8]>) { - self.0.borrow_mut().tray.set_icon(icon); - } + self.0.borrow_mut().tray.set_icon(icon); + } + + // ShellDeck patch: SDPATCH-120 — thread tray availability through X11. + fn is_tray_available(&self) -> bool { + self.0.borrow().tray.is_available() + } fn set_tray_menu(&self, menu: Vec) { self.0.borrow_mut().tray.set_menu(menu); } - fn set_tray_tooltip(&self, tooltip: &str) { - self.0.borrow_mut().tray.set_tooltip(tooltip); - } + fn set_tray_tooltip(&self, tooltip: &str) { + self.0.borrow_mut().tray.set_tooltip(tooltip); + } + + // ShellDeck patch: SDPATCH-120 — install thread-safe ksni event bridges. + fn set_tray_icon_event_handler(&self, callback: Box) { + self.0.borrow().tray.set_on_click(callback); + } + + fn set_tray_menu_action_handler(&self, callback: Box) { + self.0.borrow().tray.set_on_menu_action(callback); + } fn register_global_hotkey(&self, id: u32, keystroke: &Keystroke) -> crate::Result<()> { let mut state = self.0.borrow_mut(); diff --git a/patches/adabraka-gpui/src/platform/mac/platform.rs b/patches/adabraka-gpui/src/platform/mac/platform.rs index 6ed71b60..ad934118 100644 --- a/patches/adabraka-gpui/src/platform/mac/platform.rs +++ b/patches/adabraka-gpui/src/platform/mac/platform.rs @@ -1344,7 +1344,7 @@ impl Platform for MacPlatform { self.0.lock().keep_alive_without_windows = keep_alive; } - fn set_tray_icon(&self, icon: Option<&[u8]>) { + fn set_tray_icon(&self, icon: Option<&[u8]>) { let mut state = self.0.lock(); if state.tray.is_none() { state.tray = Some(MacTray::new()); @@ -1475,6 +1475,15 @@ impl Platform for MacPlatform { super::active_window::get_focused_window_info() } + // ShellDeck patch: SDPATCH-120 — report the retained NSStatusItem. + fn is_tray_available(&self) -> bool { + self.0 + .lock() + .tray + .as_ref() + .is_some_and(MacTray::is_available) + } + // ShellDeck patch: expose visible external top-level macOS window snapshots. fn visible_external_windows(&self) -> Vec { super::external_windows::visible_external_windows() diff --git a/patches/adabraka-gpui/src/platform/mac/tray.rs b/patches/adabraka-gpui/src/platform/mac/tray.rs index 78caed47..f9b298ec 100644 --- a/patches/adabraka-gpui/src/platform/mac/tray.rs +++ b/patches/adabraka-gpui/src/platform/mac/tray.rs @@ -38,7 +38,7 @@ impl MacTray { } } - pub fn set_icon(&self, icon_data: Option<&[u8]>) { + pub fn set_icon(&self, icon_data: Option<&[u8]>) { unsafe { let button: id = msg_send![*self.status_item, button]; if button == nil { @@ -66,7 +66,13 @@ impl MacTray { } } } - } + } + + // ShellDeck patch: SDPATCH-120 — retain the actual NSStatusItem result for + // hidden-start availability decisions. + pub fn is_available(&self) -> bool { + *self.status_item != nil + } #[allow(dead_code)] pub fn set_title(&self, title: &str) { @@ -213,16 +219,26 @@ pub(crate) unsafe fn build_menu_with_selector( unsafe { for item in items { match item { - TrayMenuItem::Action { label, id } => { + TrayMenuItem::Action { label, id } => { let title = NSString::alloc(nil).init_str(label.as_ref()); let menu_item: id = msg_send![class!(NSMenuItem), alloc]; let empty = NSString::alloc(nil).init_str(""); let menu_item: id = msg_send![menu_item, initWithTitle:title action:nil keyEquivalent:empty]; configure_actionable_item_with_selector(menu_item, id.as_ref(), selector); - let _: () = msg_send![menu, addItem: menu_item]; - } - TrayMenuItem::Separator => { + let _: () = msg_send![menu, addItem: menu_item]; + } + // ShellDeck patch: SDPATCH-120 — render informational rows disabled. + TrayMenuItem::Label { label } => { + let title = NSString::alloc(nil).init_str(label.as_ref()); + let menu_item: id = msg_send![class!(NSMenuItem), alloc]; + let empty = NSString::alloc(nil).init_str(""); + let menu_item: id = + msg_send![menu_item, initWithTitle:title action:nil keyEquivalent:empty]; + let _: () = msg_send![menu_item, setEnabled: NO]; + let _: () = msg_send![menu, addItem: menu_item]; + } + TrayMenuItem::Separator => { let separator: id = msg_send![class!(NSMenuItem), separatorItem]; let _: () = msg_send![menu, addItem: separator]; } diff --git a/patches/adabraka-gpui/src/platform/windows/platform.rs b/patches/adabraka-gpui/src/platform/windows/platform.rs index 91ab6e7f..d0b750f8 100644 --- a/patches/adabraka-gpui/src/platform/windows/platform.rs +++ b/patches/adabraka-gpui/src/platform/windows/platform.rs @@ -243,8 +243,11 @@ impl WindowsPlatform { main_receiver: Some(main_receiver), directx_devices: Some(directx_devices), }; - let result = unsafe { - CreateWindowExW( + let result = unsafe { + // ShellDeck patch: SDPATCH-120 — the tray owner must be an invisible + // top-level window because message-only windows do not receive the + // broadcast TaskbarCreated message after Explorer restarts. + CreateWindowExW( WINDOW_EX_STYLE(0), PLATFORM_WINDOW_CLASS_NAME, None, @@ -253,7 +256,7 @@ impl WindowsPlatform { 0, 0, 0, - Some(HWND_MESSAGE), + None, None, None, Some(&context as *const _ as *const _), @@ -838,7 +841,7 @@ impl Platform for WindowsPlatform { .store(keep_alive, Ordering::Release); } - fn set_tray_icon(&self, icon: Option<&[u8]>) { + fn set_tray_icon(&self, icon: Option<&[u8]>) { let mut state = self.inner.state.borrow_mut(); if let Some(ref mut tray) = state.tray { tray.set_icon(icon, self.handle); @@ -889,6 +892,19 @@ impl Platform for WindowsPlatform { super::active_window::get_focused_window_info() } + // ShellDeck patch: SDPATCH-120 — report the retained Win32 tray owner only + // when Explorer-restart recovery was registered successfully. + fn is_tray_available(&self) -> bool { + taskbar_created_message() != 0 + && self + .inner + .state + .borrow() + .tray + .as_ref() + .is_some_and(WindowsTray::is_available) + } + // ShellDeck patch: expose visible external top-level Win32 window snapshots. fn visible_external_windows(&self) -> Vec { let mut own_windows = self @@ -1112,7 +1128,7 @@ impl Platform for WindowsPlatform { } } -impl WindowsPlatformInner { +impl WindowsPlatformInner { fn new(context: &mut PlatformWindowCreateContext) -> Result> { let state = RefCell::new(WindowsPlatformState::new( context.directx_devices.take().unwrap(), @@ -1126,15 +1142,25 @@ impl WindowsPlatformInner { })) } - fn handle_msg( + fn handle_msg( self: &Rc, handle: HWND, msg: u32, wparam: WPARAM, lparam: LPARAM, ) -> LRESULT { - let handled = match msg { - WM_GPUI_CLOSE_ONE_WINDOW + // ShellDeck patch: SDPATCH-120 — restore the exact retained tray icon + // when Explorer announces that its notification area was recreated. + let handled = if msg == taskbar_created_message() && msg != 0 { + if let Some(tray) = self.state.borrow_mut().tray.as_mut() { + if !tray.handle_taskbar_created() { + log::error!("failed to restore tray icon after Explorer restart"); + } + } + Some(0) + } else { + match msg { + WM_GPUI_CLOSE_ONE_WINDOW | WM_GPUI_TASK_DISPATCHED_ON_MAIN_THREAD | WM_GPUI_DOCK_MENU_ACTION | WM_GPUI_KEYBOARD_LAYOUT_CHANGED @@ -1147,8 +1173,9 @@ impl WindowsPlatformInner { WM_GPUI_NETWORK_CHANGE => self.handle_network_change(), WM_GPUI_MEDIA_KEY => self.handle_media_key(wparam, lparam), WM_GPUI_CONTEXT_MENU_ACTION => self.handle_context_menu_action(wparam, lparam), - _ => None, - }; + _ => None, + } + }; if let Some(result) = handled { LRESULT(result) } else { @@ -1700,7 +1727,14 @@ fn handle_gpu_device_lost( } } -const PLATFORM_WINDOW_CLASS_NAME: PCWSTR = w!("Zed::PlatformWindow"); +const PLATFORM_WINDOW_CLASS_NAME: PCWSTR = w!("Zed::PlatformWindow"); + +// ShellDeck patch: SDPATCH-120 — use Windows' registered broadcast ID as the +// single Explorer-restart authority and retain registration failure as zero. +fn taskbar_created_message() -> u32 { + static MESSAGE: std::sync::OnceLock = std::sync::OnceLock::new(); + *MESSAGE.get_or_init(|| unsafe { RegisterWindowMessageW(w!("TaskbarCreated")) }) +} fn register_platform_window_class() { let wc = WNDCLASSW { @@ -1755,12 +1789,20 @@ unsafe extern "system" fn window_procedure( result } -#[cfg(test)] -mod tests { - use crate::{ClipboardItem, read_from_clipboard, write_to_clipboard}; - - #[test] - fn test_clipboard() { +#[cfg(test)] +mod tests { + use crate::{ClipboardItem, read_from_clipboard, write_to_clipboard}; + + // ShellDeck patch: SDPATCH-120 — the native Windows test runner must prove + // Explorer's registered recovery broadcast is available before hidden start. + // SDTEST-1813 + #[test] + fn sdtest_1813_taskbar_created_message_registration_is_live() { + assert_ne!(super::taskbar_created_message(), 0); + } + + #[test] + fn test_clipboard() { let item = ClipboardItem::new_string("你好,我是张小白".to_string()); write_to_clipboard(item.clone()); assert_eq!(read_from_clipboard(), Some(item)); diff --git a/patches/adabraka-gpui/src/platform/windows/tray.rs b/patches/adabraka-gpui/src/platform/windows/tray.rs index 728df794..edbb039d 100644 --- a/patches/adabraka-gpui/src/platform/windows/tray.rs +++ b/patches/adabraka-gpui/src/platform/windows/tray.rs @@ -27,64 +27,133 @@ pub(crate) struct WindowsTray { pub(crate) command_id_map: HashMap, } -impl WindowsTray { - pub fn new(hwnd: HWND) -> Self { - let mut tray = Self { - icon_added: false, - hwnd, - current_icon: None, - menu_items: Vec::new(), - command_id_map: HashMap::new(), - }; - tray.ensure_icon(hwnd); - tray - } +impl WindowsTray { + // ShellDeck patch: SDPATCH-120 — create the shell entry only after a real + // HICON exists, include it in the authoritative NIM_ADD, and retain the + // exact add result as the hidden-start availability boundary. + pub fn new(hwnd: HWND) -> Self { + Self { + icon_added: false, + hwnd, + current_icon: None, + menu_items: Vec::new(), + command_id_map: HashMap::new(), + } + } + + pub(crate) fn is_available(&self) -> bool { + self.icon_added && self.current_icon.is_some() + } + + pub fn set_icon(&mut self, icon_data: Option<&[u8]>, hwnd: HWND) { + let Some(icon_data) = icon_data else { + self.remove_icon(); + return; + }; + let Some(hicon) = create_hicon_from_bytes(icon_data) else { + return; + }; + + if self.icon_added { + let modify = NOTIFYICONDATAW { + cbSize: std::mem::size_of::() as u32, + hWnd: hwnd, + uID: TRAY_ICON_ID, + uFlags: NIF_ICON, + hIcon: hicon, + ..Default::default() + }; + if unsafe { Shell_NotifyIconW(NIM_MODIFY, &modify).as_bool() } { + self.replace_retained_icon(hicon); + return; + } + + // Explorer may have restarted and forgotten the old entry. Remove + // any surviving registration before retrying one authoritative add. + self.remove_icon(); + } + + let mut nid = NOTIFYICONDATAW { + cbSize: std::mem::size_of::() as u32, + hWnd: hwnd, + uID: TRAY_ICON_ID, + uFlags: NIF_MESSAGE | NIF_SHOWTIP | NIF_ICON, + uCallbackMessage: WM_GPUI_TRAY_ICON, + hIcon: hicon, + ..Default::default() + }; + if unsafe { Shell_NotifyIconW(NIM_ADD, &nid).as_bool() } { + self.icon_added = true; + self.replace_retained_icon(hicon); + } else { + unsafe { + let _ = DestroyIcon(hicon); + } + } + } + + fn replace_retained_icon(&mut self, hicon: HICON) { + if let Some(old_icon) = self.current_icon.replace(hicon) { + unsafe { + let _ = DestroyIcon(old_icon); + } + } + } + + // ShellDeck patch: SDPATCH-120 — Explorer discards notification-area + // registrations when it restarts. Re-add the retained HICON on the + // TaskbarCreated broadcast and make that exact NIM_ADD result authoritative. + pub(crate) fn handle_taskbar_created(&mut self) -> bool { + self.handle_taskbar_created_with(|nid| unsafe { + Shell_NotifyIconW(NIM_ADD, nid).as_bool() + }) + } + + fn handle_taskbar_created_with( + &mut self, + add_icon: impl FnOnce(&NOTIFYICONDATAW) -> bool, + ) -> bool { + self.icon_added = false; + let Some(hicon) = self.current_icon else { + return false; + }; + let nid = NOTIFYICONDATAW { + cbSize: std::mem::size_of::() as u32, + hWnd: self.hwnd, + uID: TRAY_ICON_ID, + uFlags: NIF_MESSAGE | NIF_SHOWTIP | NIF_ICON, + uCallbackMessage: WM_GPUI_TRAY_ICON, + hIcon: hicon, + ..Default::default() + }; + self.icon_added = add_icon(&nid); + self.icon_added + } + + fn remove_icon(&mut self) { + if self.icon_added { + let nid = NOTIFYICONDATAW { + cbSize: std::mem::size_of::() as u32, + hWnd: self.hwnd, + uID: TRAY_ICON_ID, + ..Default::default() + }; + unsafe { + let _ = Shell_NotifyIconW(NIM_DELETE, &nid); + } + self.icon_added = false; + } + if let Some(icon) = self.current_icon.take() { + unsafe { + let _ = DestroyIcon(icon); + } + } + } - fn ensure_icon(&mut self, hwnd: HWND) { - if self.icon_added { - return; - } - let mut nid = NOTIFYICONDATAW { - cbSize: std::mem::size_of::() as u32, - hWnd: hwnd, - uID: TRAY_ICON_ID, - uFlags: NIF_MESSAGE | NIF_SHOWTIP, - uCallbackMessage: WM_GPUI_TRAY_ICON, - ..Default::default() - }; - unsafe { - let _ = Shell_NotifyIconW(NIM_ADD, &nid); - } - self.icon_added = true; - } - - pub fn set_icon(&mut self, icon_data: Option<&[u8]>, hwnd: HWND) { - self.ensure_icon(hwnd); - if let Some(old_icon) = self.current_icon.take() { - unsafe { - let _ = DestroyIcon(old_icon); - } - } - let hicon = match icon_data { - Some(data) => create_hicon_from_bytes(data), - None => None, - }; - self.current_icon = hicon; - let mut nid = NOTIFYICONDATAW { - cbSize: std::mem::size_of::() as u32, - hWnd: hwnd, - uID: TRAY_ICON_ID, - uFlags: NIF_ICON, - hIcon: hicon.unwrap_or_default(), - ..Default::default() - }; - unsafe { - let _ = Shell_NotifyIconW(NIM_MODIFY, &nid); - } - } - - pub fn set_tooltip(&mut self, tooltip: &str, hwnd: HWND) { - self.ensure_icon(hwnd); + pub fn set_tooltip(&mut self, tooltip: &str, hwnd: HWND) { + if !self.is_available() { + return; + } let mut nid = NOTIFYICONDATAW { cbSize: std::mem::size_of::() as u32, hWnd: hwnd, @@ -164,7 +233,7 @@ impl WindowsTray { ) { for item in items.iter() { match item { - TrayMenuItem::Action { label, id } => { + TrayMenuItem::Action { label, id } => { let cmd_id = *counter; *counter += 1; id_map.insert(cmd_id, id.clone()); @@ -172,9 +241,21 @@ impl WindowsTray { unsafe { let _ = AppendMenuW(hmenu, MF_STRING, cmd_id as usize, PCWSTR(wide.as_ptr())); - } - } - TrayMenuItem::Separator => unsafe { + } + } + // ShellDeck patch: SDPATCH-120 — render informational rows disabled. + TrayMenuItem::Label { label } => { + let wide: Vec = label.encode_utf16().chain(Some(0)).collect(); + unsafe { + let _ = AppendMenuW( + hmenu, + MF_STRING | MF_DISABLED, + 0, + PCWSTR(wide.as_ptr()), + ); + } + } + TrayMenuItem::Separator => unsafe { let _ = AppendMenuW(hmenu, MF_SEPARATOR, 0, None); }, TrayMenuItem::Submenu { @@ -215,38 +296,167 @@ impl WindowsTray { } } -impl Drop for WindowsTray { - fn drop(&mut self) { - if self.icon_added { - let mut nid = NOTIFYICONDATAW { - cbSize: std::mem::size_of::() as u32, - hWnd: self.hwnd, - uID: TRAY_ICON_ID, - ..Default::default() - }; - unsafe { - let _ = Shell_NotifyIconW(NIM_DELETE, &nid); - } - } - if let Some(icon) = self.current_icon.take() { - unsafe { - let _ = DestroyIcon(icon); - } - } - } -} - -fn create_hicon_from_bytes(data: &[u8]) -> Option { - unsafe { - let offset = LookupIconIdFromDirectoryEx(data.as_ptr(), true, 0, 0, LR_DEFAULTCOLOR); - if offset <= 0 { - return None; - } - if (offset as usize) >= data.len() { - return None; - } - let icon_data = &data[offset as usize..]; - let hicon = CreateIconFromResourceEx(icon_data, true, 0x00030000, 0, 0, LR_DEFAULTCOLOR); - hicon.ok() - } -} +impl Drop for WindowsTray { + fn drop(&mut self) { + self.remove_icon(); + } +} + +// ShellDeck patch: SDPATCH-120 — decode a complete .ico file as an ICONDIR, +// select its closest high-depth image, and pass only that image resource to +// CreateIconFromResourceEx. LookupIconIdFromDirectoryEx returns a resource ID, +// not an offset into an .ico file, and cannot decode PNG bytes directly. +fn create_hicon_from_bytes(data: &[u8]) -> Option { + let resource = ico_resource(data, 32)?; + unsafe { + CreateIconFromResourceEx(resource, true, 0x00030000, 32, 32, LR_DEFAULTCOLOR).ok() + } +} + +fn ico_resource(data: &[u8], desired_size: u16) -> Option<&[u8]> { + if read_u16(data, 0)? != 0 || read_u16(data, 2)? != 1 { + return None; + } + let count = usize::from(read_u16(data, 4)?); + let directory_end = 6usize.checked_add(count.checked_mul(16)?)?; + if count == 0 || directory_end > data.len() { + return None; + } + + let mut best: Option<(u32, u16, &[u8])> = None; + for index in 0..count { + let entry = 6usize.checked_add(index.checked_mul(16)?)?; + let width = match *data.get(entry)? { + 0 => 256, + width => u16::from(width), + }; + let height = match *data.get(entry + 1)? { + 0 => 256, + height => u16::from(height), + }; + let bit_depth = read_u16(data, entry + 6)?; + let length = usize::try_from(read_u32(data, entry + 8)?).ok()?; + let offset = usize::try_from(read_u32(data, entry + 12)?).ok()?; + let end = offset.checked_add(length)?; + if length == 0 || offset < directory_end || end > data.len() { + return None; + } + let distance = u32::from(width.abs_diff(desired_size)) + .checked_add(u32::from(height.abs_diff(desired_size)))?; + let resource = data.get(offset..end)?; + if best + .as_ref() + .is_none_or(|(best_distance, best_depth, _)| { + distance < *best_distance + || (distance == *best_distance && bit_depth > *best_depth) + }) + { + best = Some((distance, bit_depth, resource)); + } + } + best.map(|(_, _, resource)| resource) +} + +fn read_u16(data: &[u8], offset: usize) -> Option { + Some(u16::from_le_bytes(data.get(offset..offset + 2)?.try_into().ok()?)) +} + +fn read_u32(data: &[u8], offset: usize) -> Option { + Some(u32::from_le_bytes(data.get(offset..offset + 4)?.try_into().ok()?)) +} + +// ShellDeck patch: SDPATCH-120 — pin Explorer-restart custody, ICO selection, +// and the real ICO-resource-to-HICON boundary on native Windows CI. +#[cfg(test)] +mod tests { + use std::mem::ManuallyDrop; + + use super::*; + + // SDTEST-1813 + #[test] + fn sdtest_1813_explorer_restart_readd_is_authoritative_and_retains_icon() { + let hwnd = HWND(1usize as *mut core::ffi::c_void); + let hicon = HICON(2usize as *mut core::ffi::c_void); + let mut tray = ManuallyDrop::new(WindowsTray { + icon_added: true, + hwnd, + current_icon: Some(hicon), + menu_items: Vec::new(), + command_id_map: HashMap::new(), + }); + + assert!(!tray.handle_taskbar_created_with(|nid| { + assert_eq!(nid.hWnd, hwnd); + assert_eq!(nid.uID, TRAY_ICON_ID); + assert_eq!(nid.hIcon, hicon); + assert_eq!(nid.uCallbackMessage, WM_GPUI_TRAY_ICON); + assert_eq!(nid.uFlags, NIF_MESSAGE | NIF_SHOWTIP | NIF_ICON); + false + })); + assert!(!tray.is_available()); + assert_eq!(tray.current_icon, Some(hicon)); + + assert!(tray.handle_taskbar_created_with(|_| true)); + assert!(tray.is_available()); + assert_eq!(tray.current_icon, Some(hicon)); + + tray.current_icon = None; + assert!(!tray.handle_taskbar_created_with(|_| panic!("no add without a retained HICON"))); + assert!(!tray.is_available()); + } + + fn test_ico(entries: &[(u8, u8, u16, &[u8])]) -> Vec { + let directory_end = 6 + entries.len() * 16; + let mut bytes = Vec::with_capacity( + directory_end + entries.iter().map(|(_, _, _, data)| data.len()).sum::(), + ); + bytes.extend_from_slice(&0u16.to_le_bytes()); + bytes.extend_from_slice(&1u16.to_le_bytes()); + bytes.extend_from_slice(&(entries.len() as u16).to_le_bytes()); + let mut offset = directory_end; + for &(width, height, depth, data) in entries { + bytes.push(width); + bytes.push(height); + bytes.push(0); + bytes.push(0); + bytes.extend_from_slice(&1u16.to_le_bytes()); + bytes.extend_from_slice(&depth.to_le_bytes()); + bytes.extend_from_slice(&(data.len() as u32).to_le_bytes()); + bytes.extend_from_slice(&(offset as u32).to_le_bytes()); + offset += data.len(); + } + for &(_, _, _, data) in entries { + bytes.extend_from_slice(data); + } + bytes + } + + // SDTEST-1814 + #[test] + fn sdtest_1814_ico_resource_selects_closest_then_highest_depth() { + let ico = test_ico(&[ + (16, 16, 32, &[0x16]), + (32, 32, 8, &[0x08]), + (32, 32, 32, &[0x32]), + (48, 48, 32, &[0x48]), + ]); + assert_eq!(ico_resource(&ico, 32), Some(&[0x32][..])); + + let mut overlapping = test_ico(&[(32, 32, 32, &[0x32])]); + overlapping[18..22].copy_from_slice(&0u32.to_le_bytes()); + assert_eq!(ico_resource(&overlapping, 32), None); + assert_eq!(ico_resource(&[0, 0, 2, 0, 0, 0], 32), None); + } + + // SDTEST-1815 + #[test] + fn sdtest_1815_shell_deck_ico_creates_a_native_hicon() { + let bytes = include_bytes!("../../../../../packaging/icons/shelldeck.ico"); + let hicon = create_hicon_from_bytes(bytes).expect("CreateIconFromResourceEx accepts ICO"); + assert_ne!(hicon, HICON::default()); + unsafe { + DestroyIcon(hicon).expect("destroy test HICON"); + } + } +} diff --git a/patches/diffs/portable-pty-SDPATCH-117-cache-upgrade.patch b/patches/diffs/portable-pty-SDPATCH-117-cache-upgrade.patch deleted file mode 100644 index 79bd8487..00000000 --- a/patches/diffs/portable-pty-SDPATCH-117-cache-upgrade.patch +++ /dev/null @@ -1,55 +0,0 @@ ---- src/cmdbuilder.rs -+++ src/cmdbuilder.rs -@@ -450,12 +450,10 @@ - use std::os::unix::process::CommandExt; - - let home = self.get_home_dir()?; -- let dir: &OsStr = self -- .cwd -- .as_ref() -- .map(|dir| dir.as_os_str()) -- .filter(|dir| std::path::Path::new(dir).is_dir()) -- .unwrap_or(home.as_ref()); -+ // ShellDeck patch: SDPATCH-117 — an explicit cwd is an authority -+ // boundary. Preserve it through Command::current_dir so Unix process -+ // creation fails closed if the directory disappeared. -+ let dir: &OsStr = self.cwd.as_deref().unwrap_or(home.as_ref()); - let shell = self.get_shell(); - - let mut cmd = if self.is_default_prog() { -@@ -757,8 +755,34 @@ - assert!(cmd.get_env("CARGO_PKG_AUTHORS").is_none()); - } - -+ // SDTEST-1745 -+ // ShellDeck patch: SDPATCH-117 — prove the Unix command preserves a missing -+ // explicit cwd instead of substituting HOME before process creation. -+ #[cfg(unix)] -+ #[test] -+ fn explicit_missing_unix_cwd_is_forwarded_instead_of_falling_back_home() { -+ use std::time::{SystemTime, UNIX_EPOCH}; -+ -+ let missing = std::env::temp_dir().join(format!( -+ "portable-pty-missing-unix-cwd-{}-{}", -+ std::process::id(), -+ SystemTime::now() -+ .duration_since(UNIX_EPOCH) -+ .unwrap() -+ .as_nanos() -+ )); -+ assert!(!missing.exists()); -+ -+ let mut cmd = CommandBuilder::new("/bin/sh"); -+ cmd.env("HOME", std::env::temp_dir()); -+ cmd.cwd(&missing); -+ -+ let command = cmd.as_command().expect("Unix command"); -+ assert_eq!(command.get_current_dir(), Some(missing.as_path())); -+ } -+ - // SDTEST-1744 -- // ShellDeck patch: SDPATCH-117 — compile-check the Windows cwd authority -+ // ShellDeck patch: SDPATCH-117 — unit-check the Windows cwd authority - // regression beside the dependency code that forwards it to CreateProcessW. - #[cfg(windows)] - #[test] diff --git a/patches/diffs/portable-pty.md b/patches/diffs/portable-pty.md index 136915ef..de350eea 100644 --- a/patches/diffs/portable-pty.md +++ b/patches/diffs/portable-pty.md @@ -1,6 +1,6 @@ # Patch diff — portable-pty -**Crate**: `portable-pty` v0.8.1 (crates.io) +**Crate**: `portable-pty` v0.9.0 (crates.io) **Upstream**: https://github.com/wez/wezterm **Mechanism**: unified diff in `patches/diffs/` + `scripts/apply-crate-patches.sh` **Not vendored** — no full copy under `patches/portable-pty/`. @@ -8,10 +8,6 @@ ## SDPATCH-117 — Preserve an explicit cwd through process creation - **Diff**: `portable-pty-SDPATCH-117.patch` -- **Cache migration**: `portable-pty-SDPATCH-117-cache-upgrade.patch` transforms - the exact prior Windows-only revision found in stale CI registry caches into - the current patch. It is never applied to vanilla source or treated as an - independent active patch. - **Files / symbols**: `src/cmdbuilder.rs` — `CommandBuilder::current_directory`, `CommandBuilder::as_command`, `tests::{explicit_missing_unix_cwd_is_forwarded_instead_of_falling_back_home,explicit_missing_cwd_is_forwarded_instead_of_falling_back_home}` @@ -20,7 +16,7 @@ - `src/cmdbuilder.rs:564` — `// ShellDeck patch: SDPATCH-117 — preserve an explicit cwd even after it` - `src/cmdbuilder.rs:759` — `// ShellDeck patch: SDPATCH-117 — prove the Unix command preserves a missing` - `src/cmdbuilder.rs:785` — `// ShellDeck patch: SDPATCH-117 — unit-check the Windows cwd authority` -- **Why**: Upstream 0.8.1 filters an explicit cwd through `Path::is_dir` +- **Why**: Upstream 0.9.0 filters an explicit cwd through `Path::is_dir` immediately before process creation on both Unix and Windows. If an authorized workspace disappears in the validation-to-spawn window, that filter silently substitutes `HOME` or `USERPROFILE` and launches outside the diff --git a/scripts/apply-crate-patches.sh b/scripts/apply-crate-patches.sh index cf2df7b7..e6884eec 100755 --- a/scripts/apply-crate-patches.sh +++ b/scripts/apply-crate-patches.sh @@ -48,31 +48,18 @@ portable_pty_patch_is_complete() { && grep -F -q '// SDTEST-1744' "$source" } -portable_pty_patch_is_legacy_complete() { - local source="$1" - local marker_count - - marker_count="$(grep -F -c 'ShellDeck patch: SDPATCH-117' "$source" || true)" - [[ "$marker_count" == "2" ]] \ - && grep -F -q 'let dir: Option<&OsStr> = self.cwd.as_deref().or(home);' "$source" \ - && grep -F -q '.filter(|dir| std::path::Path::new(dir).is_dir())' "$source" \ - && grep -F -q '// SDTEST-1744' "$source" \ - && ! grep -F -q '// SDTEST-1745' "$source" -} - apply_portable_pty() { local patch="$ROOT/patches/diffs/portable-pty-SDPATCH-117.patch" - local cache_upgrade="$ROOT/patches/diffs/portable-pty-SDPATCH-117-cache-upgrade.patch" local dir source - if [[ ! -f "$patch" || ! -f "$cache_upgrade" ]]; then + if [[ ! -f "$patch" ]]; then echo "apply-crate-patches: missing portable-pty patch input" >&2 exit 1 fi - dir="$(find "$REGISTRY" -maxdepth 2 -type d -name 'portable-pty-0.8.1' 2>/dev/null | head -1)" + dir="$(find "$REGISTRY" -maxdepth 2 -type d -name 'portable-pty-0.9.0' 2>/dev/null | head -1)" if [[ -z "$dir" ]]; then - echo "apply-crate-patches: portable-pty-0.8.1 not in registry after cargo fetch" >&2 + echo "apply-crate-patches: portable-pty-0.9.0 not in registry after cargo fetch" >&2 exit 1 fi @@ -82,19 +69,6 @@ apply_portable_pty() { echo "apply-crate-patches: portable-pty SDPATCH-117 already applied" return 0 fi - if portable_pty_patch_is_legacy_complete "$source"; then - # BSD patch (the macOS runner) handles zero-context hunks - # differently from GNU patch. Git's applicator is available on - # every supported runner and gives this replay one exact behavior. - (cd "$dir" && git apply -p0 --unidiff-zero --ignore-space-change --check "$cache_upgrade") - (cd "$dir" && git apply -p0 --unidiff-zero --ignore-space-change "$cache_upgrade") - if ! portable_pty_patch_is_complete "$source"; then - echo "apply-crate-patches: portable-pty SDPATCH-117 cache upgrade failed" >&2 - exit 1 - fi - echo "apply-crate-patches: upgraded cached portable-pty SDPATCH-117" - return 0 - fi echo "apply-crate-patches: portable-pty SDPATCH-117 is only partially applied" >&2 exit 1 fi