diff --git a/crates/sprout-relay/src/api/messages.rs b/crates/sprout-relay/src/api/messages.rs index 80917442ba..bcd9d8478f 100644 --- a/crates/sprout-relay/src/api/messages.rs +++ b/crates/sprout-relay/src/api/messages.rs @@ -129,10 +129,7 @@ pub fn validate_imeta_tags(tags: &[Vec], media_base_url: &str) -> Result return Err("imeta duration must be a valid float".into()); } } - "bitrate" - // NIP-71 standard field: bits/sec as integer, positive - if value.parse::().map_or(true, |b| b == 0) => - { + "bitrate" if value.parse::().map_or(true, |b| b == 0) => { return Err("imeta bitrate must be a positive integer".into()); } "image" => { diff --git a/desktop/package.json b/desktop/package.json index dc8afc4606..df1d473719 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -24,6 +24,7 @@ "@emoji-mart/data": "^1.2.1", "@emoji-mart/react": "^1.1.1", "@radix-ui/react-alert-dialog": "^1.1.15", + "@radix-ui/react-avatar": "^1.1.11", "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", @@ -31,6 +32,7 @@ "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-toggle": "^1.1.10", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.21", @@ -59,6 +61,7 @@ "remark-breaks": "^4.0.0", "remark-gfm": "^4.0.1", "shiki": "^4.0.2", + "sonner": "^2.0.7", "tailwind-merge": "^3.5.0", "tiptap-markdown": "^0.9.0", "yaml": "^2.8.3" diff --git a/desktop/pnpm-lock.yaml b/desktop/pnpm-lock.yaml index ef592fb3d9..98a580b631 100644 --- a/desktop/pnpm-lock.yaml +++ b/desktop/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: '@radix-ui/react-alert-dialog': specifier: ^1.1.15 version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-avatar': + specifier: ^1.1.11 + version: 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) '@radix-ui/react-checkbox': specifier: ^1.3.3 version: 1.3.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) @@ -38,6 +41,9 @@ importers: '@radix-ui/react-slot': specifier: ^1.2.4 version: 1.2.4(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-tabs': + specifier: ^1.1.13 + version: 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) '@radix-ui/react-toggle': specifier: ^1.1.10 version: 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) @@ -122,6 +128,9 @@ importers: shiki: specifier: ^4.0.2 version: 4.0.2 + sonner: + specifier: ^2.0.7 + version: 2.0.7(react-dom@19.2.5(react@19.2.5))(react@19.2.5) tailwind-merge: specifier: ^3.5.0 version: 3.5.0 @@ -304,28 +313,24 @@ packages: engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] - libc: [musl] '@biomejs/cli-linux-arm64@2.4.11': resolution: {integrity: sha512-avdJaEElXrKceK0va9FkJ4P5ci3N01TGkc6ni3P8l3BElqbOz42Wg2IyX3gbh0ZLEd4HVKEIrmuVu/AMuSeFFA==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] - libc: [glibc] '@biomejs/cli-linux-x64-musl@2.4.11': resolution: {integrity: sha512-bexd2IklK7ZgPhrz6jXzpIL6dEAH9MlJU1xGTrypx+FICxrXUp4CqtwfiuoDKse+UlgAlWtzML3jrMqeEAHEhA==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] - libc: [musl] '@biomejs/cli-linux-x64@2.4.11': resolution: {integrity: sha512-TagWV0iomp5LnEnxWFg4nQO+e52Fow349vaX0Q/PIcX6Zhk4GGBgp3qqZ8PVkpC+cuehRctMf3+6+FgQ8jCEFQ==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] - libc: [glibc] '@biomejs/cli-win32-arm64@2.4.11': resolution: {integrity: sha512-RJhaTnY8byzxDt4bDVb7AFPHkPcjOPK3xBip4ZRTrN3TEfyhjLRm3r3mqknqydgVTB74XG8l4jMLwEACEeihVg==} @@ -597,6 +602,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-avatar@1.1.11': + resolution: {integrity: sha512-0Qk603AHGV28BOBO34p7IgD5m+V5Sg/YovfayABkoDDBM5d3NCx0Mp4gGrjzLGes1jV5eNOE1r3itqOR33VC6Q==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-checkbox@1.3.3': resolution: {integrity: sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==} peerDependencies: @@ -641,6 +659,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-context@1.1.3': + resolution: {integrity: sha512-ieIFACdMpYfMEjF0rEf5KLvfVyIkOz6PDGyNnP+u+4xQ6jny3VCgA4OgXOwNx2aUkxn8zx9fiVcM8CfFYv9Lxw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-dialog@1.1.15': resolution: {integrity: sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==} peerDependencies: @@ -868,6 +895,19 @@ packages: '@types/react': optional: true + '@radix-ui/react-tabs@1.1.13': + resolution: {integrity: sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-toggle@1.1.10': resolution: {integrity: sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ==} peerDependencies: @@ -930,6 +970,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-use-is-hydrated@0.1.0': + resolution: {integrity: sha512-U+UORVEq+cTnRIaostJv9AGdV3G6Y+zbVd+12e18jQ5A3c0xL03IhnHuiU4UV69wolOQp5GfR58NW/EgdQhwOA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-use-layout-effect@1.1.1': resolution: {integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==} peerDependencies: @@ -1022,7 +1071,6 @@ packages: resolution: {integrity: sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==} cpu: [arm] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.60.1': resolution: {integrity: sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==} @@ -1034,7 +1082,6 @@ packages: resolution: {integrity: sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==} cpu: [arm64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.60.1': resolution: {integrity: sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==} @@ -1046,13 +1093,11 @@ packages: resolution: {integrity: sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==} cpu: [loong64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.60.1': resolution: {integrity: sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==} cpu: [loong64] os: [linux] - libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.60.1': resolution: {integrity: sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==} @@ -1070,7 +1115,6 @@ packages: resolution: {integrity: sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==} cpu: [riscv64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.60.1': resolution: {integrity: sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==} @@ -1082,13 +1126,11 @@ packages: resolution: {integrity: sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==} cpu: [s390x] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.60.1': resolution: {integrity: sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==} cpu: [x64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-musl@4.60.1': resolution: {integrity: sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==} @@ -1260,35 +1302,30 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [glibc] '@tauri-apps/cli-linux-arm64-musl@2.10.1': resolution: {integrity: sha512-MIj78PDDGjkg3NqGptDOGgfXks7SYJwhiMh8SBoZS+vfdz7yP5jN18bNaLnDhsVIPARcAhE1TlsZe/8Yxo2zqg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [musl] '@tauri-apps/cli-linux-riscv64-gnu@2.10.1': resolution: {integrity: sha512-X0lvOVUg8PCVaoEtEAnpxmnkwlE1gcMDTqfhbefICKDnOTJ5Est3qL0SrWxizDackIOKBcvtpejrSiVpuJI1kw==} engines: {node: '>= 10'} cpu: [riscv64] os: [linux] - libc: [glibc] '@tauri-apps/cli-linux-x64-gnu@2.10.1': resolution: {integrity: sha512-2/12bEzsJS9fAKybxgicCDFxYD1WEI9kO+tlDwX5znWG2GwMBaiWcmhGlZ8fi+DMe9CXlcVarMTYc0L3REIRxw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [glibc] '@tauri-apps/cli-linux-x64-musl@2.10.1': resolution: {integrity: sha512-Y8J0ZzswPz50UcGOFuXGEMrxbjwKSPgXftx5qnkuMs2rmwQB5ssvLb6tn54wDSYxe7S6vlLob9vt0VKuNOaCIQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [musl] '@tauri-apps/cli-win32-arm64-msvc@2.10.1': resolution: {integrity: sha512-iSt5B86jHYAPJa/IlYw++SXtFPGnWtFJriHn7X0NFBVunF6zu9+/zOn8OgqIWSl8RgzhLGXQEEtGBdR4wzpVgg==} @@ -2453,6 +2490,12 @@ packages: resolution: {integrity: sha512-eAVKTMedR5ckPo4xne/PjYQYrU3qx78gtJZ+sHlXEg5IHhhoQhMfZVzetTYuaJS0L2Ef3AcCRzCHV8T0WI6nIQ==} engines: {node: '>=20'} + sonner@2.0.7: + resolution: {integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -2997,6 +3040,19 @@ snapshots: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@radix-ui/react-avatar@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@radix-ui/react-context': 1.1.3(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-primitive': 2.1.4(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@radix-ui/react-checkbox@1.3.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: '@radix-ui/primitive': 1.1.3 @@ -3037,6 +3093,12 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 + '@radix-ui/react-context@1.1.3(@types/react@19.2.14)(react@19.2.5)': + dependencies: + react: 19.2.5 + optionalDependencies: + '@types/react': 19.2.14 + '@radix-ui/react-dialog@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: '@radix-ui/primitive': 1.1.3 @@ -3273,6 +3335,22 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 + '@radix-ui/react-tabs@1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@radix-ui/react-toggle@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: '@radix-ui/primitive': 1.1.3 @@ -3332,6 +3410,13 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 + '@radix-ui/react-use-is-hydrated@0.1.0(@types/react@19.2.14)(react@19.2.5)': + dependencies: + react: 19.2.5 + use-sync-external-store: 1.6.0(react@19.2.5) + optionalDependencies: + '@types/react': 19.2.14 + '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.14)(react@19.2.5)': dependencies: react: 19.2.5 @@ -5082,6 +5167,11 @@ snapshots: '@shikijs/vscode-textmate': 10.0.2 '@types/hast': 3.0.4 + sonner@2.0.7(react-dom@19.2.5(react@19.2.5))(react@19.2.5): + dependencies: + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + source-map-js@1.2.1: {} space-separated-tokens@2.0.2: {} diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index db221da409..0b4df7e570 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -321,7 +321,7 @@ pub fn sync_managed_agent_processes( continue; }; - if process_is_running(pid) { + if process_is_running(pid) && process_belongs_to_us(pid) { continue; } @@ -601,7 +601,7 @@ pub fn start_managed_agent_process( } if let Some(pid) = record.runtime_pid { - if process_is_running(pid) { + if process_is_running(pid) && process_belongs_to_us(pid) { record.updated_at = now_iso(); record.last_error = None; return Ok(()); diff --git a/desktop/src/features/agents/ui/CopyButton.tsx b/desktop/src/features/agents/ui/CopyButton.tsx index b18a82de0d..fa81831423 100644 --- a/desktop/src/features/agents/ui/CopyButton.tsx +++ b/desktop/src/features/agents/ui/CopyButton.tsx @@ -1,5 +1,5 @@ -import { Check, Copy } from "lucide-react"; -import * as React from "react"; +import { Copy } from "lucide-react"; +import { toast } from "sonner"; import { Button } from "@/shared/ui/button"; @@ -10,25 +10,18 @@ export function CopyButton({ value: string; label?: string; }) { - const [copied, setCopied] = React.useState(false); - return ( ); } diff --git a/desktop/src/features/agents/ui/ManagedAgentRow.tsx b/desktop/src/features/agents/ui/ManagedAgentRow.tsx index 707afba893..7eb5494f51 100644 --- a/desktop/src/features/agents/ui/ManagedAgentRow.tsx +++ b/desktop/src/features/agents/ui/ManagedAgentRow.tsx @@ -14,6 +14,7 @@ import { Trash2, UserPlus, } from "lucide-react"; +import { toast } from "sonner"; import { PresenceDot } from "@/features/presence/ui/PresenceBadge"; import { Badge } from "@/shared/ui/badge"; @@ -399,7 +400,10 @@ function AgentActionsMenu({ navigator.clipboard.writeText(agent.pubkey)} + onClick={async () => { + await navigator.clipboard.writeText(agent.pubkey); + toast.success("Copied pubkey to clipboard"); + }} > Copy pubkey diff --git a/desktop/src/features/agents/ui/ManagedAgentsSection.tsx b/desktop/src/features/agents/ui/ManagedAgentsSection.tsx index 1664e99f7b..49fcd67e39 100644 --- a/desktop/src/features/agents/ui/ManagedAgentsSection.tsx +++ b/desktop/src/features/agents/ui/ManagedAgentsSection.tsx @@ -1,8 +1,10 @@ import { Ellipsis, OctagonX, Trash2 } from "lucide-react"; import { isManagedAgentActive } from "@/features/agents/lib/managedAgentControlActions"; +import { useFeedbackToasts } from "@/shared/hooks/useToastEffect"; import type { ManagedAgent, PresenceLookup } from "@/shared/api/types"; import { Button } from "@/shared/ui/button"; +import { Card } from "@/shared/ui/card"; import { DropdownMenu, DropdownMenuContent, @@ -68,6 +70,9 @@ export function ManagedAgentsSection({ const stoppedCount = agents.filter( (a) => a.status === "stopped" || a.status === "not_deployed", ).length; + + useFeedbackToasts(actionNoticeMessage, actionErrorMessage); + return (
@@ -123,7 +128,7 @@ export function ManagedAgentsSection({
{isLoading ? ( -
+ {["first", "second"].map((key) => (
))} -
+ ) : null} {!isLoading && agents.length === 0 ? ( @@ -181,23 +186,29 @@ export function ManagedAgentsSection({ ) : null} + {!isLoading && stoppedCount > 0 ? ( +
+

+ {stoppedCount} stopped {stoppedCount === 1 ? "agent" : "agents"} +

+ +
+ ) : null} + {error ? (

{error.message}

) : null} - - {actionNoticeMessage ? ( -

- {actionNoticeMessage} -

- ) : null} - - {actionErrorMessage ? ( -

- {actionErrorMessage} -

- ) : null}
); } diff --git a/desktop/src/features/agents/ui/ModelPicker.tsx b/desktop/src/features/agents/ui/ModelPicker.tsx index ce7fa859a5..aaa1400fd6 100644 --- a/desktop/src/features/agents/ui/ModelPicker.tsx +++ b/desktop/src/features/agents/ui/ModelPicker.tsx @@ -1,4 +1,6 @@ -import { ChevronDown, Loader2 } from "lucide-react"; +import { ChevronDown } from "lucide-react"; + +import { Spinner } from "@/shared/ui/spinner"; import React from "react"; import type { AgentModelsResponse, ManagedAgent } from "@/shared/api/types"; @@ -101,7 +103,7 @@ export function ModelPicker({ > {loading ? (
- + Loading models...
) : error ? ( diff --git a/desktop/src/features/agents/ui/PersonaCatalogDetailsSheet.tsx b/desktop/src/features/agents/ui/PersonaCatalogDetailsSheet.tsx index 5c4892c03d..6dee50974b 100644 --- a/desktop/src/features/agents/ui/PersonaCatalogDetailsSheet.tsx +++ b/desktop/src/features/agents/ui/PersonaCatalogDetailsSheet.tsx @@ -1,6 +1,7 @@ import { isCatalogPersonaSelected } from "@/features/agents/lib/catalog"; import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; import type { AgentPersona } from "@/shared/api/types"; +import { Card } from "@/shared/ui/card"; import { cn } from "@/shared/lib/cn"; import { promptPreview } from "@/shared/lib/promptPreview"; import { @@ -18,8 +19,6 @@ import { } from "./personaLibraryCopy"; type PersonaCatalogDetailsSheetProps = { - feedbackErrorMessage: string | null; - feedbackNoticeMessage: string | null; isPending: boolean; onOpenChange: (open: boolean) => void; onTogglePersona: (persona: AgentPersona) => void; @@ -28,8 +27,6 @@ type PersonaCatalogDetailsSheetProps = { }; export function PersonaCatalogDetailsSheet({ - feedbackErrorMessage, - feedbackNoticeMessage, isPending, onOpenChange, onTogglePersona, @@ -109,51 +106,39 @@ export function PersonaCatalogDetailsSheet({ - {feedbackNoticeMessage ? ( -

- {feedbackNoticeMessage} -

- ) : null} - - {feedbackErrorMessage ? ( -

- {feedbackErrorMessage} -

- ) : null} -
-
+

Type

Built-in persona

-
-
+ +

Preferred model

{persona.model ?? "Use app default"}

-
-
+ +

Preferred provider

{persona.provider ?? "Use app default"}

-
+
-
+

System prompt

                 {persona.systemPrompt}
               
-
+ ) : null} diff --git a/desktop/src/features/agents/ui/PersonaCatalogSection.tsx b/desktop/src/features/agents/ui/PersonaCatalogSection.tsx index aeaf49551d..b7948d195e 100644 --- a/desktop/src/features/agents/ui/PersonaCatalogSection.tsx +++ b/desktop/src/features/agents/ui/PersonaCatalogSection.tsx @@ -3,6 +3,7 @@ import type { AgentPersona } from "@/shared/api/types"; import { promptPreview } from "@/shared/lib/promptPreview"; import { cn } from "@/shared/lib/cn"; import { Button } from "@/shared/ui/button"; +import { Card } from "@/shared/ui/card"; import { Skeleton } from "@/shared/ui/skeleton"; import { PersonaCatalogSelectionBadge } from "./PersonaCatalogSelectionBadge"; import { PersonaIdentity } from "./PersonaIdentity"; @@ -16,8 +17,6 @@ type PersonaCatalogSectionProps = { emptyDescription?: string; emptyTitle?: string; error: Error | null; - feedbackErrorMessage?: string | null; - feedbackNoticeMessage?: string | null; isLoading: boolean; isPending: boolean; onTogglePersona: (persona: AgentPersona) => void; @@ -30,8 +29,6 @@ export function PersonaCatalogSection({ emptyDescription = personaCatalogCopy.emptyCatalogDescription, emptyTitle = personaCatalogCopy.emptyCatalogTitle, error, - feedbackErrorMessage = null, - feedbackNoticeMessage = null, isLoading, isPending, onTogglePersona, @@ -55,10 +52,7 @@ export function PersonaCatalogSection({ {isLoading ? (
{["first", "second", "third", "fourth"].map((key) => ( -
+
@@ -66,7 +60,7 @@ export function PersonaCatalogSection({
-
+ ))}
) : null} @@ -167,24 +161,6 @@ export function PersonaCatalogSection({ {error.message}

) : null} - - {feedbackNoticeMessage ? ( -

- {feedbackNoticeMessage} -

- ) : null} - - {feedbackErrorMessage ? ( -

- {feedbackErrorMessage} -

- ) : null} ); } diff --git a/desktop/src/features/agents/ui/PersonaCatalogSurface.tsx b/desktop/src/features/agents/ui/PersonaCatalogSurface.tsx index f397f60716..23fd5b885e 100644 --- a/desktop/src/features/agents/ui/PersonaCatalogSurface.tsx +++ b/desktop/src/features/agents/ui/PersonaCatalogSurface.tsx @@ -2,6 +2,7 @@ import * as React from "react"; import { isCatalogPersonaSelected } from "@/features/agents/lib/catalog"; import type { AgentPersona } from "@/shared/api/types"; +import { useFeedbackToasts } from "@/shared/hooks/useToastEffect"; import { PersonaCatalogDetailsSheet } from "./PersonaCatalogDetailsSheet"; import { PersonaCatalogSection } from "./PersonaCatalogSection"; @@ -43,12 +44,12 @@ export function PersonaCatalogSurface({ onSelectPersona(persona, !isCatalogPersonaSelected(persona)); }; + useFeedbackToasts(feedbackNoticeMessage, feedbackErrorMessage); + return ( <> { if (!open) { diff --git a/desktop/src/features/agents/ui/PersonasSection.tsx b/desktop/src/features/agents/ui/PersonasSection.tsx index 5015096dbb..45870ed4a7 100644 --- a/desktop/src/features/agents/ui/PersonasSection.tsx +++ b/desktop/src/features/agents/ui/PersonasSection.tsx @@ -8,7 +8,9 @@ import { } from "lucide-react"; import type { AgentPersona } from "@/shared/api/types"; +import { useFeedbackToasts } from "@/shared/hooks/useToastEffect"; import { useFileImportZone } from "@/shared/hooks/useFileImportZone"; +import { Card } from "@/shared/ui/card"; import { DropdownMenu, DropdownMenuContent, @@ -63,6 +65,8 @@ export function PersonasSection({ openFilePicker, } = useFileImportZone({ onImportFile }); + useFeedbackToasts(feedbackNoticeMessage, feedbackErrorMessage); + return (
{["first", "second", "third", "fourth"].map((key) => ( -
+
@@ -118,7 +119,7 @@ export function PersonasSection({
-
+ ))} ) : null} @@ -126,8 +127,8 @@ export function PersonasSection({ {!isLoading && personas.length > 0 ? (
{personas.map((persona) => ( -
@@ -199,7 +200,7 @@ export function PersonasSection({
-
+ ))}
); } diff --git a/desktop/src/features/agents/ui/RelayDirectorySection.tsx b/desktop/src/features/agents/ui/RelayDirectorySection.tsx index 878596971a..18e6ea358d 100644 --- a/desktop/src/features/agents/ui/RelayDirectorySection.tsx +++ b/desktop/src/features/agents/ui/RelayDirectorySection.tsx @@ -3,6 +3,7 @@ import { ChevronDown, ChevronRight, Search } from "lucide-react"; import type { RelayAgent } from "@/shared/api/types"; import { PresenceBadge } from "@/features/presence/ui/PresenceBadge"; +import { Card } from "@/shared/ui/card"; import { Input } from "@/shared/ui/input"; import { truncatePubkey } from "./agentUi"; @@ -88,7 +89,7 @@ export function RelayDirectorySection({ : "No other agents on this relay."}

) : ( -
+
-
+ )} ) : null} diff --git a/desktop/src/features/agents/ui/TeamsSection.tsx b/desktop/src/features/agents/ui/TeamsSection.tsx index 48ded4ba85..593a93018a 100644 --- a/desktop/src/features/agents/ui/TeamsSection.tsx +++ b/desktop/src/features/agents/ui/TeamsSection.tsx @@ -21,6 +21,7 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@/shared/ui/dropdown-menu"; +import { Card } from "@/shared/ui/card"; import { Skeleton } from "@/shared/ui/skeleton"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; import { CreateNewButton } from "./CreateNewButton"; @@ -102,10 +103,7 @@ export function TeamsSection({ {isLoading ? (
{["first", "second", "third"].map((key) => ( -
+
@@ -113,7 +111,7 @@ export function TeamsSection({
-
+ ))}
) : null} @@ -132,10 +130,7 @@ export function TeamsSection({ const hasMissingPersonas = resolution.hasMissingPersonas; return ( -
+
@@ -248,7 +243,7 @@ export function TeamsSection({ exporting.

) : null} -
+ ); })}
diff --git a/desktop/src/features/channels/ui/QuickBotBar.tsx b/desktop/src/features/channels/ui/QuickBotBar.tsx index 52dc8addd8..0308354b7a 100644 --- a/desktop/src/features/channels/ui/QuickBotBar.tsx +++ b/desktop/src/features/channels/ui/QuickBotBar.tsx @@ -1,5 +1,5 @@ import * as React from "react"; -import { Loader2 } from "lucide-react"; +import { Spinner } from "@/shared/ui/spinner"; import type { AgentPersona } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; @@ -81,7 +81,7 @@ export function QuickBotBar({ personas, pending, onAdd }: QuickBotBarProps) { )} {isThisPending ? (
- +
) : null} diff --git a/desktop/src/features/huddle/components/HuddleBar.tsx b/desktop/src/features/huddle/components/HuddleBar.tsx index af6fcf0e09..f1dc3a9679 100644 --- a/desktop/src/features/huddle/components/HuddleBar.tsx +++ b/desktop/src/features/huddle/components/HuddleBar.tsx @@ -5,6 +5,7 @@ import * as React from "react"; import { cn } from "@/shared/lib/cn"; import { Button } from "@/shared/ui/button"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; import { useHuddle } from "../HuddleContext"; import { AddAgentDialog, type AgentAddResult } from "./AddAgentDialog"; import { MicControls, SpeakerControls } from "./MicControls"; @@ -288,53 +289,57 @@ export function HuddleBar({ className }: HuddleBarProps) { {/* Voice input mode — single toggle combining indicator + switch */} {micConnected ? ( <> - + + + {isPttMode ? "Push to Talk — hold Ctrl+Space to transmit. Click to switch to Auto." - : "Auto — mic is always live. Click to switch to Push to Talk." - } - > - {isPttMode ? ( - <> -
- Push to Talk - - ) : ( - <> -
0.05 - ? `rgba(34, 197, 94, ${0.4 + micLevel * 0.6})` - : "rgba(100, 116, 139, 0.4)", - }} - /> - Auto - - )} - + : "Auto — mic is always live. Click to switch to Push to Talk."} + + {isPttMode && ( {navigator.platform?.includes("Mac") ? "⌃Space" : "Ctrl+Space"} @@ -414,32 +419,40 @@ export function HuddleBar({ className }: HuddleBarProps) { {/* Leave / End buttons — pushed to the right */}
- + + + + + Leave huddle + {state?.is_creator && ( - + + + + + End huddle for everyone + )}
diff --git a/desktop/src/features/huddle/components/HuddleIndicator.tsx b/desktop/src/features/huddle/components/HuddleIndicator.tsx index 479e350763..6b5161296d 100644 --- a/desktop/src/features/huddle/components/HuddleIndicator.tsx +++ b/desktop/src/features/huddle/components/HuddleIndicator.tsx @@ -7,6 +7,7 @@ import { relayClient } from "@/shared/api/relayClient"; import type { RelayEvent } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; import { Button } from "@/shared/ui/button"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; import { useHuddle } from "../HuddleContext"; /** Huddle lifecycle event kinds */ @@ -241,25 +242,31 @@ export function HuddleIndicator({ } return ( - + + + + + + {`Huddle active — ${participantCount} participant${participantCount !== 1 ? "s" : ""}`} + + ); } diff --git a/desktop/src/features/huddle/components/ParticipantList.tsx b/desktop/src/features/huddle/components/ParticipantList.tsx index 84d9651299..93f995b144 100644 --- a/desktop/src/features/huddle/components/ParticipantList.tsx +++ b/desktop/src/features/huddle/components/ParticipantList.tsx @@ -4,6 +4,7 @@ import * as React from "react"; import { cn } from "@/shared/lib/cn"; import { useUsersBatchQuery } from "@/features/profile/hooks"; import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; type ParticipantListProps = { /** Pubkey hex strings from the Rust huddle state */ @@ -44,21 +45,22 @@ export function ParticipantList({ return (
  • {hasProfile ? ( -
    - -
    + + +
    + +
    +
    + {profile.displayName || pubkey} +
    ) : ( ) : ( - + + + + + Agent + ))}
  • ); @@ -109,18 +115,25 @@ function HexAvatar({ const isActive = activeSpeakers?.includes(pubkey); return ( -
    - {shortId} -
    + + +
    + {shortId} +
    +
    + {pubkey} +
    ); } diff --git a/desktop/src/features/messages/lib/useDrafts.ts b/desktop/src/features/messages/lib/useDrafts.ts index 04d6ec02b5..7846a25235 100644 --- a/desktop/src/features/messages/lib/useDrafts.ts +++ b/desktop/src/features/messages/lib/useDrafts.ts @@ -8,31 +8,46 @@ export type DraftState = { selectionEnd: number; }; -export function useDrafts() { - const draftsRef = React.useRef(new Map()); +const sharedDrafts = new Map(); +export function useDrafts() { const saveDraft = React.useCallback( (channelId: string, draft: DraftState) => { if (draft.content.trim().length === 0) { return; } - const drafts = draftsRef.current; - drafts.set(channelId, draft); - trimMapToSize(drafts, 50); + sharedDrafts.set(channelId, draft); + trimMapToSize(sharedDrafts, 50); }, [], ); const loadDraft = React.useCallback( (channelId: string): DraftState | undefined => { - return draftsRef.current.get(channelId); + return sharedDrafts.get(channelId); }, [], ); const clearDraft = React.useCallback((channelId: string) => { - draftsRef.current.delete(channelId); + sharedDrafts.delete(channelId); }, []); - return { saveDraft, loadDraft, clearDraft }; + /** Save draft if content is non-empty, otherwise clear it. */ + const persistDraft = React.useCallback( + (channelId: string, content: string) => { + if (content.trim().length > 0) { + saveDraft(channelId, { + content, + selectionEnd: content.length, + selectionStart: content.length, + }); + } else { + clearDraft(channelId); + } + }, + [saveDraft, clearDraft], + ); + + return { saveDraft, loadDraft, clearDraft, persistDraft }; } diff --git a/desktop/src/features/messages/ui/ComposerAttachments.tsx b/desktop/src/features/messages/ui/ComposerAttachments.tsx index 3834ff0b0d..1a1f16fc95 100644 --- a/desktop/src/features/messages/ui/ComposerAttachments.tsx +++ b/desktop/src/features/messages/ui/ComposerAttachments.tsx @@ -6,6 +6,7 @@ import { X } from "lucide-react"; import type { BlobDescriptor } from "@/shared/api/tauri"; import { rewriteRelayUrl } from "@/shared/lib/mediaUrl"; import { shortHash } from "@/features/messages/lib/useMediaUpload"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; type ComposerAttachmentsProps = { attachments: BlobDescriptor[]; @@ -108,14 +109,18 @@ export const ComposerAttachments = React.memo(function ComposerAttachments({ - + + + + + Remove attachment +
    ); diff --git a/desktop/src/features/messages/ui/ComposerEmojiPicker.tsx b/desktop/src/features/messages/ui/ComposerEmojiPicker.tsx index 19200b8576..00e049801d 100644 --- a/desktop/src/features/messages/ui/ComposerEmojiPicker.tsx +++ b/desktop/src/features/messages/ui/ComposerEmojiPicker.tsx @@ -5,6 +5,7 @@ import { SmilePlus } from "lucide-react"; import { Button } from "@/shared/ui/button"; import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; type ComposerEmojiPickerProps = { disabled?: boolean; @@ -23,20 +24,24 @@ export const ComposerEmojiPicker = React.memo(function ComposerEmojiPicker({ }: ComposerEmojiPickerProps) { return ( - - - + + + + + + + Insert emoji + )} {onExpand && ( - + + + + + Expand diff + )}
    diff --git a/desktop/src/features/messages/ui/FormattingToolbar.tsx b/desktop/src/features/messages/ui/FormattingToolbar.tsx index ecb34fd32e..4862722b63 100644 --- a/desktop/src/features/messages/ui/FormattingToolbar.tsx +++ b/desktop/src/features/messages/ui/FormattingToolbar.tsx @@ -12,6 +12,7 @@ import { } from "lucide-react"; import { Toggle } from "@/shared/ui/toggle"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; type FormattingToolbarProps = { editor: Editor | null; @@ -160,19 +161,24 @@ export const FormattingToolbar = React.memo(function FormattingToolbar({ return (
    {items.map((item) => ( - item.action()} - className="h-7 w-7 min-w-7 [&_svg]:size-3.5" - title={ - "shortcut" in item ? `${item.label} (${item.shortcut})` : item.label - } - > - - + + + item.action()} + className="h-7 w-7 min-w-7 [&_svg]:size-3.5" + > + + + + + {"shortcut" in item + ? `${item.label} (${item.shortcut})` + : item.label} + + ))}
    ); diff --git a/desktop/src/features/messages/ui/MessageActionBar.tsx b/desktop/src/features/messages/ui/MessageActionBar.tsx index 9fbb51bfd2..4828359819 100644 --- a/desktop/src/features/messages/ui/MessageActionBar.tsx +++ b/desktop/src/features/messages/ui/MessageActionBar.tsx @@ -1,12 +1,6 @@ import Picker from "@emoji-mart/react"; import data from "@emoji-mart/data"; -import { - CornerUpLeft, - LoaderCircle, - Pencil, - SmilePlus, - Trash2, -} from "lucide-react"; +import { CornerUpLeft, Pencil, SmilePlus, Trash2 } from "lucide-react"; import * as React from "react"; import type { @@ -26,6 +20,8 @@ import { } from "@/shared/ui/alert-dialog"; import { Button } from "@/shared/ui/button"; import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover"; +import { Spinner } from "@/shared/ui/spinner"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; export function MessageActionBar({ activeReplyTargetId = null, @@ -88,28 +84,32 @@ export function MessageActionBar({ onOpenChange={setIsReactionPickerOpen} open={isReactionPickerOpen} > - - - + + + + + + + React + { - onEdit?.(message); - }} - size="sm" - title="Edit" - type="button" - variant="ghost" - > - - + + + + + Edit + ) : null} {hasDeleteAction ? ( <> - + + + + + Delete + { - onReply?.(message); - }} - size="sm" - title={isReplyingToMessage ? "Cancel reply" : "Reply"} - type="button" - variant={isReplyingToMessage ? "secondary" : "ghost"} - > - - + + + + + + {isReplyingToMessage ? "Cancel reply" : "Reply"} + + ) : null}
    diff --git a/desktop/src/features/messages/ui/MessageComposer.tsx b/desktop/src/features/messages/ui/MessageComposer.tsx index c96ac5122f..06bad1f233 100644 --- a/desktop/src/features/messages/ui/MessageComposer.tsx +++ b/desktop/src/features/messages/ui/MessageComposer.tsx @@ -30,6 +30,7 @@ type MessageComposerProps = { channelId?: string | null; channelName: string; disabled?: boolean; + draftKey?: string; editTarget?: { author: string; body: string; @@ -58,6 +59,7 @@ export function MessageComposer({ channelId = null, channelName, disabled = false, + draftKey, editTarget = null, isSending = false, onCancelEdit, @@ -83,7 +85,8 @@ export function MessageComposer({ }, []); const drafts = useDrafts(); - const previousChannelIdRef = React.useRef(null); + const effectiveDraftKey = draftKey ?? channelId; + const previousDraftKeyRef = React.useRef(null); const mentions = useMentions(channelId); const channelLinks = useChannelLinks(); @@ -151,24 +154,17 @@ export function MessageComposer({ }); // ── Channel switching: save/restore drafts ────────────────────────── - // biome-ignore lint/correctness/useExhaustiveDependencies: channelId is the sole trigger + // biome-ignore lint/correctness/useExhaustiveDependencies: effectiveDraftKey is the sole trigger React.useEffect(() => { - const prevId = previousChannelIdRef.current; - if (prevId) { - const currentContent = contentRef.current; - if (currentContent.trim().length > 0) { - drafts.saveDraft(prevId, { - content: currentContent, - selectionEnd: currentContent.length, - selectionStart: currentContent.length, - }); - } else { - drafts.clearDraft(prevId); - } + const prevKey = previousDraftKeyRef.current; + if (prevKey) { + drafts.persistDraft(prevKey, contentRef.current); } - previousChannelIdRef.current = channelId; + previousDraftKeyRef.current = effectiveDraftKey; - const saved = channelId ? drafts.loadDraft(channelId) : undefined; + const saved = effectiveDraftKey + ? drafts.loadDraft(effectiveDraftKey) + : undefined; if (saved) { setContent(saved.content); contentRef.current = saved.content; @@ -184,7 +180,13 @@ export function MessageComposer({ setIsEmojiPickerOpen(false); mentions.clearMentions(); channelLinks.clearChannels(); - }, [channelId]); + + return () => { + if (effectiveDraftKey) { + drafts.persistDraft(effectiveDraftKey, contentRef.current); + } + }; + }, [effectiveDraftKey]); // ── Edit mode: pre-fill content ───────────────────────────────────── // biome-ignore lint/correctness/useExhaustiveDependencies: editTarget?.id is the trigger diff --git a/desktop/src/features/messages/ui/MessageComposerToolbar.tsx b/desktop/src/features/messages/ui/MessageComposerToolbar.tsx index a6f3dcf026..744fce9107 100644 --- a/desktop/src/features/messages/ui/MessageComposerToolbar.tsx +++ b/desktop/src/features/messages/ui/MessageComposerToolbar.tsx @@ -10,7 +10,9 @@ import { } from "lucide-react"; import { Button } from "@/shared/ui/button"; +import { Spinner } from "@/shared/ui/spinner"; import { Toggle } from "@/shared/ui/toggle"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; import { ComposerEmojiPicker } from "./ComposerEmojiPicker"; import { FormattingToolbar } from "./FormattingToolbar"; @@ -86,16 +88,20 @@ export const MessageComposerToolbar = React.memo( exit={{ x: 8, opacity: 0 }} transition={presenceSpring} > - - - + + + + + + + Formatting + - + + + + + Close formatting +
    - - + + + + + Mention someone + + + + + + Attach image + - - - + + + + + + + Formatting + )} @@ -206,16 +224,20 @@ export const MessageComposerToolbar = React.memo(
    {extraActions} - + + + + + Send (Enter) +
    ); diff --git a/desktop/src/features/messages/ui/MessageRow.tsx b/desktop/src/features/messages/ui/MessageRow.tsx index bc350227a6..e09c6d80ae 100644 --- a/desktop/src/features/messages/ui/MessageRow.tsx +++ b/desktop/src/features/messages/ui/MessageRow.tsx @@ -14,6 +14,7 @@ import { resolveMentionNames } from "@/shared/lib/resolveMentionNames"; import { Markdown } from "@/shared/ui/markdown"; import { MessageActionBar } from "./MessageActionBar"; import { MessageTimestamp } from "./MessageTimestamp"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; const DiffMessage = React.lazy(() => import("./DiffMessage")); const DiffMessageExpanded = React.lazy(() => import("./DiffMessageExpanded")); @@ -240,12 +241,12 @@ export const MessageRow = React.memo(

    ) : null} {message.edited ? ( -

    - (edited) -

    + + +

    (edited)

    +
    + This message has been edited +
    ) : null} diff --git a/desktop/src/features/messages/ui/MessageThreadPanel.tsx b/desktop/src/features/messages/ui/MessageThreadPanel.tsx index f5a473616c..32ffa7e26d 100644 --- a/desktop/src/features/messages/ui/MessageThreadPanel.tsx +++ b/desktop/src/features/messages/ui/MessageThreadPanel.tsx @@ -258,6 +258,7 @@ export function MessageThreadPanel({ channelId={channelId} channelName={channelName} disabled={disabled || isSending || !channelId} + draftKey={`thread:${threadHead.id}`} isSending={isSending} onCancelReply={composerReplyTarget ? onCancelReply : undefined} onSend={onSend} diff --git a/desktop/src/features/messages/ui/MessageTimeline.tsx b/desktop/src/features/messages/ui/MessageTimeline.tsx index 54c1655d1f..6a014ee243 100644 --- a/desktop/src/features/messages/ui/MessageTimeline.tsx +++ b/desktop/src/features/messages/ui/MessageTimeline.tsx @@ -1,10 +1,11 @@ import * as React from "react"; -import { ArrowDown, Loader2 } from "lucide-react"; +import { ArrowDown } from "lucide-react"; import type { TimelineMessage } from "@/features/messages/types"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; import { Button } from "@/shared/ui/button"; import { Separator } from "@/shared/ui/separator"; +import { Spinner } from "@/shared/ui/spinner"; import { TooltipProvider } from "@/shared/ui/tooltip"; import { TimelineSkeleton } from "./TimelineSkeleton"; import { TimelineMessageList } from "./TimelineMessageList"; @@ -115,7 +116,7 @@ export const MessageTimeline = React.memo(function MessageTimeline({ {isFetchingOlder ? (
    - +
    ) : null} diff --git a/desktop/src/features/profile/ui/ProfileAvatar.tsx b/desktop/src/features/profile/ui/ProfileAvatar.tsx index 57907dfc1a..877b7ce223 100644 --- a/desktop/src/features/profile/ui/ProfileAvatar.tsx +++ b/desktop/src/features/profile/ui/ProfileAvatar.tsx @@ -1,8 +1,9 @@ import { UserRound } from "lucide-react"; -import * as React from "react"; import { cn } from "@/shared/lib/cn"; +import { getInitials } from "@/shared/lib/initials"; import { rewriteRelayUrl } from "@/shared/lib/mediaUrl"; +import { Avatar, AvatarFallback, AvatarImage } from "@/shared/ui/avatar"; type ProfileAvatarProps = { avatarUrl: string | null; @@ -19,40 +20,31 @@ export function ProfileAvatar({ iconClassName, testId, }: ProfileAvatarProps) { - const [failedAvatarUrl, setFailedAvatarUrl] = React.useState( - null, - ); - - const initials = label - .trim() - .split(/\s+/) - .map((part) => part[0] ?? "") - .join("") - .slice(0, 2) - .toUpperCase(); - const baseClassName = cn( - "flex shrink-0 items-center justify-center overflow-hidden bg-primary/20 text-primary shadow-sm", - className, - ); - - if (avatarUrl && failedAvatarUrl !== avatarUrl) { - return ( - {`${label} { - setFailedAvatarUrl(avatarUrl); - }} - referrerPolicy="no-referrer" - src={rewriteRelayUrl(avatarUrl)} - /> - ); - } + const initials = getInitials(label); return ( -
    - {initials.length > 0 ? initials : } -
    + + {avatarUrl ? ( + + ) : null} + + {initials.length > 0 ? ( + initials + ) : ( + + )} + + ); } diff --git a/desktop/src/features/pulse/ui/PulseView.tsx b/desktop/src/features/pulse/ui/PulseView.tsx index 2f82537397..ac5b2d03e5 100644 --- a/desktop/src/features/pulse/ui/PulseView.tsx +++ b/desktop/src/features/pulse/ui/PulseView.tsx @@ -1,4 +1,4 @@ -import { Filter, RefreshCw } from "lucide-react"; +import { Check, Filter, RefreshCw } from "lucide-react"; import * as React from "react"; import { useRelayAgentsQuery } from "@/features/agents/hooks"; @@ -18,10 +18,20 @@ import { NoteCard } from "@/features/pulse/ui/NoteCard"; import type { UserNote } from "@/shared/api/socialTypes"; import type { RelayAgent, UserProfileSummary } from "@/shared/api/types"; import { Button } from "@/shared/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/shared/ui/dropdown-menu"; import { Skeleton } from "@/shared/ui/skeleton"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/shared/ui/tabs"; type PulseTab = "foryou" | "people" | "agents" | "mine"; +const tabTriggerClassName = + "rounded-none border-b-2 border-transparent px-3 py-2.5 text-sm font-medium shadow-none transition-colors data-[state=active]:border-primary data-[state=active]:text-foreground data-[state=active]:shadow-none text-muted-foreground hover:text-foreground data-[state=active]:bg-transparent"; + type PulseViewProps = { currentPubkey?: string; }; @@ -64,20 +74,6 @@ function AgentFilter({ selectedPubkey: string | null; onSelect: (pubkey: string | null) => void; }) { - const [open, setOpen] = React.useState(false); - const ref = React.useRef(null); - - React.useEffect(() => { - if (!open) return; - function handleClickOutside(e: MouseEvent) { - if (ref.current && !ref.current.contains(e.target as Node)) { - setOpen(false); - } - } - document.addEventListener("mousedown", handleClickOutside); - return () => document.removeEventListener("mousedown", handleClickOutside); - }, [open]); - const selectedName = selectedPubkey ? (profiles[selectedPubkey.toLowerCase()]?.displayName ?? agents.find((a) => a.pubkey === selectedPubkey)?.name ?? @@ -85,54 +81,48 @@ function AgentFilter({ : null; return ( -
    - - {open ? ( -
    - - {agents.map((agent) => { - const name = - profiles[agent.pubkey.toLowerCase()]?.displayName ?? - agent.name ?? - `${agent.pubkey.slice(0, 8)}...`; - return ( - - ); - })} -
    - ) : null} -
    + + + + + + onSelect(null)}> + {!selectedPubkey ? ( + + ) : ( + + )} + All agents + + {agents.map((agent) => { + const name = + profiles[agent.pubkey.toLowerCase()]?.displayName ?? + agent.name ?? + `${agent.pubkey.slice(0, 8)}...`; + const isSelected = selectedPubkey === agent.pubkey; + return ( + onSelect(agent.pubkey)} + > + {isSelected ? ( + + ) : ( + + )} + {name} + + ); + })} + + ); } @@ -279,89 +269,103 @@ export function PulseView({ currentPubkey }: PulseViewProps) { mine: "You haven't posted any notes yet.", }; + function renderTimeline() { + if (isLoading) return ; + + if (activeTab === "agents") { + return agentNoteGroups.length === 0 ? ( + + ) : ( + agentNoteGroups.map((group) => ( + + )) + ); + } + + return visibleNotes.length === 0 ? ( + + ) : ( + visibleNotes.map((note) => ( + + )) + ); + } + return (
    - {/* Tab bar */} -
    - setActiveTab("foryou")} - /> - setActiveTab("people")} - /> - setActiveTab("agents")} - /> - setActiveTab("mine")} - /> + setActiveTab(v as PulseTab)} + className="flex min-h-0 flex-1 flex-col overflow-hidden" + > + {/* Tab bar */} +
    + + + For You + + + People + + + Agents + {relayAgents.length > 0 ? ( + + {relayAgents.length} + + ) : null} + + + My Notes + + -
    - {activeTab === "agents" && relayAgents.length > 1 ? ( - - ) : null} - -
    -
    - - {/* Timeline */} -
    - {isLoading ? ( - - ) : activeTab === "agents" ? ( - agentNoteGroups.length === 0 ? ( - - ) : ( - agentNoteGroups.map((group) => ( - + {activeTab === "agents" && relayAgents.length > 1 ? ( + - )) - ) - ) : visibleNotes.length === 0 ? ( - - ) : ( - visibleNotes.map((note) => ( - - )) - )} -
    + ) : null} + +
    +
    + + {/* Timeline — TabsContent with forceMount so aria-controls resolves */} + + {renderTimeline()} + + ); } - -function TabButton({ - active, - count, - label, - onClick, -}: { - active: boolean; - count?: number; - label: string; - onClick: () => void; -}) { - return ( - - ); -} diff --git a/desktop/src/features/settings/ui/MobilePairingCard.tsx b/desktop/src/features/settings/ui/MobilePairingCard.tsx index 02e19b8eb7..231d91555a 100644 --- a/desktop/src/features/settings/ui/MobilePairingCard.tsx +++ b/desktop/src/features/settings/ui/MobilePairingCard.tsx @@ -3,13 +3,15 @@ import { QRCodeSVG } from "qrcode.react"; import { Check, Copy, - Loader2, ShieldCheck, Smartphone, TriangleAlert, X, } from "lucide-react"; import { listen } from "@tauri-apps/api/event"; +import { toast } from "sonner"; + +import { Spinner } from "@/shared/ui/spinner"; import { cancelPairing, @@ -44,7 +46,6 @@ function PairingDialog({ const [qrUri, setQrUri] = useState(null); const [sasCode, setSasCode] = useState(null); const [error, setError] = useState(null); - const [copied, setCopied] = useState(false); const stepRef = useRef(step); stepRef.current = step; @@ -56,8 +57,6 @@ function PairingDialog({ setQrUri(null); setSasCode(null); setError(null); - setCopied(false); - let cancelled = false; startPairing().then( @@ -168,8 +167,7 @@ function PairingDialog({ async function handleCopy() { if (!qrUri) return; await navigator.clipboard.writeText(qrUri); - setCopied(true); - setTimeout(() => setCopied(false), 2000); + toast.success("Copied to clipboard"); } return ( @@ -198,7 +196,7 @@ function PairingDialog({ ) : step === "generating" ? (
    - +

    Preparing secure pairing session...

    @@ -228,11 +226,7 @@ function PairingDialog({ size="sm" variant="outline" > - {copied ? ( - - ) : ( - - )} +
    @@ -284,7 +278,7 @@ function PairingDialog({ ) : step === "transferring" ? (
    - +

    Sending identity to mobile device...

    diff --git a/desktop/src/features/tokens/ui/TokenSettingsCard.tsx b/desktop/src/features/tokens/ui/TokenSettingsCard.tsx index ac5817906e..ff8a609589 100644 --- a/desktop/src/features/tokens/ui/TokenSettingsCard.tsx +++ b/desktop/src/features/tokens/ui/TokenSettingsCard.tsx @@ -1,13 +1,7 @@ import { useQuery } from "@tanstack/react-query"; -import { - Check, - Copy, - KeyRound, - Plus, - Trash2, - TriangleAlert, -} from "lucide-react"; +import { Copy, KeyRound, Plus, Trash2, TriangleAlert } from "lucide-react"; import * as React from "react"; +import { toast } from "sonner"; import { useChannelsQuery } from "@/features/channels/hooks"; import { @@ -202,7 +196,6 @@ function CreateTokenDialog({ >(new Set()); const [expiryDays, setExpiryDays] = React.useState(30); const [mintedToken, setMintedToken] = React.useState(null); - const [copied, setCopied] = React.useState(false); const canCreate = activeTokenCount < MAX_ACTIVE_TOKENS && @@ -219,7 +212,6 @@ function CreateTokenDialog({ setSelectedChannelIds(new Set()); setExpiryDays(30); setMintedToken(null); - setCopied(false); mintMutation.reset(); } @@ -268,8 +260,7 @@ function CreateTokenDialog({ async function handleCopy() { if (!mintedToken) return; await navigator.clipboard.writeText(mintedToken); - setCopied(true); - setTimeout(() => setCopied(false), 2000); + toast.success("Copied to clipboard"); } if (mintedToken) { @@ -294,11 +285,7 @@ function CreateTokenDialog({ {mintedToken}
    diff --git a/desktop/src/features/workflows/ui/WorkflowsView.tsx b/desktop/src/features/workflows/ui/WorkflowsView.tsx index b6c07c0429..d6401388f4 100644 --- a/desktop/src/features/workflows/ui/WorkflowsView.tsx +++ b/desktop/src/features/workflows/ui/WorkflowsView.tsx @@ -14,6 +14,7 @@ import { triggerWorkflow, } from "@/shared/api/tauriWorkflows"; import { Button } from "@/shared/ui/button"; +import { Card } from "@/shared/ui/card"; import { Skeleton } from "@/shared/ui/skeleton"; type WorkflowsViewProps = { @@ -38,10 +39,7 @@ function WorkflowsListSkeleton() { return (
    {["first", "second", "third", "fourth"].map((card) => ( -
    +
    @@ -60,7 +58,7 @@ function WorkflowsListSkeleton() {
    -
    +
    ))}
    ); diff --git a/desktop/src/main.tsx b/desktop/src/main.tsx index 301c155043..c80449f6cd 100644 --- a/desktop/src/main.tsx +++ b/desktop/src/main.tsx @@ -4,6 +4,8 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { App } from "@/app/App"; import "@/shared/styles/globals.css"; import { ThemeProvider } from "@/shared/theme/ThemeProvider"; +import { Toaster } from "@/shared/ui/sonner"; +import { TooltipProvider } from "@/shared/ui/tooltip"; type E2eWindow = Window & { __SPROUT_E2E__?: unknown; @@ -28,7 +30,10 @@ function renderApp() { - + + + + , diff --git a/desktop/src/shared/hooks/useToastEffect.ts b/desktop/src/shared/hooks/useToastEffect.ts new file mode 100644 index 0000000000..7aaa6d02ab --- /dev/null +++ b/desktop/src/shared/hooks/useToastEffect.ts @@ -0,0 +1,36 @@ +import * as React from "react"; +import { toast } from "sonner"; + +/** + * Show a toast when a message string becomes truthy. Uses a ref to avoid + * double-firing in React StrictMode (where effects run twice with the same + * value). + */ +function useToastEffect( + message: string | null | undefined, + variant: "success" | "error", +) { + const shownRef = React.useRef(null); + + React.useEffect(() => { + if (message && message !== shownRef.current) { + shownRef.current = message; + toast[variant](message); + } + if (!message) { + shownRef.current = null; + } + }, [message, variant]); +} + +/** + * Convenience wrapper: show success/error toasts for a pair of feedback + * message strings (common pattern after mutations). + */ +export function useFeedbackToasts( + noticeMessage: string | null | undefined, + errorMessage: string | null | undefined, +) { + useToastEffect(noticeMessage, "success"); + useToastEffect(errorMessage, "error"); +} diff --git a/desktop/src/shared/lib/initials.ts b/desktop/src/shared/lib/initials.ts new file mode 100644 index 0000000000..8068c1959c --- /dev/null +++ b/desktop/src/shared/lib/initials.ts @@ -0,0 +1,10 @@ +/** Derive up to two uppercase initials from a display name. */ +export function getInitials(name: string): string { + return name + .trim() + .split(/\s+/) + .map((part) => part[0] ?? "") + .join("") + .slice(0, 2) + .toUpperCase(); +} diff --git a/desktop/src/shared/ui/UserAvatar.tsx b/desktop/src/shared/ui/UserAvatar.tsx index 833c8838e8..02af8e80bc 100644 --- a/desktop/src/shared/ui/UserAvatar.tsx +++ b/desktop/src/shared/ui/UserAvatar.tsx @@ -1,7 +1,7 @@ -import * as React from "react"; - import { cn } from "@/shared/lib/cn"; +import { getInitials } from "@/shared/lib/initials"; import { rewriteRelayUrl } from "@/shared/lib/mediaUrl"; +import { Avatar, AvatarFallback, AvatarImage } from "@/shared/ui/avatar"; type UserAvatarSize = "xs" | "sm" | "md"; @@ -28,43 +28,33 @@ export function UserAvatar({ className, testId, }: UserAvatarProps) { - const [failedUrl, setFailedUrl] = React.useState(null); - const hasError = failedUrl === avatarUrl; - - const initials = displayName - .split(" ") - .map((part) => part[0]) - .join("") - .slice(0, 2) - .toUpperCase(); - - const base = cn(sizeClasses[size], "rounded-lg shadow-sm", className); - - if (avatarUrl && !hasError) { - return ( - {`${displayName} setFailedUrl(avatarUrl)} - referrerPolicy="no-referrer" - src={rewriteRelayUrl(avatarUrl)} - /> - ); - } + const initials = getInitials(displayName); return ( -
    - {initials} -
    + {avatarUrl ? ( + + ) : null} + + {initials} + + ); } diff --git a/desktop/src/shared/ui/ViewLoadingFallback.tsx b/desktop/src/shared/ui/ViewLoadingFallback.tsx index 3ff1e474cc..6458b13f6d 100644 --- a/desktop/src/shared/ui/ViewLoadingFallback.tsx +++ b/desktop/src/shared/ui/ViewLoadingFallback.tsx @@ -1,3 +1,4 @@ +import { Card } from "@/shared/ui/card"; import { Skeleton } from "@/shared/ui/skeleton"; type ViewLoadingFallbackKind = @@ -64,10 +65,7 @@ function AgentsLoadingBody() {
    {["first", "second", "third", "fourth"].map((card) => ( -
    +
    @@ -75,7 +73,7 @@ function AgentsLoadingBody() {
    -
    + ))}
    @@ -87,10 +85,7 @@ function AgentsLoadingBody() {
    {["alpha", "beta", "gamma"].map((card) => ( -
    +
    @@ -102,7 +97,7 @@ function AgentsLoadingBody() {
    -
    + ))}
    @@ -112,7 +107,7 @@ function AgentsLoadingBody() {
    -
    + {["one", "two", "three"].map((row) => (
    ))} -
    +
    @@ -145,10 +140,7 @@ function WorkflowsLoadingBody() {
    {["first", "second", "third", "fourth"].map((card) => ( -
    +
    @@ -167,7 +159,7 @@ function WorkflowsLoadingBody() {
    -
    +
    ))}
    @@ -224,10 +216,7 @@ function ForumLoadingBody() {
    {["first", "second", "third"].map((card) => ( -
    +
    @@ -241,7 +230,7 @@ function ForumLoadingBody() {
    -
    + ))}
    diff --git a/desktop/src/shared/ui/avatar.tsx b/desktop/src/shared/ui/avatar.tsx new file mode 100644 index 0000000000..a9ee8f9cfe --- /dev/null +++ b/desktop/src/shared/ui/avatar.tsx @@ -0,0 +1,48 @@ +import * as React from "react"; +import * as AvatarPrimitive from "@radix-ui/react-avatar"; + +import { cn } from "@/shared/lib/cn"; + +const Avatar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +Avatar.displayName = AvatarPrimitive.Root.displayName; + +const AvatarImage = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AvatarImage.displayName = AvatarPrimitive.Image.displayName; + +const AvatarFallback = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName; + +export { Avatar, AvatarImage, AvatarFallback }; diff --git a/desktop/src/shared/ui/card.tsx b/desktop/src/shared/ui/card.tsx new file mode 100644 index 0000000000..975e87a2d9 --- /dev/null +++ b/desktop/src/shared/ui/card.tsx @@ -0,0 +1,83 @@ +import * as React from "react"; + +import { cn } from "@/shared/lib/cn"; + +const Card = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
    +)); +Card.displayName = "Card"; + +const CardHeader = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
    +)); +CardHeader.displayName = "CardHeader"; + +const CardTitle = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
    +)); +CardTitle.displayName = "CardTitle"; + +const CardDescription = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
    +)); +CardDescription.displayName = "CardDescription"; + +const CardContent = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
    +)); +CardContent.displayName = "CardContent"; + +const CardFooter = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
    +)); +CardFooter.displayName = "CardFooter"; + +export { + Card, + CardHeader, + CardFooter, + CardTitle, + CardDescription, + CardContent, +}; diff --git a/desktop/src/shared/ui/import-status-icon.tsx b/desktop/src/shared/ui/import-status-icon.tsx index 6b1f011f0e..9617e44eba 100644 --- a/desktop/src/shared/ui/import-status-icon.tsx +++ b/desktop/src/shared/ui/import-status-icon.tsx @@ -1,4 +1,6 @@ -import { Check, Loader2, X } from "lucide-react"; +import { Check, X } from "lucide-react"; + +import { Spinner } from "@/shared/ui/spinner"; export type ImportItemStatus = "pending" | "importing" | "done" | "error"; @@ -14,9 +16,7 @@ export function ImportStatusIcon({ }) { switch (status) { case "importing": - return ( - - ); + return ; case "done": return ; case "error": diff --git a/desktop/src/shared/ui/sonner.tsx b/desktop/src/shared/ui/sonner.tsx new file mode 100644 index 0000000000..101514f58b --- /dev/null +++ b/desktop/src/shared/ui/sonner.tsx @@ -0,0 +1,30 @@ +import { Toaster as Sonner } from "sonner"; + +import { useTheme } from "@/shared/theme/ThemeProvider"; + +type ToasterProps = React.ComponentProps; + +const Toaster = ({ ...props }: ToasterProps) => { + const { isDark } = useTheme(); + + return ( + + ); +}; + +export { Toaster }; diff --git a/desktop/src/shared/ui/spinner.tsx b/desktop/src/shared/ui/spinner.tsx new file mode 100644 index 0000000000..e3f37181ce --- /dev/null +++ b/desktop/src/shared/ui/spinner.tsx @@ -0,0 +1,26 @@ +import { Loader2 } from "lucide-react"; + +import { cn } from "@/shared/lib/cn"; + +type SpinnerProps = React.ComponentPropsWithoutRef<"svg"> & { + className?: string; + size?: number; +}; + +export function Spinner({ + className, + size, + role = "status", + "aria-label": ariaLabel = "Loading", + ...rest +}: SpinnerProps) { + return ( + + ); +} diff --git a/desktop/src/shared/ui/tabs.tsx b/desktop/src/shared/ui/tabs.tsx new file mode 100644 index 0000000000..4e18cccea3 --- /dev/null +++ b/desktop/src/shared/ui/tabs.tsx @@ -0,0 +1,53 @@ +import * as React from "react"; +import * as TabsPrimitive from "@radix-ui/react-tabs"; + +import { cn } from "@/shared/lib/cn"; + +const Tabs = TabsPrimitive.Root; + +const TabsList = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +TabsList.displayName = TabsPrimitive.List.displayName; + +const TabsTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +TabsTrigger.displayName = TabsPrimitive.Trigger.displayName; + +const TabsContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +TabsContent.displayName = TabsPrimitive.Content.displayName; + +export { Tabs, TabsList, TabsTrigger, TabsContent }; diff --git a/desktop/tests/e2e/agents.spec.ts b/desktop/tests/e2e/agents.spec.ts index 09dbfa9643..f335c4a1ea 100644 --- a/desktop/tests/e2e/agents.spec.ts +++ b/desktop/tests/e2e/agents.spec.ts @@ -171,8 +171,10 @@ test("built-in personas are chosen from the dialog and can be selected", async ( .getByTestId("persona-catalog-card-target-builtin:reviewer") .click(); await expect( - page.getByTestId("persona-catalog-feedback-notice"), - ).toContainText("Selected Reviewer for My Agents."); + page + .locator("[data-sonner-toast]") + .filter({ hasText: "Selected Reviewer for My Agents." }), + ).toBeVisible(); await expect(page.getByTestId("agents-library-personas")).toContainText( "Reviewer", @@ -186,8 +188,10 @@ test("built-in personas are chosen from the dialog and can be selected", async ( .getByTestId("persona-catalog-card-target-builtin:reviewer") .click(); await expect( - page.getByTestId("persona-catalog-feedback-notice"), - ).toContainText("Deselected Reviewer from My Agents."); + page + .locator("[data-sonner-toast]") + .filter({ hasText: "Deselected Reviewer from My Agents." }), + ).toBeVisible(); await expect(page.getByTestId("agents-library-personas")).not.toContainText( "Reviewer", ); @@ -228,8 +232,10 @@ test("persona catalog chooser order stays stable when selection changes", async await page.getByTestId("persona-catalog-card-target-builtin:solo").click(); await expect( - page.getByTestId("persona-catalog-feedback-notice"), - ).toContainText("Selected Solo for My Agents."); + page + .locator("[data-sonner-toast]") + .filter({ hasText: "Selected Solo for My Agents." }), + ).toBeVisible(); expect(await getCatalogOrder(page)).toEqual(before); }); @@ -313,8 +319,10 @@ test("built-in deselection failures show up in Persona Catalog", async ({ .click(); await expect( - page.getByTestId("persona-catalog-feedback-error"), - ).toContainText("Reviewer is still referenced by a team."); + page + .locator("[data-sonner-toast]") + .filter({ hasText: "Reviewer is still referenced by a team." }), + ).toBeVisible(); }); test("channel quick add falls back to added personas when defaults are absent", async ({ diff --git a/desktop/tests/e2e/channels.spec.ts b/desktop/tests/e2e/channels.spec.ts index d98c5d81cd..269f1f79f0 100644 --- a/desktop/tests/e2e/channels.spec.ts +++ b/desktop/tests/e2e/channels.spec.ts @@ -872,9 +872,11 @@ test("members sidebar can respawn a stopped managed bot", async ({ page }) => { await agentAction.click(); await expect(agentStatus).toContainText("Running"); - await expect(page.getByTestId("members-sidebar-action-notice")).toContainText( - `Respawned ${agentName}.`, - ); + await expect( + page + .locator("[data-sonner-toast]") + .filter({ hasText: `Respawned ${agentName}.` }), + ).toBeVisible(); const commands = await readCommandLog(page); expect( @@ -905,9 +907,11 @@ test("members sidebar supports bulk remove for managed bots", async ({ await page.getByTestId("members-sidebar-agent-controls").click(); await page.getByTestId("members-sidebar-remove-all").click(); - await expect(page.getByTestId("members-sidebar-action-notice")).toContainText( - "Removed 2 managed bots from this channel.", - ); + await expect( + page + .locator("[data-sonner-toast]") + .filter({ hasText: "Removed 2 managed bots from this channel." }), + ).toBeVisible(); await expect( page.getByTestId(`sidebar-member-${firstAgentPubkey}`), ).toHaveCount(0); diff --git a/rust-toolchain.toml b/rust-toolchain.toml index 02cb8fcb53..f92020c653 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,3 +1,3 @@ [toolchain] -channel = "stable" +channel = "1.95.0" profile = "default"