From d26fda9e4d3a51b832d5e9d3c6a7f5c1e09500f3 Mon Sep 17 00:00:00 2001
From: Wes
Date: Tue, 21 Apr 2026 14:03:48 -0600
Subject: [PATCH 1/3] refactor(desktop): standardize UI with shadcn components
(#379)
Co-authored-by: Claude Opus 4.6 (1M context)
---
desktop/package.json | 3 +
desktop/pnpm-lock.yaml | 122 ++++++-
desktop/src/features/agents/ui/CopyButton.tsx | 17 +-
.../features/agents/ui/ManagedAgentRow.tsx | 6 +-
.../agents/ui/ManagedAgentsSection.tsx | 21 +-
.../src/features/agents/ui/ModelPicker.tsx | 6 +-
.../agents/ui/PersonaCatalogDetailsSheet.tsx | 33 +-
.../agents/ui/PersonaCatalogSection.tsx | 30 +-
.../agents/ui/PersonaCatalogSurface.tsx | 7 +-
.../features/agents/ui/PersonasSection.tsx | 35 +-
.../agents/ui/RelayDirectorySection.tsx | 5 +-
.../src/features/agents/ui/TeamsSection.tsx | 15 +-
.../features/channels/ui/MembersSidebar.tsx | 16 +-
.../src/features/channels/ui/QuickBotBar.tsx | 4 +-
.../features/huddle/components/HuddleBar.tsx | 155 +++++----
.../huddle/components/HuddleIndicator.tsx | 47 +--
.../huddle/components/ParticipantList.tsx | 83 +++--
.../messages/ui/ComposerAttachments.tsx | 21 +-
.../messages/ui/ComposerEmojiPicker.tsx | 33 +-
.../src/features/messages/ui/DiffMessage.tsx | 26 +-
.../messages/ui/FormattingToolbar.tsx | 32 +-
.../features/messages/ui/MessageActionBar.tsx | 156 +++++----
.../messages/ui/MessageComposerToolbar.tsx | 170 ++++++----
.../src/features/messages/ui/MessageRow.tsx | 13 +-
.../features/messages/ui/MessageTimeline.tsx | 5 +-
.../src/features/profile/ui/ProfileAvatar.tsx | 60 ++--
desktop/src/features/pulse/ui/PulseView.tsx | 319 ++++++++----------
.../settings/ui/MobilePairingCard.tsx | 20 +-
.../features/tokens/ui/TokenSettingsCard.tsx | 21 +-
.../features/workflows/ui/WorkflowsView.tsx | 8 +-
desktop/src/main.tsx | 7 +-
desktop/src/shared/hooks/useToastEffect.ts | 36 ++
desktop/src/shared/lib/initials.ts | 10 +
desktop/src/shared/ui/UserAvatar.tsx | 64 ++--
desktop/src/shared/ui/ViewLoadingFallback.tsx | 33 +-
desktop/src/shared/ui/avatar.tsx | 48 +++
desktop/src/shared/ui/card.tsx | 83 +++++
desktop/src/shared/ui/import-status-icon.tsx | 8 +-
desktop/src/shared/ui/sonner.tsx | 30 ++
desktop/src/shared/ui/spinner.tsx | 26 ++
desktop/src/shared/ui/tabs.tsx | 53 +++
desktop/tests/e2e/agents.spec.ts | 24 +-
desktop/tests/e2e/channels.spec.ts | 16 +-
43 files changed, 1132 insertions(+), 795 deletions(-)
create mode 100644 desktop/src/shared/hooks/useToastEffect.ts
create mode 100644 desktop/src/shared/lib/initials.ts
create mode 100644 desktop/src/shared/ui/avatar.tsx
create mode 100644 desktop/src/shared/ui/card.tsx
create mode 100644 desktop/src/shared/ui/sonner.tsx
create mode 100644 desktop/src/shared/ui/spinner.tsx
create mode 100644 desktop/src/shared/ui/tabs.tsx
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/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..3ba0dccc92 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 ? (
@@ -186,18 +191,6 @@ export function ManagedAgentsSection({
{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 ? (
<>
-
+
+
+ {
+ onReply?.(message);
+ }}
+ size="sm"
+ type="button"
+ variant={isReplyingToMessage ? "secondary" : "ghost"}
+ >
+
+
+
+
+ {isReplyingToMessage ? "Cancel reply" : "Reply"}
+
+
) : null}
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
+
- onFormattingToggle(false)}
- size="icon"
- title="Close formatting"
- type="button"
- variant="ghost"
- className="h-7 w-7 shrink-0"
- >
-
-
+
+
+ onFormattingToggle(false)}
+ size="icon"
+ type="button"
+ variant="ghost"
+ className="h-7 w-7 shrink-0"
+ >
+
+
+
+ Close formatting
+
-
-
-
-
- {isUploading ? (
-
- ) : (
-
- )}
-
+
+
+
+
+
+
+ Mention someone
+
+
+
+
+ {isUploading ? (
+
+ ) : (
+
+ )}
+
+
+ Attach image
+
-
-
-
+
+
+
+
+
+
+ Formatting
+
)}
@@ -206,16 +224,20 @@ export const MessageComposerToolbar = React.memo(
{extraActions}
-
-
- {isSending ? "Sending" : "Send"}
-
+
+
+
+
+ {isSending ? "Sending" : "Send"}
+
+
+ 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/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 (
-
{
- 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 (
-
-
setOpen(!open)}
- size="sm"
- variant={selectedPubkey ? "secondary" : "ghost"}
- >
-
- {selectedName ?? "All agents"}
-
- {open ? (
-
- {
- onSelect(null);
- setOpen(false);
- }}
- type="button"
- >
- All agents
-
- {agents.map((agent) => {
- const name =
- profiles[agent.pubkey.toLowerCase()]?.displayName ??
- agent.name ??
- `${agent.pubkey.slice(0, 8)}...`;
- return (
- {
- onSelect(agent.pubkey);
- setOpen(false);
- }}
- type="button"
- >
- {name}
-
- );
- })}
-
- ) : null}
-
+
+
+
+
+ {selectedName ?? "All agents"}
+
+
+
+ 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 (
-
- {label}
- {count != null && count > 0 ? (
-
- {count}
-
- ) : null}
-
- );
-}
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}
- {copied ? (
-
- ) : (
-
- )}
+
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 (
-
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);
From 4079e483a4de59fd033d1f4b19f60bb28384b53e Mon Sep 17 00:00:00 2001
From: Wes
Date: Tue, 21 Apr 2026 15:29:15 -0600
Subject: [PATCH 2/3] fix(desktop): persist chat drafts across section
navigation (#381)
Co-authored-by: Claude Opus 4.6 (1M context)
---
.../src/features/messages/lib/useDrafts.ts | 31 +++++++++++-----
.../features/messages/ui/MessageComposer.tsx | 36 ++++++++++---------
.../messages/ui/MessageThreadPanel.tsx | 1 +
3 files changed, 43 insertions(+), 25 deletions(-)
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/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/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}
From 3df7e7107f106690e83c1ae283abd3a55466d243 Mon Sep 17 00:00:00 2001
From: Wes
Date: Tue, 21 Apr 2026 15:56:00 -0600
Subject: [PATCH 3/3] Fix zombie agent detection and surface stopped agent
cleanup (#380)
Co-authored-by: Claude Opus 4.6 (1M context)
---
crates/sprout-relay/src/api/messages.rs | 7 ++-----
crates/sprout-relay/src/handlers/ingest.rs | 12 ++++--------
.../src-tauri/src/managed_agents/runtime.rs | 4 ++--
.../agents/ui/ManagedAgentsSection.tsx | 18 ++++++++++++++++++
rust-toolchain.toml | 2 +-
5 files changed, 27 insertions(+), 16 deletions(-)
diff --git a/crates/sprout-relay/src/api/messages.rs b/crates/sprout-relay/src/api/messages.rs
index ef80c0d80c..bcd9d8478f 100644
--- a/crates/sprout-relay/src/api/messages.rs
+++ b/crates/sprout-relay/src/api/messages.rs
@@ -129,11 +129,8 @@ 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) {
- return Err("imeta bitrate must be a positive integer".into());
- }
+ "bitrate" if value.parse::().map_or(true, |b| b == 0) => {
+ return Err("imeta bitrate must be a positive integer".into());
}
"image" => {
// NIP-71 poster frame — must be a local media URL with an image extension.
diff --git a/crates/sprout-relay/src/handlers/ingest.rs b/crates/sprout-relay/src/handlers/ingest.rs
index 60c5f8c834..72fbe6c2a0 100644
--- a/crates/sprout-relay/src/handlers/ingest.rs
+++ b/crates/sprout-relay/src/handlers/ingest.rs
@@ -703,15 +703,11 @@ fn validate_diff_event(event: &Event) -> Result<(), String> {
return Err("parent-commit SHA must be at least 7 hex characters".to_string());
}
}
- "branch" => {
- if parts.len() < 3 || parts[1].is_empty() || parts[2].is_empty() {
- return Err("branch tag requires both source and target".to_string());
- }
+ "branch" if (parts.len() < 3 || parts[1].is_empty() || parts[2].is_empty()) => {
+ return Err("branch tag requires both source and target".to_string());
}
- "pr" => {
- if parts[1].parse::().map(|n| n == 0).unwrap_or(true) {
- return Err("pr number must be a positive integer".to_string());
- }
+ "pr" if parts[1].parse::().map(|n| n == 0).unwrap_or(true) => {
+ return Err("pr number must be a positive integer".to_string());
}
_ => {}
}
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/ManagedAgentsSection.tsx b/desktop/src/features/agents/ui/ManagedAgentsSection.tsx
index 3ba0dccc92..49fcd67e39 100644
--- a/desktop/src/features/agents/ui/ManagedAgentsSection.tsx
+++ b/desktop/src/features/agents/ui/ManagedAgentsSection.tsx
@@ -186,6 +186,24 @@ export function ManagedAgentsSection({
) : null}
+ {!isLoading && stoppedCount > 0 ? (
+
+
+ {stoppedCount} stopped {stoppedCount === 1 ? "agent" : "agents"}
+
+
+
+ Remove stopped
+
+
+ ) : null}
+
{error ? (
{error.message}
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"