From 18158c0499776fe6a2bab68ede9870b271940196 Mon Sep 17 00:00:00 2001 From: Daniel Schwarz Date: Tue, 14 Jul 2026 14:24:14 +0200 Subject: [PATCH 1/3] feat: follow pull request activity --- Cargo.lock | 453 +++++++++++++- README.md | 7 +- ROADMAP.md | 12 + TASKS.md | 18 +- crates/strand-tauri/Cargo.toml | 1 + crates/strand-tauri/capabilities/default.json | 1 + crates/strand-tauri/src/commands.rs | 25 + crates/strand-tauri/src/main.rs | 3 + crates/strand-tauri/src/pull_requests.rs | 588 +++++++++++++++++- docs/learnings.md | 24 + docs/pull-request-improvements.md | 22 +- pnpm-lock.yaml | 10 + ui/package.json | 1 + ui/src/App.tsx | 39 +- ui/src/components/Icon.tsx | 2 + ui/src/components/PullRequestMonitor.tsx | 59 ++ ui/src/lib/pullRequestActivity.test.ts | 89 +++ ui/src/lib/pullRequestActivity.ts | 113 ++++ ui/src/lib/pullRequestFollows.test.ts | 27 + ui/src/lib/pullRequestFollows.ts | 34 + ui/src/lib/pullRequests.test.ts | 2 + ui/src/lib/pullRequests.ts | 6 +- ui/src/lib/tauri.ts | 6 + ui/src/lib/types.ts | 41 ++ ui/src/stores/pullRequests.ts | 366 +++++++++++ ui/src/styles/features.css | 55 ++ ui/src/views/PullRequests.tsx | 239 ++++++- website/docs/pull-requests.md | 52 +- 28 files changed, 2225 insertions(+), 70 deletions(-) create mode 100644 ui/src/components/PullRequestMonitor.tsx create mode 100644 ui/src/lib/pullRequestActivity.test.ts create mode 100644 ui/src/lib/pullRequestActivity.ts create mode 100644 ui/src/lib/pullRequestFollows.test.ts create mode 100644 ui/src/lib/pullRequestFollows.ts create mode 100644 ui/src/stores/pullRequests.ts diff --git a/Cargo.lock b/Cargo.lock index b1d9bb7..8548f3d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -100,6 +100,137 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix 1.1.4", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix 1.1.4", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix 1.1.4", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "atk" version = "0.18.2" @@ -222,6 +353,19 @@ dependencies = [ "objc2", ] +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + [[package]] name = "borsh" version = "1.6.1" @@ -943,6 +1087,33 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -992,6 +1163,16 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "faster-hex" version = "0.9.0" @@ -1173,6 +1354,19 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + [[package]] name = "futures-macro" version = "0.3.32" @@ -2325,6 +2519,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + [[package]] name = "hex" version = "0.4.3" @@ -3069,6 +3269,20 @@ version = "0.4.30" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5" +[[package]] +name = "mac-notification-sys" +version = "0.6.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd604973958ddcc11b561193c0fb96ba146506ef2f231ef2e7c35fd2cbc9beca" +dependencies = [ + "cc", + "log", + "objc2", + "objc2-foundation", + "time", + "uuid", +] + [[package]] name = "markup5ever" version = "0.38.0" @@ -3261,6 +3475,20 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "notify-rust" +version = "4.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5b4c1b4f2aa9f25f63a7a49d3dd0ed567b3670da15330a66b29434be899b891" +dependencies = [ + "futures-lite", + "log", + "mac-notification-sys", + "serde", + "tauri-winrt-notification", + "zbus", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -3281,7 +3509,7 @@ dependencies = [ "num-integer", "num-iter", "num-traits", - "rand", + "rand 0.8.6", "smallvec", "zeroize", ] @@ -3582,6 +3810,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + [[package]] name = "os_info" version = "3.15.0" @@ -3756,6 +3994,17 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + [[package]] name = "pkcs1" version = "0.7.5" @@ -3828,6 +4077,20 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + [[package]] name = "portable-atomic" version = "1.13.1" @@ -4014,8 +4277,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" dependencies = [ "libc", - "rand_chacha", - "rand_core", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", ] [[package]] @@ -4025,7 +4298,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", ] [[package]] @@ -4037,6 +4320,15 @@ dependencies = [ "getrandom 0.2.17", ] +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + [[package]] name = "raw-window-handle" version = "0.6.2" @@ -4249,7 +4541,7 @@ dependencies = [ "num-traits", "pkcs1", "pkcs8", - "rand_core", + "rand_core 0.6.4", "signature", "spki", "subtle", @@ -4266,7 +4558,7 @@ dependencies = [ "borsh", "bytes", "num-traits", - "rand", + "rand 0.8.6", "rkyv", "serde", "serde_json", @@ -4800,7 +5092,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ "digest", - "rand_core", + "rand_core 0.6.4", ] [[package]] @@ -5040,7 +5332,7 @@ dependencies = [ "memchr", "once_cell", "percent-encoding", - "rand", + "rand 0.8.6", "rsa", "rust_decimal", "serde", @@ -5081,7 +5373,7 @@ dependencies = [ "md-5", "memchr", "once_cell", - "rand", + "rand 0.8.6", "rust_decimal", "serde", "serde_json", @@ -5159,6 +5451,7 @@ dependencies = [ "tauri", "tauri-build", "tauri-plugin-dialog", + "tauri-plugin-notification", "tauri-plugin-os", "tauri-plugin-process", "tauri-plugin-shell", @@ -5538,6 +5831,25 @@ dependencies = [ "url", ] +[[package]] +name = "tauri-plugin-notification" +version = "2.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01fc2c5ff41105bd1f7242d8201fdf3efd70749b82fa013a17f2126357d194cc" +dependencies = [ + "log", + "notify-rust", + "rand 0.9.5", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", + "time", + "url", +] + [[package]] name = "tauri-plugin-os" version = "2.3.2" @@ -5741,6 +6053,17 @@ dependencies = [ "toml 1.1.2+spec-1.1.0", ] +[[package]] +name = "tauri-winrt-notification" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed071c670382e85fc2f48ae706492d8c338f4f89bf72520d32f8abfe880aade" +dependencies = [ + "thiserror 2.0.18", + "windows", + "windows-version", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -6196,6 +6519,17 @@ version = "1.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + [[package]] name = "uluru" version = "3.1.0" @@ -7413,6 +7747,67 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zbus" +version = "5.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28b97f866896a4be7aefd2b5a8e01bb6773d19a775d54ab28b4d094b9a4480e" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix 1.1.4", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow 1.0.3", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e05ad887425eecf5e8384dc2406a4a9313eb73468712fc1cdea362eb4fe0469" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1039ca249fee9559680f3a9f05b55e0761fee51af4f6c1e7d8c1f31e549721d2" +dependencies = [ + "serde", + "winnow 1.0.3", + "zvariant", +] + [[package]] name = "zerocopy" version = "0.8.48" @@ -7510,3 +7905,43 @@ name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zvariant" +version = "5.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cf057bb00bf5c9ad77abb6147b0ca4818236a1858416e9d988e40d6322fefa7" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow 1.0.3", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8118ca6bda77bfc0ab51d660db0c955f2505eef854c9a449435bccb616933b31" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90cb9383f9b45290407a1258b202d3f8f01db719eb60b4e4055c6375af4fc7c7" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 2.0.117", + "winnow 1.0.3", +] diff --git a/README.md b/README.md index 07a515e..adb34bb 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,12 @@ keyboard alone, and the mouse stays first-class. jump). - **Hosted pull requests** — browse the latest 100 GitHub or Azure DevOps PRs for the active repository, with the active PR for your checked-out branch - opening automatically. Each PR gets a full-width workspace: read rendered + opening and being followed automatically even before the PR view is opened. + Persistent Follow controls and native desktop notifications surface new + comments, review decisions, failed checks, pushes, and merged/closed state. + Refreshes keep existing content, focus, tabs, drafts, and diffs in place + while lightweight activity is revalidated in the background. Each PR gets a + full-width workspace: read rendered Markdown descriptions and avatar-led timeline conversations, compose top-level comments with formatting, preview, and hosted screenshot links, inspect lazily loaded code changes in the Local Changes-style Pierre file tree, diff --git a/ROADMAP.md b/ROADMAP.md index 06f9e87..954e38f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1615,6 +1615,18 @@ code in Changes, so a refresh reflects comments created in Strand or on GitHub. Conversation now links file-backed comments straight to that file/thread in Changes and transfers keyboard focus to the destination. +**Followed PRs and seamless refresh shipped (2026-07-14):** Strand now follows +the checked-out branch's active GitHub or Azure DevOps PR even when the PR view +has never opened, with persistent manual follow/mute state and lightweight, +patch-free activity baselines shared across worktrees. A global two-at-a-time +monitor polls on hydration, every 60 seconds, and window focus; it coalesces +new comments/replies, review decisions, failed checks/policies, pushes, and +terminal state into native notifications, then auto-unfollows merged/closed +PRs. The PR workspace now refreshes stale-while-revalidate: existing list, +detail, focus, tabs, drafts, scroll, and patch stay mounted; rich detail reloads +only after activity changes, and the patch reloads only for a new head while a +stale copy remains read-only. + --- ## 1.1+ — Post-1.0 diff --git a/TASKS.md b/TASKS.md index 9cdc5da..0ae6bb9 100644 --- a/TASKS.md +++ b/TASKS.md @@ -1279,6 +1279,18 @@ tree: watch the agent work, review fast, accept or reject safely. arrow or j/k selects and Enter/click opens. Back restores list focus, and refresh, open-on-host, command-palette entry, and actionable CLI/auth errors remain available. + - ☑ Persistent followed-PR monitoring (`repo_pull_request_for_branch`, + `repo_pull_request_activity`, `stores/pullRequests.ts`, + `PullRequestMonitor`): the active branch's open PR auto-follows without the + PR view mounted; manual Follow/Unfollow, muted auto-follow keys, hosted-PR + worktree deduplication, SQLite-backed baselines, bounded two-PR polling, + native coalesced notifications, and terminal auto-unfollow survive + navigation and relaunch. + - ☑ Seamless stale-while-revalidate refresh (`PullRequests.tsx`): populated + list/detail/tabs/drafts/scroll stay mounted during updates and failures; + lightweight activity gates rich-detail reloads, patches reload only for a + changed head, and a stale patch remains readable but cannot submit inline + comments until its replacement succeeds. - ☑ Hosted diff and changed-file browser (`repo_pull_request_diff`, `PullRequestChanges`): provider patches load only when Changes opens; the keyboard-operable 22% Pierre folder tree and compact Local Changes-style @@ -1335,8 +1347,10 @@ tree: watch the agent work, review fast, accept or reject safely. where the provider exposes a boundary, safe Open in worktree / Update branch, suggestions, and unresolved-feedback export for external agents. - ◐ Checks render provider states as green success, yellow running, red - failure, or neutral. Azure policies, merge queue/auto-complete, and - required-review detail remain. + failure, or neutral. Azure PR policy evaluations now join readiness and + background activity when their query succeeds; incomplete policy calls + remain neutral. Merge queue/auto-complete and richer required-review detail + remain. - ◐ Hosted PR lifecycle actions. - ☑ Merge with provider-supported strategies (`repo_pull_request_merge`, stale-head guard, keyboard-operable `PullRequestMergeControl`, and command-palette action). diff --git a/crates/strand-tauri/Cargo.toml b/crates/strand-tauri/Cargo.toml index 736404c..a835641 100644 --- a/crates/strand-tauri/Cargo.toml +++ b/crates/strand-tauri/Cargo.toml @@ -27,6 +27,7 @@ tauri-plugin-process = "2" tauri-plugin-dialog = "2" tauri-plugin-shell = "2" tauri-plugin-os = "2" +tauri-plugin-notification = "2" serde.workspace = true serde_json.workspace = true diff --git a/crates/strand-tauri/capabilities/default.json b/crates/strand-tauri/capabilities/default.json index 7a684d3..c59b098 100644 --- a/crates/strand-tauri/capabilities/default.json +++ b/crates/strand-tauri/capabilities/default.json @@ -15,6 +15,7 @@ "core:event:default", "dialog:default", "os:default", + "notification:default", "updater:default", "process:default", "sql:default", diff --git a/crates/strand-tauri/src/commands.rs b/crates/strand-tauri/src/commands.rs index 06f1770..8e81b0f 100644 --- a/crates/strand-tauri/src/commands.rs +++ b/crates/strand-tauri/src/commands.rs @@ -190,6 +190,31 @@ pub async fn repo_pull_requests(path: String) -> CmdResult { .await } +/// Active pull request for one checked-out branch. This targeted query lets +/// automatic following work without loading the full hosted-PR workspace. +#[tauri::command(async)] +pub async fn repo_pull_request_for_branch( + path: String, + branch: String, +) -> CmdResult> { + run_blocking("pull request for branch", move || { + pull_requests::for_branch(&path, &branch).map_err(|message| CmdError { message }) + }) + .await +} + +/// Small, patch-free snapshot used by the followed-PR monitor. +#[tauri::command(async)] +pub async fn repo_pull_request_activity( + path: String, + id: u64, +) -> CmdResult { + run_blocking("pull request activity", move || { + pull_requests::activity(&path, id).map_err(|message| CmdError { message }) + }) + .await +} + /// Rich fields for one selected pull request. Kept separate from the list so /// GitHub never expands every PR's nested GraphQL connections in one query. #[tauri::command(async)] diff --git a/crates/strand-tauri/src/main.rs b/crates/strand-tauri/src/main.rs index 26e1245..9c34020 100644 --- a/crates/strand-tauri/src/main.rs +++ b/crates/strand-tauri/src/main.rs @@ -51,6 +51,7 @@ fn main() { tauri::Builder::default() .plugin(tauri_plugin_os::init()) .plugin(tauri_plugin_shell::init()) + .plugin(tauri_plugin_notification::init()) .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_updater::Builder::default().build()) .plugin(tauri_plugin_process::init()) @@ -77,6 +78,8 @@ fn main() { commands::repo_search_log, commands::repo_refs, commands::repo_pull_requests, + commands::repo_pull_request_for_branch, + commands::repo_pull_request_activity, commands::repo_pull_request, commands::repo_pull_request_diff, commands::repo_pull_request_comment, diff --git a/crates/strand-tauri/src/pull_requests.rs b/crates/strand-tauri/src/pull_requests.rs index 9cca5f0..00c604a 100644 --- a/crates/strand-tauri/src/pull_requests.rs +++ b/crates/strand-tauri/src/pull_requests.rs @@ -21,6 +21,8 @@ use crate::ai::bin::{base_command, resolve_cli}; const COMMAND_TIMEOUT: Duration = Duration::from_secs(30); const MAX_COMMENT_BYTES: usize = 65_536; const MAX_DIFF_BYTES: usize = 16 * 1024 * 1024; +const GITHUB_BRANCH_STATE: &str = "open"; +const AZURE_BRANCH_STATUS: &str = "active"; const GITHUB_LIST_FIELDS: &str = concat!( "number,title,state,isDraft,author,headRefName,baseRefName,createdAt,updatedAt,", "url,reviewDecision" @@ -30,6 +32,27 @@ const GITHUB_DETAIL_FIELDS: &str = concat!( "url,body,mergeStateStatus,reviewDecision,comments,commits,additions,deletions,", "changedFiles,reviewRequests,latestReviews,labels,statusCheckRollup,headRefOid" ); +const GITHUB_ACTIVITY_QUERY: &str = r#"query($owner: String!, $repo: String!, $number: Int!) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $number) { + number title state url updatedAt headRefName headRefOid + comments(last: 100) { nodes { id author { login } } } + reviews(last: 100) { nodes { id state author { login } } } + reviewThreads(last: 100) { + nodes { comments(last: 100) { nodes { id author { login } } } } + } + statusCheckRollup { + contexts(first: 100) { + nodes { + __typename + ... on CheckRun { databaseId name status conclusion } + ... on StatusContext { id context state } + } + } + } + } + } +}"#; const GITHUB_REVIEW_THREADS_QUERY: &str = r#"query($owner: String!, $repo: String!, $number: Int!) { repository(owner: $owner, name: $repo) { pullRequest(number: $number) { @@ -137,6 +160,7 @@ pub struct PullRequest { pub labels: Vec, pub reviewers: Vec, pub checks: Vec, + pub checks_complete: bool, pub comments: Vec, pub review_threads: Vec, } @@ -147,6 +171,50 @@ pub struct PullRequestList { pub pull_requests: Vec, } +#[derive(Debug, Clone, Serialize)] +pub struct PullRequestBranchMatch { + pub repository: PullRequestRepository, + pub pull_request: PullRequest, +} + +#[derive(Debug, Clone, Serialize)] +pub struct PullRequestActivityComment { + pub id: String, + pub author: String, + pub kind: String, + pub is_system: bool, +} + +#[derive(Debug, Clone, Serialize)] +pub struct PullRequestActivityReview { + pub id: String, + pub author: String, + pub state: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct PullRequestActivityCheck { + pub id: String, + pub name: String, + pub status: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct PullRequestActivitySnapshot { + pub repository: PullRequestRepository, + pub id: u64, + pub title: String, + pub url: String, + pub state: String, + pub source_branch: String, + pub source_commit: String, + pub updated_at: String, + pub comments: Vec, + pub reviews: Vec, + pub checks: Vec, + pub checks_complete: bool, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] #[serde(rename_all = "snake_case")] pub enum PullRequestDiffSide { @@ -179,6 +247,30 @@ pub fn list(path: &str) -> Result { } } +pub fn for_branch(path: &str, branch: &str) -> Result> { + let (remote, host) = host_for_path(path)?; + match host { + HostRepo::GitHub { owner, repo } => for_branch_github(path, remote, owner, repo, branch), + HostRepo::Azure { + organization, + project, + repo, + } => for_branch_azure(path, remote, organization, project, repo, branch), + } +} + +pub fn activity(path: &str, id: u64) -> Result { + let (remote, host) = host_for_path(path)?; + match host { + HostRepo::GitHub { owner, repo } => activity_github(path, remote, owner, repo, id), + HostRepo::Azure { + organization, + project, + repo, + } => activity_azure(path, remote, organization, project, repo, id), + } +} + pub fn detail(path: &str, id: u64) -> Result { let (_, host) = host_for_path(path)?; match host { @@ -266,9 +358,19 @@ pub fn merge( HostRepo::GitHub { owner, repo } => { merge_github(path, &owner, &repo, id, strategy, expected_head) } - HostRepo::Azure { organization, project, repo } => { - merge_azure(path, &organization, &project, &repo, id, strategy, expected_head) - } + HostRepo::Azure { + organization, + project, + repo, + } => merge_azure( + path, + &organization, + &project, + &repo, + id, + strategy, + expected_head, + ), } } @@ -326,6 +428,83 @@ fn list_github(cwd: &str, remote: String, owner: String, repo: String) -> Result }) } +fn for_branch_github( + cwd: &str, + remote: String, + owner: String, + repo: String, + branch: &str, +) -> Result> { + let slug = format!("{owner}/{repo}"); + let branch = branch.strip_prefix("refs/heads/").unwrap_or(branch); + let output = run_command( + cwd, + "gh", + &[ + "pr", + "list", + "--repo", + &slug, + "--head", + branch, + "--state", + GITHUB_BRANCH_STATE, + "--limit", + "1", + "--json", + GITHUB_LIST_FIELDS, + ], + &[("GH_PROMPT_DISABLED", "1")], + )?; + let values: Vec = serde_json::from_slice(&output) + .map_err(|error| format!("GitHub CLI returned invalid JSON: {error}"))?; + Ok(values + .first() + .and_then(parse_github_pr) + .map(|pull_request| PullRequestBranchMatch { + repository: PullRequestRepository { + provider: PullRequestProvider::GitHub, + remote, + label: slug, + }, + pull_request, + })) +} + +fn activity_github( + cwd: &str, + remote: String, + owner: String, + repo: String, + id: u64, +) -> Result { + let query = format!("query={GITHUB_ACTIVITY_QUERY}"); + let owner_arg = format!("owner={owner}"); + let repo_arg = format!("repo={repo}"); + let number = format!("number={id}"); + let output = run_command( + cwd, + "gh", + &[ + "api", "graphql", "-f", &query, "-F", &owner_arg, "-F", &repo_arg, "-F", &number, + ], + &[("GH_PROMPT_DISABLED", "1")], + )?; + let value: Value = serde_json::from_slice(&output) + .map_err(|error| format!("GitHub CLI returned invalid activity JSON: {error}"))?; + let pull_request = value + .pointer("/data/repository/pullRequest") + .ok_or_else(|| format!("GitHub returned no activity data for PR #{id}"))?; + parse_github_activity( + pull_request, + PullRequestRepository { + provider: PullRequestProvider::GitHub, + remote, + label: format!("{owner}/{repo}"), + }, + ) +} + fn detail_github(cwd: &str, owner: String, repo: String, id: u64) -> Result { let slug = format!("{owner}/{repo}"); let id_string = id.to_string(); @@ -347,6 +526,7 @@ fn detail_github(cwd: &str, owner: String, repo: String, id: u64) -> Result Result> { + let organization_url = format!("https://dev.azure.com/{organization}/"); + let branch = branch.strip_prefix("refs/heads/").unwrap_or(branch); + let output = run_command( + cwd, + "az", + &[ + "repos", + "pr", + "list", + "--organization", + &organization_url, + "--project", + &project, + "--repository", + &repo, + "--source-branch", + branch, + "--status", + AZURE_BRANCH_STATUS, + "--top", + "1", + "--output", + "json", + "--only-show-errors", + ], + &[("AZURE_EXTENSION_USE_DYNAMIC_INSTALL", "no")], + )?; + let values: Vec = serde_json::from_slice(&output) + .map_err(|error| format!("Azure CLI returned invalid JSON: {error}"))?; + Ok(values.first().and_then(|value| { + parse_azure_pr(value, &organization, &project, &repo).map(|pull_request| { + PullRequestBranchMatch { + repository: PullRequestRepository { + provider: PullRequestProvider::AzureDevOps, + remote, + label: format!("{organization}/{project}/{repo}"), + }, + pull_request, + } + }) + })) +} + fn detail_azure( cwd: &str, organization: String, @@ -547,9 +778,69 @@ fn detail_azure( .ok_or_else(|| format!("Azure CLI returned no data for PR #{id}"))?; pull_request.comments = azure_comments(cwd, &organization, &project, &repo, id)?; pull_request.comment_count = pull_request.comments.len(); + if let Ok(checks) = azure_policies(cwd, &organization, id) { + pull_request.checks = checks + .iter() + .map(|check| PullRequestCheck { + name: check.name.clone(), + status: check.status.clone(), + }) + .collect(); + pull_request.checks_complete = true; + } Ok(pull_request) } +fn activity_azure( + cwd: &str, + remote: String, + organization: String, + project: String, + repo: String, + id: u64, +) -> Result { + let value = azure_pr_value(cwd, &organization, id)?; + let pull_request = parse_azure_pr(&value, &organization, &project, &repo) + .ok_or_else(|| format!("Azure CLI returned no data for PR #{id}"))?; + let comments = azure_comments(cwd, &organization, &project, &repo, id)? + .into_iter() + .map(|comment| PullRequestActivityComment { + id: comment.id, + author: comment.author, + kind: if comment.path.is_some() { + "thread" + } else { + "comment" + } + .into(), + is_system: comment.is_system, + }) + .collect(); + let reviews = array(&value, "reviewers") + .iter() + .filter_map(parse_azure_activity_review) + .collect(); + let checks = azure_policies(cwd, &organization, id)?; + Ok(PullRequestActivitySnapshot { + repository: PullRequestRepository { + provider: PullRequestProvider::AzureDevOps, + remote, + label: format!("{organization}/{project}/{repo}"), + }, + id: pull_request.id, + title: pull_request.title, + url: pull_request.url, + state: pull_request.state, + source_branch: pull_request.source_branch, + source_commit: pull_request.source_commit, + updated_at: pull_request.updated_at, + comments, + reviews, + checks, + checks_complete: true, + }) +} + fn azure_pr_value(cwd: &str, organization: &str, id: u64) -> Result { let organization_url = format!("https://dev.azure.com/{organization}/"); let id = id.to_string(); @@ -620,6 +911,34 @@ fn azure_comments( )) } +fn azure_policies(cwd: &str, organization: &str, id: u64) -> Result> { + let organization_url = format!("https://dev.azure.com/{organization}/"); + let id = id.to_string(); + let output = run_command( + cwd, + "az", + &[ + "repos", + "pr", + "policy", + "list", + "--id", + &id, + "--organization", + &organization_url, + "--top", + "100", + "--output", + "json", + "--only-show-errors", + ], + &[("AZURE_EXTENSION_USE_DYNAMIC_INSTALL", "no")], + )?; + let value: Value = serde_json::from_slice(&output) + .map_err(|error| format!("Azure CLI returned invalid policy JSON: {error}"))?; + Ok(parse_azure_policies(&value)) +} + fn add_comment_azure( cwd: &str, organization: String, @@ -957,6 +1276,175 @@ fn azure_merge_strategy(strategy: PullRequestMergeStrategy) -> &'static str { } } +fn parse_github_activity( + value: &Value, + repository: PullRequestRepository, +) -> Result { + let id = value + .get("number") + .and_then(Value::as_u64) + .ok_or_else(|| "GitHub activity did not include a pull request number".to_string())?; + let mut comments = value + .pointer("/comments/nodes") + .and_then(Value::as_array) + .map(Vec::as_slice) + .unwrap_or(&[]) + .iter() + .filter_map(|comment| { + Some(PullRequestActivityComment { + id: text(comment.get("id"))?, + author: text(comment.pointer("/author/login")).unwrap_or_else(|| "unknown".into()), + kind: "comment".into(), + is_system: false, + }) + }) + .collect::>(); + for thread in value + .pointer("/reviewThreads/nodes") + .and_then(Value::as_array) + .map(Vec::as_slice) + .unwrap_or(&[]) + { + comments.extend( + thread + .pointer("/comments/nodes") + .and_then(Value::as_array) + .map(Vec::as_slice) + .unwrap_or(&[]) + .iter() + .filter_map(|comment| { + Some(PullRequestActivityComment { + id: text(comment.get("id"))?, + author: text(comment.pointer("/author/login")) + .unwrap_or_else(|| "unknown".into()), + kind: "thread".into(), + is_system: false, + }) + }), + ); + } + comments.sort_by(|left, right| left.id.cmp(&right.id)); + comments.dedup_by(|left, right| left.id == right.id); + + let reviews = value + .pointer("/reviews/nodes") + .and_then(Value::as_array) + .map(Vec::as_slice) + .unwrap_or(&[]) + .iter() + .filter_map(|review| { + Some(PullRequestActivityReview { + id: text(review.get("id"))?, + author: text(review.pointer("/author/login")).unwrap_or_else(|| "unknown".into()), + state: text(review.get("state")).unwrap_or_else(|| "unknown".into()), + }) + }) + .collect(); + let checks = value + .pointer("/statusCheckRollup/contexts/nodes") + .and_then(Value::as_array) + .map(Vec::as_slice) + .unwrap_or(&[]) + .iter() + .filter_map(parse_github_activity_check) + .collect(); + + Ok(PullRequestActivitySnapshot { + repository, + id, + title: text(value.get("title")).unwrap_or_default(), + url: text(value.get("url")).unwrap_or_default(), + state: text(value.get("state")) + .unwrap_or_else(|| "unknown".into()) + .to_lowercase(), + source_branch: text(value.get("headRefName")).unwrap_or_default(), + source_commit: text(value.get("headRefOid")).unwrap_or_default(), + updated_at: text(value.get("updatedAt")).unwrap_or_default(), + comments, + reviews, + checks, + checks_complete: true, + }) +} + +fn parse_github_activity_check(value: &Value) -> Option { + let kind = text(value.get("__typename")).unwrap_or_else(|| "Check".into()); + let name = text(value.get("name")).or_else(|| text(value.get("context")))?; + let raw_id = value + .get("databaseId") + .and_then(Value::as_i64) + .map(|id| id.to_string()) + .or_else(|| text(value.get("id"))) + .unwrap_or_else(|| name.clone()); + Some(PullRequestActivityCheck { + id: format!("{kind}:{raw_id}"), + name, + status: text(value.get("conclusion")) + .filter(|status| !status.is_empty()) + .or_else(|| text(value.get("state"))) + .or_else(|| text(value.get("status"))) + .unwrap_or_else(|| "unknown".into()), + }) +} + +fn parse_azure_activity_review(value: &Value) -> Option { + let vote = value.get("vote").and_then(Value::as_i64).unwrap_or(0); + Some(PullRequestActivityReview { + id: text(value.get("id")) + .or_else(|| text(value.get("descriptor"))) + .or_else(|| text(value.get("uniqueName")))?, + author: text(value.get("displayName")).unwrap_or_else(|| "unknown".into()), + state: match vote { + 10 => "approved", + 5 => "approved_with_suggestions", + -5 => "waiting_for_author", + -10 => "changes_requested", + _ => "pending", + } + .into(), + }) +} + +fn parse_azure_policies(value: &Value) -> Vec { + value + .as_array() + .or_else(|| value.get("value").and_then(Value::as_array)) + .map(Vec::as_slice) + .unwrap_or(&[]) + .iter() + .filter_map(|policy| { + let name = text(policy.pointer("/configuration/type/displayName")) + .or_else(|| text(policy.pointer("/configuration/settings/displayName"))) + .unwrap_or_else(|| "Azure policy".into()); + Some(PullRequestActivityCheck { + id: text(policy.get("evaluationId")) + .or_else(|| { + policy + .get("configuration") + .and_then(|config| config.get("id")) + .and_then(Value::as_i64) + .map(|id| id.to_string()) + }) + .unwrap_or_else(|| name.clone()), + name, + status: normalize_azure_policy_status( + &text(policy.get("status")).unwrap_or_else(|| "unknown".into()), + ), + }) + }) + .collect() +} + +fn normalize_azure_policy_status(status: &str) -> String { + match status.trim().to_ascii_lowercase().as_str() { + "approved" => "success", + "rejected" | "broken" => "failure", + "queued" | "running" => "pending", + value => value, + } + .into() +} + fn parse_github_pr(value: &Value) -> Option { let id = value.get("number")?.as_u64()?; let comments = array(value, "comments") @@ -1039,6 +1527,7 @@ fn parse_github_pr(value: &Value) -> Option { .collect(), reviewers, checks, + checks_complete: value.get("statusCheckRollup").is_some(), comments, review_threads: Vec::new(), }) @@ -1186,6 +1675,7 @@ fn parse_azure_pr( .collect(), reviewers, checks: Vec::new(), + checks_complete: false, comments: Vec::new(), review_threads: Vec::new(), }) @@ -1477,6 +1967,98 @@ mod tests { assert!(auth_hint("az", "Please run az login").contains("az login")); } + #[test] + fn branch_lookup_is_active_only_and_uses_shallow_fields() { + assert_eq!(GITHUB_BRANCH_STATE, "open"); + assert_eq!(AZURE_BRANCH_STATUS, "active"); + for nested in [ + "comments", + "commits", + "latestReviews", + "statusCheckRollup", + "body", + ] { + assert!(!GITHUB_LIST_FIELDS.contains(nested)); + } + } + + #[test] + fn github_activity_query_is_bounded_and_never_requests_a_patch() { + assert!(GITHUB_ACTIVITY_QUERY.contains("comments(last: 100)")); + assert!(GITHUB_ACTIVITY_QUERY.contains("reviews(last: 100)")); + assert!(GITHUB_ACTIVITY_QUERY.contains("reviewThreads(last: 100)")); + assert!(GITHUB_ACTIVITY_QUERY.contains("contexts(first: 100)")); + for heavy in ["patch", "diff", "files(", "body"] { + assert!(!GITHUB_ACTIVITY_QUERY.contains(heavy)); + } + } + + #[test] + fn normalizes_github_activity_with_stable_ids() { + let value = serde_json::json!({ + "number": 42, + "title": "Ship it", + "state": "OPEN", + "url": "https://github.com/acme/app/pull/42", + "updatedAt": "2026-07-14T10:00:00Z", + "headRefName": "feature", + "headRefOid": "1111111111111111111111111111111111111111", + "comments": { "nodes": [ + { "id": "IC_1", "author": { "login": "ada" } } + ] }, + "reviews": { "nodes": [ + { "id": "PRR_1", "state": "APPROVED", "author": { "login": "grace" } } + ] }, + "reviewThreads": { "nodes": [{ "comments": { "nodes": [ + { "id": "PRRC_1", "author": { "login": "linus" } } + ] } }] }, + "statusCheckRollup": { "contexts": { "nodes": [ + { "__typename": "CheckRun", "databaseId": 99, "name": "CI", "status": "COMPLETED", "conclusion": "FAILURE" }, + { "__typename": "StatusContext", "id": "SC_1", "context": "lint", "state": "SUCCESS" } + ] } } + }); + let activity = parse_github_activity( + &value, + PullRequestRepository { + provider: PullRequestProvider::GitHub, + remote: "origin".into(), + label: "acme/app".into(), + }, + ) + .unwrap(); + assert_eq!( + activity.source_commit, + "1111111111111111111111111111111111111111" + ); + assert_eq!(activity.comments[0].id, "IC_1"); + assert_eq!(activity.comments[1].id, "PRRC_1"); + assert_eq!(activity.reviews[0].id, "PRR_1"); + assert_eq!(activity.checks[0].id, "CheckRun:99"); + assert_eq!(activity.checks[0].status, "FAILURE"); + assert_eq!(activity.checks[1].id, "StatusContext:SC_1"); + assert!(activity.checks_complete); + } + + #[test] + fn normalizes_azure_policy_states_for_readiness() { + let value = serde_json::json!([ + { "evaluationId": "one", "status": "approved", "configuration": { "type": { "displayName": "Build" } } }, + { "evaluationId": "two", "status": "rejected", "configuration": { "type": { "displayName": "Coverage" } } }, + { "evaluationId": "three", "status": "broken", "configuration": { "type": { "displayName": "Security" } } }, + { "evaluationId": "four", "status": "queued", "configuration": { "type": { "displayName": "Deploy" } } }, + { "evaluationId": "five", "status": "running", "configuration": { "type": { "displayName": "Test" } } } + ]); + let checks = parse_azure_policies(&value); + assert_eq!( + checks + .iter() + .map(|check| check.status.as_str()) + .collect::>(), + ["success", "failure", "failure", "pending", "pending",] + ); + assert_eq!(checks[1].id, "two"); + } + #[test] fn normalizes_github_and_azure_comments() { let github: Value = serde_json::from_str( diff --git a/docs/learnings.md b/docs/learnings.md index 8799601..68a1df0 100644 --- a/docs/learnings.md +++ b/docs/learnings.md @@ -1175,3 +1175,27 @@ completion requests include `lastMergeSourceCommit`. Keep required checks, reviews, queues, and branch policies provider-authoritative, preserve their failure text next to the initiating control, and refresh the PR after a successful request because queued completion may leave it active temporarily. + +--- + +## Background PR refreshes use activity snapshots, never patches + +**Rule.** A followed pull request is monitored with its provider-neutral +activity snapshot only: identity/state/head SHA, stable comment and review IDs, +and normalized check or policy states. Background work must never request or +parse the hosted patch, and a shallow-list refresh must not invalidate the +currently mounted detail. + +**Why.** Pull-request patches are the largest provider response and the most +expensive UI resource. Polling them would waste provider/CLI work, repeatedly +parse large diffs, and destroy the reviewer's focus, scroll, file selection, and +unsent drafts. Provider failures also must not turn a previously known failure +green or erase a successful activity baseline. + +**How to apply.** Poll immediately after hydration, at a modest interval, and +on focus without overlapping cycles. Share in-flight activity calls with the +visible PR. Revalidate rich detail only when the activity fingerprint changes; +reload the patch only when the head SHA changes. Keep the old patch visible +while that replacement loads or fails, label it stale, and disable writes that +depend on exact patch coordinates. Treat incomplete policy/check reads as +unknown and retain the last complete baseline. diff --git a/docs/pull-request-improvements.md b/docs/pull-request-improvements.md index 3e9b577..26e0054 100644 --- a/docs/pull-request-improvements.md +++ b/docs/pull-request-improvements.md @@ -5,11 +5,14 @@ > `PullRequests.tsx` workspace and keeps Strand's performance, keyboard, provider- > neutral, and resizable-pane rules as hard constraints. -> Implementation status (2026-07-13): the readiness ledger, in-context +> Implementation status (2026-07-14): the readiness ledger, in-context > stacked/split controls, Pierre line-range selection, and stale-head-guarded -> GitHub inline publishing are now present on `codex/pr-review-ledger`. Inline -> comments publish immediately in this first slice; the pending **Add to -> review** queue described below remains the intended batched-review workflow. +> GitHub inline publishing are present. Followed PRs now persist across relaunch, +> the checked-out branch auto-follows independently of this view, native +> notifications report review activity, and list/detail/patch refreshes use +> stale-while-revalidate resource boundaries. Inline comments still publish +> immediately; the pending **Add to review** queue described below remains the +> intended batched-review workflow. ## Outcome @@ -90,8 +93,10 @@ Sources: review summary, and batched submission are still host-only. 7. **No commit/version lens.** A reviewer cannot understand how the PR evolved or isolate changes since an earlier pass. -8. **Refresh is manual.** CI and review feedback can become stale while the PR - remains open, with no visible “last updated” age. +8. **Refresh now follows activity signals.** Lightweight snapshots update on a + 60-second cadence and focus, while populated content stays mounted. Rich + detail reloads only after activity changes and patches only after the head + SHA changes; independent Checks and Commits tabs remain future work. ## Recommended UX and UI @@ -196,6 +201,11 @@ quietly take over a dirty checkout. #### 7. Refresh by signal, not by whole-page polling +Implemented in the followed-PR slice: the global monitor shares in-flight +activity requests with the visible view, never downloads patches, and preserves +successful baselines through provider failures. The remaining work here is to +split Checks and Commits into their own lazy resources. + - Show the last successful provider refresh time. - Refresh lightweight readiness data on window focus and on a modest interval only while the PR view is visible. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e48d5be..491fab0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,6 +26,9 @@ importers: '@tauri-apps/plugin-dialog': specifier: ^2 version: 2.7.1 + '@tauri-apps/plugin-notification': + specifier: ^2 + version: 2.3.3 '@tauri-apps/plugin-os': specifier: ^2 version: 2.3.2 @@ -590,6 +593,9 @@ packages: '@tauri-apps/plugin-dialog@2.7.1': resolution: {integrity: sha512-OK1UBXYt+ojcmxMktzzuyonYIFta8CmAASpX+CA+DTGK24KlHjhYI6x2iOJ/TjZF4N7/ACK1oFmEOjIY9IhzOQ==} + '@tauri-apps/plugin-notification@2.3.3': + resolution: {integrity: sha512-Zw+ZH18RJb41G4NrfHgIuofJiymusqN+q8fGUIIV7vyCH+5sSn5coqRv/MWB9qETsUs97vmU045q7OyseCV3Qg==} + '@tauri-apps/plugin-os@2.3.2': resolution: {integrity: sha512-n+nXWeuSeF9wcEsSPmRnBEGrRgOy6jjkSU+UVCOV8YUGKb2erhDOxis7IqRXiRVHhY8XMKks00BJ0OAdkpf6+A==} @@ -2178,6 +2184,10 @@ snapshots: dependencies: '@tauri-apps/api': 2.11.0 + '@tauri-apps/plugin-notification@2.3.3': + dependencies: + '@tauri-apps/api': 2.11.0 + '@tauri-apps/plugin-os@2.3.2': dependencies: '@tauri-apps/api': 2.11.0 diff --git a/ui/package.json b/ui/package.json index 0d80442..8137896 100644 --- a/ui/package.json +++ b/ui/package.json @@ -20,6 +20,7 @@ "@tauri-apps/api": "^2", "@tauri-apps/plugin-dialog": "^2", "@tauri-apps/plugin-os": "^2", + "@tauri-apps/plugin-notification": "^2", "@tauri-apps/plugin-process": "^2.3.1", "@tauri-apps/plugin-shell": "^2", "@tauri-apps/plugin-sql": "^2", diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 5d358c4..c4bccf2 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -11,11 +11,13 @@ import { Icon } from './components/Icon'; import { copyToClipboard } from './components/PierreTree'; import { Presence } from './components/Presence'; import { ProgressPopup, formatDuration } from './components/ProgressPopup'; +import { PullRequestMonitor } from './components/PullRequestMonitor'; import { RepoRail } from './components/RepoRail'; import { Sidebar } from './components/Sidebar'; import { StatusBar } from './components/StatusBar'; import { Topbar } from './components/Topbar'; import { FONTS, useSettings } from './stores/settings'; +import { usePullRequests } from './stores/pullRequests'; import { useRepo } from './stores/repo'; import { useRepoIcons } from './stores/repoIcons'; import { DEFAULT_WORKSPACE_ID, useWorkspaces } from './stores/workspaces'; @@ -208,6 +210,10 @@ export function App() { // handful of user-created entries, so subscribing whole is cheap. const workspaces = useWorkspaces((s) => s.workspaces); const activeWorkspaceId = useWorkspaces((s) => s.activeWorkspaceId); + const activePullRequestKey = usePullRequests((s) => s.active?.key ?? null); + const activePullRequestFollowed = usePullRequests((s) => + activePullRequestKey ? Boolean(s.followed[activePullRequestKey]) : false); + const toggleActivePullRequest = usePullRequests((s) => s.toggleActive); const [paletteOpen, setPaletteOpen] = useState(false); const [repoSwitcherOpen, setRepoSwitcherOpen] = useState(false); @@ -1110,13 +1116,28 @@ export function App() { { id: 'reflog', label: 'Show: Reflog', group: 'Actions', shortcut: keyHint('view-reflog'), keywords: 'history head recover lost orphan', run: () => { setView('reflog'); selectFile(null); } }, { id: 'review-view', label: 'Show: Review', group: 'Actions', shortcut: keyHint('view-review'), keywords: 'ai agent review session changes verdict', run: () => { setView('review'); selectFile(null); } }, { id: 'pull-requests', label: 'Show: Pull Requests', group: 'Actions', keywords: 'pr github azure devops code review merge request', run: () => { setView('pull-requests'); selectFile(null); } }, - ...(view === 'pull-requests' ? [{ - id: 'pull-request-merge', - label: 'Pull Requests: merge open pull request…', - group: 'Actions', - keywords: 'pr github azure devops complete squash rebase', - run: () => window.dispatchEvent(new CustomEvent('strand:pull-request-merge')), - } satisfies PaletteAction] : []), + ...(view === 'pull-requests' ? [ + { + id: 'pull-request-merge', + label: 'Pull Requests: merge open pull request…', + group: 'Actions', + keywords: 'pr github azure devops complete squash rebase', + run: () => window.dispatchEvent(new CustomEvent('strand:pull-request-merge')), + } satisfies PaletteAction, + ...(activePullRequestKey ? [{ + id: 'pull-request-follow', + label: activePullRequestFollowed + ? 'Pull Requests: unfollow open pull request' + : 'Pull Requests: follow open pull request', + group: 'Actions', + keywords: 'pr notifications bell watch follow unfollow', + run: () => { + void toggleActivePullRequest().catch((caught) => { + showToast(`Could not update pull request following: ${errMessage(caught)}`, 'error'); + }); + }, + } satisfies PaletteAction] : []), + ] : []), { id: 'workspace-review-view', label: 'Show: Workspace Review', group: 'Actions', shortcut: keyHint('view-workspace-review'), keywords: 'workspace review aggregate cross repo multi combined agent', run: () => { setView('workspace-review'); selectFile(null); } }, { id: 'worktrees', label: 'Show: Worktrees', group: 'Actions', shortcut: keyHint('view-worktrees'), keywords: 'worktree agent feature checkout overview', run: () => { setView('worktrees'); selectFile(null); } }, { id: 'tab-next', label: 'Next repository', group: 'Actions', shortcut: keyHint('tab-next'), keywords: 'switch repo tab next cycle', run: () => cycleTab(1) }, @@ -1306,7 +1327,8 @@ export function App() { baseline, setBaseline, clearBaseline, stageReviewed, commits, resetTo, unstagedCount, stagedCount, baselineDiffCount, copyDiffs, reviewNoteCount, clearReviewNotes, keyHint, platform, cycleTab, view, - workspaces, activeWorkspaceId, importCodeWorkspaceFlow, pruneWorktrees]); + workspaces, activeWorkspaceId, importCodeWorkspaceFlow, pruneWorktrees, + activePullRequestKey, activePullRequestFollowed, toggleActivePullRequest]); const rootStyle = { '--font-ui': FONTS.ui[uiFont], @@ -1316,6 +1338,7 @@ export function App() { return (
+
setPaletteOpen(true)} diff --git a/ui/src/components/Icon.tsx b/ui/src/components/Icon.tsx index 8667b8b..41566a8 100644 --- a/ui/src/components/Icon.tsx +++ b/ui/src/components/Icon.tsx @@ -8,6 +8,7 @@ export type IconName = | 'history' | 'compare' | 'blame' | 'content' | 'terminal' | 'external' | 'eye' | 'sparkle' | 'split' | 'unified' | 'rebase' | 'circle' | 'lock' | 'star' | 'gpg' | 'settings' | 'trash' | 'workspace' + | 'bell' | 'win-min' | 'win-max' | 'win-restore' | 'win-close'; interface Props extends Omit, 'name' | 'stroke'> { @@ -46,6 +47,7 @@ export function Icon({ name, size = 14, stroke = 1.5, ...rest }: Props) { case 'file': return ; case 'folder': return ; case 'workspace': return ; + case 'bell': return ; case 'folder-open': return ; case 'changes': return ; case 'search': return ; diff --git a/ui/src/components/PullRequestMonitor.tsx b/ui/src/components/PullRequestMonitor.tsx new file mode 100644 index 0000000..d6f1ca3 --- /dev/null +++ b/ui/src/components/PullRequestMonitor.tsx @@ -0,0 +1,59 @@ +import { useEffect, useRef } from 'react'; + +import { isTauri, tauri } from '../lib/tauri'; +import { usePullRequests } from '../stores/pullRequests'; +import { useRepo } from '../stores/repo'; +import { Icon } from './Icon'; + +const POLL_MS = 60_000; + +/** Global followed-PR lifecycle. It stays mounted when the PR view is not. */ +export function PullRequestMonitor() { + const path = useRepo((state) => state.activePath); + const branch = useRepo((state) => state.meta && !state.meta.detached ? state.meta.branch : null); + const hydrated = usePullRequests((state) => state.hydrated); + const hydrate = usePullRequests((state) => state.hydrate); + const pollAll = usePullRequests((state) => state.pollAll); + const followBranchMatch = usePullRequests((state) => state.followBranchMatch); + const permission = usePullRequests((state) => state.permission); + const followedCount = usePullRequests((state) => Object.keys(state.followed).length); + const branchGeneration = useRef(0); + + useEffect(() => { + if (isTauri()) void hydrate(); + }, [hydrate]); + + useEffect(() => { + if (!hydrated || !isTauri()) return; + void pollAll(); + const timer = window.setInterval(() => { void pollAll(); }, POLL_MS); + const onFocus = () => { void pollAll(); }; + window.addEventListener('focus', onFocus); + return () => { + window.clearInterval(timer); + window.removeEventListener('focus', onFocus); + }; + }, [hydrated, pollAll]); + + useEffect(() => { + if (!hydrated || !path || !branch || !isTauri()) return; + const generation = ++branchGeneration.current; + void tauri.repoPullRequestForBranch(path, branch).then((match) => { + if (branchGeneration.current === generation && match) { + void followBranchMatch(path, match).catch(() => {}); + } + }).catch(() => { + // Unsupported remotes, missing CLIs, and auth failures are surfaced when + // the user opens Pull Requests; automatic discovery stays quiet. + }); + return () => { branchGeneration.current += 1; }; + }, [branch, followBranchMatch, hydrated, path]); + + if (permission !== 'denied' || followedCount === 0) return null; + return ( +
+ + Following {followedCount} pull request{followedCount === 1 ? '' : 's'}, but desktop notifications are blocked. Allow Strand in system settings. +
+ ); +} diff --git a/ui/src/lib/pullRequestActivity.test.ts b/ui/src/lib/pullRequestActivity.test.ts new file mode 100644 index 0000000..11ff038 --- /dev/null +++ b/ui/src/lib/pullRequestActivity.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from 'vitest'; + +import { + isTerminalPullRequest, + pullRequestActivityChanged, + pullRequestActivityEvents, + pullRequestFollowKey, + pullRequestNotificationBody, +} from './pullRequestActivity'; +import type { PullRequestActivitySnapshot } from './types'; + +function snapshot(overrides: Partial = {}): PullRequestActivitySnapshot { + return { + repository: { provider: 'git_hub', remote: 'origin', label: 'acme/app' }, + id: 42, + title: 'Ship it', + url: 'https://github.com/acme/app/pull/42', + state: 'open', + source_branch: 'feature', + source_commit: '1'.repeat(40), + updated_at: '2026-07-14T10:00:00Z', + comments: [], + reviews: [], + checks: [{ id: 'ci', name: 'CI', status: 'SUCCESS' }], + checks_complete: true, + ...overrides, + }; +} + +describe('pull request activity', () => { + it('uses hosted repository identity rather than a local worktree path', () => { + const repo = snapshot().repository; + expect(pullRequestFollowKey(repo, 42)).toBe('git_hub:acme/app:42'); + }); + + it('does not notify for a first or unchanged snapshot or non-failing check transition', () => { + const previous = snapshot({ checks: [{ id: 'ci', name: 'CI', status: 'RUNNING' }] }); + const next = snapshot({ checks: [{ id: 'ci', name: 'CI', status: 'SUCCESS' }] }); + expect(pullRequestActivityEvents(null, next)).toEqual([]); + expect(pullRequestActivityEvents(previous, previous)).toEqual([]); + expect(pullRequestActivityEvents(previous, next)).toEqual([]); + expect(pullRequestActivityEvents(next, previous)).toEqual([]); + expect(pullRequestActivityChanged(previous, next)).toBe(true); + }); + + it('coalesces new human comments, decisions, failures, and pushes', () => { + const previous = snapshot({ + comments: [{ id: 'old', author: 'Ada', kind: 'comment', is_system: false }], + reviews: [{ id: 'grace', author: 'Grace', state: 'PENDING' }], + checks: [{ id: 'ci', name: 'CI', status: 'RUNNING' }], + }); + const next = snapshot({ + source_commit: '2'.repeat(40), + comments: [ + ...previous.comments, + { id: 'new', author: 'Ada', kind: 'thread', is_system: false }, + { id: 'system', author: 'Build', kind: 'comment', is_system: true }, + ], + reviews: [{ id: 'grace', author: 'Grace', state: 'APPROVED' }], + checks: [{ id: 'ci', name: 'CI', status: 'FAILURE' }], + }); + const events = pullRequestActivityEvents(previous, next); + expect(events.map((event) => event.kind)).toEqual(['comments', 'reviews', 'failed-checks', 'push']); + expect(pullRequestNotificationBody(events)).toContain('Ada added 1 new comment'); + expect(pullRequestNotificationBody(events)).toContain('Grace approved'); + expect(pullRequestNotificationBody(events)).toContain('Failed: CI'); + }); + + it('notifies when a new check first appears failed', () => { + const events = pullRequestActivityEvents(snapshot({ checks: [] }), snapshot({ + checks: [{ id: 'lint', name: 'Lint', status: 'broken' }], + })); + expect(events).toEqual([{ kind: 'failed-checks', names: ['Lint'] }]); + }); + + it('notifies for a newly submitted request for changes', () => { + const events = pullRequestActivityEvents(snapshot(), snapshot({ + reviews: [{ id: 'review-1', author: 'Grace', state: 'CHANGES_REQUESTED' }], + })); + expect(events).toEqual([{ kind: 'reviews', decisions: ['Grace requested changes'] }]); + }); + + it('treats merged and closed states as terminal', () => { + const merged = snapshot({ state: 'merged' }); + expect(pullRequestActivityEvents(snapshot(), merged)).toContainEqual({ kind: 'terminal', state: 'merged' }); + expect(isTerminalPullRequest(merged)).toBe(true); + expect(isTerminalPullRequest(snapshot())).toBe(false); + }); +}); diff --git a/ui/src/lib/pullRequestActivity.ts b/ui/src/lib/pullRequestActivity.ts new file mode 100644 index 0000000..d60f5f8 --- /dev/null +++ b/ui/src/lib/pullRequestActivity.ts @@ -0,0 +1,113 @@ +import type { + PullRequestActivitySnapshot, + PullRequestRepository, +} from './types'; + +export type PullRequestActivityEvent = + | { kind: 'comments'; count: number; authors: string[] } + | { kind: 'reviews'; decisions: string[] } + | { kind: 'failed-checks'; names: string[] } + | { kind: 'push'; branch: string } + | { kind: 'terminal'; state: 'merged' | 'closed' }; + +export function pullRequestFollowKey(repository: PullRequestRepository, id: number): string { + return `${repository.provider}:${repository.label}:${id}`; +} + +function normalized(value: string): string { + return value.trim().toUpperCase().replaceAll('-', '_').replaceAll(' ', '_'); +} + +function failedCheck(status: string): boolean { + return ['FAILURE', 'FAILED', 'ERROR', 'TIMED_OUT', 'CANCELLED', 'ACTION_REQUIRED', 'REJECTED', 'BROKEN'] + .includes(normalized(status)); +} + +function terminalState(state: string): 'merged' | 'closed' | null { + const value = normalized(state); + if (value === 'MERGED' || value === 'COMPLETED') return 'merged'; + if (value === 'CLOSED' || value === 'ABANDONED') return 'closed'; + return null; +} + +function reviewDecision(state: string): string | null { + const value = normalized(state); + if (value === 'APPROVED' || value === 'APPROVED_WITH_SUGGESTIONS') return 'approved'; + if (value === 'CHANGES_REQUESTED' || value === 'REJECTED' || value === 'WAITING_FOR_AUTHOR') { + return 'requested changes'; + } + return null; +} + +export function pullRequestActivityEvents( + previous: PullRequestActivitySnapshot | null, + next: PullRequestActivitySnapshot, +): PullRequestActivityEvent[] { + if (!previous) return []; + const events: PullRequestActivityEvent[] = []; + const previousComments = new Set(previous.comments.map((comment) => comment.id)); + const comments = next.comments.filter((comment) => !comment.is_system && !previousComments.has(comment.id)); + if (comments.length > 0) { + events.push({ + kind: 'comments', + count: comments.length, + authors: [...new Set(comments.map((comment) => comment.author).filter(Boolean))], + }); + } + + const previousReviews = new Map(previous.reviews.map((review) => [review.id, normalized(review.state)])); + const decisions = next.reviews.flatMap((review) => { + const decision = reviewDecision(review.state); + if (!decision || previousReviews.get(review.id) === normalized(review.state)) return []; + return [`${review.author} ${decision}`]; + }); + if (decisions.length > 0) events.push({ kind: 'reviews', decisions }); + + const previousChecks = new Map(previous.checks.map((check) => [check.id, check.status])); + const failed = next.checks + .filter((check) => failedCheck(check.status) && !failedCheck(previousChecks.get(check.id) ?? '')) + .map((check) => check.name); + if (failed.length > 0) events.push({ kind: 'failed-checks', names: [...new Set(failed)] }); + + if (previous.source_commit && next.source_commit && previous.source_commit !== next.source_commit) { + events.push({ kind: 'push', branch: next.source_branch }); + } + + const previousTerminal = terminalState(previous.state); + const nextTerminal = terminalState(next.state); + if (!previousTerminal && nextTerminal) events.push({ kind: 'terminal', state: nextTerminal }); + return events; +} + +export function pullRequestActivityChanged( + previous: PullRequestActivitySnapshot, + next: PullRequestActivitySnapshot, +): boolean { + const fingerprint = (snapshot: PullRequestActivitySnapshot) => JSON.stringify({ + state: snapshot.state, + source_commit: snapshot.source_commit, + updated_at: snapshot.updated_at, + checks_complete: snapshot.checks_complete, + comments: snapshot.comments.map((comment) => [comment.id, comment.is_system]), + reviews: snapshot.reviews.map((review) => [review.id, review.state]), + checks: snapshot.checks.map((check) => [check.id, check.status]), + }); + return fingerprint(previous) !== fingerprint(next); +} + +export function pullRequestNotificationBody(events: readonly PullRequestActivityEvent[]): string { + return events.map((event) => { + if (event.kind === 'comments') { + const who = event.authors.length === 1 ? `${event.authors[0]} added ` : ''; + return `${who}${event.count} new ${event.count === 1 ? 'comment' : 'comments'}`; + } + if (event.kind === 'reviews') return event.decisions.join(', '); + if (event.kind === 'failed-checks') return `Failed: ${event.names.join(', ')}`; + if (event.kind === 'push') return `New commits pushed to ${event.branch}`; + return event.state === 'merged' ? 'Pull request merged' : 'Pull request closed'; + }).join(' · '); +} + +export function isTerminalPullRequest(snapshot: PullRequestActivitySnapshot): boolean { + return terminalState(snapshot.state) != null; +} diff --git a/ui/src/lib/pullRequestFollows.test.ts b/ui/src/lib/pullRequestFollows.test.ts new file mode 100644 index 0000000..7fe4bc3 --- /dev/null +++ b/ui/src/lib/pullRequestFollows.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; + +import { + activityErrorRecord, + applyPullRequestFollow, + applyPullRequestUnfollow, +} from './pullRequestFollows'; + +describe('pull request follow persistence', () => { + const key = 'git_hub:acme/app:42'; + + it('blocks automatic re-follow until a manual follow clears the mute', () => { + expect(applyPullRequestFollow([key], key, false)).toEqual({ allowed: false, muted: [key] }); + expect(applyPullRequestFollow([key], key, true)).toEqual({ allowed: true, muted: [] }); + }); + + it('mutes explicit unfollows but not terminal auto-removal', () => { + expect(applyPullRequestUnfollow([], key, true)).toEqual([key]); + expect(applyPullRequestUnfollow([], key, false)).toEqual([]); + }); + + it('retains the last successful baseline when a poll fails', () => { + const snapshot = { id: 42, source_commit: 'abc' }; + const record = { snapshot, error: null }; + expect(activityErrorRecord(record, 'offline')).toEqual({ snapshot, error: 'offline' }); + }); +}); diff --git a/ui/src/lib/pullRequestFollows.ts b/ui/src/lib/pullRequestFollows.ts new file mode 100644 index 0000000..8e9150f --- /dev/null +++ b/ui/src/lib/pullRequestFollows.ts @@ -0,0 +1,34 @@ +export interface FollowIntent { + allowed: boolean; + muted: string[]; +} + +/** Manual follows clear a mute; automatic follows respect it. */ +export function applyPullRequestFollow( + muted: readonly string[], + key: string, + manual: boolean, +): FollowIntent { + const isMuted = muted.includes(key); + return { + allowed: manual || !isMuted, + muted: manual && isMuted ? muted.filter((item) => item !== key) : [...muted], + }; +} + +/** Only an explicit user unfollow creates a persistent auto-follow mute. */ +export function applyPullRequestUnfollow( + muted: readonly string[], + key: string, + explicit: boolean, +): string[] { + if (!explicit || muted.includes(key)) return [...muted]; + return [...muted, key]; +} + +export function activityErrorRecord( + record: T, + error: string, +): T { + return { ...record, error }; +} diff --git a/ui/src/lib/pullRequests.test.ts b/ui/src/lib/pullRequests.test.ts index de781be..ae0e830 100644 --- a/ui/src/lib/pullRequests.test.ts +++ b/ui/src/lib/pullRequests.test.ts @@ -48,6 +48,7 @@ function pullRequest(overrides: Partial = {}): PullRequest { labels: [], reviewers: [], checks: [{ name: 'CI', status: 'SUCCESS' }], + checks_complete: true, comments: [], review_threads: [], ...overrides, @@ -91,6 +92,7 @@ describe('pullRequestReadiness', () => { const readiness = pullRequestReadiness(pullRequest({ merge_status: 'succeeded', checks: [], + checks_complete: false, }), 'azure_dev_ops'); expect(readiness.tone).toBe('neutral'); expect(readiness.label).toBe('Status incomplete'); diff --git a/ui/src/lib/pullRequests.ts b/ui/src/lib/pullRequests.ts index 28ff05b..1fecde6 100644 --- a/ui/src/lib/pullRequests.ts +++ b/ui/src/lib/pullRequests.ts @@ -141,8 +141,10 @@ export function pullRequestReadiness( : 'Merge readiness was not reported.'); } - if (provider === 'azure_dev_ops') { - unknown.push('Azure policy and check details are not reported by this integration.'); + if (!pr.checks_complete) { + unknown.push(provider === 'azure_dev_ops' + ? 'Azure policy and check details could not be loaded.' + : 'Provider check details could not be loaded.'); } if (details.length > 0) { diff --git a/ui/src/lib/tauri.ts b/ui/src/lib/tauri.ts index 72f3a33..8ea92fb 100644 --- a/ui/src/lib/tauri.ts +++ b/ui/src/lib/tauri.ts @@ -22,6 +22,8 @@ import type { NetworkOutcome, Progress, PullRequest, + PullRequestActivitySnapshot, + PullRequestBranchMatch, PullRequestList, PullRequestMergeStrategy, RebaseEntry, @@ -99,6 +101,10 @@ export const tauri = { invoke('repo_search_log', { path, query, mode, limit }), repoRefs: (path: string) => invoke('repo_refs', { path }), repoPullRequests: (path: string) => invoke('repo_pull_requests', { path }), + repoPullRequestForBranch: (path: string, branch: string) => + invoke('repo_pull_request_for_branch', { path, branch }), + repoPullRequestActivity: (path: string, id: number) => + invoke('repo_pull_request_activity', { path, id }), repoPullRequest: (path: string, id: number) => invoke('repo_pull_request', { path, id }), repoPullRequestDiff: (path: string, id: number) => invoke('repo_pull_request_diff', { path, id }), diff --git a/ui/src/lib/types.ts b/ui/src/lib/types.ts index 011bfa4..babe8e6 100644 --- a/ui/src/lib/types.ts +++ b/ui/src/lib/types.ts @@ -247,6 +247,8 @@ export interface PullRequest { labels: string[]; reviewers: PullRequestReviewer[]; checks: PullRequestCheck[]; + /** True when the provider's complete check/policy query succeeded. */ + checks_complete: boolean; comments: PullRequestComment[]; review_threads: PullRequestReviewThread[]; } @@ -256,6 +258,45 @@ export interface PullRequestList { pull_requests: PullRequest[]; } +export interface PullRequestBranchMatch { + repository: PullRequestRepository; + pull_request: PullRequest; +} + +export interface PullRequestActivityComment { + id: string; + author: string; + kind: string; + is_system: boolean; +} + +export interface PullRequestActivityReview { + id: string; + author: string; + state: string; +} + +export interface PullRequestActivityCheck { + id: string; + name: string; + status: string; +} + +export interface PullRequestActivitySnapshot { + repository: PullRequestRepository; + id: number; + title: string; + url: string; + state: string; + source_branch: string; + source_commit: string; + updated_at: string; + comments: PullRequestActivityComment[]; + reviews: PullRequestActivityReview[]; + checks: PullRequestActivityCheck[]; + checks_complete: boolean; +} + /** The branch a ref was forked from + the fork point to review against. */ export interface BaseBranch { name: string; diff --git a/ui/src/stores/pullRequests.ts b/ui/src/stores/pullRequests.ts new file mode 100644 index 0000000..96ccd43 --- /dev/null +++ b/ui/src/stores/pullRequests.ts @@ -0,0 +1,366 @@ +import { + isPermissionGranted, + requestPermission, + sendNotification, +} from '@tauri-apps/plugin-notification'; +import { create } from 'zustand'; + +import { settings as settingsDb } from '../lib/db'; +import { + isTerminalPullRequest, + pullRequestActivityChanged, + pullRequestActivityEvents, + pullRequestFollowKey, + pullRequestNotificationBody, +} from '../lib/pullRequestActivity'; +import { + activityErrorRecord, + applyPullRequestFollow, + applyPullRequestUnfollow, +} from '../lib/pullRequestFollows'; +import { errMessage, isTauri, tauri } from '../lib/tauri'; +import type { + PullRequest, + PullRequestActivitySnapshot, + PullRequestBranchMatch, + PullRequestRepository, +} from '../lib/types'; + +const STORAGE_KEY = 'followed-pull-requests:v1'; + +export interface FollowedPullRequest { + key: string; + repoPath: string; + repository: PullRequestRepository; + id: number; + title: string; + url: string; + sourceBranch: string; + followedAt: number; + lastCheckedAt: number | null; + snapshot: PullRequestActivitySnapshot | null; + error: string | null; +} + +interface StoredFollowState { + version: 1; + followed: FollowedPullRequest[]; + muted: string[]; +} + +interface ActivePullRequest { + key: string; + repoPath: string; + repository: PullRequestRepository; + pr: Pick; +} + +type NotificationPermission = 'unknown' | 'granted' | 'denied'; + +interface PullRequestMonitorState { + hydrated: boolean; + followed: Record; + muted: string[]; + permission: NotificationPermission; + permissionRequested: boolean; + activityRevision: Record; + active: ActivePullRequest | null; + hydrate(): Promise; + follow( + repoPath: string, + repository: PullRequestRepository, + pr: Pick, + manual: boolean, + ): Promise; + followBranchMatch(repoPath: string, match: PullRequestBranchMatch): Promise; + unfollow(key: string, mute: boolean): Promise; + pollAll(): Promise; + activity(path: string, id: number): Promise; + seed(snapshot: PullRequestActivitySnapshot, repoPath: string): Promise; + seedAfterProviderWrite(path: string, id: number): Promise; + setActive( + repoPath: string, + repository: PullRequestRepository, + pr: Pick, + ): void; + clearActive(key: string): void; + toggleActive(): Promise; +} + +const activityRequests = new Map>(); +const activityQueue: Array<() => void> = []; +let activeActivityRequests = 0; +let polling = false; + +function queuedActivityRequest(path: string, id: number): Promise { + return new Promise((resolve, reject) => { + const start = () => { + activeActivityRequests += 1; + void tauri.repoPullRequestActivity(path, id).then(resolve, reject).finally(() => { + activeActivityRequests -= 1; + activityQueue.shift()?.(); + }); + }; + if (activeActivityRequests < 2) start(); + else activityQueue.push(start); + }); +} + +function activityRequest(path: string, id: number): Promise { + const key = `${path}\0${id}`; + const existing = activityRequests.get(key); + if (existing) return existing; + const request = queuedActivityRequest(path, id).finally(() => { + if (activityRequests.get(key) === request) activityRequests.delete(key); + }); + activityRequests.set(key, request); + return request; +} + +async function persist(state: Pick): Promise { + const payload: StoredFollowState = { + version: 1, + followed: Object.values(state.followed), + muted: state.muted, + }; + await settingsDb.set(STORAGE_KEY, payload); +} + +function summaryFromSnapshot(snapshot: PullRequestActivitySnapshot): Pick { + return { + title: snapshot.title, + url: snapshot.url, + sourceBranch: snapshot.source_branch, + }; +} + +export const usePullRequests = create((set, get) => ({ + hydrated: false, + followed: {}, + muted: [], + permission: 'unknown', + permissionRequested: false, + activityRevision: {}, + active: null, + + async hydrate() { + if (get().hydrated) return; + let stored: StoredFollowState | null = null; + try { + stored = await settingsDb.get(STORAGE_KEY); + } catch { + // Persistence failure must not prevent the app from starting. + } + let permission: NotificationPermission = 'unknown'; + if (isTauri()) { + try { + permission = await isPermissionGranted() ? 'granted' : 'denied'; + } catch { + permission = 'denied'; + } + } + const followed = Object.fromEntries( + (stored?.version === 1 ? stored.followed : []).map((entry) => [entry.key, entry]), + ); + set({ + hydrated: true, + followed, + muted: stored?.version === 1 ? stored.muted : [], + permission, + }); + }, + + async follow(repoPath, repository, pr, manual) { + const key = pullRequestFollowKey(repository, pr.id); + const current = get().followed[key]; + const intent = applyPullRequestFollow(get().muted, key, manual); + if (!intent.allowed) return key; + const muted = intent.muted; + if (current) { + set({ + followed: { + ...get().followed, + [key]: { ...current, repoPath, repository, title: pr.title, url: pr.url, sourceBranch: pr.source_branch }, + }, + muted, + }); + await persist(get()); + return key; + } + + const record: FollowedPullRequest = { + key, + repoPath, + repository, + id: pr.id, + title: pr.title, + url: pr.url, + sourceBranch: pr.source_branch, + followedAt: Date.now(), + lastCheckedAt: null, + snapshot: null, + error: null, + }; + set({ followed: { ...get().followed, [key]: record }, muted }); + await persist(get()); + + if (isTauri() && get().permission !== 'granted' && (manual || !get().permissionRequested)) { + set({ permissionRequested: true }); + try { + const result = await requestPermission(); + set({ permission: result === 'granted' ? 'granted' : 'denied' }); + } catch { + set({ permission: 'denied' }); + } + } + + try { + const snapshot = await activityRequest(repoPath, pr.id); + await get().seed(snapshot, repoPath); + } catch (caught) { + const latest = get().followed[key]; + if (latest) { + set({ followed: { ...get().followed, [key]: { ...latest, error: errMessage(caught) } } }); + await persist(get()); + } + } + return key; + }, + + async followBranchMatch(repoPath, match) { + const key = pullRequestFollowKey(match.repository, match.pull_request.id); + if (!applyPullRequestFollow(get().muted, key, false).allowed) return null; + return get().follow(repoPath, match.repository, match.pull_request, false); + }, + + async unfollow(key, mute) { + const followed = { ...get().followed }; + delete followed[key]; + const muted = applyPullRequestUnfollow(get().muted, key, mute); + set({ followed, muted }); + await persist(get()); + }, + + activity(path, id) { + return activityRequest(path, id); + }, + + async seed(snapshot, repoPath) { + const key = pullRequestFollowKey(snapshot.repository, snapshot.id); + const current = get().followed[key]; + if (!current) return; + set({ + followed: { + ...get().followed, + [key]: { + ...current, + ...summaryFromSnapshot(snapshot), + repoPath, + repository: snapshot.repository, + lastCheckedAt: Date.now(), + snapshot, + error: null, + }, + }, + }); + await persist(get()); + }, + + async seedAfterProviderWrite(path, id) { + const requestKey = `${path}\0${id}`; + const inFlight = activityRequests.get(requestKey); + if (inFlight) { + try { + await inFlight; + } catch { + // A fresh request below is authoritative after the provider write. + } + } + const snapshot = await activityRequest(path, id); + await get().seed(snapshot, path); + }, + + async pollAll() { + if (polling || !get().hydrated) return; + polling = true; + try { + const keys = Object.keys(get().followed); + let cursor = 0; + const worker = async () => { + while (cursor < keys.length) { + const key = keys[cursor++]; + const current = get().followed[key]; + if (!current) continue; + try { + const snapshot = await activityRequest(current.repoPath, current.id); + const latest = get().followed[key]; + if (!latest) continue; + const events = pullRequestActivityEvents(latest.snapshot, snapshot); + const changed = latest.snapshot ? pullRequestActivityChanged(latest.snapshot, snapshot) : false; + const nextRecord: FollowedPullRequest = { + ...latest, + ...summaryFromSnapshot(snapshot), + repository: snapshot.repository, + snapshot, + lastCheckedAt: Date.now(), + error: null, + }; + set({ + followed: { ...get().followed, [key]: nextRecord }, + activityRevision: changed + ? { ...get().activityRevision, [key]: (get().activityRevision[key] ?? 0) + 1 } + : get().activityRevision, + }); + + const body = pullRequestNotificationBody(events); + if (body && get().permission === 'granted') { + try { + sendNotification({ + title: `PR #${snapshot.id} · ${snapshot.repository.label}`, + body, + group: key, + }); + } catch { + // Following and refresh remain useful even if the OS rejects a notification. + } + } + if (isTerminalPullRequest(snapshot)) { + const followed = { ...get().followed }; + delete followed[key]; + set({ followed }); + } + } catch (caught) { + const latest = get().followed[key]; + if (latest) { + set({ + followed: { + ...get().followed, + [key]: activityErrorRecord(latest, errMessage(caught)), + }, + }); + } + } + } + }; + await Promise.all([worker(), worker()]); + await persist(get()); + } finally { + polling = false; + } + }, + + setActive(repoPath, repository, pr) { + set({ active: { key: pullRequestFollowKey(repository, pr.id), repoPath, repository, pr } }); + }, + + clearActive(key) { + if (get().active?.key === key) set({ active: null }); + }, + + async toggleActive() { + const active = get().active; + if (!active) return; + if (get().followed[active.key]) await get().unfollow(active.key, true); + else await get().follow(active.repoPath, active.repository, active.pr, true); + }, +})); diff --git a/ui/src/styles/features.css b/ui/src/styles/features.css index 00b2525..565007b 100644 --- a/ui/src/styles/features.css +++ b/ui/src/styles/features.css @@ -4514,6 +4514,27 @@ textarea.clone-input { } /* Pull requests — provider-neutral list + detail workspace. */ +.pr-notification-banner { + position: fixed; + z-index: 180; + right: 14px; + top: 44px; + width: min(430px, calc(100vw - 28px)); + display: flex; + align-items: flex-start; + gap: 8px; + padding: 8px 10px; + border: 1px solid color-mix(in oklch, var(--warn) 55%, var(--border)); + border-radius: var(--r-md); + background: color-mix(in oklch, var(--bg-elev) 96%, transparent); + box-shadow: var(--shadow-3); + color: var(--warn); + font-size: 10px; + line-height: 1.4; + backdrop-filter: blur(10px); +} +.pr-notification-banner .ico { flex: none; margin-top: 1px; } + .pr-view { flex: 1; min-height: 0; @@ -4532,10 +4553,12 @@ textarea.clone-input { } .pr-toolbar > div { min-width: 0; display: flex; align-items: baseline; gap: 8px; } +.pr-toolbar .pr-toolbar-actions { flex: 0 0 auto; justify-content: flex-end; } .pr-toolbar strong { color: var(--text); font-size: 12px; } .pr-toolbar span { color: var(--text-muted); font-size: 10px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .pr-toolbar .h-link { display: inline-flex; align-items: center; gap: 5px; } .pr-toolbar .pr-back { flex: 0 0 auto; color: var(--text); font-weight: 600; } +.pr-toolbar .pr-refresh-failed { color: var(--del); } .pr-main { flex: 1; min-height: 0; } @@ -4576,6 +4599,9 @@ textarea.clone-input { .pr-row:focus-visible { outline: 1px solid var(--accent-2); } .pr-row > strong { font-size: 12px; line-height: 1.35; } .pr-row-top { display: flex; align-items: center; justify-content: space-between; gap: 8px; color: var(--text-muted); font-size: 10px; } +.pr-row-status, +.pr-followed-badge { display: inline-flex; align-items: center; gap: 5px; } +.pr-followed-badge { color: var(--accent-2); font-size: 9px; font-weight: 600; } .pr-row-meta { color: var(--text-muted); font-size: 10px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .pr-state { @@ -4607,6 +4633,11 @@ textarea.clone-input { .pr-detail-head h2 { margin: 5px 0 0; color: var(--text); font-size: 20px; line-height: 1.25; overflow-wrap: anywhere; } .pr-detail-head .btn { display: inline-flex; align-items: center; gap: 6px; flex: 0 0 auto; } .pr-detail-actions { display: flex; align-items: center; gap: 7px; flex: 0 0 auto; } +.pr-follow.on { + color: var(--accent-2); + border-color: var(--accent); + background: color-mix(in oklch, var(--accent) 12%, var(--bg-elev)); +} .pr-detail-kicker { color: var(--text-muted); font-size: 10px; } .pr-merge-control { position: relative; flex: 0 0 auto; } @@ -4726,6 +4757,7 @@ textarea.clone-input { .pr-readiness.pending .pr-readiness-icon { color: var(--warn); } .pr-readiness-facts { justify-content: flex-end; flex-wrap: wrap; gap: 6px 12px; color: var(--text-muted); font-size: 9px; } .pr-readiness-facts span { display: inline-flex; align-items: center; gap: 4px; white-space: nowrap; } +.pr-readiness-facts .pr-notification-warning { color: var(--warn); } .pr-readiness-details { grid-column: 1 / -1; padding-top: 6px; border-top: 1px solid var(--border); color: var(--text-muted); font-size: 9px; } .pr-readiness-details summary { width: fit-content; border-radius: var(--r-sm); color: var(--text-2); cursor: pointer; } .pr-readiness-details summary:hover { color: var(--text); } @@ -5086,11 +5118,34 @@ textarea.clone-input { } .pr-changes { + position: relative; height: 100%; min-height: 0; min-width: 0; width: 100%; } +.pr-inline-refresh { + position: absolute; + z-index: 12; + top: 8px; + left: 50%; + transform: translateX(-50%); + max-width: min(620px, calc(100% - 32px)); + display: flex; + align-items: center; + gap: 7px; + padding: 5px 9px; + border: 1px solid var(--border-strong); + border-radius: 999px; + background: color-mix(in oklch, var(--bg-elev) 94%, transparent); + box-shadow: var(--shadow-2); + color: var(--text-muted); + font-size: 10px; + backdrop-filter: blur(8px); +} +.pr-inline-refresh span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.pr-inline-refresh.error { color: var(--del); border-color: color-mix(in oklch, var(--del) 55%, var(--border)); } +.pr-inline-refresh .h-link { flex: none; color: inherit; } .pr-file-tree { display: flex; flex-direction: column; diff --git a/ui/src/views/PullRequests.tsx b/ui/src/views/PullRequests.tsx index e22a2e0..0506bd1 100644 --- a/ui/src/views/PullRequests.tsx +++ b/ui/src/views/PullRequests.tsx @@ -18,10 +18,12 @@ import { pullRequestForBranch, relativeTimeLabel, } from '../lib/pullRequests'; +import { pullRequestActivityChanged, pullRequestFollowKey } from '../lib/pullRequestActivity'; import { errMessage, tauri } from '../lib/tauri'; import { treeFileOrder } from '../lib/treeOrder'; -import type { PullRequest, PullRequestCheck, PullRequestComment, PullRequestList, PullRequestReviewThread } from '../lib/types'; +import type { PullRequest, PullRequestActivitySnapshot, PullRequestCheck, PullRequestComment, PullRequestList, PullRequestReviewThread } from '../lib/types'; import { useRepo } from '../stores/repo'; +import { usePullRequests } from '../stores/pullRequests'; import { useSettings } from '../stores/settings'; import { PullRequestMergeControl } from './PullRequestMergeControl'; @@ -494,7 +496,10 @@ function PullRequestChanges({ setError(null); void tauri.repoPullRequestDiff(path, pr.id).then( (next) => { - if (generation.current === current) setPatch(next); + if (generation.current === current) { + setPatch(next); + setError(null); + } }, (caught) => { if (generation.current === current) setError(errMessage(caught)); @@ -503,7 +508,7 @@ function PullRequestChanges({ if (generation.current === current) setLoading(false); }); return () => { generation.current += 1; }; - }, [path, pr.id, reload]); + }, [path, pr.id, pr.source_commit, reload]); const parsed = useMemo(() => { if (patch == null) return { files: [] as FileDiffMetadata[], error: null as string | null }; @@ -555,8 +560,9 @@ function PullRequestChanges({ setCommentMessage(null); }, [selectedPath]); + const stalePatch = patch != null && (loading || error != null); const openForReview = pr.state === 'open' || pr.state === 'active'; - const inlineCommentsSupported = provider === 'git_hub' && openForReview; + const inlineCommentsSupported = provider === 'git_hub' && openForReview && !stalePatch; const selectLines = useCallback((range: SelectedLineRange | null) => { if (!inlineCommentsSupported || !range) { setSelectedLines(null); @@ -600,7 +606,7 @@ function PullRequestChanges({ }, [selectedLines, selectedThreads]); const submitInlineComment = async () => { - if (!selectedFile || !selectedLines || postingComment) return; + if (!selectedFile || !selectedLines || postingComment || stalePatch) return; const body = commentDraft.trim(); if (!body) return; const side = selectedLines.side ?? 'additions'; @@ -643,10 +649,10 @@ function PullRequestChanges({ setSelectedPath(treePaths[next]); }; - if (loading) { + if (loading && patch == null) { return
Loading code changes…
; } - if (error || parsed.error) { + if ((error && patch == null) || parsed.error) { return (
@@ -662,6 +668,13 @@ function PullRequestChanges({ return (
+ {stalePatch && ( +
+ + {error ? `Could not update changes: ${error}` : 'Updating changes for the latest push…'} + {error && } +
+ )}
void; onToast: (message: string, kind?: 'success' | 'error') => void; + followed: boolean; + notificationPermission: 'unknown' | 'granted' | 'denied'; + onToggleFollow: () => void; }) { const [tab, setTab] = useState('overview'); const [changesTarget, setChangesTarget] = useState(null); @@ -867,6 +886,15 @@ function PullRequestDetails({

{pr.title}

+ Updated {relativeTimeLabel(pr.updated_at)} + {followed && notificationPermission === 'denied' && ( + + Notifications blocked + + )}
{readiness.details.length > 0 && (
@@ -981,6 +1014,16 @@ export function PullRequests({ const path = useRepo((state) => state.activePath); const meta = useRepo((state) => state.meta); const currentBranch = meta && !meta.detached ? meta.branch : null; + const followed = usePullRequests((state) => state.followed); + const notificationPermission = usePullRequests((state) => state.permission); + const activityRevision = usePullRequests((state) => state.activityRevision); + const follow = usePullRequests((state) => state.follow); + const unfollow = usePullRequests((state) => state.unfollow); + const setActive = usePullRequests((state) => state.setActive); + const clearActive = usePullRequests((state) => state.clearActive); + const activity = usePullRequests((state) => state.activity); + const pollFollowed = usePullRequests((state) => state.pollAll); + const seedAfterProviderWrite = usePullRequests((state) => state.seedAfterProviderWrite); const [data, setData] = useState(null); const [selectedId, setSelectedId] = useState(null); const [openedId, setOpenedId] = useState(null); @@ -990,9 +1033,11 @@ export function PullRequests({ const [detailReload, setDetailReload] = useState(0); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); + const [lastUpdatedAt, setLastUpdatedAt] = useState(null); const generation = useRef(0); const detailGeneration = useRef(0); const autoOpenedContext = useRef(null); + const visibleActivity = useRef(null); const refresh = useCallback(async () => { if (!path) return; @@ -1017,17 +1062,25 @@ export function PullRequests({ autoOpenedContext.current = autoOpenContext; setOpenedId(branchPullRequest?.id ?? null); } + setLastUpdatedAt(Date.now()); } catch (caught) { if (generation.current !== current) return; - setData(null); - setSelectedId(null); - setOpenedId(null); setError(errMessage(caught)); } finally { if (generation.current === current) setLoading(false); } }, [currentBranch, path]); + useEffect(() => { + setData(null); + setSelectedId(null); + setOpenedId(null); + setDetail(null); + setError(null); + setDetailError(null); + visibleActivity.current = null; + }, [path]); + useEffect(() => { void refresh(); return () => { @@ -1044,16 +1097,82 @@ export function PullRequests({ () => data?.pull_requests.find((pr) => pr.id === openedId) ?? null, [data, openedId], ); + const openedKey = data && openedId != null + ? pullRequestFollowKey(data.repository, openedId) + : null; + const openedFollowed = openedKey ? followed[openedKey] ?? null : null; + const openedRevision = openedKey ? activityRevision[openedKey] ?? 0 : 0; + const observedRevision = useRef(0); + + useEffect(() => { + observedRevision.current = openedRevision; + }, [openedKey]); + + useEffect(() => { + if (!openedKey || openedRevision === observedRevision.current) return; + observedRevision.current = openedRevision; + setDetailReload((value) => value + 1); + }, [openedKey, openedRevision]); useEffect(() => { - if (!path || openedId == null || !data) { + const pr = detail ?? openedSummary; + if (!path || !data || !pr) return; + const key = pullRequestFollowKey(data.repository, pr.id); + setActive(path, data.repository, pr); + return () => clearActive(key); + }, [clearActive, data, detail, openedSummary, path, setActive]); + + useEffect(() => { + if (!path || !data || openedId == null || openedFollowed) { + visibleActivity.current = null; + return; + } + let cancelled = false; + const revalidate = async () => { + try { + const next = await activity(path, openedId); + if (cancelled) return; + const previous = visibleActivity.current; + visibleActivity.current = next; + if (previous && pullRequestActivityChanged(previous, next)) { + setDetailReload((value) => value + 1); + } + } catch { + // Full-detail refresh keeps its own visible error state; background + // activity failures do not replace or clear the current PR. + } + }; + void revalidate(); + const timer = window.setInterval(() => { void revalidate(); }, 60_000); + const onFocus = () => { void revalidate(); }; + window.addEventListener('focus', onFocus); + return () => { + cancelled = true; + window.clearInterval(timer); + window.removeEventListener('focus', onFocus); + }; + }, [activity, data, openedFollowed, openedId, path]); + + useEffect(() => { + if (openedId != null || !data) return; + const timer = window.setInterval(() => { void refresh(); }, 60_000); + const onFocus = () => { void refresh(); }; + window.addEventListener('focus', onFocus); + return () => { + window.clearInterval(timer); + window.removeEventListener('focus', onFocus); + }; + }, [data, openedId, refresh]); + + useEffect(() => { + if (!path || openedId == null) { setDetail(null); setDetailError(null); setDetailLoading(false); return; } const current = ++detailGeneration.current; - setDetail(null); + setDetail((currentDetail) => currentDetail?.id === openedId ? currentDetail : null); setDetailError(null); setDetailLoading(true); // Briefly coalesce rapid pointer activation; keyboard list navigation does @@ -1061,7 +1180,11 @@ export function PullRequests({ const timer = window.setTimeout(() => { void tauri.repoPullRequest(path, openedId).then( (next) => { - if (detailGeneration.current === current) setDetail(next); + if (detailGeneration.current === current) { + setDetail(next); + setDetailError(null); + setLastUpdatedAt(Date.now()); + } }, (caught) => { if (detailGeneration.current === current) setDetailError(errMessage(caught)); @@ -1074,7 +1197,7 @@ export function PullRequests({ window.clearTimeout(timer); detailGeneration.current += 1; }; - }, [path, openedId, data, detailReload]); + }, [path, openedId, detailReload]); const move = (delta: number) => { if (!data?.pull_requests.length) return; @@ -1100,7 +1223,34 @@ export function PullRequests({ ...current, pull_requests: current.pull_requests.map((item) => item.id === next.id ? next : item), } : current); - }, []); + if (path && data) { + const key = pullRequestFollowKey(data.repository, next.id); + if (usePullRequests.getState().followed[key]) { + void seedAfterProviderWrite(path, next.id).catch(() => {}); + } + } + }, [data, path, seedAfterProviderWrite]); + + const toggleFollow = useCallback(() => { + if (!path || !data || !detail) return; + const key = pullRequestFollowKey(data.repository, detail.id); + if (followed[key]) { + void unfollow(key, true).then( + () => onToast(`Stopped following PR #${detail.id}`), + (caught) => onToast(`Could not unfollow PR #${detail.id}: ${errMessage(caught)}`, 'error'), + ); + } else { + void follow(path, data.repository, detail, true).then( + () => onToast(`Following PR #${detail.id}`), + (caught) => onToast(`Could not follow PR #${detail.id}: ${errMessage(caught)}`, 'error'), + ); + } + }, [data, detail, follow, followed, onToast, path, unfollow]); + + const manualRefresh = useCallback(() => { + void refresh(); + if (openedId != null) setDetailReload((value) => value + 1); + }, [openedId, refresh]); const requestMergeMenu = useCallback((pr: PullRequest | null) => { if (!pr || pr.is_draft || !['open', 'active'].includes(pr.state) || !pr.source_commit) { @@ -1131,19 +1281,30 @@ export function PullRequests({ {providerName(data.repository.provider)} · {data.repository.label} · {data.repository.remote} ) : null}
- +
+ {openedFollowed?.error ? ( + + Updates delayed · + + ) : (error && data) || (detailError && detail) ? ( + Refresh failed + ) : lastUpdatedAt ? ( + Updated {relativeTimeLabel(new Date(lastUpdatedAt).toISOString())} + ) : null} + +
- {error ? ( + {error && !data ? (
Pull requests are not available yet

{error}

Strand uses the signed-in provider CLI so it never stores your access token. - +
) : loading && !data ? (
Loading pull requests…
@@ -1168,7 +1329,9 @@ export function PullRequests({ else if (event.key === 'Enter' && selectedSummary) { event.preventDefault(); openPullRequest(selectedSummary.id); } }} > - {data.pull_requests.map((pr) => ( + {data.pull_requests.map((pr) => { + const isFollowed = Boolean(followed[pullRequestFollowKey(data.repository, pr.id)]); + return ( - ))} + ); + })}
+ ) : detail && path ? ( + ) : detailLoading ? (
@@ -1198,15 +1380,6 @@ export function PullRequests({

{detailError}

- ) : detail && path ? ( - ) : null} ) : null} diff --git a/website/docs/pull-requests.md b/website/docs/pull-requests.md index 56fda17..2b14f94 100644 --- a/website/docs/pull-requests.md +++ b/website/docs/pull-requests.md @@ -21,11 +21,40 @@ shows the provider error and the setup command. Provider calls time out after The list contains up to the latest 100 open, closed, and merged pull requests. If the checked-out branch has an active PR, Strand opens that PR automatically. +It also follows that PR in the background even if you never open Pull Requests. Closed and merged PRs never auto-open. Otherwise, click a row to inspect it, or use `Up`/`Down` or `j`/`k` and press `Enter`. **Pull Requests** in the detail toolbar returns to the list and restores its keyboard focus. **Open on host** hands the active PR to the provider website. +## Follow PR activity + +Choose **Follow** in a PR header, or run “Pull Requests: follow open pull +request” from the command palette. Followed rows carry a bell badge. Strand +persists followed PRs, their last successful activity snapshot, and the most +recent worktree path for that hosted repository across relaunches. Switching +branches may add another automatically followed PR; it does not remove earlier +ones. + +While Strand is running or minimized, it checks followed PRs after startup, +every 60 seconds, and when the window regains focus. A single desktop +notification per PR can summarize new non-system comments or thread replies, +approvals or requested changes, newly failing checks or Azure policies, a new +head commit, and merged or closed state. The first successful snapshot is only +a baseline and never notifies. Merged or closed PRs notify once and are then +removed from Following. + +The first follow asks for desktop-notification permission. Denying permission +does not stop following; Strand keeps a persistent warning and a later manual +Follow attempt can ask again. Explicitly choosing **Following** to unfollow +mutes automatic following for that PR until you manually follow it again. +Automatic removal after merge or close does not create that mute. + +Provider or network failures keep the last successful snapshot and retry later, +so a temporary outage cannot create false activity or turn an unknown check +state green. Monitoring uses small provider activity queries and never fetches +or parses patches. + For an active, non-draft PR, choose **Merge** in the detail header or run "Pull Requests: merge open pull request…" from the command palette. The split merge control works like GitHub: its primary button immediately runs the @@ -47,9 +76,13 @@ you choose Merge. The list loads only compact row data; Strand loads rich metadata only after a PR is opened. This keeps large repositories below provider query limits and -means moving through the list does not start provider calls. The opened PR uses -the full content width and has three tabs. Use `Left`/`Right`, `Home`, and `End` -while the tab bar is focused. +means moving through the list does not start provider calls. Refreshing keeps +the current list or detail mounted, including focus, scroll, active tab, file +selection, and unsent drafts. The toolbar shows **Updating…**, the last update +age, or a non-blocking failure with Retry instead of blanking the workspace. +Lightweight activity reloads rich detail only when something changed. The +opened PR uses the full content width and has three tabs. Use `Left`/`Right`, +`Home`, and `End` while the tab bar is focused. ### Overview @@ -109,17 +142,24 @@ the pull request head is still the commit used by the displayed patch; if it changed, the draft stays in place and Changes asks you to refresh and reselect. Closed and merged pull requests stay read-only. +When a new head commit is detected, Strand keeps the existing patch visible +while the replacement loads and labels it stale. Inline-comment submission is +disabled until the new patch succeeds, so comments cannot be anchored to old +coordinates. Background monitoring never reloads a patch when the head SHA is +unchanged. + Fetched GitHub review threads remain visible directly beneath their anchored line or range. Replies stay grouped in the same card, and resolved or outdated threads are labeled. The same review comments also appear in Conversation, so comments added on GitHub are visible after refreshing the pull request. -Azure does not expose every check or policy field through the same provider -command, so absent data is shown honestly rather than inferred. Azure inline +Azure policy evaluations participate in readiness when their dedicated query +succeeds. A failed or incomplete policy query remains unknown instead of being +treated as green. Azure inline comments also require provider iteration/change-tracking coordinates that the current patch fetch does not include, so Strand disables that action and directs you to the host instead of creating a wrongly anchored thread. -Creating replies, resolving threads, suggestions, approve/request-changes actions, Azure policy details, +Creating replies, resolving threads, suggestions, approve/request-changes actions, richer Azure policy details, branch updates, and close/reopen controls are planned but are not presented as available yet. GitLab and Bitbucket adapters will use the same workspace in a later slice. From 2939c7a70c79be881f661b0201f3610514be73e7 Mon Sep 17 00:00:00 2001 From: Daniel Schwarz Date: Tue, 14 Jul 2026 14:43:31 +0200 Subject: [PATCH 2/3] Add hosted pull request creation flow --- README.md | 3 + ROADMAP.md | 8 + TASKS.md | 6 + crates/strand-tauri/src/commands.rs | 26 +++ crates/strand-tauri/src/main.rs | 1 + crates/strand-tauri/src/pull_requests.rs | 199 ++++++++++++++++++++++ docs/learnings.md | 19 +++ docs/pull-request-improvements.md | 4 +- ui/src/App.tsx | 11 ++ ui/src/lib/tauri.ts | 11 ++ ui/src/lib/types.ts | 5 + ui/src/styles/features.css | 11 ++ ui/src/views/PullRequestCreateDialog.tsx | 204 +++++++++++++++++++++++ ui/src/views/PullRequests.tsx | 64 ++++++- website/docs/pull-requests.md | 13 ++ 15 files changed, 583 insertions(+), 2 deletions(-) create mode 100644 ui/src/views/PullRequestCreateDialog.tsx diff --git a/README.md b/README.md index adb34bb..98c1d51 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,9 @@ keyboard alone, and the mouse stays first-class. - **Hosted pull requests** — browse the latest 100 GitHub or Azure DevOps PRs for the active repository, with the active PR for your checked-out branch opening and being followed automatically even before the PR view is opened. + Create a PR or draft for the checked-out branch from the toolbar or command + palette, choosing its title, description, and target branch without an + implicit push. Persistent Follow controls and native desktop notifications surface new comments, review decisions, failed checks, pushes, and merged/closed state. Refreshes keep existing content, focus, tabs, drafts, and diffs in place diff --git a/ROADMAP.md b/ROADMAP.md index 954e38f..4dd8e41 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1627,6 +1627,14 @@ detail, focus, tabs, drafts, scroll, and patch stay mounted; rich detail reloads only after activity changes, and the patch reloads only for a new head while a stale copy remains read-only. +**Create pull requests shipped (2026-07-14):** The Pull Requests toolbar and +command palette now open a keyboard-operable creation dialog for the checked-out +branch. GitHub and Azure DevOps creation share one typed IPC path and support a +title, Markdown description, target branch, and draft state through the +provider CLI. A successful PR opens immediately and joins the follow monitor; +creation deliberately requires the source branch to exist remotely instead of +hiding an implicit push. + --- ## 1.1+ — Post-1.0 diff --git a/TASKS.md b/TASKS.md index 0ae6bb9..fe24679 100644 --- a/TASKS.md +++ b/TASKS.md @@ -1286,6 +1286,12 @@ tree: watch the agent work, review fast, accept or reject safely. worktree deduplication, SQLite-backed baselines, bounded two-PR polling, native coalesced notifications, and terminal auto-unfollow survive navigation and relaunch. + - ☑ Create pull requests from the checked-out branch + (`repo_pull_request_create`, `PullRequestCreateDialog`): the PR toolbar and + command palette create GitHub or Azure DevOps PRs with title, description, + target branch, and draft state through the signed-in provider CLI, then + open and automatically follow the result. Strand never silently pushes the + source branch as part of PR creation. - ☑ Seamless stale-while-revalidate refresh (`PullRequests.tsx`): populated list/detail/tabs/drafts/scroll stay mounted during updates and failures; lightweight activity gates rich-detail reloads, patches reload only for a diff --git a/crates/strand-tauri/src/commands.rs b/crates/strand-tauri/src/commands.rs index 8e81b0f..1155c4c 100644 --- a/crates/strand-tauri/src/commands.rs +++ b/crates/strand-tauri/src/commands.rs @@ -203,6 +203,32 @@ pub async fn repo_pull_request_for_branch( .await } +/// Create a pull request for an existing remote branch through the signed-in +/// provider CLI. Strand deliberately does not push as part of this action. +#[tauri::command(async)] +#[allow(clippy::too_many_arguments)] +pub async fn repo_pull_request_create( + path: String, + source_branch: String, + target_branch: String, + title: String, + description: String, + is_draft: bool, +) -> CmdResult { + run_blocking("create pull request", move || { + pull_requests::create( + &path, + &source_branch, + &target_branch, + &title, + &description, + is_draft, + ) + .map_err(|message| CmdError { message }) + }) + .await +} + /// Small, patch-free snapshot used by the followed-PR monitor. #[tauri::command(async)] pub async fn repo_pull_request_activity( diff --git a/crates/strand-tauri/src/main.rs b/crates/strand-tauri/src/main.rs index 9c34020..be4d0bb 100644 --- a/crates/strand-tauri/src/main.rs +++ b/crates/strand-tauri/src/main.rs @@ -79,6 +79,7 @@ fn main() { commands::repo_refs, commands::repo_pull_requests, commands::repo_pull_request_for_branch, + commands::repo_pull_request_create, commands::repo_pull_request_activity, commands::repo_pull_request, commands::repo_pull_request_diff, diff --git a/crates/strand-tauri/src/pull_requests.rs b/crates/strand-tauri/src/pull_requests.rs index 00c604a..4630524 100644 --- a/crates/strand-tauri/src/pull_requests.rs +++ b/crates/strand-tauri/src/pull_requests.rs @@ -20,6 +20,8 @@ use crate::ai::bin::{base_command, resolve_cli}; const COMMAND_TIMEOUT: Duration = Duration::from_secs(30); const MAX_COMMENT_BYTES: usize = 65_536; +const MAX_PR_DESCRIPTION_BYTES: usize = 65_536; +const MAX_PR_TITLE_BYTES: usize = 512; const MAX_DIFF_BYTES: usize = 16 * 1024 * 1024; const GITHUB_BRANCH_STATE: &str = "open"; const AZURE_BRANCH_STATUS: &str = "active"; @@ -177,6 +179,12 @@ pub struct PullRequestBranchMatch { pub pull_request: PullRequest, } +#[derive(Debug, Clone, Serialize)] +pub struct PullRequestCreateOutcome { + pub id: u64, + pub url: String, +} + #[derive(Debug, Clone, Serialize)] pub struct PullRequestActivityComment { pub id: String, @@ -259,6 +267,45 @@ pub fn for_branch(path: &str, branch: &str) -> Result Result { + validate_create(source_branch, target_branch, title, description)?; + let (_, host) = host_for_path(path)?; + match host { + HostRepo::GitHub { owner, repo } => create_github( + path, + &owner, + &repo, + source_branch, + target_branch, + title, + description, + is_draft, + ), + HostRepo::Azure { + organization, + project, + repo, + } => create_azure( + path, + &organization, + &project, + &repo, + source_branch, + target_branch, + title, + description, + is_draft, + ), + } +} + pub fn activity(path: &str, id: u64) -> Result { let (remote, host) = host_for_path(path)?; match host { @@ -471,6 +518,61 @@ fn for_branch_github( })) } +#[allow(clippy::too_many_arguments)] +fn create_github( + cwd: &str, + owner: &str, + repo: &str, + source_branch: &str, + target_branch: &str, + title: &str, + description: &str, + is_draft: bool, +) -> Result { + let slug = format!("{owner}/{repo}"); + let source_branch = branch_name(source_branch.to_string()); + let target_branch = branch_name(target_branch.to_string()); + let mut args = vec![ + "pr", + "create", + "--repo", + &slug, + "--head", + &source_branch, + "--base", + &target_branch, + "--title", + title, + "--body-file", + "-", + ]; + if is_draft { + args.push("--draft"); + } + let output = run_command_input( + cwd, + "gh", + &args, + &[("GH_PROMPT_DISABLED", "1")], + Some(description.as_bytes()), + )?; + let url = String::from_utf8(output) + .map_err(|error| format!("GitHub CLI returned invalid text: {error}"))? + .lines() + .rev() + .find(|line| !line.trim().is_empty()) + .map(str::trim) + .unwrap_or_default() + .to_string(); + let id = url + .trim_end_matches('/') + .rsplit('/') + .next() + .and_then(|value| value.parse::().ok()) + .ok_or_else(|| "GitHub created the pull request but returned no usable PR URL".to_string())?; + Ok(PullRequestCreateOutcome { id, url }) +} + fn activity_github( cwd: &str, remote: String, @@ -766,6 +868,65 @@ fn for_branch_azure( })) } +#[allow(clippy::too_many_arguments)] +fn create_azure( + cwd: &str, + organization: &str, + project: &str, + repo: &str, + source_branch: &str, + target_branch: &str, + title: &str, + description: &str, + is_draft: bool, +) -> Result { + let organization_url = format!("https://dev.azure.com/{organization}/"); + let source_branch = branch_name(source_branch.to_string()); + let target_branch = branch_name(target_branch.to_string()); + let draft = if is_draft { "true" } else { "false" }; + let output = run_command( + cwd, + "az", + &[ + "repos", + "pr", + "create", + "--organization", + &organization_url, + "--project", + project, + "--repository", + repo, + "--source-branch", + &source_branch, + "--target-branch", + &target_branch, + "--title", + title, + "--description", + description, + "--draft", + draft, + "--output", + "json", + "--only-show-errors", + ], + &[("AZURE_EXTENSION_USE_DYNAMIC_INSTALL", "no")], + )?; + let value: Value = serde_json::from_slice(&output) + .map_err(|error| format!("Azure CLI returned invalid JSON: {error}"))?; + let id = value + .get("pullRequestId") + .and_then(Value::as_u64) + .ok_or_else(|| "Azure CLI created the pull request but returned no PR id".to_string())?; + Ok(PullRequestCreateOutcome { + id, + url: format!( + "https://dev.azure.com/{organization}/{project}/_git/{repo}/pullrequest/{id}" + ), + }) +} + fn detail_azure( cwd: &str, organization: String, @@ -1253,6 +1414,29 @@ fn validate_comment(body: &str) -> Result<()> { Ok(()) } +fn validate_create( + source_branch: &str, + target_branch: &str, + title: &str, + description: &str, +) -> Result<()> { + for (label, branch) in [("Source", source_branch), ("Target", target_branch)] { + if branch.trim().is_empty() || branch.contains(['\r', '\n', '\0']) { + return Err(format!("{label} branch is invalid")); + } + } + if title.trim().is_empty() { + return Err("Pull request title is required".into()); + } + if title.contains(['\r', '\n', '\0']) || title.len() > MAX_PR_TITLE_BYTES { + return Err("Pull request title is invalid or exceeds Strand's 512-byte limit".into()); + } + if description.len() > MAX_PR_DESCRIPTION_BYTES { + return Err("Pull request description exceeds Strand's 64 KB limit".into()); + } + Ok(()) +} + fn validate_commit(commit: &str) -> Result<()> { if !matches!(commit.len(), 40 | 64) || !commit.bytes().all(|byte| byte.is_ascii_hexdigit()) { return Err("Pull request source commit is missing or invalid; refresh the PR and try again".into()); @@ -2133,6 +2317,21 @@ mod tests { assert!(validate_comment(&"x".repeat(MAX_COMMENT_BYTES + 1)).is_err()); } + #[test] + fn validates_pull_request_creation_fields() { + assert!(validate_create("feature", "main", "Ship it", "Details").is_ok()); + assert!(validate_create("feature", "main", " ", "Details").is_err()); + assert!(validate_create("feature\nother", "main", "Ship it", "Details").is_err()); + assert!(validate_create("feature", "main\0other", "Ship it", "Details").is_err()); + assert!(validate_create( + "feature", + "main", + "Ship it", + &"x".repeat(MAX_PR_DESCRIPTION_BYTES + 1), + ) + .is_err()); + } + #[test] fn validates_expected_heads_and_maps_provider_merge_strategies() { assert!(validate_commit("0123456789abcdef0123456789abcdef01234567").is_ok()); diff --git a/docs/learnings.md b/docs/learnings.md index 68a1df0..b26b05c 100644 --- a/docs/learnings.md +++ b/docs/learnings.md @@ -1199,3 +1199,22 @@ reload the patch only when the head SHA changes. Keep the old patch visible while that replacement loads or fails, label it stale, and disable writes that depend on exact patch coordinates. Treat incomplete policy/check reads as unknown and retain the last complete baseline. + +--- + +## Pull-request creation never implies a push + +**Rule.** Creating a hosted pull request is a provider write against a branch +that already exists remotely. Do not silently push, set an upstream, or publish +other local commits as part of that action. + +**Why.** A push changes Git state and can publish more work than the creation +dialog describes. Keeping it separate makes the source commit and provider +failure predictable, and preserves the user's ability to inspect or amend the +branch before publishing it. + +**How to apply.** The creation UI names the checked-out source branch, explains +that it must already exist remotely, and sends only title, description, target, +and draft state through `repo_pull_request_create`. Surface missing-branch and +authentication failures inline. After success, query the new PR by branch, +open it, and enroll it in the existing follow monitor. diff --git a/docs/pull-request-improvements.md b/docs/pull-request-improvements.md index 26e0054..5390c2e 100644 --- a/docs/pull-request-improvements.md +++ b/docs/pull-request-improvements.md @@ -10,7 +10,9 @@ > GitHub inline publishing are present. Followed PRs now persist across relaunch, > the checked-out branch auto-follows independently of this view, native > notifications report review activity, and list/detail/patch refreshes use -> stale-while-revalidate resource boundaries. Inline comments still publish +> stale-while-revalidate resource boundaries. The checked-out branch can now +> create a GitHub or Azure DevOps PR/draft from the toolbar or command palette, +> then opens and follows it without an implicit push. Inline comments still publish > immediately; the pending **Add to review** queue described below remains the > intended batched-review workflow. diff --git a/ui/src/App.tsx b/ui/src/App.tsx index c4bccf2..383bcfd 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -1116,6 +1116,17 @@ export function App() { { id: 'reflog', label: 'Show: Reflog', group: 'Actions', shortcut: keyHint('view-reflog'), keywords: 'history head recover lost orphan', run: () => { setView('reflog'); selectFile(null); } }, { id: 'review-view', label: 'Show: Review', group: 'Actions', shortcut: keyHint('view-review'), keywords: 'ai agent review session changes verdict', run: () => { setView('review'); selectFile(null); } }, { id: 'pull-requests', label: 'Show: Pull Requests', group: 'Actions', keywords: 'pr github azure devops code review merge request', run: () => { setView('pull-requests'); selectFile(null); } }, + ...(!meta.detached ? [{ + id: 'pull-request-create', + label: 'Pull Requests: create for current branch…', + group: 'Actions', + keywords: 'pr github azure devops open publish current branch', + run: () => { + setView('pull-requests'); + selectFile(null); + window.setTimeout(() => window.dispatchEvent(new CustomEvent('strand:pull-request-create')), 50); + }, + } satisfies PaletteAction] : []), ...(view === 'pull-requests' ? [ { id: 'pull-request-merge', diff --git a/ui/src/lib/tauri.ts b/ui/src/lib/tauri.ts index 8ea92fb..c37d767 100644 --- a/ui/src/lib/tauri.ts +++ b/ui/src/lib/tauri.ts @@ -24,6 +24,7 @@ import type { PullRequest, PullRequestActivitySnapshot, PullRequestBranchMatch, + PullRequestCreateOutcome, PullRequestList, PullRequestMergeStrategy, RebaseEntry, @@ -103,6 +104,16 @@ export const tauri = { repoPullRequests: (path: string) => invoke('repo_pull_requests', { path }), repoPullRequestForBranch: (path: string, branch: string) => invoke('repo_pull_request_for_branch', { path, branch }), + repoPullRequestCreate: ( + path: string, + sourceBranch: string, + targetBranch: string, + title: string, + description: string, + isDraft: boolean, + ) => invoke('repo_pull_request_create', { + path, sourceBranch, targetBranch, title, description, isDraft, + }), repoPullRequestActivity: (path: string, id: number) => invoke('repo_pull_request_activity', { path, id }), repoPullRequest: (path: string, id: number) => invoke('repo_pull_request', { path, id }), diff --git a/ui/src/lib/types.ts b/ui/src/lib/types.ts index babe8e6..3e91aa3 100644 --- a/ui/src/lib/types.ts +++ b/ui/src/lib/types.ts @@ -263,6 +263,11 @@ export interface PullRequestBranchMatch { pull_request: PullRequest; } +export interface PullRequestCreateOutcome { + id: number; + url: string; +} + export interface PullRequestActivityComment { id: string; author: string; diff --git a/ui/src/styles/features.css b/ui/src/styles/features.css index 565007b..910076f 100644 --- a/ui/src/styles/features.css +++ b/ui/src/styles/features.css @@ -4559,6 +4559,17 @@ textarea.clone-input { .pr-toolbar .h-link { display: inline-flex; align-items: center; gap: 5px; } .pr-toolbar .pr-back { flex: 0 0 auto; color: var(--text); font-weight: 600; } .pr-toolbar .pr-refresh-failed { color: var(--del); } +.pr-toolbar .pr-create-button { + min-height: 26px; + padding: 0 8px; + display: inline-flex; + align-items: center; + gap: 5px; + white-space: nowrap; +} + +.pr-create-dialog form { display: flex; flex-direction: column; min-height: 0; } +.pr-create-description { min-height: 112px !important; } .pr-main { flex: 1; min-height: 0; } diff --git a/ui/src/views/PullRequestCreateDialog.tsx b/ui/src/views/PullRequestCreateDialog.tsx new file mode 100644 index 0000000..a20706d --- /dev/null +++ b/ui/src/views/PullRequestCreateDialog.tsx @@ -0,0 +1,204 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; + +import { Icon } from '../components/Icon'; +import { errMessage, tauri } from '../lib/tauri'; +import type { PullRequestCreateOutcome, PullRequestProvider, Refs } from '../lib/types'; + +function targetBranches(refs: Refs | null, sourceBranch: string, knownTargets: string[]): string[] { + const candidates = [ + ...knownTargets, + ...(refs?.branches.map((branch) => branch.name) ?? []), + ...(refs?.remote_branches.map((branch) => branch.branch) ?? []), + ].filter((branch) => branch && branch !== sourceBranch); + const unique = [...new Set(candidates)]; + return unique.sort((left, right) => { + const rank = (branch: string) => branch === 'main' ? 0 : branch === 'master' ? 1 : 2; + return rank(left) - rank(right) || left.localeCompare(right); + }); +} + +export function PullRequestCreateDialog({ + path, + provider, + sourceBranch, + refs, + knownTargets, + onCreated, + onClose, +}: { + path: string; + provider: PullRequestProvider; + sourceBranch: string; + refs: Refs | null; + knownTargets: string[]; + onCreated: (outcome: PullRequestCreateOutcome) => void; + onClose: () => void; +}) { + const targets = useMemo( + () => targetBranches(refs, sourceBranch, knownTargets), + [knownTargets, refs, sourceBranch], + ); + const [title, setTitle] = useState(''); + const [description, setDescription] = useState(''); + const [targetBranch, setTargetBranch] = useState(targets[0] ?? 'main'); + const [draft, setDraft] = useState(false); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const dialogRef = useRef(null); + const mountedRef = useRef(true); + + useEffect(() => { + mountedRef.current = true; + return () => { mountedRef.current = false; }; + }, []); + + useEffect(() => { + const previous = document.activeElement as HTMLElement | null; + return () => previous?.focus?.(); + }, []); + + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape' && !busy) onClose(); + }; + document.addEventListener('keydown', onKeyDown); + return () => document.removeEventListener('keydown', onKeyDown); + }, [busy, onClose]); + + function trapFocus(event: React.KeyboardEvent) { + if (event.key !== 'Tab' || !dialogRef.current) return; + const focusable = Array.from(dialogRef.current.querySelectorAll( + 'button:not([disabled]), input:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])', + )); + const first = focusable[0]; + const last = focusable.at(-1); + if (!first || !last) return; + if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first.focus(); + } + } + + async function submit() { + if (busy) return; + if (!title.trim()) { + setError('Title is required.'); + return; + } + if (!targetBranch.trim()) { + setError('Target branch is required.'); + return; + } + setBusy(true); + setError(null); + try { + const outcome = await tauri.repoPullRequestCreate( + path, + sourceBranch, + targetBranch.trim(), + title.trim(), + description, + draft, + ); + onCreated(outcome); + } catch (caught) { + if (mountedRef.current) setError(errMessage(caught)); + } finally { + if (mountedRef.current) setBusy(false); + } + } + + const providerLabel = provider === 'git_hub' ? 'GitHub' : 'Azure DevOps'; + + return ( +
{ + if (event.target === event.currentTarget && !busy) onClose(); + }} + > +
+
+ + Create pull request + +
+ +
{ event.preventDefault(); void submit(); }}> +
+

+ Create on {providerLabel} from {sourceBranch}. Strand will not push the branch; it must already exist on the remote. +

+ + + + + +