diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 031f010..625d240 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -27,6 +27,7 @@ "dependencies": { "@posthog/react": "^1.10.5", "@tauri-apps/api": "^2.4.0", + "@tauri-apps/plugin-notification": "^2.4.0", "@xterm/addon-fit": "^0.10.0", "@xterm/addon-web-links": "^0.11.0", "@xterm/addon-webgl": "^0.19.0", @@ -34,7 +35,8 @@ "lucide-react": "^1.37.0", "posthog-js": "^1.422.5", "react": "^18.3.1", - "react-dom": "^18.3.1" + "react-dom": "^18.3.1", + "react-markdown": "^10.1.0" }, "devDependencies": { "@ladle/react": "^5.1.1", diff --git a/apps/desktop/pnpm-lock.yaml b/apps/desktop/pnpm-lock.yaml index abc76cb..6b8c2a6 100644 --- a/apps/desktop/pnpm-lock.yaml +++ b/apps/desktop/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: '@tauri-apps/api': specifier: ^2.4.0 version: 2.11.1 + '@tauri-apps/plugin-notification': + specifier: ^2.4.0 + version: 2.4.0 '@xterm/addon-fit': specifier: ^0.10.0 version: 0.10.0(@xterm/xterm@5.5.0) @@ -38,6 +41,9 @@ importers: react-dom: specifier: ^18.3.1 version: 18.3.1(react@18.3.1) + react-markdown: + specifier: ^10.1.0 + version: 10.1.0(@types/react@18.3.31)(react@18.3.1) devDependencies: '@ladle/react': specifier: ^5.1.1 @@ -1403,6 +1409,9 @@ packages: engines: {node: '>= 10'} hasBin: true + '@tauri-apps/plugin-notification@2.4.0': + resolution: {integrity: sha512-xlJXMcUoKOjNupzDue5wrEsa1wytf+l/2gCAPhafHyP683Y3N7J/8clUWLZ3vpnwkpT2C1zcLMMQFjjecIG2xg==} + '@testing-library/dom@10.4.1': resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} engines: {node: '>=18'} @@ -2280,6 +2289,9 @@ packages: html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + html-url-attributes@3.0.1: + resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==} + html-void-elements@3.0.0: resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} @@ -2869,6 +2881,12 @@ packages: react-is@17.0.2: resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react-markdown@10.1.0: + resolution: {integrity: sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==} + peerDependencies: + '@types/react': '>=18' + react: '>=18' + react-refresh@0.17.0: resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} engines: {node: '>=0.10.0'} @@ -4344,6 +4362,10 @@ snapshots: '@tauri-apps/cli-win32-ia32-msvc': 2.11.4 '@tauri-apps/cli-win32-x64-msvc': 2.11.4 + '@tauri-apps/plugin-notification@2.4.0': + dependencies: + '@tauri-apps/api': 2.11.1 + '@testing-library/dom@10.4.1': dependencies: '@babel/code-frame': 7.29.7 @@ -5252,6 +5274,8 @@ snapshots: html-escaper@2.0.2: {} + html-url-attributes@3.0.1: {} + html-void-elements@3.0.0: {} http-assert@1.5.0: @@ -6190,6 +6214,24 @@ snapshots: react-is@17.0.2: {} + react-markdown@10.1.0(@types/react@18.3.31)(react@18.3.1): + dependencies: + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + '@types/react': 18.3.31 + devlop: 1.1.0 + hast-util-to-jsx-runtime: 2.3.6 + html-url-attributes: 3.0.1 + mdast-util-to-hast: 13.2.1 + react: 18.3.1 + remark-parse: 11.0.0 + remark-rehype: 11.1.2 + unified: 11.0.5 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + transitivePeerDependencies: + - supports-color + react-refresh@0.17.0: {} react@18.3.1: diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock index ca9e1da..8949330 100644 --- a/apps/desktop/src-tauri/Cargo.lock +++ b/apps/desktop/src-tauri/Cargo.lock @@ -56,6 +56,137 @@ dependencies = [ "derive_arbitrary", ] +[[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", + "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", +] + +[[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.119", +] + +[[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", + "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.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + [[package]] name = "atk" version = "0.18.2" @@ -151,6 +282,19 @@ dependencies = [ "objc2", ] +[[package]] +name = "blocking" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + [[package]] name = "brotli" version = "8.0.4" @@ -340,6 +484,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "cookie" version = "0.18.2" @@ -752,6 +905,33 @@ version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" +[[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.119", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -779,6 +959,26 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "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 = "fastrand" version = "2.5.0" @@ -911,6 +1111,19 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" +[[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.34" @@ -1227,6 +1440,7 @@ dependencies = [ "tauri", "tauri-build", "tauri-plugin-global-shortcut", + "tauri-plugin-notification", "tauri-plugin-updater", "toml 0.8.2", "ureq", @@ -1308,6 +1522,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" + [[package]] name = "hex" version = "0.4.3" @@ -1888,6 +2108,20 @@ version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" +[[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" @@ -2008,6 +2242,20 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[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 = "num-conv" version = "0.2.2" @@ -2271,6 +2519,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 = "osakit" version = "0.3.1" @@ -2310,6 +2568,12 @@ dependencies = [ "system-deps", ] +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.5" @@ -2398,6 +2662,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 = "pkg-config" version = "0.3.34" @@ -2443,6 +2718,20 @@ dependencies = [ "miniz_oxide 0.8.9", ] +[[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", + "windows-sys 0.61.2", +] + [[package]] name = "portable-atomic" version = "1.15.0" @@ -2473,6 +2762,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "precomputed-hash" version = "0.1.1" @@ -2571,6 +2869,35 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[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", +] + +[[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" @@ -3110,6 +3437,16 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + [[package]] name = "simd-adler32" version = "0.3.10" @@ -3538,6 +3875,25 @@ dependencies = [ "thiserror 2.0.20", ] +[[package]] +name = "tauri-plugin-notification" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad2fd40946aef810c4be9fd33a2d1b9b397cb79042b2d21c81a0a8f204354fd1" +dependencies = [ + "log", + "notify-rust", + "rand", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "thiserror 2.0.20", + "time", + "url", +] + [[package]] name = "tauri-plugin-updater" version = "2.10.1" @@ -3671,6 +4027,17 @@ dependencies = [ "toml 1.1.4+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.20", + "windows", + "windows-version", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -3997,9 +4364,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "pin-project-lite", + "tracing-attributes", "tracing-core", ] +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "tracing-core" version = "0.1.36" @@ -4049,6 +4428,17 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[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 = "unic-char-property" version = "0.9.0" @@ -5033,6 +5423,96 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zbus" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" +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", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow 1.0.4", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 3.0.4", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" +dependencies = [ + "serde", + "winnow 1.0.4", + "zvariant", +] + +[[package]] +name = "zcheapstr" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473" +dependencies = [ + "serde", +] + +[[package]] +name = "zerocopy" +version = "0.8.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d35102a9f36d089ccae9e4c6802bc118be4487b80aaffc0ab4e0cf5ce92d2873" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c01f5ab44258da43cf276c74a2763db2ff3969c9c652c3f2de07041d0b2bc" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "zerofrom" version = "0.1.8" @@ -5116,3 +5596,44 @@ name = "zmij" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zvariant" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow 1.0.4", + "zcheapstr", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 3.0.4", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 3.0.4", + "winnow 1.0.4", +] diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 82297d1..985a99c 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -19,6 +19,7 @@ tauri-build = { version = "2", features = [] } tauri = { version = "2", features = [] } tauri-plugin-updater = "2" tauri-plugin-global-shortcut = "2" +tauri-plugin-notification = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" semver = "1" diff --git a/apps/desktop/src-tauri/capabilities/default.json b/apps/desktop/src-tauri/capabilities/default.json index d640086..e035309 100644 --- a/apps/desktop/src-tauri/capabilities/default.json +++ b/apps/desktop/src-tauri/capabilities/default.json @@ -4,7 +4,8 @@ "description": "Default capability for the main window", "permissions": [ "core:default", - "core:window:allow-start-dragging" + "core:window:allow-start-dragging", + "notification:default" ], "windows": ["main"] } diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 1c933d8..5956047 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -175,6 +175,7 @@ pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_updater::Builder::new().build()) .plugin(tauri_plugin_global_shortcut::Builder::new().build()) + .plugin(tauri_plugin_notification::init()) .manage(shortcut::ToggleShortcut::default()) .invoke_handler(tauri::generate_handler![ read_client_token, diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index c8919a3..33fc88a 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -6,6 +6,7 @@ import type { FirstRunSetup } from "./app/useFirstRunSetup"; import { useDaemonState } from "./app/useDaemonState"; import type { DaemonState } from "./app/useDaemonState"; import { useDesktopShell } from "./app/useDesktopShell"; +import { usePendingDecisions } from "./app/usePendingDecisions"; import { useOverlays } from "./app/useOverlays"; import type { OverlaysApi } from "./app/useOverlays"; import { usePaletteActions } from "./app/usePaletteActions"; @@ -120,11 +121,17 @@ export default function App(): ReactElement { [select], ); - const paletteActions = usePaletteActions({ bots, select, openSettings: overlays.openSettings }); + const pending = usePendingDecisions(client, daemon.connected); + + const paletteActions = usePaletteActions({ + bots, + select, + openSettings: overlays.openSettings, + }); const failedByBot = useMemo(() => countByBot(daemon.failedDeliveries), [daemon.failedDeliveries]); - useDesktopShell(daemon.unreadBots, addToast); + useDesktopShell(daemon.unreadBots, pending.total, addToast); const setup = useFirstRunSetup(client, daemon.changeEndpoint); @@ -140,6 +147,7 @@ export default function App(): ReactElement { failedByBot={failedByBot} nextRun={daemon.nextRun} activityByBot={daemon.activityByBot} + pendingDecisions={pending} selection={daemon.selection} canControl={canControl} onSelect={select} @@ -157,6 +165,7 @@ export default function App(): ReactElement { addToast={addToast} onCreateProject={actions.createProjectWithBot} onRenameProject={actions.renameProject} + onSetProjectLead={actions.setProjectLead} onDeleteProject={actions.deleteProject} /> diff --git a/apps/desktop/src/analytics.tsx b/apps/desktop/src/analytics.tsx index e05ae17..1f61cc5 100644 --- a/apps/desktop/src/analytics.tsx +++ b/apps/desktop/src/analytics.tsx @@ -12,6 +12,16 @@ interface AnalyticsEvents { readonly runtime: "browser" | "tauri"; }; readonly bot_created: Record; + readonly control_center_opened: Record; + readonly decision_answered: { + readonly picked_option: boolean; + }; + readonly decision_deleted: Record; + readonly decision_held: Record; + readonly decision_published: { + readonly count: number; + readonly notified: number; + }; readonly bot_deleted: Record; readonly daemon_connected: { readonly can_control: boolean; @@ -22,6 +32,9 @@ interface AnalyticsEvents { }; readonly device_revoked: Record; readonly project_created: Record; + readonly project_lead_set: { + readonly cleared: boolean; + }; readonly project_deleted: Record; readonly project_renamed: Record; readonly routine_created: { @@ -48,6 +61,13 @@ export type ErrorOperation = | "bot_create" | "bot_delete" | "daemon_restart" + | "decision_answer" + | "decision_comment" + | "decision_delete" + | "decision_hold" + | "decision_list" + | "decision_publish" + | "decision_update" | "daemon_state_load" | "daemon_update" | "device_create" @@ -55,12 +75,15 @@ export type ErrorOperation = | "device_revoke" | "project_create" | "project_delete" + | "project_lead_set" | "project_rename" | "routine_create" | "routine_list" | "routine_run" | "routine_toggle" | "setup_install" + | "tag_list" + | "tag_update" | "terminal_file_drop" | "window_shortcut"; diff --git a/apps/desktop/src/app/selection.ts b/apps/desktop/src/app/selection.ts index 3eeb7fa..3acfcee 100644 --- a/apps/desktop/src/app/selection.ts +++ b/apps/desktop/src/app/selection.ts @@ -2,4 +2,9 @@ export type Selection = | { readonly kind: "none" } | { readonly kind: "bot"; readonly botId: string } - | { readonly kind: "project"; readonly projectId: string }; + | { readonly kind: "project"; readonly projectId: string } + /** + * The Control center. `decisionId` opens straight onto one record, which is + * what a notification or a palette entry hands over. + */ + | { readonly kind: "control"; readonly decisionId?: string }; diff --git a/apps/desktop/src/app/useDaemonActions.ts b/apps/desktop/src/app/useDaemonActions.ts index 495ea4c..30edf70 100644 --- a/apps/desktop/src/app/useDaemonActions.ts +++ b/apps/desktop/src/app/useDaemonActions.ts @@ -10,6 +10,7 @@ export interface DaemonActions { /** Resolves to the new project's id, or undefined when creation failed. */ readonly createProject: (name: string) => Promise; readonly renameProject: (projectId: string, name: string) => Promise; + readonly setProjectLead: (projectId: string, botId: string | null) => Promise; readonly deleteProject: (projectId: string) => Promise; readonly createBot: (projectId: string) => Promise; /** Creates a project and its first bot, so a fresh install lands on a bot. */ @@ -63,6 +64,23 @@ export function useDaemonActions(deps: ActionDeps): DaemonActions { [addToast, client, refreshAll], ); + const setProjectLead = useCallback( + async (projectId: string, botId: string | null): Promise => { + try { + await client.request( + { type: "set_project_lead", project_id: projectId, bot_id: botId }, + "project", + ); + capture("project_lead_set", { cleared: botId === null }); + await refreshAll(); + } catch (error) { + captureException(error, "project_lead_set"); + addToast("error", "Failed to set the lead bot", errText(error)); + } + }, + [addToast, client, refreshAll], + ); + /** * Deleting a project archives every bot inside it, so anything the main pane * was showing is gone by the time this returns; the selection is cleared @@ -127,11 +145,20 @@ export function useDaemonActions(deps: ActionDeps): DaemonActions { () => ({ createProject, renameProject, + setProjectLead, deleteProject, createBot, createProjectWithBot, deleteBot, }), - [createBot, createProject, createProjectWithBot, deleteBot, deleteProject, renameProject], + [ + createBot, + createProject, + createProjectWithBot, + deleteBot, + deleteProject, + renameProject, + setProjectLead, + ], ); } diff --git a/apps/desktop/src/app/useDaemonPushes.ts b/apps/desktop/src/app/useDaemonPushes.ts index 5d77d90..a3eef17 100644 --- a/apps/desktop/src/app/useDaemonPushes.ts +++ b/apps/desktop/src/app/useDaemonPushes.ts @@ -1,5 +1,6 @@ import { useEffect } from "react"; import type { MutableRefObject } from "react"; +import { notifyNatively } from "../notify"; import type { DaemonApi } from "../protocol/api"; import type { Conversation } from "../protocol/entities"; import type { Selection } from "./selection"; @@ -19,6 +20,10 @@ export interface PushDeps { readonly onSelectBot: (botId: string) => void; /** Refetches the sidebar preview lines; a finished turn changes them. */ readonly refreshActivity: () => void; + /** Opens the Control center, optionally straight onto one decision. */ + readonly onOpenDecision: (decisionId: string) => void; + /** Whether an urgent or nearly-due decision may interrupt with a native notice. */ + readonly decisionNotifications: boolean; } /** True when new activity on `botId` happened out of the user's sight. */ @@ -98,7 +103,25 @@ function subscribe(ref: MutableRefObject): Array<() => void> { }); }), client.on("notify", (push) => { - ref.current.addToast(push.level, push.title, push.body); + const deps = ref.current; + const decisionId = push.decision_id; + if (decisionId === undefined) { + deps.addToast(push.level, push.title, push.body); + return; + } + // A decision notice always offers the way to act on it: the failure this + // replaces was a question the owner could not find. + deps.addToast(push.level, push.title, push.body, { + action: { + label: "Open", + run: () => { + deps.onOpenDecision(decisionId); + }, + }, + }); + if (deps.decisionNotifications) { + void notifyNatively(push.title, push.body); + } }), ]; } diff --git a/apps/desktop/src/app/useDaemonState.ts b/apps/desktop/src/app/useDaemonState.ts index eba4477..6b0bf62 100644 --- a/apps/desktop/src/app/useDaemonState.ts +++ b/apps/desktop/src/app/useDaemonState.ts @@ -10,6 +10,7 @@ import type { Project, Routine, } from "../protocol/entities"; +import { useDecisionNotificationsPref } from "../prefs"; import { errText } from "../util"; import type { Selection } from "./selection"; import { fetchActivity, fetchSnapshot } from "./snapshot"; @@ -94,6 +95,13 @@ export function useDaemonState(client: DaemonApi, addToast: AddToast): DaemonSta [botsRef], ); + const openDecision = useCallback( + (decisionId: string): void => { + select({ kind: "control", decisionId }); + }, + [select], + ); + const refreshActivity = useCallback((): void => { void fetchActivity(client).then(entities.applyActivity); // `applyActivity` is stable. @@ -109,6 +117,8 @@ export function useDaemonState(client: DaemonApi, addToast: AddToast): DaemonSta botName, onSelectBot: selectBot, refreshActivity, + onOpenDecision: openDecision, + decisionNotifications: useDecisionNotificationsPref(), }); return { diff --git a/apps/desktop/src/app/useDesktopShell.ts b/apps/desktop/src/app/useDesktopShell.ts index d9bc31b..dfcd4d8 100644 --- a/apps/desktop/src/app/useDesktopShell.ts +++ b/apps/desktop/src/app/useDesktopShell.ts @@ -6,8 +6,9 @@ import { useWindowShortcut } from "./useWindowShortcut"; /** What the desktop shell mirrors from app state: the dock badge and the global shortcut. */ export function useDesktopShell( unreadBots: Readonly>, + pendingDecisions: number, addToast: AddToast, ): void { - useDockBadge(unreadBots, useDockBadgePref()); + useDockBadge(unreadBots, pendingDecisions, useDockBadgePref()); useWindowShortcut(addToast); } diff --git a/apps/desktop/src/app/useDockBadge.test.ts b/apps/desktop/src/app/useDockBadge.test.ts index 8e93362..ba43cd6 100644 --- a/apps/desktop/src/app/useDockBadge.test.ts +++ b/apps/desktop/src/app/useDockBadge.test.ts @@ -8,10 +8,14 @@ vi.mock("../badge", () => ({ setDockBadge })); type Unread = Readonly>; /** Renders the hook so the caller can hand it a fresh set of counters. */ -function render(initialProps: Unread, enabled = true): { rerender: (next: Unread) => void } { +function render( + initialProps: Unread, + enabled = true, + pending = 0, +): { rerender: (next: Unread) => void } { return renderHook( (unread) => { - useDockBadge(unread, enabled); + useDockBadge(unread, pending, enabled); }, { initialProps }, ); @@ -59,3 +63,17 @@ describe("useDockBadge", () => { expect(setDockBadge).not.toHaveBeenCalledWith(2); }); }); + +describe("the badge counts decisions alongside messages", () => { + it("adds pending decisions to the unread total", () => { + setDockBadge.mockClear(); + render({ b1: 2 }, true, 3); + expect(setDockBadge).toHaveBeenLastCalledWith(5); + }); + + it("stays dark when the preference is off", () => { + setDockBadge.mockClear(); + render({ b1: 2 }, false, 3); + expect(setDockBadge).toHaveBeenLastCalledWith(0); + }); +}); diff --git a/apps/desktop/src/app/useDockBadge.ts b/apps/desktop/src/app/useDockBadge.ts index e2c988a..68e08b6 100644 --- a/apps/desktop/src/app/useDockBadge.ts +++ b/apps/desktop/src/app/useDockBadge.ts @@ -10,9 +10,19 @@ export function totalUnread(unreadBots: Readonly>): numbe return total; } -/** Mirrors the unread total onto the dock icon whenever it moves. */ -export function useDockBadge(unreadBots: Readonly>, enabled: boolean): void { - const total = enabled ? totalUnread(unreadBots) : 0; +/** + * Mirrors what wants the owner onto the dock icon whenever it moves. + * + * Pending decisions count alongside unread messages: a bot waiting on a ruling + * is asking for attention just as much as one that has said something, and the + * whole point of the registry is that such a question is never invisible. + */ +export function useDockBadge( + unreadBots: Readonly>, + pendingDecisions: number, + enabled: boolean, +): void { + const total = enabled ? totalUnread(unreadBots) + pendingDecisions : 0; useEffect(() => { void setDockBadge(total); }, [total]); diff --git a/apps/desktop/src/app/usePaletteActions.ts b/apps/desktop/src/app/usePaletteActions.ts index 007932c..6083d48 100644 --- a/apps/desktop/src/app/usePaletteActions.ts +++ b/apps/desktop/src/app/usePaletteActions.ts @@ -25,6 +25,14 @@ export function usePaletteActions(deps: PaletteDeps): readonly PaletteAction[] { select({ kind: "bot", botId: bot.id }); }, })), + { + id: "open-control-center", + label: "Control center", + hint: "open view", + run: (): void => { + select({ kind: "control" }); + }, + }, { id: "open-settings", label: "Settings", diff --git a/apps/desktop/src/app/usePendingDecisions.ts b/apps/desktop/src/app/usePendingDecisions.ts new file mode 100644 index 0000000..ff5a664 --- /dev/null +++ b/apps/desktop/src/app/usePendingDecisions.ts @@ -0,0 +1,49 @@ +import { useCallback, useEffect, useState } from "react"; +import { useLoadOnConnect } from "../hooks/useLoadOnConnect"; +import type { DaemonApi } from "../protocol/api"; +import type { PendingCounts } from "../protocol/decisions"; + +const EMPTY: PendingCounts = { by_project: {}, total: 0, urgent: 0, due_soon: 0 }; + +/** + * How many decisions are waiting on the owner, for the sidebar row and the + * dock badge. + * + * Counted by the daemon rather than derived from a loaded list, so the badge is + * right before the Control center has ever been opened — which is the whole + * point of it: a question a bot asked while nobody was looking should be + * visible without going to find it. + * + * A daemon without the `decisions` capability answers with an error; that + * degrades to zero rather than failing the connection. + */ +export function usePendingDecisions(client: DaemonApi, connected: boolean): PendingCounts { + const [counts, setCounts] = useState(EMPTY); + + const load = useCallback(async (): Promise => { + try { + const reply = await client.request({ type: "count_pending_decisions" }, "pending_decisions"); + setCounts(reply.counts); + } catch { + setCounts(EMPTY); + } + }, [client]); + + useLoadOnConnect(connected, load); + + // Any change to any decision can move the count, and the pushes are cheap; + // recounting is one query and keeps the badge honest without a poll. + useEffect(() => { + const off = [ + client.on("decision_update", () => void load()), + client.on("decision_deleted", () => void load()), + ]; + return () => { + for (const unsubscribe of off) { + unsubscribe(); + } + }; + }, [client, load]); + + return counts; +} diff --git a/apps/desktop/src/components/MainPane.tsx b/apps/desktop/src/components/MainPane.tsx index 0494a5e..764130e 100644 --- a/apps/desktop/src/components/MainPane.tsx +++ b/apps/desktop/src/components/MainPane.tsx @@ -6,6 +6,7 @@ import quietArt from "../assets/empty/quiet.png"; import type { DaemonApi } from "../protocol/api"; import { connectionStatusLabel } from "../protocol/connection"; import BotView from "./BotView"; +import ControlCenterView from "./control/ControlCenterView"; import ProjectView from "./ProjectView"; import NewProjectForm from "./sidebar/NewProjectForm"; @@ -15,6 +16,7 @@ interface MainPaneProps { readonly addToast: AddToast; readonly onCreateProject: (name: string) => Promise; readonly onRenameProject: (projectId: string, name: string) => Promise; + readonly onSetProjectLead: (projectId: string, botId: string | null) => Promise; readonly onDeleteProject: (projectId: string) => Promise; } @@ -66,6 +68,20 @@ export default function MainPane(props: MainPaneProps): ReactElement { const { client, daemon, addToast } = props; const { selection, bots, connected, canControl } = daemon; + if (selection.kind === "control") { + return ( + + ); + } + if (selection.kind === "project") { const project = daemon.projects.find((item) => item.id === selection.projectId); return project === undefined ? ( @@ -78,6 +94,7 @@ export default function MainPane(props: MainPaneProps): ReactElement { connected={connected} canControl={canControl} onRename={props.onRenameProject} + onSetLead={props.onSetProjectLead} onDelete={props.onDeleteProject} /> ); diff --git a/apps/desktop/src/components/ProjectView.test.tsx b/apps/desktop/src/components/ProjectView.test.tsx index 57a980f..69a8553 100644 --- a/apps/desktop/src/components/ProjectView.test.tsx +++ b/apps/desktop/src/components/ProjectView.test.tsx @@ -9,6 +9,9 @@ function renderView(over: Partial[0]> = {}) { Promise.resolve(), ); const onDelete = vi.fn<(projectId: string) => Promise>(() => Promise.resolve()); + const onSetLead = vi.fn<(projectId: string, botId: string | null) => Promise>(() => + Promise.resolve(), + ); render( [0]> = {}) { connected canControl onRename={onRename} + onSetLead={onSetLead} onDelete={onDelete} {...over} />, ); - return { onRename, onDelete }; + return { onRename, onSetLead, onDelete }; } describe("ProjectView", () => { @@ -69,3 +73,17 @@ describe("ProjectView", () => { expect(screen.queryByRole("button", { name: "Delete project" })).not.toBeInTheDocument(); }); }); + +describe("the lead bot picker", () => { + it("names the bot that must know about every decision here", async () => { + const { onSetLead } = renderView(); + await userEvent.selectOptions(screen.getByLabelText(/Lead bot/), "b1"); + expect(onSetLead).toHaveBeenCalledWith("p1", "b1"); + }); + + it("falls back to whoever hired the asking bot when cleared", async () => { + const { onSetLead } = renderView({ project: fx.project({ lead_bot_id: "b1" }) }); + await userEvent.selectOptions(screen.getByLabelText(/Lead bot/), ""); + expect(onSetLead).toHaveBeenCalledWith("p1", null); + }); +}); diff --git a/apps/desktop/src/components/ProjectView.tsx b/apps/desktop/src/components/ProjectView.tsx index e40bc8c..9cc144c 100644 --- a/apps/desktop/src/components/ProjectView.tsx +++ b/apps/desktop/src/components/ProjectView.tsx @@ -9,6 +9,7 @@ interface ProjectViewProps { readonly connected: boolean; readonly canControl: boolean; readonly onRename: (projectId: string, name: string) => Promise; + readonly onSetLead: (projectId: string, botId: string | null) => Promise; readonly onDelete: (projectId: string) => Promise; } @@ -23,7 +24,7 @@ export function deletionBody(project: Project, botCount: number): string { /** Project settings: the name, what it holds, and deletion. */ export default function ProjectView(props: ProjectViewProps): ReactElement { - const { project, bots, connected, canControl, onRename, onDelete } = props; + const { project, bots, connected, canControl, onRename, onSetLead, onDelete } = props; const [name, setName] = useState(project.name); const [saving, setSaving] = useState(false); const [confirming, setConfirming] = useState(false); @@ -90,6 +91,30 @@ export default function ProjectView(props: ProjectViewProps): ReactElement { + +
Folder
{project.dir_name}
diff --git a/apps/desktop/src/components/Sidebar.test.tsx b/apps/desktop/src/components/Sidebar.test.tsx index 36fdfa5..8a5e6cb 100644 --- a/apps/desktop/src/components/Sidebar.test.tsx +++ b/apps/desktop/src/components/Sidebar.test.tsx @@ -1,6 +1,7 @@ import { screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, describe, expect, it, vi } from "vitest"; +import * as dfx from "../test/decisionFixtures"; import * as fx from "../test/fixtures"; import { renderSidebar } from "../test/sidebar"; import { stubLocalStorage } from "../test/spies"; @@ -261,4 +262,26 @@ describe("Sidebar", () => { renderSidebar({ status: "auth_failed" }); expect(screen.getByText(/auth failed/)).toBeInTheDocument(); }); + + it("badges the control center quietly when nothing is urgent", () => { + renderSidebar({ pendingDecisions: dfx.pendingCounts({ total: 3 }) }); + const pill = screen.getByText("3", { selector: ".control-row-pill" }); + expect(pill).not.toHaveClass("control-row-pill-urgent"); + expect(document.querySelector(".control-row-dot")).toBeNull(); + }); + + it("turns the control center red only for urgency", () => { + renderSidebar({ pendingDecisions: dfx.pendingCounts({ total: 3, urgent: 1, due_soon: 2 }) }); + expect(screen.getByText("3", { selector: ".control-row-pill" })).toHaveClass( + "control-row-pill-urgent", + ); + expect(document.querySelector(".control-row-dot")).not.toBeNull(); + expect(screen.getByTitle("1 urgent, 2 due within a day")).toBeInTheDocument(); + }); + + it("shows no control center count when nothing waits", () => { + renderSidebar(); + expect(document.querySelector(".control-row-count")).toBeNull(); + expect(screen.getByTitle("Decisions waiting on you")).toBeInTheDocument(); + }); }); diff --git a/apps/desktop/src/components/Sidebar.tsx b/apps/desktop/src/components/Sidebar.tsx index b82169b..220e30a 100644 --- a/apps/desktop/src/components/Sidebar.tsx +++ b/apps/desktop/src/components/Sidebar.tsx @@ -2,9 +2,11 @@ import { useState } from "react"; import type { ReactElement } from "react"; import { usePinnedBots } from "../app/usePinnedBots"; import type { ConnectionStatus, Endpoint } from "../protocol/connection"; +import type { PendingCounts } from "../protocol/decisions"; import type { Bot, Project } from "../protocol/entities"; import { PlusIcon, SearchIcon } from "./sidebar/icons"; import NewProjectForm from "./sidebar/NewProjectForm"; +import ControlCenterRow from "./sidebar/ControlCenterRow"; import ProjectSection from "./sidebar/ProjectSection"; import SidebarFooter from "./sidebar/SidebarFooter"; import type { SidebarTreeProps } from "./sidebar/tree"; @@ -16,6 +18,7 @@ type SidebarProps = Omit void; + readonly pendingDecisions: PendingCounts; readonly onCreateProject: (name: string) => Promise; /** The tree gets the sidebar's wrapper around this, which tracks the request. */ readonly onCreateBot: (projectId: string) => Promise; @@ -75,6 +78,12 @@ export default function Sidebar(props: SidebarProps): ReactElement { + onSelect({ kind: "control" })} + /> + {creatingProject ? ( ) : null} diff --git a/apps/desktop/src/components/control/Composer.stories.tsx b/apps/desktop/src/components/control/Composer.stories.tsx new file mode 100644 index 0000000..e548f5e --- /dev/null +++ b/apps/desktop/src/components/control/Composer.stories.tsx @@ -0,0 +1,51 @@ +import type { Story } from "@ladle/react"; +import { useRef, useState } from "react"; +import type { ReactElement } from "react"; +import { decision } from "../../test/decisionFixtures"; +import Composer from "./Composer"; +import { useComposer } from "./useComposer"; + +const noop = (): void => {}; +const subject = decision({ id: "d1" }); + +interface DemoProps { + readonly pick?: string; + readonly hold?: boolean; +} + +/** The composer is driven by `useComposer`, so the stories drive it too. */ +function Demo({ pick, hold = false }: DemoProps): ReactElement { + const composer = useComposer("d1"); + const ref = useRef(null); + const [applied, setApplied] = useState(false); + if (!applied) { + setApplied(true); + if (pick !== undefined) { + composer.togglePick(pick); + } + if (hold) { + composer.setHoldOpen(true); + } + } + + return ( +
+ +
+ ); +} + +export const Blank: Story = () => ; + +export const Picked: Story = () => ; + +export const HoldOpen: Story = () => ; diff --git a/apps/desktop/src/components/control/Composer.test.tsx b/apps/desktop/src/components/control/Composer.test.tsx new file mode 100644 index 0000000..6f05378 --- /dev/null +++ b/apps/desktop/src/components/control/Composer.test.tsx @@ -0,0 +1,138 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { useRef } from "react"; +import type { ReactElement } from "react"; +import { describe, expect, it, vi } from "vitest"; +import { decision } from "../../test/decisionFixtures"; +import Composer from "./Composer"; +import { useComposer } from "./useComposer"; + +const subject = decision({ id: "d1" }); + +interface HarnessProps { + readonly canControl?: boolean; + readonly busy?: boolean; + readonly onSave: () => void; + readonly onAsk: () => void; + readonly onHold: () => void; +} + +/** The real composer state, so the toggles behave as they do in the view. */ +function Harness({ + canControl = true, + busy = false, + onSave, + onAsk, + onHold, +}: HarnessProps): ReactElement { + const composer = useComposer("d1"); + const ref = useRef(null); + return ( + <> + + + + ); +} + +interface Spies { + readonly onSave: ReturnType void>>; + readonly onAsk: ReturnType void>>; + readonly onHold: ReturnType void>>; +} + +function renderComposer(over: Partial = {}): Spies { + const spies: Spies = { + onSave: vi.fn<() => void>(), + onAsk: vi.fn<() => void>(), + onHold: vi.fn<() => void>(), + }; + render(); + return spies; +} + +describe("Composer", () => { + it("will not save an empty ruling, but an option alone will do", async () => { + const user = userEvent.setup(); + renderComposer(); + expect(screen.getByRole("button", { name: /Save ruling/ })).toBeDisabled(); + + await user.click(screen.getByRole("button", { name: "pick start" })); + expect(screen.getByRole("button", { name: /Save ruling/ })).toBeEnabled(); + // Asking still needs a question. + expect(screen.getByRole("button", { name: "Ask in thread" })).toBeDisabled(); + + await user.click(screen.getByRole("button", { name: "pick start" })); + expect(screen.getByRole("button", { name: /Save ruling/ })).toBeDisabled(); + await user.type(screen.getByLabelText("Your ruling"), "Start today."); + expect(screen.getByRole("button", { name: /Save ruling/ })).toBeEnabled(); + }); + + it("hands the words to the view", async () => { + const user = userEvent.setup(); + const spies = renderComposer(); + await user.type(screen.getByLabelText("Your ruling"), "Start today."); + + await user.click(screen.getByRole("button", { name: "Ask in thread" })); + expect(spies.onAsk).toHaveBeenCalled(); + + await user.click(screen.getByRole("button", { name: /Save ruling/ })); + expect(spies.onSave).toHaveBeenCalled(); + }); + + it("opens the hold strip from Later", async () => { + const user = userEvent.setup(); + const spies = renderComposer(); + expect(screen.queryByLabelText("Hold until")).not.toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Later" })); + await user.click(screen.getByRole("button", { name: "Hold" })); + expect(spies.onHold).toHaveBeenCalled(); + + await user.click(screen.getByRole("button", { name: "Later" })); + expect(screen.queryByLabelText("Hold until")).not.toBeInTheDocument(); + }); + + it("shows the picked option and clears it", async () => { + const user = userEvent.setup(); + renderComposer(); + await user.click(screen.getByRole("button", { name: "pick start" })); + + expect(screen.getByText("Start today")).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "clear" })); + expect(screen.queryByText("Start today")).not.toBeInTheDocument(); + }); + + it("says what the words are for, picked or not", async () => { + const user = userEvent.setup(); + renderComposer(); + expect(screen.getByLabelText("Your ruling")).toHaveAttribute( + "placeholder", + "auction will quote this verbatim. Pick an option, write, or both.", + ); + + await user.click(screen.getByRole("button", { name: "pick start" })); + expect(screen.getByLabelText("Your ruling")).toHaveAttribute( + "placeholder", + "Why start, or any conditions (optional). auction will quote this verbatim.", + ); + }); + + it("is inert for a read-only client", () => { + renderComposer({ canControl: false }); + expect(screen.getByLabelText("Your ruling")).toBeDisabled(); + expect(screen.getByRole("button", { name: /Save ruling/ })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Ask in thread" })).toBeDisabled(); + }); +}); diff --git a/apps/desktop/src/components/control/Composer.tsx b/apps/desktop/src/components/control/Composer.tsx new file mode 100644 index 0000000..9f80dde --- /dev/null +++ b/apps/desktop/src/components/control/Composer.tsx @@ -0,0 +1,124 @@ +import type { ReactElement, RefObject } from "react"; +import type { Decision } from "../../protocol/decisions"; +import type { Composer as ComposerState } from "./useComposer"; + +interface ComposerProps { + readonly decision: Decision; + readonly composer: ComposerState; + readonly canControl: boolean; + readonly busy: boolean; + readonly textareaRef: RefObject; + readonly onSave: () => void; + readonly onAsk: () => void; + readonly onHold: () => void; +} + +/** + * Where the ruling is written. + * + * Picking an option is never enough on its own: the bots quote the words, so + * the button stays dead until there are some. + */ +export default function Composer({ + decision, + composer, + canControl, + busy, + textareaRef, + onSave, + onAsk, + onHold, +}: ComposerProps): ReactElement { + const picked = decision.options.find((option) => option.key === composer.pick); + const asker = decision.raised_by.name; + const placeholder = + picked === undefined + ? `${asker} will quote this verbatim. Pick an option, write, or both.` + : `Why ${picked.key}, or any conditions (optional). ${asker} will quote this verbatim.`; + const hasWords = composer.text.trim().length > 0; + // A picked option is a ruling on its own: its label becomes the words. + const ready = hasWords || picked !== undefined; + + return ( +
+
+
+ {picked === undefined ? null : ( +
+ + {picked.key} + {picked.label} + + +
+ )} +