mirror of
https://github.com/block/buzz.git
synced 2026-11-04 08:21:48 +00:00
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:
@@ -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" => {
|
||||
|
||||
@@ -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
122
desktop/pnpm-lock.yaml
generated
@@ -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: {}
|
||||
|
||||
@@ -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(());
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>,
|
||||
|
||||
36
desktop/src/shared/hooks/useToastEffect.ts
Normal file
36
desktop/src/shared/hooks/useToastEffect.ts
Normal 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");
|
||||
}
|
||||
10
desktop/src/shared/lib/initials.ts
Normal file
10
desktop/src/shared/lib/initials.ts
Normal 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();
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
48
desktop/src/shared/ui/avatar.tsx
Normal file
48
desktop/src/shared/ui/avatar.tsx
Normal 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 };
|
||||
83
desktop/src/shared/ui/card.tsx
Normal file
83
desktop/src/shared/ui/card.tsx
Normal 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,
|
||||
};
|
||||
@@ -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":
|
||||
|
||||
30
desktop/src/shared/ui/sonner.tsx
Normal file
30
desktop/src/shared/ui/sonner.tsx
Normal 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 };
|
||||
26
desktop/src/shared/ui/spinner.tsx
Normal file
26
desktop/src/shared/ui/spinner.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
53
desktop/src/shared/ui/tabs.tsx
Normal file
53
desktop/src/shared/ui/tabs.tsx
Normal 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 };
|
||||
@@ -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 ({
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
[toolchain]
|
||||
channel = "stable"
|
||||
channel = "1.95.0"
|
||||
profile = "default"
|
||||
|
||||
Reference in New Issue
Block a user