merge: resolve conflict with main (clippy fix already applied upstream)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Wes
2026-04-21 16:15:20 -07:00
49 changed files with 1197 additions and 827 deletions

View File

@@ -129,10 +129,7 @@ pub fn validate_imeta_tags(tags: &[Vec<String>], 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::<u64>().map_or(true, |b| b == 0) =>
{
"bitrate" if value.parse::<u64>().map_or(true, |b| b == 0) => {
return Err("imeta bitrate must be a positive integer".into());
}
"image" => {

View File

@@ -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"

122
desktop/pnpm-lock.yaml generated
View File

@@ -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: {}

View File

@@ -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(());

View File

@@ -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 (
<Button
onClick={async () => {
await navigator.clipboard.writeText(value);
setCopied(true);
window.setTimeout(() => setCopied(false), 1_500);
toast.success("Copied to clipboard");
}}
size="sm"
type="button"
variant="outline"
>
{copied ? (
<Check className="h-3.5 w-3.5" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
<span>{copied ? "Copied" : (label ?? "Copy")}</span>
<Copy className="h-3.5 w-3.5" />
<span>{label ?? "Copy"}</span>
</Button>
);
}

View File

@@ -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({
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => navigator.clipboard.writeText(agent.pubkey)}
onClick={async () => {
await navigator.clipboard.writeText(agent.pubkey);
toast.success("Copied pubkey to clipboard");
}}
>
<Clipboard className="h-4 w-4" />
Copy pubkey

View File

@@ -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 (
<section className="space-y-4">
<div className="flex items-center justify-between gap-3">
@@ -123,7 +128,7 @@ export function ManagedAgentsSection({
</div>
{isLoading ? (
<div className="overflow-hidden rounded-xl border border-border/70 bg-card/80 shadow-sm">
<Card className="overflow-hidden">
{["first", "second"].map((key) => (
<div
className="flex items-center gap-4 border-b border-border/60 px-4 py-3 last:border-b-0"
@@ -135,7 +140,7 @@ export function ManagedAgentsSection({
<Skeleton className="h-4 w-20" />
</div>
))}
</div>
</Card>
) : null}
{!isLoading && agents.length === 0 ? (
@@ -181,23 +186,29 @@ export function ManagedAgentsSection({
</div>
) : null}
{!isLoading && stoppedCount > 0 ? (
<div className="flex items-center justify-between rounded-xl border border-border/60 bg-muted/30 px-4 py-2.5">
<p className="text-sm text-muted-foreground">
{stoppedCount} stopped {stoppedCount === 1 ? "agent" : "agents"}
</p>
<Button
className="text-destructive"
disabled={isActionPending}
onClick={onBulkRemoveStopped}
size="sm"
variant="ghost"
>
<Trash2 className="mr-1.5 h-3.5 w-3.5" />
Remove stopped
</Button>
</div>
) : null}
{error ? (
<p className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{error.message}
</p>
) : null}
{actionNoticeMessage ? (
<p className="rounded-2xl border border-primary/20 bg-primary/10 px-4 py-3 text-sm text-primary">
{actionNoticeMessage}
</p>
) : null}
{actionErrorMessage ? (
<p className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{actionErrorMessage}
</p>
) : null}
</section>
);
}

View File

@@ -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 ? (
<div className="flex items-center gap-2 px-3 py-2 text-sm text-muted-foreground">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
<Spinner className="h-3.5 w-3.5" />
Loading models...
</div>
) : error ? (

View File

@@ -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({
</div>
</button>
{feedbackNoticeMessage ? (
<p className="rounded-2xl border border-primary/20 bg-primary/10 px-4 py-3 text-sm text-primary">
{feedbackNoticeMessage}
</p>
) : null}
{feedbackErrorMessage ? (
<p className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{feedbackErrorMessage}
</p>
) : null}
<div className="grid gap-3 sm:grid-cols-2">
<div className="rounded-xl border border-border/70 bg-card/70 p-4">
<Card className="p-4">
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-muted-foreground">
Type
</p>
<p className="mt-2 text-sm font-medium">Built-in persona</p>
</div>
<div className="rounded-xl border border-border/70 bg-card/70 p-4">
</Card>
<Card className="p-4">
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-muted-foreground">
Preferred model
</p>
<p className="mt-2 text-sm font-medium">
{persona.model ?? "Use app default"}
</p>
</div>
<div className="rounded-xl border border-border/70 bg-card/70 p-4 sm:col-span-2">
</Card>
<Card className="p-4 sm:col-span-2">
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-muted-foreground">
Preferred provider
</p>
<p className="mt-2 text-sm font-medium">
{persona.provider ?? "Use app default"}
</p>
</div>
</Card>
</div>
<div className="rounded-xl border border-border/70 bg-card/70 p-4">
<Card className="p-4">
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-muted-foreground">
System prompt
</p>
<pre className="mt-3 whitespace-pre-wrap break-words font-sans text-sm leading-6 text-foreground">
{persona.systemPrompt}
</pre>
</div>
</Card>
</div>
) : null}
</SheetContent>

View File

@@ -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 ? (
<div className="grid gap-3 md:grid-cols-3 xl:grid-cols-4">
{["first", "second", "third", "fourth"].map((key) => (
<div
className="rounded-xl border border-border/70 bg-card/80 p-3 shadow-sm"
key={key}
>
<Card className="p-3" key={key}>
<div className="flex items-center gap-2.5">
<Skeleton className="h-8 w-8 rounded-lg" />
<div className="space-y-2">
@@ -66,7 +60,7 @@ export function PersonaCatalogSection({
<Skeleton className="h-3 w-20" />
</div>
</div>
</div>
</Card>
))}
</div>
) : null}
@@ -167,24 +161,6 @@ export function PersonaCatalogSection({
{error.message}
</p>
) : null}
{feedbackNoticeMessage ? (
<p
className="rounded-2xl border border-primary/20 bg-primary/10 px-4 py-3 text-sm text-primary"
data-testid="persona-catalog-feedback-notice"
>
{feedbackNoticeMessage}
</p>
) : null}
{feedbackErrorMessage ? (
<p
className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive"
data-testid="persona-catalog-feedback-error"
>
{feedbackErrorMessage}
</p>
) : null}
</section>
);
}

View File

@@ -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 (
<>
<PersonaCatalogSection
error={error}
feedbackErrorMessage={feedbackErrorMessage}
feedbackNoticeMessage={feedbackNoticeMessage}
isLoading={isLoading}
isPending={isPending}
onTogglePersona={handleTogglePersona}
@@ -61,8 +62,6 @@ export function PersonaCatalogSurface({
/>
<PersonaCatalogDetailsSheet
feedbackErrorMessage={feedbackErrorMessage}
feedbackNoticeMessage={feedbackNoticeMessage}
isPending={isPending}
onOpenChange={(open) => {
if (!open) {

View File

@@ -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 (
<section
className="relative space-y-4"
@@ -107,10 +111,7 @@ export function PersonasSection({
{isLoading ? (
<div className="grid gap-3 md:grid-cols-3 xl:grid-cols-4">
{["first", "second", "third", "fourth"].map((key) => (
<div
className="rounded-xl border border-border/70 bg-card/80 p-2 shadow-sm"
key={key}
>
<Card className="p-2" key={key}>
<div className="flex items-center gap-2.5">
<Skeleton className="h-8 w-8 rounded-lg" />
<div className="space-y-2">
@@ -118,7 +119,7 @@ export function PersonasSection({
<Skeleton className="h-3 w-14 rounded-full" />
</div>
</div>
</div>
</Card>
))}
</div>
) : null}
@@ -126,8 +127,8 @@ export function PersonasSection({
{!isLoading && personas.length > 0 ? (
<div className="grid gap-3 md:grid-cols-3 xl:grid-cols-4">
{personas.map((persona) => (
<div
className="overflow-hidden rounded-xl border border-border/70 bg-card/80 p-2 shadow-sm"
<Card
className="overflow-hidden p-2"
data-testid={`library-persona-${persona.id}`}
key={persona.id}
>
@@ -199,7 +200,7 @@ export function PersonasSection({
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</Card>
))}
<button
className="flex cursor-pointer items-center justify-center gap-2 rounded-xl border border-dashed border-primary p-2 text-primary transition-colors hover:bg-primary/5"
@@ -241,24 +242,6 @@ export function PersonasSection({
{error.message}
</p>
) : null}
{feedbackNoticeMessage ? (
<p
className="rounded-2xl border border-primary/20 bg-primary/10 px-4 py-3 text-sm text-primary"
data-testid="personas-library-feedback-notice"
>
{feedbackNoticeMessage}
</p>
) : null}
{feedbackErrorMessage ? (
<p
className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive"
data-testid="personas-library-feedback-error"
>
{feedbackErrorMessage}
</p>
) : null}
</section>
);
}

View File

@@ -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."}
</p>
) : (
<div className="overflow-hidden rounded-xl border border-border/70 bg-card/80 shadow-sm">
<Card className="overflow-hidden">
<div className="overflow-x-auto">
<table
className="w-full border-collapse text-left text-sm"
@@ -139,7 +140,7 @@ export function RelayDirectorySection({
</tbody>
</table>
</div>
</div>
</Card>
)}
</>
) : null}

View File

@@ -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 ? (
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{["first", "second", "third"].map((key) => (
<div
className="rounded-xl border border-border/70 bg-card/80 p-3 shadow-sm"
key={key}
>
<Card className="p-3" key={key}>
<div className="flex items-center gap-2.5">
<Skeleton className="h-8 w-8 rounded-lg" />
<div className="space-y-2">
@@ -113,7 +111,7 @@ export function TeamsSection({
<Skeleton className="h-3 w-20 rounded-full" />
</div>
</div>
</div>
</Card>
))}
</div>
) : null}
@@ -132,10 +130,7 @@ export function TeamsSection({
const hasMissingPersonas = resolution.hasMissingPersonas;
return (
<div
className="rounded-xl border border-border/70 bg-card/80 p-3 shadow-sm"
key={team.id}
>
<Card className="p-3" key={team.id}>
<div className="flex items-start justify-between gap-2.5">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
@@ -248,7 +243,7 @@ export function TeamsSection({
exporting.
</p>
) : null}
</div>
</Card>
);
})}
<button

View File

@@ -10,6 +10,7 @@ import { useUsersBatchQuery } from "@/features/profile/hooks";
import { usePresenceQuery } from "@/features/presence/hooks";
import { changeChannelMemberRole } from "@/shared/api/tauri";
import type { Channel, ChannelMember } from "@/shared/api/types";
import { useFeedbackToasts } from "@/shared/hooks/useToastEffect";
import { normalizePubkey } from "@/shared/lib/pubkey";
import {
Sheet,
@@ -138,6 +139,8 @@ export function MembersSidebar({
onOpenChange,
});
useFeedbackToasts(actionNoticeMessage, actionErrorMessage);
if (!channel) {
return null;
}
@@ -263,21 +266,12 @@ export function MembersSidebar({
</div>
</section>
{actionNoticeMessage ? (
<p
className="text-sm text-muted-foreground"
data-testid="members-sidebar-action-notice"
>
{actionNoticeMessage}
</p>
) : null}
{actionErrorMessage || changeRoleError ? (
{changeRoleError ? (
<p
className="text-sm text-destructive"
data-testid="members-sidebar-action-error"
>
{actionErrorMessage ?? changeRoleError}
{changeRoleError}
</p>
) : null}
</div>

View File

@@ -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 ? (
<div className="absolute inset-0 flex items-center justify-center rounded-full bg-background/70">
<Loader2 className="h-3.5 w-3.5 animate-spin text-primary" />
<Spinner className="h-3.5 w-3.5 text-primary" />
</div>
) : null}
</button>

View File

@@ -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 ? (
<>
<Button
aria-label={
isPttMode
? "Push to Talk mode — click to switch to Auto"
: "Auto mode — click to switch to Push to Talk"
}
className="h-7 gap-1.5 px-2 text-xs"
onClick={() =>
void setVoiceInputMode(
isPttMode ? "voice_activity" : "push_to_talk",
)
}
size="sm"
variant="ghost"
title={
isPttMode
<Tooltip>
<TooltipTrigger asChild>
<Button
aria-label={
isPttMode
? "Push to Talk mode — click to switch to Auto"
: "Auto mode — click to switch to Push to Talk"
}
className="h-7 gap-1.5 px-2 text-xs"
onClick={() =>
void setVoiceInputMode(
isPttMode ? "voice_activity" : "push_to_talk",
)
}
size="sm"
variant="ghost"
>
{isPttMode ? (
<>
<div
className={cn(
"h-2 w-2 rounded-full transition-colors",
pttActive && !isMuted
? "animate-pulse bg-green-500"
: "bg-zinc-500",
)}
/>
Push to Talk
</>
) : (
<>
<div
className="h-2 w-2 rounded-full transition-colors"
style={{
backgroundColor:
micLevel > 0.05
? `rgba(34, 197, 94, ${0.4 + micLevel * 0.6})`
: "rgba(100, 116, 139, 0.4)",
}}
/>
Auto
</>
)}
</Button>
</TooltipTrigger>
<TooltipContent>
{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 ? (
<>
<div
className={cn(
"h-2 w-2 rounded-full transition-colors",
pttActive && !isMuted
? "animate-pulse bg-green-500"
: "bg-zinc-500",
)}
/>
Push to Talk
</>
) : (
<>
<div
className="h-2 w-2 rounded-full transition-colors"
style={{
backgroundColor:
micLevel > 0.05
? `rgba(34, 197, 94, ${0.4 + micLevel * 0.6})`
: "rgba(100, 116, 139, 0.4)",
}}
/>
Auto
</>
)}
</Button>
: "Auto — mic is always live. Click to switch to Push to Talk."}
</TooltipContent>
</Tooltip>
{isPttMode && (
<kbd className="rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">
{navigator.platform?.includes("Mac") ? "⌃Space" : "Ctrl+Space"}
@@ -414,32 +419,40 @@ export function HuddleBar({ className }: HuddleBarProps) {
{/* Leave / End buttons — pushed to the right */}
<div className="ml-auto flex items-center gap-2">
<Button
aria-label="Leave huddle"
className="h-8 gap-1.5 px-3"
disabled={isLeaving}
aria-busy={isLeaving}
onClick={() => void handleLeave()}
size="sm"
variant="destructive"
title="Leave huddle"
>
<PhoneOff className="h-4 w-4" />
Leave
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
aria-label="Leave huddle"
className="h-8 gap-1.5 px-3"
disabled={isLeaving}
aria-busy={isLeaving}
onClick={() => void handleLeave()}
size="sm"
variant="destructive"
>
<PhoneOff className="h-4 w-4" />
Leave
</Button>
</TooltipTrigger>
<TooltipContent>Leave huddle</TooltipContent>
</Tooltip>
{state?.is_creator && (
<Button
aria-label="End huddle for everyone"
className="h-8 gap-1.5 px-3"
disabled={isLeaving}
onClick={() => void handleEnd()}
size="sm"
variant="destructive"
title="End huddle for everyone"
>
<PhoneOff className="h-4 w-4" />
End for all
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
aria-label="End huddle for everyone"
className="h-8 gap-1.5 px-3"
disabled={isLeaving}
onClick={() => void handleEnd()}
size="sm"
variant="destructive"
>
<PhoneOff className="h-4 w-4" />
End for all
</Button>
</TooltipTrigger>
<TooltipContent>End huddle for everyone</TooltipContent>
</Tooltip>
)}
</div>

View File

@@ -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 (
<Button
aria-label={`Join active huddle (${participantCount} participant${participantCount !== 1 ? "s" : ""})`}
className={cn("h-9 w-9 rounded-full relative", className)}
disabled={isJoining || isStarting}
onClick={() => void handleJoin()}
size="icon"
type="button"
variant="outline"
title={`Huddle active — ${participantCount} participant${participantCount !== 1 ? "s" : ""}`}
>
<Headphones className="h-4 w-4 text-green-500" />
{/* Green glow effect */}
<span className="absolute inset-0 animate-pulse rounded-full ring-2 ring-green-500/40" />
{/* Participant count badge */}
{participantCount > 0 && (
<span className="absolute -right-1 -top-1 flex h-4 min-w-4 items-center justify-center rounded-full bg-green-500 px-0.5 text-[10px] font-bold text-white">
{participantCount}
</span>
)}
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
aria-label={`Join active huddle (${participantCount} participant${participantCount !== 1 ? "s" : ""})`}
className={cn("h-9 w-9 rounded-full relative", className)}
disabled={isJoining || isStarting}
onClick={() => void handleJoin()}
size="icon"
type="button"
variant="outline"
>
<Headphones className="h-4 w-4 text-green-500" />
{/* Green glow effect */}
<span className="absolute inset-0 animate-pulse rounded-full ring-2 ring-green-500/40" />
{/* Participant count badge */}
{participantCount > 0 && (
<span className="absolute -right-1 -top-1 flex h-4 min-w-4 items-center justify-center rounded-full bg-green-500 px-0.5 text-[10px] font-bold text-white">
{participantCount}
</span>
)}
</Button>
</TooltipTrigger>
<TooltipContent>
{`Huddle active — ${participantCount} participant${participantCount !== 1 ? "s" : ""}`}
</TooltipContent>
</Tooltip>
);
}

View File

@@ -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 (
<li key={pubkey} className="group/participant relative">
{hasProfile ? (
<div
aria-label={ariaLabel}
role="img"
title={profile.displayName || pubkey}
>
<ProfileAvatar
avatarUrl={profile.avatarUrl ?? null}
label={profile.displayName || pubkey.slice(0, 6)}
className={cn(
"h-7 w-7 rounded-lg text-[9px]",
isActive &&
"ring-2 ring-green-500 ring-offset-1 ring-offset-background",
)}
/>
</div>
<Tooltip>
<TooltipTrigger asChild>
<div aria-label={ariaLabel} role="img">
<ProfileAvatar
avatarUrl={profile.avatarUrl ?? null}
label={profile.displayName || pubkey.slice(0, 6)}
className={cn(
"h-7 w-7 rounded-lg text-[9px]",
isActive &&
"ring-2 ring-green-500 ring-offset-1 ring-offset-background",
)}
/>
</div>
</TooltipTrigger>
<TooltipContent>{profile.displayName || pubkey}</TooltipContent>
</Tooltip>
) : (
<HexAvatar
pubkey={pubkey}
@@ -77,13 +79,17 @@ export function ParticipantList({
<X className="h-2.5 w-2.5" />
</button>
) : (
<span
aria-hidden="true"
className="absolute -bottom-1 -right-1 text-[9px] leading-none"
title="Agent"
>
🤖
</span>
<Tooltip>
<TooltipTrigger asChild>
<span
aria-hidden="true"
className="absolute -bottom-1 -right-1 text-[9px] leading-none"
>
🤖
</span>
</TooltipTrigger>
<TooltipContent>Agent</TooltipContent>
</Tooltip>
))}
</li>
);
@@ -109,18 +115,25 @@ function HexAvatar({
const isActive = activeSpeakers?.includes(pubkey);
return (
<div
aria-label={ariaLabel ?? `Participant ${pubkey.slice(0, 8)}`}
role="img"
className={cn(
"flex h-7 w-7 items-center justify-center rounded-lg text-[9px] font-semibold shadow-sm",
isActive &&
"ring-2 ring-green-500 ring-offset-1 ring-offset-background",
)}
style={{ backgroundColor: `hsl(${hue}, ${sat}%, 55%)`, color: "#fff" }}
title={pubkey}
>
{shortId}
</div>
<Tooltip>
<TooltipTrigger asChild>
<div
aria-label={ariaLabel ?? `Participant ${pubkey.slice(0, 8)}`}
role="img"
className={cn(
"flex h-7 w-7 items-center justify-center rounded-lg text-[9px] font-semibold shadow-sm",
isActive &&
"ring-2 ring-green-500 ring-offset-1 ring-offset-background",
)}
style={{
backgroundColor: `hsl(${hue}, ${sat}%, 55%)`,
color: "#fff",
}}
>
{shortId}
</div>
</TooltipTrigger>
<TooltipContent>{pubkey}</TooltipContent>
</Tooltip>
);
}

View File

@@ -8,31 +8,46 @@ export type DraftState = {
selectionEnd: number;
};
export function useDrafts() {
const draftsRef = React.useRef(new Map<string, DraftState>());
const sharedDrafts = new Map<string, DraftState>();
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 };
}

View File

@@ -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({
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
</DialogPrimitive.Root>
<button
type="button"
onClick={() => onRemove(attachment.url)}
className="absolute -right-1 -top-1 hidden h-4 w-4 items-center justify-center rounded-full bg-foreground text-background group-hover:flex"
title="Remove attachment"
>
<X className="h-2.5 w-2.5" />
</button>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => onRemove(attachment.url)}
className="absolute -right-1 -top-1 hidden h-4 w-4 items-center justify-center rounded-full bg-foreground text-background group-hover:flex"
>
<X className="h-2.5 w-2.5" />
</button>
</TooltipTrigger>
<TooltipContent>Remove attachment</TooltipContent>
</Tooltip>
</div>
</motion.div>
);

View File

@@ -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 (
<Popover onOpenChange={onOpenChange} open={open}>
<PopoverTrigger asChild>
<Button
aria-label="Insert emoji"
data-testid="composer-emoji-button"
disabled={disabled}
onMouseDown={onTriggerMouseDown}
size="icon"
title="Insert emoji"
type="button"
variant="ghost"
>
<SmilePlus className="h-4 w-4" />
</Button>
</PopoverTrigger>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button
aria-label="Insert emoji"
data-testid="composer-emoji-button"
disabled={disabled}
onMouseDown={onTriggerMouseDown}
size="icon"
type="button"
variant="ghost"
>
<SmilePlus className="h-4 w-4" />
</Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent>Insert emoji</TooltipContent>
</Tooltip>
<PopoverContent
align="start"
className="w-auto p-0 rounded-2xl overflow-hidden border-0 bg-transparent shadow-none"

View File

@@ -1,6 +1,7 @@
import { FileDiff, Maximize2 } from "lucide-react";
import { Button } from "@/shared/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
import { DiffViewer } from "./DiffViewer";
type DiffMessageProps = {
@@ -84,16 +85,21 @@ export default function DiffMessage({
</span>
)}
{onExpand && (
<Button
className="h-6 w-6 p-0 text-muted-foreground hover:text-foreground"
onClick={onExpand}
size="sm"
title="Expand diff"
type="button"
variant="ghost"
>
<Maximize2 className="h-3.5 w-3.5" />
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
aria-label="Expand diff"
className="h-6 w-6 p-0 text-muted-foreground hover:text-foreground"
onClick={onExpand}
size="sm"
type="button"
variant="ghost"
>
<Maximize2 className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>Expand diff</TooltipContent>
</Tooltip>
)}
</div>

View File

@@ -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 (
<div className="flex items-center gap-0.5">
{items.map((item) => (
<Toggle
key={item.label}
aria-label={item.label}
disabled={disabled}
pressed={item.active}
onPressedChange={() => 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.icon className="h-3.5 w-3.5" />
</Toggle>
<Tooltip key={item.label}>
<TooltipTrigger asChild>
<Toggle
aria-label={item.label}
disabled={disabled}
pressed={item.active}
onPressedChange={() => item.action()}
className="h-7 w-7 min-w-7 [&_svg]:size-3.5"
>
<item.icon className="h-3.5 w-3.5" />
</Toggle>
</TooltipTrigger>
<TooltipContent>
{"shortcut" in item
? `${item.label} (${item.shortcut})`
: item.label}
</TooltipContent>
</Tooltip>
))}
</div>
);

View File

@@ -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}
>
<PopoverTrigger asChild>
<Button
aria-label="Open reactions"
className="h-6 w-6 rounded-full p-0"
data-testid={`react-message-${message.id}`}
disabled={reactionPending}
size="sm"
title="React"
type="button"
variant={
isReactionPickerOpen || selectedReactionCount > 0
? "secondary"
: "ghost"
}
>
{reactionPending ? (
<LoaderCircle className="h-3 w-3 animate-spin" />
) : (
<SmilePlus className="h-3 w-3" />
)}
</Button>
</PopoverTrigger>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button
aria-label="Open reactions"
className="h-6 w-6 rounded-full p-0"
data-testid={`react-message-${message.id}`}
disabled={reactionPending}
size="sm"
type="button"
variant={
isReactionPickerOpen || selectedReactionCount > 0
? "secondary"
: "ghost"
}
>
{reactionPending ? (
<Spinner className="h-3 w-3" />
) : (
<SmilePlus className="h-3 w-3" />
)}
</Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent>React</TooltipContent>
</Tooltip>
<PopoverContent
align="end"
className="w-auto p-0 rounded-2xl overflow-hidden border-0 bg-transparent shadow-none"
@@ -150,38 +150,46 @@ export function MessageActionBar({
) : null}
{hasEditAction ? (
<Button
aria-label="Edit"
className="h-6 w-6 rounded-full p-0"
data-testid={`edit-message-${message.id}`}
onClick={() => {
onEdit?.(message);
}}
size="sm"
title="Edit"
type="button"
variant="ghost"
>
<Pencil className="h-3 w-3" />
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
aria-label="Edit"
className="h-6 w-6 rounded-full p-0"
data-testid={`edit-message-${message.id}`}
onClick={() => {
onEdit?.(message);
}}
size="sm"
type="button"
variant="ghost"
>
<Pencil className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent>Edit</TooltipContent>
</Tooltip>
) : null}
{hasDeleteAction ? (
<>
<Button
aria-label="Delete"
className="h-6 w-6 rounded-full p-0"
data-testid={`delete-message-${message.id}`}
onClick={() => {
setIsDeleteDialogOpen(true);
}}
size="sm"
title="Delete"
type="button"
variant="ghost"
>
<Trash2 className="h-3 w-3" />
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
aria-label="Delete"
className="h-6 w-6 rounded-full p-0"
data-testid={`delete-message-${message.id}`}
onClick={() => {
setIsDeleteDialogOpen(true);
}}
size="sm"
type="button"
variant="ghost"
>
<Trash2 className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent>Delete</TooltipContent>
</Tooltip>
<AlertDialog
onOpenChange={setIsDeleteDialogOpen}
@@ -217,20 +225,26 @@ export function MessageActionBar({
) : null}
{hasReplyAction ? (
<Button
aria-label={isReplyingToMessage ? "Cancel reply" : "Reply"}
className="h-6 w-6 rounded-full p-0"
data-testid={`reply-message-${message.id}`}
onClick={() => {
onReply?.(message);
}}
size="sm"
title={isReplyingToMessage ? "Cancel reply" : "Reply"}
type="button"
variant={isReplyingToMessage ? "secondary" : "ghost"}
>
<CornerUpLeft className="h-3 w-3" />
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
aria-label={isReplyingToMessage ? "Cancel reply" : "Reply"}
className="h-6 w-6 rounded-full p-0"
data-testid={`reply-message-${message.id}`}
onClick={() => {
onReply?.(message);
}}
size="sm"
type="button"
variant={isReplyingToMessage ? "secondary" : "ghost"}
>
<CornerUpLeft className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent>
{isReplyingToMessage ? "Cancel reply" : "Reply"}
</TooltipContent>
</Tooltip>
) : null}
</div>
</div>

View File

@@ -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<string | null>(null);
const effectiveDraftKey = draftKey ?? channelId;
const previousDraftKeyRef = React.useRef<string | null>(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

View File

@@ -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}
>
<Toggle
aria-label="Toggle formatting"
disabled={composerDisabled}
pressed={isFormattingOpen}
onPressedChange={onFormattingToggle}
size="sm"
title="Formatting"
>
<ALargeSmall className="h-4 w-4" />
</Toggle>
<Tooltip>
<TooltipTrigger asChild>
<Toggle
aria-label="Toggle formatting"
disabled={composerDisabled}
pressed={isFormattingOpen}
onPressedChange={onFormattingToggle}
size="sm"
>
<ALargeSmall className="h-4 w-4" />
</Toggle>
</TooltipTrigger>
<TooltipContent>Formatting</TooltipContent>
</Tooltip>
</motion.div>
<motion.div
className="flex items-center gap-1"
@@ -104,18 +110,22 @@ export const MessageComposerToolbar = React.memo(
exit={{ opacity: 0, scale: 0.95 }}
transition={{ ...presenceSpring, delay: 0.15 }}
>
<Button
aria-label="Close formatting"
disabled={composerDisabled}
onClick={() => onFormattingToggle(false)}
size="icon"
title="Close formatting"
type="button"
variant="ghost"
className="h-7 w-7 shrink-0"
>
<X className="h-3.5 w-3.5" />
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
aria-label="Close formatting"
disabled={composerDisabled}
onClick={() => onFormattingToggle(false)}
size="icon"
type="button"
variant="ghost"
className="h-7 w-7 shrink-0"
>
<X className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>Close formatting</TooltipContent>
</Tooltip>
<div className="mx-1 h-5 w-px shrink-0 bg-border/60" />
</motion.div>
<motion.div
@@ -143,38 +153,42 @@ export const MessageComposerToolbar = React.memo(
exit={{ opacity: 0, x: -12 }}
transition={presenceSpring}
>
<Button
aria-label="Mention someone"
data-testid="message-insert-mention"
disabled={composerDisabled}
onClick={onOpenMentionPicker}
onMouseDown={onCaptureSelection}
size="icon"
title="Mention someone"
type="button"
variant="ghost"
>
<AtSign className="h-4 w-4" />
</Button>
<Button
aria-label="Attach image"
disabled={composerDisabled || isUploading}
onClick={onPaperclip}
size="icon"
title="Attach image"
type="button"
variant="ghost"
>
{isUploading ? (
<span
className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent"
role="status"
aria-label="Uploading"
/>
) : (
<Paperclip className="h-4 w-4" />
)}
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
aria-label="Mention someone"
data-testid="message-insert-mention"
disabled={composerDisabled}
onClick={onOpenMentionPicker}
onMouseDown={onCaptureSelection}
size="icon"
type="button"
variant="ghost"
>
<AtSign className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Mention someone</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
aria-label="Attach image"
disabled={composerDisabled || isUploading}
onClick={onPaperclip}
size="icon"
type="button"
variant="ghost"
>
{isUploading ? (
<Spinner className="h-4 w-4" />
) : (
<Paperclip className="h-4 w-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>Attach image</TooltipContent>
</Tooltip>
<ComposerEmojiPicker
disabled={composerDisabled}
onEmojiSelect={onEmojiSelect}
@@ -188,16 +202,20 @@ export const MessageComposerToolbar = React.memo(
exit={{ x: -8, opacity: 0 }}
transition={presenceSpring}
>
<Toggle
aria-label="Toggle formatting"
disabled={composerDisabled}
pressed={isFormattingOpen}
onPressedChange={onFormattingToggle}
size="sm"
title="Formatting"
>
<ALargeSmall className="h-4 w-4" />
</Toggle>
<Tooltip>
<TooltipTrigger asChild>
<Toggle
aria-label="Toggle formatting"
disabled={composerDisabled}
pressed={isFormattingOpen}
onPressedChange={onFormattingToggle}
size="sm"
>
<ALargeSmall className="h-4 w-4" />
</Toggle>
</TooltipTrigger>
<TooltipContent>Formatting</TooltipContent>
</Tooltip>
</motion.div>
</motion.div>
)}
@@ -206,16 +224,20 @@ export const MessageComposerToolbar = React.memo(
<div className="flex items-center gap-2">
{extraActions}
<Button
className="gap-2"
data-testid="send-message"
disabled={sendDisabled || isSending}
title="Send (Enter)"
type="submit"
>
<SendHorizontal className="h-4 w-4" />
{isSending ? "Sending" : "Send"}
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
className="gap-2"
data-testid="send-message"
disabled={sendDisabled || isSending}
type="submit"
>
<SendHorizontal className="h-4 w-4" />
{isSending ? "Sending" : "Send"}
</Button>
</TooltipTrigger>
<TooltipContent>Send (Enter)</TooltipContent>
</Tooltip>
</div>
</div>
);

View File

@@ -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(
</p>
) : null}
{message.edited ? (
<p
className="text-muted-foreground/70"
title="This message has been edited"
>
(edited)
</p>
<Tooltip>
<TooltipTrigger asChild>
<p className="text-muted-foreground/70">(edited)</p>
</TooltipTrigger>
<TooltipContent>This message has been edited</TooltipContent>
</Tooltip>
) : null}
<MessageTimestamp createdAt={message.createdAt} time={message.time} />
</div>

View File

@@ -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}

View File

@@ -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 ? (
<div className="flex justify-center py-2">
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
<Spinner className="h-4 w-4 text-muted-foreground" />
</div>
) : null}

View File

@@ -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<string | null>(
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 (
<img
alt={`${label} avatar`}
className={cn(baseClassName, "object-cover")}
data-testid={testId}
onError={() => {
setFailedAvatarUrl(avatarUrl);
}}
referrerPolicy="no-referrer"
src={rewriteRelayUrl(avatarUrl)}
/>
);
}
const initials = getInitials(label);
return (
<div className={cn(baseClassName, "font-semibold")} data-testid={testId}>
{initials.length > 0 ? initials : <UserRound className={iconClassName} />}
</div>
<Avatar
className={cn("shrink-0 bg-primary/20 text-primary shadow-sm", className)}
data-testid={testId}
>
{avatarUrl ? (
<AvatarImage
alt={`${label} avatar`}
className="object-cover"
referrerPolicy="no-referrer"
src={rewriteRelayUrl(avatarUrl)}
/>
) : null}
<AvatarFallback
className="bg-primary/20 font-semibold text-primary"
delayMs={200}
>
{initials.length > 0 ? (
initials
) : (
<UserRound className={iconClassName} />
)}
</AvatarFallback>
</Avatar>
);
}

View File

@@ -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<HTMLDivElement>(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 (
<div className="relative" ref={ref}>
<Button
className="h-7 gap-1.5 px-2 text-xs"
onClick={() => setOpen(!open)}
size="sm"
variant={selectedPubkey ? "secondary" : "ghost"}
>
<Filter className="h-3 w-3" />
{selectedName ?? "All agents"}
</Button>
{open ? (
<div className="absolute right-0 top-8 z-50 max-h-48 w-48 overflow-y-auto rounded-lg border border-border bg-popover p-1 shadow-lg">
<button
className={`w-full rounded-md px-2 py-1.5 text-left text-xs hover:bg-accent ${
!selectedPubkey ? "bg-accent font-medium" : ""
}`}
onClick={() => {
onSelect(null);
setOpen(false);
}}
type="button"
>
All agents
</button>
{agents.map((agent) => {
const name =
profiles[agent.pubkey.toLowerCase()]?.displayName ??
agent.name ??
`${agent.pubkey.slice(0, 8)}...`;
return (
<button
className={`w-full rounded-md px-2 py-1.5 text-left text-xs hover:bg-accent ${
selectedPubkey === agent.pubkey ? "bg-accent font-medium" : ""
}`}
key={agent.pubkey}
onClick={() => {
onSelect(agent.pubkey);
setOpen(false);
}}
type="button"
>
{name}
</button>
);
})}
</div>
) : null}
</div>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button
className="h-7 gap-1.5 px-2 text-xs"
size="sm"
variant={selectedPubkey ? "secondary" : "ghost"}
>
<Filter className="h-3 w-3" />
{selectedName ?? "All agents"}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="max-h-48 overflow-y-auto">
<DropdownMenuItem onClick={() => onSelect(null)}>
{!selectedPubkey ? (
<Check className="h-3.5 w-3.5" />
) : (
<span className="h-3.5 w-3.5" />
)}
All agents
</DropdownMenuItem>
{agents.map((agent) => {
const name =
profiles[agent.pubkey.toLowerCase()]?.displayName ??
agent.name ??
`${agent.pubkey.slice(0, 8)}...`;
const isSelected = selectedPubkey === agent.pubkey;
return (
<DropdownMenuItem
key={agent.pubkey}
onClick={() => onSelect(agent.pubkey)}
>
{isSelected ? (
<Check className="h-3.5 w-3.5" />
) : (
<span className="h-3.5 w-3.5" />
)}
{name}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
);
}
@@ -279,89 +269,103 @@ export function PulseView({ currentPubkey }: PulseViewProps) {
mine: "You haven't posted any notes yet.",
};
function renderTimeline() {
if (isLoading) return <TimelineSkeleton />;
if (activeTab === "agents") {
return agentNoteGroups.length === 0 ? (
<EmptyState message={emptyMessages.agents} />
) : (
agentNoteGroups.map((group) => (
<AgentActivityCard
agentStatus={agentStatusMap[group.pubkey]}
group={group}
key={`${group.pubkey}-${group.latestAt}`}
profile={profiles[group.pubkey.toLowerCase()] ?? null}
/>
))
);
}
return visibleNotes.length === 0 ? (
<EmptyState message={emptyMessages[activeTab]} />
) : (
visibleNotes.map((note) => (
<NoteCard
isAgent={agentPubkeySet.has(note.pubkey)}
isFollowing={followingSet.has(note.pubkey)}
isOwnNote={note.pubkey === currentPubkey}
key={note.id}
note={note}
onFollow={handleFollow}
onUnfollow={handleUnfollow}
profile={profiles[note.pubkey.toLowerCase()] ?? null}
/>
))
);
}
return (
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
{/* Tab bar */}
<div className="flex items-center gap-1 border-b border-border/60 px-4 sm:px-6">
<TabButton
active={activeTab === "foryou"}
label="For You"
onClick={() => setActiveTab("foryou")}
/>
<TabButton
active={activeTab === "people"}
label="People"
onClick={() => setActiveTab("people")}
/>
<TabButton
active={activeTab === "agents"}
count={relayAgents.length}
label="Agents"
onClick={() => setActiveTab("agents")}
/>
<TabButton
active={activeTab === "mine"}
label="My Notes"
onClick={() => setActiveTab("mine")}
/>
<Tabs
value={activeTab}
onValueChange={(v) => setActiveTab(v as PulseTab)}
className="flex min-h-0 flex-1 flex-col overflow-hidden"
>
{/* Tab bar */}
<div className="flex items-center gap-1 border-b border-border/60 px-4 sm:px-6">
<TabsList className="h-auto gap-1 rounded-none border-none bg-transparent p-0">
<TabsTrigger value="foryou" className={tabTriggerClassName}>
For You
</TabsTrigger>
<TabsTrigger value="people" className={tabTriggerClassName}>
People
</TabsTrigger>
<TabsTrigger value="agents" className={tabTriggerClassName}>
Agents
{relayAgents.length > 0 ? (
<span className="ml-1.5 inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-muted px-1 text-[10px] font-medium text-muted-foreground">
{relayAgents.length}
</span>
) : null}
</TabsTrigger>
<TabsTrigger value="mine" className={tabTriggerClassName}>
My Notes
</TabsTrigger>
</TabsList>
<div className="ml-auto flex items-center gap-1">
{activeTab === "agents" && relayAgents.length > 1 ? (
<AgentFilter
agents={relayAgents}
onSelect={setAgentFilter}
profiles={profiles}
selectedPubkey={agentFilter}
/>
) : null}
<Button
className="h-7 w-7"
disabled={isRefetching}
onClick={handleRefresh}
size="icon"
variant="ghost"
>
<RefreshCw
className={`h-3.5 w-3.5 ${isRefetching ? "animate-spin" : ""}`}
/>
</Button>
</div>
</div>
{/* Timeline */}
<div className="min-h-0 flex-1 overflow-y-auto">
{isLoading ? (
<TimelineSkeleton />
) : activeTab === "agents" ? (
agentNoteGroups.length === 0 ? (
<EmptyState message={emptyMessages.agents} />
) : (
agentNoteGroups.map((group) => (
<AgentActivityCard
agentStatus={agentStatusMap[group.pubkey]}
group={group}
key={`${group.pubkey}-${group.latestAt}`}
profile={profiles[group.pubkey.toLowerCase()] ?? null}
<div className="ml-auto flex items-center gap-1">
{activeTab === "agents" && relayAgents.length > 1 ? (
<AgentFilter
agents={relayAgents}
onSelect={setAgentFilter}
profiles={profiles}
selectedPubkey={agentFilter}
/>
))
)
) : visibleNotes.length === 0 ? (
<EmptyState message={emptyMessages[activeTab]} />
) : (
visibleNotes.map((note) => (
<NoteCard
isAgent={agentPubkeySet.has(note.pubkey)}
isFollowing={followingSet.has(note.pubkey)}
isOwnNote={note.pubkey === currentPubkey}
key={note.id}
note={note}
onFollow={handleFollow}
onUnfollow={handleUnfollow}
profile={profiles[note.pubkey.toLowerCase()] ?? null}
/>
))
)}
</div>
) : null}
<Button
className="h-7 w-7"
disabled={isRefetching}
onClick={handleRefresh}
size="icon"
variant="ghost"
>
<RefreshCw
className={`h-3.5 w-3.5 ${isRefetching ? "animate-spin" : ""}`}
/>
</Button>
</div>
</div>
{/* Timeline — TabsContent with forceMount so aria-controls resolves */}
<TabsContent
value={activeTab}
forceMount
className="mt-0 min-h-0 flex-1 overflow-y-auto"
>
{renderTimeline()}
</TabsContent>
</Tabs>
<ComposeNote
errorMessage={
@@ -379,34 +383,3 @@ export function PulseView({ currentPubkey }: PulseViewProps) {
</div>
);
}
function TabButton({
active,
count,
label,
onClick,
}: {
active: boolean;
count?: number;
label: string;
onClick: () => void;
}) {
return (
<button
className={`border-b-2 px-3 py-2.5 text-sm font-medium transition-colors ${
active
? "border-primary text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground"
}`}
onClick={onClick}
type="button"
>
{label}
{count != null && count > 0 ? (
<span className="ml-1.5 inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-muted px-1 text-[10px] font-medium text-muted-foreground">
{count}
</span>
) : null}
</button>
);
}

View File

@@ -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<string | null>(null);
const [sasCode, setSasCode] = useState<string | null>(null);
const [error, setError] = useState<string | null>(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({
</div>
) : step === "generating" ? (
<div className="flex flex-col items-center justify-center gap-3 py-8">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
<Spinner className="h-6 w-6 text-muted-foreground" />
<p className="text-sm text-muted-foreground">
Preparing secure pairing session...
</p>
@@ -228,11 +226,7 @@ function PairingDialog({
size="sm"
variant="outline"
>
{copied ? (
<Check className="h-3.5 w-3.5" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
<Copy className="h-3.5 w-3.5" />
</Button>
</div>
</div>
@@ -284,7 +278,7 @@ function PairingDialog({
</div>
) : step === "transferring" ? (
<div className="flex flex-col items-center justify-center gap-3 py-8">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
<Spinner className="h-6 w-6 text-muted-foreground" />
<p className="text-sm text-muted-foreground">
Sending identity to mobile device...
</p>

View File

@@ -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<number>(30);
const [mintedToken, setMintedToken] = React.useState<string | null>(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}
</code>
<Button onClick={handleCopy} size="sm" variant="outline">
{copied ? (
<Check className="h-3.5 w-3.5" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
<Copy className="h-3.5 w-3.5" />
</Button>
</div>
<div className="flex items-start gap-2 rounded-lg border border-yellow-500/30 bg-yellow-500/10 px-3 py-2 text-sm text-warning">

View File

@@ -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 (
<div className="space-y-2">
{["first", "second", "third", "fourth"].map((card) => (
<div
className="rounded-xl border border-border/70 bg-card/80 p-4 shadow-sm"
key={card}
>
<Card className="p-4" key={card}>
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 flex-1 space-y-3">
<div className="flex items-center gap-2">
@@ -60,7 +58,7 @@ function WorkflowsListSkeleton() {
<Skeleton className="h-8 w-8 rounded-lg" />
</div>
</div>
</div>
</Card>
))}
</div>
);

View File

@@ -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() {
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<ThemeProvider defaultTheme="houston">
<App />
<TooltipProvider delayDuration={300}>
<App />
<Toaster />
</TooltipProvider>
</ThemeProvider>
</QueryClientProvider>
</React.StrictMode>,

View File

@@ -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<string | null>(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");
}

View File

@@ -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();
}

View File

@@ -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<string | null>(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 (
<img
alt={`${displayName} avatar`}
className={cn(base, "bg-secondary object-cover")}
data-testid={testId ? `${testId}-image` : undefined}
onError={() => setFailedUrl(avatarUrl)}
referrerPolicy="no-referrer"
src={rewriteRelayUrl(avatarUrl)}
/>
);
}
const initials = getInitials(displayName);
return (
<div
className={cn(
base,
"flex items-center justify-center font-semibold",
accent
? "bg-primary text-primary-foreground"
: "bg-secondary text-secondary-foreground",
)}
data-testid={testId ? `${testId}-fallback` : undefined}
<Avatar
className={cn(sizeClasses[size], "rounded-lg shadow-sm", className)}
>
{initials}
</div>
{avatarUrl ? (
<AvatarImage
alt={`${displayName} avatar`}
className="bg-secondary object-cover"
data-testid={testId ? `${testId}-image` : undefined}
referrerPolicy="no-referrer"
src={rewriteRelayUrl(avatarUrl)}
/>
) : null}
<AvatarFallback
className={cn(
"rounded-lg font-semibold",
accent
? "bg-primary text-primary-foreground"
: "bg-secondary text-secondary-foreground",
)}
data-testid={testId ? `${testId}-fallback` : undefined}
delayMs={200}
>
{initials}
</AvatarFallback>
</Avatar>
);
}

View File

@@ -1,3 +1,4 @@
import { Card } from "@/shared/ui/card";
import { Skeleton } from "@/shared/ui/skeleton";
type ViewLoadingFallbackKind =
@@ -64,10 +65,7 @@ function AgentsLoadingBody() {
</div>
<div className="grid gap-3 md:grid-cols-3 xl:grid-cols-4">
{["first", "second", "third", "fourth"].map((card) => (
<div
className="rounded-xl border border-border/70 bg-card/80 p-3 shadow-sm"
key={card}
>
<Card className="p-3" key={card}>
<div className="flex items-center gap-2.5">
<Skeleton className="h-8 w-8 rounded-lg" />
<div className="min-w-0 flex-1 space-y-2">
@@ -75,7 +73,7 @@ function AgentsLoadingBody() {
<Skeleton className="h-3 w-16 rounded-full" />
</div>
</div>
</div>
</Card>
))}
</div>
</section>
@@ -87,10 +85,7 @@ function AgentsLoadingBody() {
</div>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{["alpha", "beta", "gamma"].map((card) => (
<div
className="rounded-xl border border-border/70 bg-card/80 p-4 shadow-sm"
key={card}
>
<Card className="p-4" key={card}>
<div className="space-y-3">
<div className="flex items-center justify-between gap-3">
<div className="space-y-2">
@@ -102,7 +97,7 @@ function AgentsLoadingBody() {
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-4/5" />
</div>
</div>
</Card>
))}
</div>
</section>
@@ -112,7 +107,7 @@ function AgentsLoadingBody() {
<Skeleton className="h-5 w-36" />
<Skeleton className="h-4 w-72 max-w-full" />
</div>
<div className="overflow-hidden rounded-xl border border-border/70 bg-card/80 shadow-sm">
<Card className="overflow-hidden">
{["one", "two", "three"].map((row) => (
<div
className="flex items-center gap-4 border-b border-border/60 px-4 py-3 last:border-b-0"
@@ -124,7 +119,7 @@ function AgentsLoadingBody() {
<Skeleton className="ml-auto h-8 w-24 rounded-lg" />
</div>
))}
</div>
</Card>
</section>
</div>
</div>
@@ -145,10 +140,7 @@ function WorkflowsLoadingBody() {
<div className="space-y-2">
{["first", "second", "third", "fourth"].map((card) => (
<div
className="rounded-xl border border-border/70 bg-card/80 p-4 shadow-sm"
key={card}
>
<Card className="p-4" key={card}>
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 flex-1 space-y-3">
<div className="flex items-center gap-2">
@@ -167,7 +159,7 @@ function WorkflowsLoadingBody() {
<Skeleton className="h-8 w-8 rounded-lg" />
</div>
</div>
</div>
</Card>
))}
</div>
</div>
@@ -224,10 +216,7 @@ function ForumLoadingBody() {
<div className="flex-1 overflow-y-auto p-4">
<div className="space-y-3">
{["first", "second", "third"].map((card) => (
<div
className="rounded-xl border border-border/70 bg-card/80 p-4 shadow-sm"
key={card}
>
<Card className="p-4" key={card}>
<div className="space-y-3">
<div className="flex items-center gap-2">
<Skeleton className="h-4 w-40" />
@@ -241,7 +230,7 @@ function ForumLoadingBody() {
<Skeleton className="h-4 w-14" />
</div>
</div>
</div>
</Card>
))}
</div>
</div>

View File

@@ -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<typeof AvatarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn(
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
className,
)}
{...props}
/>
));
Avatar.displayName = AvatarPrimitive.Root.displayName;
const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image
ref={ref}
className={cn("aspect-square h-full w-full", className)}
{...props}
/>
));
AvatarImage.displayName = AvatarPrimitive.Image.displayName;
const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn(
"flex h-full w-full items-center justify-center rounded-full bg-muted",
className,
)}
{...props}
/>
));
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
export { Avatar, AvatarImage, AvatarFallback };

View File

@@ -0,0 +1,83 @@
import * as React from "react";
import { cn } from "@/shared/lib/cn";
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-xl border border-border/70 bg-card/80 text-card-foreground shadow-sm",
className,
)}
{...props}
/>
));
Card.displayName = "Card";
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
));
CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("font-semibold leading-none tracking-tight", className)}
{...props}
/>
));
CardTitle.displayName = "CardTitle";
const CardDescription = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
CardDescription.displayName = "CardDescription";
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
));
CardContent.displayName = "CardContent";
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
));
CardFooter.displayName = "CardFooter";
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardDescription,
CardContent,
};

View File

@@ -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 (
<Loader2 className="h-4 w-4 shrink-0 animate-spin text-muted-foreground" />
);
return <Spinner className="h-4 w-4 shrink-0 text-muted-foreground" />;
case "done":
return <Check className="h-4 w-4 shrink-0 text-green-500" />;
case "error":

View File

@@ -0,0 +1,30 @@
import { Toaster as Sonner } from "sonner";
import { useTheme } from "@/shared/theme/ThemeProvider";
type ToasterProps = React.ComponentProps<typeof Sonner>;
const Toaster = ({ ...props }: ToasterProps) => {
const { isDark } = useTheme();
return (
<Sonner
theme={isDark ? "dark" : "light"}
className="toaster group"
toastOptions={{
classNames: {
toast:
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
description: "group-[.toast]:text-muted-foreground",
actionButton:
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
cancelButton:
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
},
}}
{...props}
/>
);
};
export { Toaster };

View File

@@ -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 (
<Loader2
className={cn("animate-spin", className)}
size={size}
role={role}
aria-label={ariaLabel}
{...rest}
/>
);
}

View File

@@ -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<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
className,
)}
{...props}
/>
));
TabsList.displayName = TabsPrimitive.List.displayName;
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
className,
)}
{...props}
/>
));
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className,
)}
{...props}
/>
));
TabsContent.displayName = TabsPrimitive.Content.displayName;
export { Tabs, TabsList, TabsTrigger, TabsContent };

View File

@@ -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 ({

View File

@@ -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);

View File

@@ -1,3 +1,3 @@
[toolchain]
channel = "stable"
channel = "1.95.0"
profile = "default"