Add router-backed app navigation and skeleton loading states (#259)

This commit is contained in:
Wes
2026-04-07 12:20:42 -07:00
committed by GitHub
parent a6e5c85c7d
commit e10d4529de
39 changed files with 2153 additions and 489 deletions
+3
View File
@@ -36,6 +36,7 @@
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-query": "^5.90.21",
"@tanstack/react-router": "^1.168.10",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-notification": "^2.3.3",
"@tauri-apps/plugin-opener": "^2",
@@ -60,6 +61,8 @@
"@biomejs/biome": "^2.4.6",
"@noble/hashes": "^2.0.1",
"@playwright/test": "^1.58.2",
"@tanstack/router-plugin": "^1.167.12",
"@tanstack/virtual-file-routes": "^1.161.7",
"@tauri-apps/cli": "^2",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
+359 -12
View File
@@ -44,6 +44,9 @@ importers:
'@tanstack/react-query':
specifier: ^5.90.21
version: 5.90.21(react@19.2.4)
'@tanstack/react-router':
specifier: ^1.168.10
version: 1.168.10(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@tauri-apps/api':
specifier: ^2
version: 2.10.1
@@ -111,6 +114,12 @@ importers:
'@playwright/test':
specifier: ^1.58.2
version: 1.58.2
'@tanstack/router-plugin':
specifier: ^1.167.12
version: 1.167.12(@tanstack/react-router@1.168.10(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@25.5.2)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.3))
'@tanstack/virtual-file-routes':
specifier: ^1.161.7
version: 1.161.7
'@tauri-apps/cli':
specifier: ^2
version: 2.10.1
@@ -122,7 +131,7 @@ importers:
version: 19.2.3(@types/react@19.2.14)
'@vitejs/plugin-react':
specifier: ^4.6.0
version: 4.7.0(vite@7.3.1(@types/node@25.5.2)(jiti@1.21.7)(yaml@2.8.3))
version: 4.7.0(vite@7.3.1(@types/node@25.5.2)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.3))
autoprefixer:
specifier: ^10.4.27
version: 10.4.27(postcss@8.5.8)
@@ -134,16 +143,16 @@ importers:
version: 8.5.8
tailwindcss:
specifier: ^3.4.17
version: 3.4.19(yaml@2.8.3)
version: 3.4.19(tsx@4.21.0)(yaml@2.8.3)
tailwindcss-animate:
specifier: ^1.0.7
version: 1.0.7(tailwindcss@3.4.19(yaml@2.8.3))
version: 1.0.7(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.3))
typescript:
specifier: ~5.8.3
version: 5.8.3
vite:
specifier: ^7.0.4
version: 7.3.1(@types/node@25.5.2)(jiti@1.21.7)(yaml@2.8.3)
version: 7.3.1(@types/node@25.5.2)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.3)
packages:
@@ -210,6 +219,18 @@ packages:
engines: {node: '>=6.0.0'}
hasBin: true
'@babel/plugin-syntax-jsx@7.28.6':
resolution: {integrity: sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
'@babel/plugin-syntax-typescript@7.28.6':
resolution: {integrity: sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
'@babel/plugin-transform-react-jsx-self@7.27.1':
resolution: {integrity: sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==}
engines: {node: '>=6.9.0'}
@@ -950,6 +971,7 @@ packages:
resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.59.0':
resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==}
@@ -960,21 +982,25 @@ packages:
resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.59.0':
resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-loong64-gnu@4.59.0':
resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==}
cpu: [loong64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-loong64-musl@4.59.0':
resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==}
cpu: [loong64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-ppc64-gnu@4.59.0':
resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==}
@@ -985,11 +1011,13 @@ packages:
resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==}
cpu: [ppc64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-riscv64-gnu@4.59.0':
resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-musl@4.59.0':
resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==}
@@ -1000,11 +1028,13 @@ packages:
resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.59.0':
resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.59.0':
resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==}
@@ -1081,6 +1111,10 @@ packages:
'@shikijs/vscode-textmate@10.0.2':
resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==}
'@tanstack/history@1.161.6':
resolution: {integrity: sha512-NaOGLRrddszbQj9upGat6HG/4TKvXLvu+osAIgfxPYA+eIvYKv8GKDJOrY2D3/U9MRnKfMWD7bU4jeD4xmqyIg==}
engines: {node: '>=20.19'}
'@tanstack/query-core@5.90.20':
resolution: {integrity: sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg==}
@@ -1089,6 +1123,62 @@ packages:
peerDependencies:
react: ^18 || ^19
'@tanstack/react-router@1.168.10':
resolution: {integrity: sha512-/RmDlOwDkCug609KdPB3U+U1zmrtadJpvsmRg2zEn8TRCKRNri7dYZIjQZbNg8PgUiRL4T6njrZBV1ChzblNaA==}
engines: {node: '>=20.19'}
peerDependencies:
react: '>=18.0.0 || >=19.0.0'
react-dom: '>=18.0.0 || >=19.0.0'
'@tanstack/react-store@0.9.3':
resolution: {integrity: sha512-y2iHd/N9OkoQbFJLUX1T9vbc2O9tjH0pQRgTcx1/Nz4IlwLvkgpuglXUx+mXt0g5ZDFrEeDnONPqkbfxXJKwRg==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
'@tanstack/router-core@1.168.9':
resolution: {integrity: sha512-18oeEwEDyXOIuO1VBP9ACaK7tYHZUjynGDCoUh/5c/BNhia9vCJCp9O0LfhZXOorDc/PmLSgvmweFhVmIxF10g==}
engines: {node: '>=20.19'}
hasBin: true
'@tanstack/router-generator@1.166.24':
resolution: {integrity: sha512-vdaGKwuH+r+DPe6R1mjk+TDDmDH6NTG7QqwxHqGEvOH4aGf9sPjhmRKNJZqQr8cPIbfp6u5lXyZ1TeDcSNMVEA==}
engines: {node: '>=20.19'}
'@tanstack/router-plugin@1.167.12':
resolution: {integrity: sha512-StEHcctCuFI5taSjO+lhR/yQ+EK63BdyYa+ne6FoNQPB3MMrOUrz2ZVnbqILRLkh2b+p2EfBKt65sgAKdKygPQ==}
engines: {node: '>=20.19'}
hasBin: true
peerDependencies:
'@rsbuild/core': '>=1.0.2'
'@tanstack/react-router': ^1.168.10
vite: '>=5.0.0 || >=6.0.0 || >=7.0.0'
vite-plugin-solid: ^2.11.10
webpack: '>=5.92.0'
peerDependenciesMeta:
'@rsbuild/core':
optional: true
'@tanstack/react-router':
optional: true
vite:
optional: true
vite-plugin-solid:
optional: true
webpack:
optional: true
'@tanstack/router-utils@1.161.6':
resolution: {integrity: sha512-nRcYw+w2OEgK6VfjirYvGyPLOK+tZQz1jkYcmH5AjMamQ9PycnlxZF2aEZtPpNoUsaceX2bHptn6Ub5hGXqNvw==}
engines: {node: '>=20.19'}
'@tanstack/store@0.9.3':
resolution: {integrity: sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw==}
'@tanstack/virtual-file-routes@1.161.7':
resolution: {integrity: sha512-olW33+Cn+bsCsZKPwEGhlkqS6w3M2slFv11JIobdnCFKMLG97oAI2kWKdx5/zsywTL8flpnoIgaZZPlQTFYhdQ==}
engines: {node: '>=20.19'}
hasBin: true
'@tauri-apps/api@2.10.1':
resolution: {integrity: sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==}
@@ -1231,6 +1321,15 @@ packages:
peerDependencies:
vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0
acorn@8.16.0:
resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==}
engines: {node: '>=0.4.0'}
hasBin: true
ansis@4.2.0:
resolution: {integrity: sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==}
engines: {node: '>=14'}
any-promise@1.3.0:
resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==}
@@ -1245,6 +1344,10 @@ packages:
resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==}
engines: {node: '>=10'}
ast-types@0.16.1:
resolution: {integrity: sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==}
engines: {node: '>=4'}
autoprefixer@10.4.27:
resolution: {integrity: sha512-NP9APE+tO+LuJGn7/9+cohklunJsXWiaWEfV3si4Gi/XHDwVNgkwr1J3RQYFIvPy76GmJ9/bW8vyoU1LcxwKHA==}
engines: {node: ^10 || ^12 || >=14}
@@ -1252,6 +1355,9 @@ packages:
peerDependencies:
postcss: ^8.1.0
babel-dead-code-elimination@1.0.12:
resolution: {integrity: sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig==}
bail@2.0.2:
resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==}
@@ -1322,6 +1428,9 @@ packages:
convert-source-map@2.0.0:
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
cookie-es@2.0.1:
resolution: {integrity: sha512-aVf4A4hI2w70LnF7GG+7xDQUkliwiXWXFvTjkip4+b64ygDQ2sJPRSKFDHbxn8o0xu9QzPkMuuiWIXyFSE2slA==}
cssesc@3.0.0:
resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==}
engines: {node: '>=4'}
@@ -1358,6 +1467,10 @@ packages:
diff-match-patch@1.0.5:
resolution: {integrity: sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw==}
diff@8.0.4:
resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==}
engines: {node: '>=0.3.1'}
dlv@1.1.3:
resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==}
@@ -1380,6 +1493,11 @@ packages:
resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==}
engines: {node: '>=12'}
esprima@4.0.1:
resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==}
engines: {node: '>=4'}
hasBin: true
estree-util-is-identifier-name@3.0.0:
resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==}
@@ -1430,6 +1548,9 @@ packages:
resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==}
engines: {node: '>=6'}
get-tsconfig@4.13.7:
resolution: {integrity: sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==}
gitdiff-parser@0.3.1:
resolution: {integrity: sha512-YQJnY8aew65id8okGxKCksH3efDCJ9HzV7M9rsvd65habf39Pkh4cgYJ27AaoDMqo1X98pgNJhNMrm/kpV7UVQ==}
@@ -1499,6 +1620,10 @@ packages:
resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==}
engines: {node: '>=12'}
isbot@5.1.37:
resolution: {integrity: sha512-5bcicX81xf6NlTEV8rWdg7Pk01LFizDetuYGHx6d/f6y3lR2/oo8IfxjzJqn1UdDEyCcwT9e7NRloj8DwCYujQ==}
engines: {node: '>=18'}
jdenticon@3.3.0:
resolution: {integrity: sha512-DhuBRNRIybGPeAjMjdHbkIfiwZCCmf8ggu7C49jhp6aJ7DYsZfudnvnTY5/1vgUhrGA7JaDAx1WevnpjCPvaGg==}
engines: {node: '>=6.4.0'}
@@ -1738,6 +1863,9 @@ packages:
path-parse@1.0.7:
resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==}
pathe@2.0.3:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
@@ -1814,6 +1942,11 @@ packages:
resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==}
engines: {node: ^10 || ^12 || >=14}
prettier@3.8.1:
resolution: {integrity: sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==}
engines: {node: '>=14'}
hasBin: true
property-information@7.1.0:
resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==}
@@ -1881,6 +2014,10 @@ packages:
resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
engines: {node: '>=8.10.0'}
recast@0.23.11:
resolution: {integrity: sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==}
engines: {node: '>= 4'}
regex-recursion@6.0.2:
resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==}
@@ -1905,6 +2042,9 @@ packages:
remark-stringify@11.0.0:
resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==}
resolve-pkg-maps@1.0.0:
resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
resolve@1.22.11:
resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==}
engines: {node: '>= 0.4'}
@@ -1929,6 +2069,16 @@ packages:
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
hasBin: true
seroval-plugins@1.5.2:
resolution: {integrity: sha512-qpY0Cl+fKYFn4GOf3cMiq6l72CpuVaawb6ILjubOQ+diJ54LfOWaSSPsaswN8DRPIPW4Yq+tE1k5aKd7ILyaFg==}
engines: {node: '>=10'}
peerDependencies:
seroval: ^1.0
seroval@1.5.2:
resolution: {integrity: sha512-xcRN39BdsnO9Tf+VzsE7b3JyTJASItIV1FVFewJKCFcW4s4haIKS3e6vj8PGB9qBwC7tnuOywQMdv5N4qkzi7Q==}
engines: {node: '>=10'}
shallow-equal@3.1.0:
resolution: {integrity: sha512-pfVOw8QZIXpMbhBWvzBISicvToTiM5WBF1EeAUZDDSb5Dt29yl4AYbyywbJFSEsRUMr7gJaxqCdr4L3tQf9wVg==}
@@ -1940,6 +2090,14 @@ packages:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
source-map@0.6.1:
resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==}
engines: {node: '>=0.10.0'}
source-map@0.7.6:
resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==}
engines: {node: '>= 12'}
space-separated-tokens@2.0.2:
resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==}
@@ -1981,6 +2139,9 @@ packages:
thenify@3.3.1:
resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==}
tiny-invariant@1.3.3:
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
tinyglobby@0.2.15:
resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
engines: {node: '>=12.0.0'}
@@ -2001,6 +2162,11 @@ packages:
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
tsx@4.21.0:
resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==}
engines: {node: '>=18.0.0'}
hasBin: true
typescript@5.8.3:
resolution: {integrity: sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==}
engines: {node: '>=14.17'}
@@ -2027,6 +2193,10 @@ packages:
unist-util-visit@5.1.0:
resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==}
unplugin@2.3.11:
resolution: {integrity: sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==}
engines: {node: '>=18.12.0'}
update-browserslist-db@1.2.3:
resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==}
hasBin: true
@@ -2053,6 +2223,11 @@ packages:
'@types/react':
optional: true
use-sync-external-store@1.6.0:
resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
@@ -2105,6 +2280,9 @@ packages:
warning@4.0.3:
resolution: {integrity: sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==}
webpack-virtual-modules@0.6.2:
resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==}
yallist@3.1.1:
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
@@ -2113,6 +2291,9 @@ packages:
engines: {node: '>= 14.6'}
hasBin: true
zod@3.25.76:
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
zwitch@2.0.4:
resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}
@@ -2199,6 +2380,16 @@ snapshots:
dependencies:
'@babel/types': 7.29.0
'@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0)':
dependencies:
'@babel/core': 7.29.0
'@babel/helper-plugin-utils': 7.28.6
'@babel/plugin-syntax-typescript@7.28.6(@babel/core@7.29.0)':
dependencies:
'@babel/core': 7.29.0
'@babel/helper-plugin-utils': 7.28.6
'@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.29.0)':
dependencies:
'@babel/core': 7.29.0
@@ -2928,6 +3119,8 @@ snapshots:
'@shikijs/vscode-textmate@10.0.2': {}
'@tanstack/history@1.161.6': {}
'@tanstack/query-core@5.90.20': {}
'@tanstack/react-query@5.90.21(react@19.2.4)':
@@ -2935,6 +3128,81 @@ snapshots:
'@tanstack/query-core': 5.90.20
react: 19.2.4
'@tanstack/react-router@1.168.10(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@tanstack/history': 1.161.6
'@tanstack/react-store': 0.9.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@tanstack/router-core': 1.168.9
isbot: 5.1.37
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
'@tanstack/react-store@0.9.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@tanstack/store': 0.9.3
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
use-sync-external-store: 1.6.0(react@19.2.4)
'@tanstack/router-core@1.168.9':
dependencies:
'@tanstack/history': 1.161.6
cookie-es: 2.0.1
seroval: 1.5.2
seroval-plugins: 1.5.2(seroval@1.5.2)
'@tanstack/router-generator@1.166.24':
dependencies:
'@tanstack/router-core': 1.168.9
'@tanstack/router-utils': 1.161.6
'@tanstack/virtual-file-routes': 1.161.7
prettier: 3.8.1
recast: 0.23.11
source-map: 0.7.6
tsx: 4.21.0
zod: 3.25.76
transitivePeerDependencies:
- supports-color
'@tanstack/router-plugin@1.167.12(@tanstack/react-router@1.168.10(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@25.5.2)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.3))':
dependencies:
'@babel/core': 7.29.0
'@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0)
'@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.0)
'@babel/template': 7.28.6
'@babel/traverse': 7.29.0
'@babel/types': 7.29.0
'@tanstack/router-core': 1.168.9
'@tanstack/router-generator': 1.166.24
'@tanstack/router-utils': 1.161.6
'@tanstack/virtual-file-routes': 1.161.7
chokidar: 3.6.0
unplugin: 2.3.11
zod: 3.25.76
optionalDependencies:
'@tanstack/react-router': 1.168.10(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
vite: 7.3.1(@types/node@25.5.2)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.3)
transitivePeerDependencies:
- supports-color
'@tanstack/router-utils@1.161.6':
dependencies:
'@babel/core': 7.29.0
'@babel/generator': 7.29.1
'@babel/parser': 7.29.0
'@babel/types': 7.29.0
ansis: 4.2.0
babel-dead-code-elimination: 1.0.12
diff: 8.0.4
pathe: 2.0.3
tinyglobby: 0.2.15
transitivePeerDependencies:
- supports-color
'@tanstack/store@0.9.3': {}
'@tanstack/virtual-file-routes@1.161.7': {}
'@tauri-apps/api@2.10.1': {}
'@tauri-apps/cli-darwin-arm64@2.10.1':
@@ -3059,7 +3327,7 @@ snapshots:
'@ungap/structured-clone@1.3.0': {}
'@vitejs/plugin-react@4.7.0(vite@7.3.1(@types/node@25.5.2)(jiti@1.21.7)(yaml@2.8.3))':
'@vitejs/plugin-react@4.7.0(vite@7.3.1(@types/node@25.5.2)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.3))':
dependencies:
'@babel/core': 7.29.0
'@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0)
@@ -3067,10 +3335,14 @@ snapshots:
'@rolldown/pluginutils': 1.0.0-beta.27
'@types/babel__core': 7.20.5
react-refresh: 0.17.0
vite: 7.3.1(@types/node@25.5.2)(jiti@1.21.7)(yaml@2.8.3)
vite: 7.3.1(@types/node@25.5.2)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.3)
transitivePeerDependencies:
- supports-color
acorn@8.16.0: {}
ansis@4.2.0: {}
any-promise@1.3.0: {}
anymatch@3.1.3:
@@ -3084,6 +3356,10 @@ snapshots:
dependencies:
tslib: 2.8.1
ast-types@0.16.1:
dependencies:
tslib: 2.8.1
autoprefixer@10.4.27(postcss@8.5.8):
dependencies:
browserslist: 4.28.1
@@ -3093,6 +3369,15 @@ snapshots:
postcss: 8.5.8
postcss-value-parser: 4.2.0
babel-dead-code-elimination@1.0.12:
dependencies:
'@babel/core': 7.29.0
'@babel/parser': 7.29.0
'@babel/traverse': 7.29.0
'@babel/types': 7.29.0
transitivePeerDependencies:
- supports-color
bail@2.0.2: {}
baseline-browser-mapping@2.10.0: {}
@@ -3155,6 +3440,8 @@ snapshots:
convert-source-map@2.0.0: {}
cookie-es@2.0.1: {}
cssesc@3.0.0: {}
csstype@3.2.3: {}
@@ -3179,6 +3466,8 @@ snapshots:
diff-match-patch@1.0.5: {}
diff@8.0.4: {}
dlv@1.1.3: {}
electron-to-chromium@1.5.307: {}
@@ -3218,6 +3507,8 @@ snapshots:
escape-string-regexp@5.0.0: {}
esprima@4.0.1: {}
estree-util-is-identifier-name@3.0.0: {}
extend@3.0.2: {}
@@ -3256,6 +3547,10 @@ snapshots:
get-nonce@1.0.1: {}
get-tsconfig@4.13.7:
dependencies:
resolve-pkg-maps: 1.0.0
gitdiff-parser@0.3.1: {}
glob-parent@5.1.2:
@@ -3343,6 +3638,8 @@ snapshots:
is-plain-obj@4.1.0: {}
isbot@5.1.37: {}
jdenticon@3.3.0:
dependencies:
canvas-renderer: 2.2.1
@@ -3785,6 +4082,8 @@ snapshots:
path-parse@1.0.7: {}
pathe@2.0.3: {}
picocolors@1.1.1: {}
picomatch@2.3.2: {}
@@ -3815,12 +4114,13 @@ snapshots:
camelcase-css: 2.0.1
postcss: 8.5.8
postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.8)(yaml@2.8.3):
postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.8)(tsx@4.21.0)(yaml@2.8.3):
dependencies:
lilconfig: 3.1.3
optionalDependencies:
jiti: 1.21.7
postcss: 8.5.8
tsx: 4.21.0
yaml: 2.8.3
postcss-nested@6.2.0(postcss@8.5.8):
@@ -3841,6 +4141,8 @@ snapshots:
picocolors: 1.1.1
source-map-js: 1.2.1
prettier@3.8.1: {}
property-information@7.1.0: {}
queue-microtask@1.2.3: {}
@@ -3917,6 +4219,14 @@ snapshots:
dependencies:
picomatch: 2.3.2
recast@0.23.11:
dependencies:
ast-types: 0.16.1
esprima: 4.0.1
source-map: 0.6.1
tiny-invariant: 1.3.3
tslib: 2.8.1
regex-recursion@6.0.2:
dependencies:
regex-utilities: 2.3.0
@@ -3967,6 +4277,8 @@ snapshots:
mdast-util-to-markdown: 2.1.2
unified: 11.0.5
resolve-pkg-maps@1.0.0: {}
resolve@1.22.11:
dependencies:
is-core-module: 2.16.1
@@ -4014,6 +4326,12 @@ snapshots:
semver@6.3.1: {}
seroval-plugins@1.5.2(seroval@1.5.2):
dependencies:
seroval: 1.5.2
seroval@1.5.2: {}
shallow-equal@3.1.0: {}
shiki@4.0.2:
@@ -4029,6 +4347,10 @@ snapshots:
source-map-js@1.2.1: {}
source-map@0.6.1: {}
source-map@0.7.6: {}
space-separated-tokens@2.0.2: {}
stringify-entities@4.0.4:
@@ -4058,11 +4380,11 @@ snapshots:
tailwind-merge@3.5.0: {}
tailwindcss-animate@1.0.7(tailwindcss@3.4.19(yaml@2.8.3)):
tailwindcss-animate@1.0.7(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.3)):
dependencies:
tailwindcss: 3.4.19(yaml@2.8.3)
tailwindcss: 3.4.19(tsx@4.21.0)(yaml@2.8.3)
tailwindcss@3.4.19(yaml@2.8.3):
tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.3):
dependencies:
'@alloc/quick-lru': 5.2.0
arg: 5.0.2
@@ -4081,7 +4403,7 @@ snapshots:
postcss: 8.5.8
postcss-import: 15.1.0(postcss@8.5.8)
postcss-js: 4.1.0(postcss@8.5.8)
postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.8)(yaml@2.8.3)
postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.8)(tsx@4.21.0)(yaml@2.8.3)
postcss-nested: 6.2.0(postcss@8.5.8)
postcss-selector-parser: 6.1.2
resolve: 1.22.11
@@ -4098,6 +4420,8 @@ snapshots:
dependencies:
any-promise: 1.3.0
tiny-invariant@1.3.3: {}
tinyglobby@0.2.15:
dependencies:
fdir: 6.5.0(picomatch@4.0.3)
@@ -4115,6 +4439,13 @@ snapshots:
tslib@2.8.1: {}
tsx@4.21.0:
dependencies:
esbuild: 0.27.3
get-tsconfig: 4.13.7
optionalDependencies:
fsevents: 2.3.3
typescript@5.8.3: {}
undici-types@7.18.2: {}
@@ -4152,6 +4483,13 @@ snapshots:
unist-util-is: 6.0.1
unist-util-visit-parents: 6.0.2
unplugin@2.3.11:
dependencies:
'@jridgewell/remapping': 2.3.5
acorn: 8.16.0
picomatch: 4.0.3
webpack-virtual-modules: 0.6.2
update-browserslist-db@1.2.3(browserslist@4.28.1):
dependencies:
browserslist: 4.28.1
@@ -4173,6 +4511,10 @@ snapshots:
optionalDependencies:
'@types/react': 19.2.14
use-sync-external-store@1.6.0(react@19.2.4):
dependencies:
react: 19.2.4
util-deprecate@1.0.2: {}
vfile-message@4.0.3:
@@ -4185,7 +4527,7 @@ snapshots:
'@types/unist': 3.0.3
vfile-message: 4.0.3
vite@7.3.1(@types/node@25.5.2)(jiti@1.21.7)(yaml@2.8.3):
vite@7.3.1(@types/node@25.5.2)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.3):
dependencies:
esbuild: 0.27.3
fdir: 6.5.0(picomatch@4.0.3)
@@ -4197,14 +4539,19 @@ snapshots:
'@types/node': 25.5.2
fsevents: 2.3.3
jiti: 1.21.7
tsx: 4.21.0
yaml: 2.8.3
warning@4.0.3:
dependencies:
loose-envify: 1.4.0
webpack-virtual-modules@0.6.2: {}
yallist@3.1.1: {}
yaml@2.8.3: {}
zod@3.25.76: {}
zwitch@2.0.4: {}
+3 -2
View File
@@ -1,12 +1,13 @@
import { getCurrentWindow } from "@tauri-apps/api/window";
import { RouterProvider } from "@tanstack/react-router";
import { useLayoutEffect } from "react";
import { AppShell } from "@/app/AppShell";
import { router } from "@/app/router";
export function App() {
useLayoutEffect(() => {
void getCurrentWindow().show();
}, []);
return <AppShell />;
return <RouterProvider router={router} />;
}
+225 -307
View File
@@ -1,10 +1,15 @@
import { ChevronLeft, ChevronRight } from "lucide-react";
import * as React from "react";
import { useQueryClient } from "@tanstack/react-query";
import { Outlet, useLocation } from "@tanstack/react-router";
import { AppShellProvider } from "@/app/AppShellContext";
import {
AppShellOverlays,
type BrowseDialogType,
} from "@/app/AppShellOverlays";
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
import { useBackForwardControls } from "@/app/navigation/useBackForwardControls";
import { useWebviewZoomShortcuts } from "@/app/useWebviewZoomShortcuts";
import {
channelsQueryKey,
@@ -12,10 +17,8 @@ import {
useCreateChannelMutation,
useHideDmMutation,
useOpenDmMutation,
useSelectedChannel,
} from "@/features/channels/hooks";
import { useUnreadChannels } from "@/features/channels/useUnreadChannels";
import { HomeScreen } from "@/features/home/ui/HomeScreen";
import { useHomeFeedNotifications } from "@/features/notifications/hooks";
import {
listenForDesktopNotificationActions,
@@ -30,10 +33,10 @@ import { SettingsScreen } from "@/features/settings/ui/SettingsScreen";
import { AppSidebar } from "@/features/sidebar/ui/AppSidebar";
import { relayClient } from "@/shared/api/relayClient";
import { useIdentityQuery } from "@/shared/api/hooks";
import { getEventById, joinChannel } from "@/shared/api/tauri";
import type { Channel, RelayEvent, SearchHit } from "@/shared/api/types";
import { joinChannel } from "@/shared/api/tauri";
import type { SearchHit } from "@/shared/api/types";
import { ChannelNavigationProvider } from "@/shared/context/ChannelNavigationContext";
import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback";
import { Button } from "@/shared/ui/button";
import {
SidebarInset,
SidebarProvider,
@@ -43,20 +46,7 @@ import {
type AppView = "home" | "channel" | "agents" | "workflows";
const DEFAULT_SETTINGS_SECTION: SettingsSection = "profile";
const AgentsScreen = React.lazy(async () => {
const module = await import("@/features/agents/ui/AgentsScreen");
return { default: module.AgentsScreen };
});
const ChannelScreen = React.lazy(async () => {
const module = await import("@/features/channels/ui/ChannelScreen");
return { default: module.ChannelScreen };
});
const WorkflowsScreen = React.lazy(async () => {
const module = await import("@/features/workflows/ui/WorkflowsScreen");
return { default: module.WorkflowsScreen };
});
function toSearchAnchor(target: DesktopNotificationTarget): SearchHit | null {
function toSearchHit(target: DesktopNotificationTarget): SearchHit | null {
if (!target.eventId) {
return null;
}
@@ -73,10 +63,41 @@ function toSearchAnchor(target: DesktopNotificationTarget): SearchHit | null {
};
}
function deriveShellRoute(pathname: string): {
selectedChannelId: string | null;
selectedView: AppView;
} {
if (pathname.startsWith("/channels/")) {
const [, , rawChannelId] = pathname.split("/");
return {
selectedChannelId: rawChannelId ? decodeURIComponent(rawChannelId) : null,
selectedView: "channel",
};
}
if (pathname === "/agents") {
return {
selectedChannelId: null,
selectedView: "agents",
};
}
if (pathname === "/workflows" || pathname.startsWith("/workflows/")) {
return {
selectedChannelId: null,
selectedView: "workflows",
};
}
return {
selectedChannelId: null,
selectedView: "home",
};
}
export function AppShell() {
useWebviewZoomShortcuts();
const [selectedView, setSelectedView] = React.useState<AppView>("home");
const [settingsOpen, setSettingsOpen] = React.useState(false);
const [settingsSection, setSettingsSection] = React.useState<SettingsSection>(
DEFAULT_SETTINGS_SECTION,
@@ -86,21 +107,16 @@ export function AppShell() {
const [isSearchOpen, setIsSearchOpen] = React.useState(false);
const [browseDialogType, setBrowseDialogType] =
React.useState<BrowseDialogType>(null);
const [searchAnchor, setSearchAnchor] = React.useState<SearchHit | null>(
null,
);
const [searchAnchorChannelId, setSearchAnchorChannelId] = React.useState<
string | null
>(null);
const [searchAnchorEvent, setSearchAnchorEvent] =
React.useState<RelayEvent | null>(null);
const location = useLocation();
const queryClient = useQueryClient();
const selectView = React.useCallback((view: AppView) => {
React.startTransition(() => {
setSelectedView(view);
});
}, []);
const { goAgents, goChannel, goHome, goWorkflows, openSearchHit } =
useAppNavigation();
const { canGoBack, canGoForward, goBack, goForward } =
useBackForwardControls();
const { selectedChannelId, selectedView } = React.useMemo(
() => deriveShellRoute(location.pathname),
[location.pathname],
);
const identityQuery = useIdentityQuery();
const profileQuery = useProfileQuery();
@@ -121,15 +137,13 @@ export function AppShell() {
() => channels.filter((channel) => channel.isMember),
[channels],
);
const availableChannelIds = React.useMemo(
() => new Set(channels.map((channel) => channel.id)),
[channels],
const activeChannel = React.useMemo(
() =>
selectedChannelId
? (channels.find((channel) => channel.id === selectedChannelId) ?? null)
: null,
[channels, selectedChannelId],
);
const { selectedChannel, setSelectedChannelId } = useSelectedChannel(
channels,
null,
);
const activeChannel = selectedView === "channel" ? selectedChannel : null;
const { markChannelRead, unreadChannelIds } = useUnreadChannels(
channels,
@@ -166,54 +180,6 @@ export function AppShell() {
}
}, []);
const resolveChannel = React.useCallback(
async (channelId: string): Promise<Channel | null> => {
const cachedChannels =
queryClient.getQueryData<Channel[]>(channelsQueryKey);
const knownChannel =
channels.find((channel) => channel.id === channelId) ??
cachedChannels?.find((channel) => channel.id === channelId) ??
null;
if (knownChannel) {
return knownChannel;
}
const refreshed = await refetchChannels();
return (
refreshed.data?.find((channel) => channel.id === channelId) ?? null
);
},
[channels, queryClient, refetchChannels],
);
const openChannelView = React.useCallback(
(channelId: string) => {
React.startTransition(() => {
setSelectedChannelId(channelId);
setSelectedView("channel");
});
},
[setSelectedChannelId],
);
const handleOpenChannel = React.useCallback(
async (channelId: string) => {
try {
const channel = await resolveChannel(channelId);
if (!channel) {
console.error("Failed to resolve channel before opening", channelId);
return;
}
openChannelView(channel.id);
} catch (error) {
console.error("Failed to open channel", channelId, error);
}
},
[openChannelView, resolveChannel],
);
const handleBrowseChannelJoin = React.useCallback(
async (channelId: string) => {
await joinChannel(channelId);
@@ -230,11 +196,11 @@ export function AppShell() {
return;
}
if (selectedChannel?.id === channelId) {
selectView("home");
if (selectedChannelId === channelId) {
void goHome();
}
},
[hideDmMutation, selectView, selectedChannel?.id],
[goHome, hideDmMutation, selectedChannelId],
);
const handleOpenSettings = React.useCallback(
@@ -251,55 +217,11 @@ export function AppShell() {
setSettingsOpen(false);
}, []);
const handleTargetReached = React.useCallback((messageId: string) => {
setSearchAnchor((current) =>
current?.eventId === messageId ? null : current,
);
}, []);
const openEventAnchor = React.useCallback(
(hit: SearchHit) => {
setSearchAnchor(hit);
setSearchAnchorChannelId(hit.channelId);
setSearchAnchorEvent({
id: hit.eventId,
pubkey: hit.pubkey,
created_at: hit.createdAt,
kind: hit.kind,
tags: hit.channelId ? [["h", hit.channelId]] : [],
content: hit.content,
sig: "",
});
if (hit.channelId) {
void handleOpenChannel(hit.channelId);
}
void getEventById(hit.eventId)
.then((event) => {
setSearchAnchorEvent((current) => {
if (current?.id !== hit.eventId) {
return current;
}
return event;
});
})
.catch((error) => {
console.error(
"Failed to load search result event",
hit.eventId,
error,
);
});
},
[handleOpenChannel],
);
const handleOpenSearchResult = React.useCallback(
(hit: SearchHit) => {
openEventAnchor(hit);
void openSearchHit(hit);
},
[openEventAnchor],
[openSearchHit],
);
const handleDesktopNotificationAction = React.useEffectEvent(
@@ -307,23 +229,17 @@ export function AppShell() {
await revealDesktopAppWindow();
if (!target.channelId) {
setSearchAnchor(null);
setSearchAnchorChannelId(null);
setSearchAnchorEvent(null);
selectView("home");
void goHome();
return;
}
const anchor = toSearchAnchor(target);
const anchor = toSearchHit(target);
if (!anchor) {
setSearchAnchor(null);
setSearchAnchorChannelId(null);
setSearchAnchorEvent(null);
await handleOpenChannel(target.channelId);
await goChannel(target.channelId);
return;
}
openEventAnchor(anchor);
await openSearchHit(anchor);
},
);
@@ -427,169 +343,171 @@ export function AppShell() {
}, [handleCloseSettings, handleOpenSettings, settingsOpen]);
return (
<ChannelNavigationProvider
channels={channels}
onOpenChannel={handleOpenChannel}
>
<SidebarProvider className="h-dvh overflow-hidden overscroll-none">
<SidebarTrigger className="fixed left-[80px] top-[8px] z-50 h-6 w-6 text-muted-foreground/70 hover:bg-muted/60 hover:text-foreground" />
<AppSidebar
channels={memberChannels}
currentPubkey={identityQuery.data?.pubkey}
errorMessage={
channelsQuery.error instanceof Error
? channelsQuery.error.message
: undefined
}
fallbackDisplayName={identityQuery.data?.displayName}
homeBadgeCount={homeBadgeCount}
isCreatingChannel={createChannelMutation.isPending}
isCreatingForum={createForumMutation.isPending}
isLoading={channelsQuery.isLoading}
isOpeningDm={openDmMutation.isPending}
isPresencePending={presenceSession.isPending}
selfPresenceStatus={presenceSession.currentStatus}
onCreateChannel={async ({
description,
name,
visibility,
ttlSeconds,
}) => {
const createdChannel = await createChannelMutation.mutateAsync({
name,
description,
channelType: "stream",
visibility,
ttlSeconds,
});
openChannelView(createdChannel.id);
}}
onCreateForum={async ({
description,
name,
visibility,
ttlSeconds,
}) => {
const createdForum = await createForumMutation.mutateAsync({
name,
description,
channelType: "forum",
visibility,
ttlSeconds,
});
openChannelView(createdForum.id);
}}
onHideDm={handleHideDm}
onOpenBrowseChannels={handleOpenBrowseChannels}
onOpenBrowseForums={handleOpenBrowseForums}
onOpenDm={async ({ pubkeys }) => {
const directMessage = await openDmMutation.mutateAsync({
pubkeys,
});
openChannelView(directMessage.id);
}}
onOpenSearch={handleOpenSearch}
onSelectAgents={() => selectView("agents")}
onSelectChannel={handleOpenChannel}
onSelectHome={() => {
selectView("home");
}}
onSelectSettings={handleOpenSettings}
onSelectWorkflows={() => selectView("workflows")}
onSetPresenceStatus={(status) => presenceSession.setStatus(status)}
profile={profileQuery.data}
selectedChannelId={selectedChannel?.id ?? null}
selectedView={selectedView}
unreadChannelIds={unreadChannelIds}
/>
<SidebarInset className="min-h-0 min-w-0 overflow-hidden">
{selectedView === "home" ? (
<HomeScreen
availableChannelIds={availableChannelIds}
currentPubkey={identityQuery.data?.pubkey}
onOpenChannel={handleOpenChannel}
/>
) : selectedView === "agents" ? (
<React.Suspense
fallback={<ViewLoadingFallback label="Loading agents..." />}
<ChannelNavigationProvider channels={channels}>
<AppShellProvider
value={{
markChannelRead,
openChannelManagement: () => {
setIsChannelManagementOpen(true);
},
}}
>
<SidebarProvider className="h-dvh overflow-hidden overscroll-none">
<div className="fixed left-[80px] top-[8px] z-50 flex items-center gap-1.5">
<SidebarTrigger className="h-6 w-6 text-muted-foreground/70 hover:bg-muted/60 hover:text-foreground" />
<Button
aria-label="Go back"
className="h-6 w-6 text-muted-foreground/70 hover:bg-muted/60 hover:text-foreground"
data-testid="global-back"
disabled={!canGoBack}
onClick={goBack}
size="icon"
variant="ghost"
>
<AgentsScreen />
</React.Suspense>
) : selectedView === "workflows" ? (
<React.Suspense
fallback={<ViewLoadingFallback label="Loading workflows..." />}
<ChevronLeft className="h-3.5 w-3.5" />
</Button>
<Button
aria-label="Go forward"
className="h-6 w-6 text-muted-foreground/70 hover:bg-muted/60 hover:text-foreground"
data-testid="global-forward"
disabled={!canGoForward}
onClick={goForward}
size="icon"
variant="ghost"
>
<WorkflowsScreen channels={memberChannels} />
</React.Suspense>
) : (
<React.Suspense
fallback={<ViewLoadingFallback label="Loading channel..." />}
>
<ChannelScreen
activeChannel={activeChannel}
currentIdentity={identityQuery.data}
currentProfile={profileQuery.data}
onManageChannel={() => {
setIsChannelManagementOpen(true);
}}
onMarkChannelRead={markChannelRead}
onTargetReached={handleTargetReached}
searchAnchor={searchAnchor}
searchAnchorChannelId={searchAnchorChannelId}
searchAnchorEvent={searchAnchorEvent}
/>
</React.Suspense>
)}
</SidebarInset>
<AppShellOverlays
activeChannel={activeChannel}
browseDialogType={browseDialogType}
channels={channels}
currentPubkey={identityQuery.data?.pubkey}
isChannelManagementOpen={isChannelManagementOpen}
isSearchOpen={isSearchOpen}
onBrowseChannelJoin={handleBrowseChannelJoin}
onBrowseDialogOpenChange={handleBrowseDialogOpenChange}
onChannelManagementOpenChange={setIsChannelManagementOpen}
onDeleteActiveChannel={() => {
setIsChannelManagementOpen(false);
selectView("home");
}}
onOpenSearchResult={handleOpenSearchResult}
onSearchOpenChange={setIsSearchOpen}
onSelectChannel={handleOpenChannel}
/>
{settingsOpen ? (
<SettingsScreen
<ChevronRight className="h-3.5 w-3.5" />
</Button>
</div>
<AppSidebar
channels={memberChannels}
currentPubkey={identityQuery.data?.pubkey}
errorMessage={
channelsQuery.error instanceof Error
? channelsQuery.error.message
: undefined
}
fallbackDisplayName={identityQuery.data?.displayName}
isUpdatingDesktopNotifications={
notificationSettings.isUpdatingDesktopEnabled
}
notificationErrorMessage={notificationSettings.errorMessage}
notificationPermission={notificationSettings.permission}
notificationSettings={notificationSettings.settings}
onClose={handleCloseSettings}
onSectionChange={setSettingsSection}
onSetDesktopNotificationsEnabled={
notificationSettings.setDesktopEnabled
}
onSetHomeBadgeEnabled={notificationSettings.setHomeBadgeEnabled}
onSetMentionNotificationsEnabled={
notificationSettings.setMentionsEnabled
}
onSetNeedsActionNotificationsEnabled={
notificationSettings.setNeedsActionEnabled
}
section={settingsSection}
homeBadgeCount={homeBadgeCount}
isCreatingChannel={createChannelMutation.isPending}
isCreatingForum={createForumMutation.isPending}
isLoading={channelsQuery.isLoading}
isOpeningDm={openDmMutation.isPending}
isPresencePending={presenceSession.isPending}
selfPresenceStatus={presenceSession.currentStatus}
onCreateChannel={async ({
description,
name,
visibility,
ttlSeconds,
}) => {
const createdChannel = await createChannelMutation.mutateAsync({
name,
description,
channelType: "stream",
visibility,
ttlSeconds,
});
await goChannel(createdChannel.id);
}}
onCreateForum={async ({
description,
name,
visibility,
ttlSeconds,
}) => {
const createdForum = await createForumMutation.mutateAsync({
name,
description,
channelType: "forum",
visibility,
ttlSeconds,
});
await goChannel(createdForum.id);
}}
onHideDm={handleHideDm}
onOpenBrowseChannels={handleOpenBrowseChannels}
onOpenBrowseForums={handleOpenBrowseForums}
onOpenDm={async ({ pubkeys }) => {
const directMessage = await openDmMutation.mutateAsync({
pubkeys,
});
await goChannel(directMessage.id);
}}
onOpenSearch={handleOpenSearch}
onSelectAgents={() => {
void goAgents();
}}
onSelectChannel={(channelId) => {
void goChannel(channelId);
}}
onSelectHome={() => {
void goHome();
}}
onSelectSettings={handleOpenSettings}
onSelectWorkflows={() => {
void goWorkflows();
}}
onSetPresenceStatus={(status) => presenceSession.setStatus(status)}
profile={profileQuery.data}
selectedChannelId={selectedChannelId}
selectedView={selectedView}
unreadChannelIds={unreadChannelIds}
/>
) : null}
</SidebarProvider>
<SidebarInset className="min-h-0 min-w-0 overflow-hidden">
<Outlet />
</SidebarInset>
<AppShellOverlays
activeChannel={activeChannel}
browseDialogType={browseDialogType}
channels={channels}
currentPubkey={identityQuery.data?.pubkey}
isChannelManagementOpen={isChannelManagementOpen}
isSearchOpen={isSearchOpen}
onBrowseChannelJoin={handleBrowseChannelJoin}
onBrowseDialogOpenChange={handleBrowseDialogOpenChange}
onChannelManagementOpenChange={setIsChannelManagementOpen}
onDeleteActiveChannel={() => {
setIsChannelManagementOpen(false);
void goHome({ replace: true });
}}
onOpenSearchResult={handleOpenSearchResult}
onSearchOpenChange={setIsSearchOpen}
onSelectChannel={(channelId) => {
void goChannel(channelId);
}}
/>
{settingsOpen ? (
<SettingsScreen
currentPubkey={identityQuery.data?.pubkey}
fallbackDisplayName={identityQuery.data?.displayName}
isUpdatingDesktopNotifications={
notificationSettings.isUpdatingDesktopEnabled
}
notificationErrorMessage={notificationSettings.errorMessage}
notificationPermission={notificationSettings.permission}
notificationSettings={notificationSettings.settings}
onClose={handleCloseSettings}
onSectionChange={setSettingsSection}
onSetDesktopNotificationsEnabled={
notificationSettings.setDesktopEnabled
}
onSetHomeBadgeEnabled={notificationSettings.setHomeBadgeEnabled}
onSetMentionNotificationsEnabled={
notificationSettings.setMentionsEnabled
}
onSetNeedsActionNotificationsEnabled={
notificationSettings.setNeedsActionEnabled
}
section={settingsSection}
/>
) : null}
</SidebarProvider>
</AppShellProvider>
</ChannelNavigationProvider>
);
}
+32
View File
@@ -0,0 +1,32 @@
import * as React from "react";
type AppShellContextValue = {
markChannelRead: (
channelId: string,
readAt: string | null | undefined,
) => void;
openChannelManagement: () => void;
};
const AppShellContext = React.createContext<AppShellContextValue>({
markChannelRead: () => {},
openChannelManagement: () => {},
});
export function AppShellProvider({
children,
value,
}: {
children: React.ReactNode;
value: AppShellContextValue;
}) {
return (
<AppShellContext.Provider value={value}>
{children}
</AppShellContext.Provider>
);
}
export function useAppShell() {
return React.useContext(AppShellContext);
}
@@ -0,0 +1,72 @@
import { getThreadReference } from "@/features/messages/lib/threading";
import { getEventById } from "@/shared/api/tauri";
import type { SearchHit } from "@/shared/api/types";
import { KIND_FORUM_COMMENT, KIND_FORUM_POST } from "@/shared/constants/kinds";
export type SearchHitDestination =
| {
kind: "channel";
channelId: string;
messageId?: string;
}
| {
kind: "forum-post";
channelId: string;
postId: string;
replyId?: string;
};
export async function resolveSearchHitDestination(
hit: SearchHit,
): Promise<SearchHitDestination | null> {
if (!hit.channelId) {
return null;
}
if (hit.kind === KIND_FORUM_POST) {
return {
kind: "forum-post",
channelId: hit.channelId,
postId: hit.eventId,
};
}
if (hit.kind === KIND_FORUM_COMMENT) {
try {
const event = await getEventById(hit.eventId);
const thread = getThreadReference(event.tags);
const postId = thread.rootId ?? thread.parentId ?? null;
if (!postId) {
return {
kind: "channel",
channelId: hit.channelId,
};
}
return {
kind: "forum-post",
channelId: hit.channelId,
postId,
replyId: hit.eventId,
};
} catch (error) {
console.error(
"Failed to resolve forum reply search destination",
hit.eventId,
error,
);
return {
kind: "channel",
channelId: hit.channelId,
};
}
}
return {
kind: "channel",
channelId: hit.channelId,
messageId: hit.eventId,
};
}
@@ -0,0 +1,49 @@
import type { RelayEvent, SearchHit } from "@/shared/api/types";
const MAX_CACHED_EVENTS = 200;
const searchHitEventCache = new Map<string, RelayEvent>();
function trimCache() {
if (searchHitEventCache.size <= MAX_CACHED_EVENTS) {
return;
}
const overflow = searchHitEventCache.size - MAX_CACHED_EVENTS;
let removed = 0;
for (const key of searchHitEventCache.keys()) {
if (removed >= overflow) {
break;
}
searchHitEventCache.delete(key);
removed++;
}
}
export function buildSearchHitEvent(hit: SearchHit): RelayEvent {
return {
id: hit.eventId,
pubkey: hit.pubkey,
created_at: hit.createdAt,
kind: hit.kind,
tags: hit.channelId ? [["h", hit.channelId]] : [],
content: hit.content,
sig: "",
};
}
export function cacheSearchHitEvent(hit: SearchHit): RelayEvent {
const event = buildSearchHitEvent(hit);
searchHitEventCache.set(event.id, event);
trimCache();
return event;
}
export function getCachedSearchHitEvent(
eventId: string | null | undefined,
): RelayEvent | null {
if (!eventId) {
return null;
}
return searchHitEventCache.get(eventId) ?? null;
}
@@ -0,0 +1,200 @@
import * as React from "react";
import {
useCanGoBack,
useLocation,
useNavigate,
useRouter,
} from "@tanstack/react-router";
import { cacheSearchHitEvent } from "@/app/navigation/searchHitEventCache";
import { resolveSearchHitDestination } from "@/app/navigation/resolveSearchHitDestination";
import type { SearchHit } from "@/shared/api/types";
type NavigationBehavior = {
replace?: boolean;
resetScroll?: boolean;
};
export function useAppNavigation() {
const router = useRouter();
const navigate = useNavigate();
const location = useLocation();
const canGoBack = useCanGoBack();
const commitNavigation = React.useCallback(
async (
next: {
to: string;
params?: Record<string, string>;
search?: Record<string, string | undefined>;
},
behavior: NavigationBehavior = {},
) => {
const nextLocation = router.buildLocation(next as never);
if (location.href === nextLocation.href) {
return false;
}
await navigate({
...next,
replace: behavior.replace,
resetScroll: behavior.resetScroll,
} as never);
return true;
},
[location.href, navigate, router],
);
const goHome = React.useCallback(
(behavior?: NavigationBehavior) =>
commitNavigation(
{
to: "/",
},
behavior,
),
[commitNavigation],
);
const goAgents = React.useCallback(
(behavior?: NavigationBehavior) =>
commitNavigation(
{
to: "/agents",
},
behavior,
),
[commitNavigation],
);
const goWorkflows = React.useCallback(
(behavior?: NavigationBehavior) =>
commitNavigation(
{
to: "/workflows",
},
behavior,
),
[commitNavigation],
);
const goWorkflow = React.useCallback(
(workflowId: string, behavior?: NavigationBehavior) =>
commitNavigation(
{
to: "/workflows/$workflowId",
params: {
workflowId,
},
},
behavior,
),
[commitNavigation],
);
const goChannel = React.useCallback(
(
channelId: string,
options?: {
messageId?: string;
replace?: boolean;
},
) =>
commitNavigation(
{
to: "/channels/$channelId",
params: {
channelId,
},
search: options?.messageId ? { messageId: options.messageId } : {},
},
{
replace: options?.replace,
resetScroll: options?.messageId ? false : undefined,
},
),
[commitNavigation],
);
const goForumPost = React.useCallback(
(
channelId: string,
postId: string,
options?: {
replace?: boolean;
replyId?: string;
},
) =>
commitNavigation(
{
to: "/channels/$channelId/posts/$postId",
params: {
channelId,
postId,
},
search: options?.replyId ? { replyId: options.replyId } : {},
},
{
replace: options?.replace,
resetScroll: false,
},
),
[commitNavigation],
);
const closeWorkflowDetail = React.useCallback(() => {
if (canGoBack) {
router.history.back();
return;
}
void goWorkflows({ replace: true });
}, [canGoBack, goWorkflows, router.history]);
const closeForumPost = React.useCallback(
(channelId: string) => {
if (canGoBack) {
router.history.back();
return;
}
void goChannel(channelId, { replace: true });
},
[canGoBack, goChannel, router.history],
);
const openSearchHit = React.useCallback(
async (hit: SearchHit) => {
cacheSearchHitEvent(hit);
const destination = await resolveSearchHitDestination(hit);
if (!destination) {
return false;
}
if (destination.kind === "forum-post") {
return goForumPost(destination.channelId, destination.postId, {
replyId: destination.replyId,
});
}
return goChannel(destination.channelId, {
messageId: destination.messageId,
});
},
[goChannel, goForumPost],
);
return {
closeForumPost,
closeWorkflowDetail,
goAgents,
goChannel,
goForumPost,
goHome,
goWorkflow,
goWorkflows,
openSearchHit,
};
}
@@ -0,0 +1,137 @@
import * as React from "react";
import {
useCanGoBack,
useRouter,
useRouterState,
} from "@tanstack/react-router";
type RouterHistoryState = {
__TSR_index?: number;
__TSR_key?: string;
key?: string;
};
function isEditableTarget(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) {
return false;
}
return (
target.isContentEditable ||
target.closest(
'input, textarea, select, [contenteditable=""], [contenteditable="true"]',
) !== null
);
}
function isMacPlatform() {
return window.navigator.platform.toLowerCase().includes("mac");
}
export function useBackForwardControls() {
const router = useRouter();
const canGoBack = useCanGoBack();
const locationState = useRouterState({
select: (state) => state.location.state,
}) as RouterHistoryState;
const locationIndex = locationState.__TSR_index ?? 0;
const locationKey =
locationState.__TSR_key ?? locationState.key ?? String(locationIndex);
const keysByIndexRef = React.useRef(new Map<number, string>());
const [maxIndex, setMaxIndex] = React.useState(locationIndex);
React.useEffect(() => {
const keysByIndex = keysByIndexRef.current;
const currentKey = keysByIndex.get(locationIndex);
if (currentKey && currentKey !== locationKey) {
for (const storedIndex of [...keysByIndex.keys()]) {
if (storedIndex >= locationIndex) {
keysByIndex.delete(storedIndex);
}
}
}
keysByIndex.set(locationIndex, locationKey);
setMaxIndex((current: number) => {
if (currentKey && currentKey !== locationKey) {
return locationIndex;
}
return Math.max(current, locationIndex);
});
}, [locationIndex, locationKey]);
const canGoForward = locationIndex < maxIndex;
const goBack = React.useCallback(() => {
if (!canGoBack) {
return;
}
router.history.back();
}, [canGoBack, router.history]);
const goForward = React.useCallback(() => {
if (!canGoForward) {
return;
}
router.history.forward();
}, [canGoForward, router.history]);
const handleKeyDown = React.useEffectEvent((event: KeyboardEvent) => {
if (isEditableTarget(event.target)) {
return;
}
const isMac = isMacPlatform();
const isBackShortcut = isMac
? event.metaKey &&
!event.ctrlKey &&
!event.altKey &&
!event.shiftKey &&
(event.key === "[" || event.code === "BracketLeft")
: event.altKey &&
!event.metaKey &&
!event.ctrlKey &&
!event.shiftKey &&
event.key === "ArrowLeft";
const isForwardShortcut = isMac
? event.metaKey &&
!event.ctrlKey &&
!event.altKey &&
!event.shiftKey &&
(event.key === "]" || event.code === "BracketRight")
: event.altKey &&
!event.metaKey &&
!event.ctrlKey &&
!event.shiftKey &&
event.key === "ArrowRight";
if (isBackShortcut) {
event.preventDefault();
goBack();
return;
}
if (isForwardShortcut) {
event.preventDefault();
goForward();
}
});
React.useEffect(() => {
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
};
}, []);
return {
canGoBack,
canGoForward,
goBack,
goForward,
};
}
+166
View File
@@ -0,0 +1,166 @@
// biome-ignore-all lint: generated by TanStack Router
// This file was automatically generated by TanStack Router.
// You should NOT make any changes in this file as it will be overwritten.
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from "./routes/root";
import { Route as workflowsRouteImport } from "./routes/workflows";
import { Route as agentsRouteImport } from "./routes/agents";
import { Route as indexRouteImport } from "./routes/index";
import { Route as workflowsDotworkflowIdRouteImport } from "./routes/workflows.$workflowId";
import { Route as channelsDotchannelIdRouteImport } from "./routes/channels.$channelId";
import { Route as channelsDotchannelIdDotpostsDotpostIdRouteImport } from "./routes/channels.$channelId.posts.$postId";
const workflowsRoute = workflowsRouteImport.update({
id: "/workflows",
path: "/workflows",
getParentRoute: () => rootRouteImport,
} as any);
const agentsRoute = agentsRouteImport.update({
id: "/agents",
path: "/agents",
getParentRoute: () => rootRouteImport,
} as any);
const indexRoute = indexRouteImport.update({
id: "/",
path: "/",
getParentRoute: () => rootRouteImport,
} as any);
const workflowsDotworkflowIdRoute = workflowsDotworkflowIdRouteImport.update({
id: "/workflows/$workflowId",
path: "/workflows/$workflowId",
getParentRoute: () => rootRouteImport,
} as any);
const channelsDotchannelIdRoute = channelsDotchannelIdRouteImport.update({
id: "/channels/$channelId",
path: "/channels/$channelId",
getParentRoute: () => rootRouteImport,
} as any);
const channelsDotchannelIdDotpostsDotpostIdRoute =
channelsDotchannelIdDotpostsDotpostIdRouteImport.update({
id: "/channels/$channelId/posts/$postId",
path: "/channels/$channelId/posts/$postId",
getParentRoute: () => rootRouteImport,
} as any);
export interface FileRoutesByFullPath {
"/": typeof indexRoute;
"/agents": typeof agentsRoute;
"/workflows": typeof workflowsRoute;
"/channels/$channelId": typeof channelsDotchannelIdRoute;
"/workflows/$workflowId": typeof workflowsDotworkflowIdRoute;
"/channels/$channelId/posts/$postId": typeof channelsDotchannelIdDotpostsDotpostIdRoute;
}
export interface FileRoutesByTo {
"/": typeof indexRoute;
"/agents": typeof agentsRoute;
"/workflows": typeof workflowsRoute;
"/channels/$channelId": typeof channelsDotchannelIdRoute;
"/workflows/$workflowId": typeof workflowsDotworkflowIdRoute;
"/channels/$channelId/posts/$postId": typeof channelsDotchannelIdDotpostsDotpostIdRoute;
}
export interface FileRoutesById {
__root__: typeof rootRouteImport;
"/": typeof indexRoute;
"/agents": typeof agentsRoute;
"/workflows": typeof workflowsRoute;
"/channels/$channelId": typeof channelsDotchannelIdRoute;
"/workflows/$workflowId": typeof workflowsDotworkflowIdRoute;
"/channels/$channelId/posts/$postId": typeof channelsDotchannelIdDotpostsDotpostIdRoute;
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath;
fullPaths:
| "/"
| "/agents"
| "/workflows"
| "/channels/$channelId"
| "/workflows/$workflowId"
| "/channels/$channelId/posts/$postId";
fileRoutesByTo: FileRoutesByTo;
to:
| "/"
| "/agents"
| "/workflows"
| "/channels/$channelId"
| "/workflows/$workflowId"
| "/channels/$channelId/posts/$postId";
id:
| "__root__"
| "/"
| "/agents"
| "/workflows"
| "/channels/$channelId"
| "/workflows/$workflowId"
| "/channels/$channelId/posts/$postId";
fileRoutesById: FileRoutesById;
}
export interface RootRouteChildren {
indexRoute: typeof indexRoute;
agentsRoute: typeof agentsRoute;
workflowsRoute: typeof workflowsRoute;
channelsDotchannelIdRoute: typeof channelsDotchannelIdRoute;
workflowsDotworkflowIdRoute: typeof workflowsDotworkflowIdRoute;
channelsDotchannelIdDotpostsDotpostIdRoute: typeof channelsDotchannelIdDotpostsDotpostIdRoute;
}
declare module "@tanstack/react-router" {
interface FileRoutesByPath {
"/workflows": {
id: "/workflows";
path: "/workflows";
fullPath: "/workflows";
preLoaderRoute: typeof workflowsRouteImport;
parentRoute: typeof rootRouteImport;
};
"/agents": {
id: "/agents";
path: "/agents";
fullPath: "/agents";
preLoaderRoute: typeof agentsRouteImport;
parentRoute: typeof rootRouteImport;
};
"/": {
id: "/";
path: "/";
fullPath: "/";
preLoaderRoute: typeof indexRouteImport;
parentRoute: typeof rootRouteImport;
};
"/workflows/$workflowId": {
id: "/workflows/$workflowId";
path: "/workflows/$workflowId";
fullPath: "/workflows/$workflowId";
preLoaderRoute: typeof workflowsDotworkflowIdRouteImport;
parentRoute: typeof rootRouteImport;
};
"/channels/$channelId": {
id: "/channels/$channelId";
path: "/channels/$channelId";
fullPath: "/channels/$channelId";
preLoaderRoute: typeof channelsDotchannelIdRouteImport;
parentRoute: typeof rootRouteImport;
};
"/channels/$channelId/posts/$postId": {
id: "/channels/$channelId/posts/$postId";
path: "/channels/$channelId/posts/$postId";
fullPath: "/channels/$channelId/posts/$postId";
preLoaderRoute: typeof channelsDotchannelIdDotpostsDotpostIdRouteImport;
parentRoute: typeof rootRouteImport;
};
}
}
const rootRouteChildren: RootRouteChildren = {
indexRoute: indexRoute,
agentsRoute: agentsRoute,
workflowsRoute: workflowsRoute,
channelsDotchannelIdRoute: channelsDotchannelIdRoute,
workflowsDotworkflowIdRoute: workflowsDotworkflowIdRoute,
channelsDotchannelIdDotpostsDotpostIdRoute:
channelsDotchannelIdDotpostsDotpostIdRoute,
};
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>();
+17
View File
@@ -0,0 +1,17 @@
import { createHashHistory, createRouter } from "@tanstack/react-router";
import { routeTree } from "@/app/routeTree.gen";
export const router = createRouter({
routeTree,
history: createHashHistory(),
scrollRestoration: true,
getScrollRestorationKey: (location: { pathname: string }) =>
location.pathname,
});
declare module "@tanstack/react-router" {
interface Register {
router: typeof router;
}
}
+13
View File
@@ -0,0 +1,13 @@
import { index, rootRoute, route } from "@tanstack/virtual-file-routes";
export const routes = rootRoute("root.tsx", [
index("index.tsx"),
route("/agents", "agents.tsx"),
route("/workflows", "workflows.tsx"),
route("/workflows/$workflowId", "workflows.$workflowId.tsx"),
route("/channels/$channelId", "channels.$channelId.tsx"),
route(
"/channels/$channelId/posts/$postId",
"channels.$channelId.posts.$postId.tsx",
),
]);
+23
View File
@@ -0,0 +1,23 @@
import * as React from "react";
import { createFileRoute } from "@tanstack/react-router";
import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback";
const AgentsScreen = React.lazy(async () => {
const module = await import("@/features/agents/ui/AgentsScreen");
return { default: module.AgentsScreen };
});
export const Route = createFileRoute("/agents")({
component: AgentsRouteComponent,
});
function AgentsRouteComponent() {
return (
<React.Suspense
fallback={<ViewLoadingFallback includeHeader kind="agents" />}
>
<AgentsScreen />
</React.Suspense>
);
}
@@ -0,0 +1,37 @@
import { createFileRoute } from "@tanstack/react-router";
import { ChannelRouteScreen } from "@/app/routes/channels.$channelId";
type ForumPostRouteSearch = {
replyId?: string;
};
function validateForumPostSearch(
search: Record<string, unknown>,
): ForumPostRouteSearch {
return {
replyId:
typeof search.replyId === "string" && search.replyId.length > 0
? search.replyId
: undefined,
};
}
export const Route = createFileRoute("/channels/$channelId/posts/$postId")({
validateSearch: validateForumPostSearch,
component: ForumPostRouteComponent,
});
function ForumPostRouteComponent() {
const { channelId, postId } = Route.useParams();
const search = Route.useSearch();
return (
<ChannelRouteScreen
channelId={channelId}
selectedPostId={postId}
targetMessageId={null}
targetReplyId={search.replyId ?? null}
/>
);
}
@@ -0,0 +1,124 @@
import * as React from "react";
import { createFileRoute } from "@tanstack/react-router";
import { getCachedSearchHitEvent } from "@/app/navigation/searchHitEventCache";
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
import { useChannelsQuery } from "@/features/channels/hooks";
import { ChannelScreen } from "@/features/channels/ui/ChannelScreen";
import { useProfileQuery } from "@/features/profile/hooks";
import { useIdentityQuery } from "@/shared/api/hooks";
import { getEventById } from "@/shared/api/tauri";
import type { RelayEvent } from "@/shared/api/types";
import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback";
type ChannelRouteSearch = {
messageId?: string;
};
function validateChannelSearch(
search: Record<string, unknown>,
): ChannelRouteSearch {
return {
messageId:
typeof search.messageId === "string" && search.messageId.length > 0
? search.messageId
: undefined,
};
}
export const Route = createFileRoute("/channels/$channelId")({
validateSearch: validateChannelSearch,
component: ChannelRouteComponent,
});
export function ChannelRouteScreen({
channelId,
selectedPostId,
targetMessageId,
targetReplyId,
}: {
channelId: string;
selectedPostId: string | null;
targetMessageId: string | null;
targetReplyId: string | null;
}) {
const { closeForumPost, goForumPost } = useAppNavigation();
const channelsQuery = useChannelsQuery();
const identityQuery = useIdentityQuery();
const profileQuery = useProfileQuery();
const channels = channelsQuery.data ?? [];
const activeChannel =
channels.find((channel) => channel.id === channelId) ?? null;
const [targetMessageEvent, setTargetMessageEvent] =
React.useState<RelayEvent | null>(() =>
getCachedSearchHitEvent(targetMessageId),
);
React.useEffect(() => {
let isCancelled = false;
if (!targetMessageId || selectedPostId) {
setTargetMessageEvent(null);
return () => {
isCancelled = true;
};
}
setTargetMessageEvent(getCachedSearchHitEvent(targetMessageId));
void getEventById(targetMessageId)
.then((event) => {
if (!isCancelled) {
setTargetMessageEvent(event);
}
})
.catch((error) => {
if (!isCancelled) {
console.error(
"Failed to load route target event",
targetMessageId,
error,
);
}
});
return () => {
isCancelled = true;
};
}, [selectedPostId, targetMessageId]);
if (channelsQuery.isPending && !activeChannel) {
return <ViewLoadingFallback includeHeader kind="channel" />;
}
return (
<ChannelScreen
activeChannel={activeChannel}
currentIdentity={identityQuery.data}
currentProfile={profileQuery.data}
onCloseForumPost={() => {
void closeForumPost(channelId);
}}
onSelectForumPost={(postId) => {
void goForumPost(channelId, postId);
}}
selectedForumPostId={selectedPostId}
targetForumReplyId={targetReplyId}
targetMessageEvent={targetMessageEvent}
targetMessageId={targetMessageId}
/>
);
}
function ChannelRouteComponent() {
const { channelId } = Route.useParams();
const search = Route.useSearch();
return (
<ChannelRouteScreen
channelId={channelId}
selectedPostId={null}
targetMessageId={search.messageId ?? null}
targetReplyId={null}
/>
);
}
+28
View File
@@ -0,0 +1,28 @@
import { createFileRoute } from "@tanstack/react-router";
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
import { useChannelsQuery } from "@/features/channels/hooks";
import { useIdentityQuery } from "@/shared/api/hooks";
import { HomeScreen } from "@/features/home/ui/HomeScreen";
export const Route = createFileRoute("/")({
component: HomeRouteComponent,
});
function HomeRouteComponent() {
const { goChannel } = useAppNavigation();
const channelsQuery = useChannelsQuery();
const identityQuery = useIdentityQuery();
const channels = channelsQuery.data ?? [];
const availableChannelIds = new Set(channels.map((channel) => channel.id));
return (
<HomeScreen
availableChannelIds={availableChannelIds}
currentPubkey={identityQuery.data?.pubkey}
onOpenChannel={(channelId) => {
void goChannel(channelId);
}}
/>
);
}
+7
View File
@@ -0,0 +1,7 @@
import { createRootRoute } from "@tanstack/react-router";
import { AppShell } from "@/app/AppShell";
export const Route = createRootRoute({
component: AppShell,
});
@@ -0,0 +1,13 @@
import { createFileRoute } from "@tanstack/react-router";
import { WorkflowsRouteScreen } from "@/app/routes/workflows";
export const Route = createFileRoute("/workflows/$workflowId")({
component: WorkflowDetailRouteComponent,
});
function WorkflowDetailRouteComponent() {
const { workflowId } = Route.useParams();
return <WorkflowsRouteScreen selectedWorkflowId={workflowId} />;
}
+35
View File
@@ -0,0 +1,35 @@
import { createFileRoute } from "@tanstack/react-router";
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
import { useChannelsQuery } from "@/features/channels/hooks";
import { WorkflowsScreen } from "@/features/workflows/ui/WorkflowsScreen";
export const Route = createFileRoute("/workflows")({
component: WorkflowsRouteComponent,
});
export function WorkflowsRouteScreen({
selectedWorkflowId,
}: {
selectedWorkflowId: string | null;
}) {
const { closeWorkflowDetail, goWorkflow } = useAppNavigation();
const channelsQuery = useChannelsQuery();
const channels = channelsQuery.data ?? [];
const memberChannels = channels.filter((channel) => channel.isMember);
return (
<WorkflowsScreen
channels={memberChannels}
onCloseWorkflow={closeWorkflowDetail}
onSelectWorkflow={(workflowId) => {
void goWorkflow(workflowId);
}}
selectedWorkflowId={selectedWorkflowId}
/>
);
}
function WorkflowsRouteComponent() {
return <WorkflowsRouteScreen selectedWorkflowId={null} />;
}
@@ -17,9 +17,7 @@ export function AgentsScreen() {
title="Agents"
/>
<React.Suspense
fallback={<ViewLoadingFallback label="Loading agents..." />}
>
<React.Suspense fallback={<ViewLoadingFallback kind="agents" />}>
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
<AgentsView />
</div>
@@ -32,7 +32,7 @@ type ChannelPaneProps = {
mentionPubkeys: string[],
mediaTags?: string[][],
) => Promise<void>;
onTargetReached: (messageId: string) => void;
onTargetReached?: (messageId: string) => void;
onToggleReaction?: (
message: TimelineMessage,
emoji: string,
@@ -1,6 +1,7 @@
import * as React from "react";
import { useQueryClient } from "@tanstack/react-query";
import { useAppShell } from "@/app/AppShellContext";
import { ChatHeader } from "@/features/chat/ui/ChatHeader";
import { useActiveChannelHeader } from "@/features/channels/useActiveChannelHeader";
import { useChannelPaneHandlers } from "@/features/channels/useChannelPaneHandlers";
@@ -36,7 +37,6 @@ import type {
Identity,
Profile,
RelayEvent,
SearchHit,
} from "@/shared/api/types";
import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback";
@@ -54,29 +54,27 @@ type ChannelScreenProps = {
activeChannel: Channel | null;
currentIdentity?: Identity;
currentProfile?: Profile;
onManageChannel: () => void;
onMarkChannelRead: (
channelId: string,
readAt: string | null | undefined,
) => void;
onTargetReached: (messageId: string) => void;
searchAnchor: SearchHit | null;
searchAnchorChannelId: string | null;
searchAnchorEvent: RelayEvent | null;
onCloseForumPost: () => void;
onSelectForumPost: (postId: string) => void;
selectedForumPostId: string | null;
targetForumReplyId: string | null;
targetMessageEvent: RelayEvent | null;
targetMessageId: string | null;
};
export function ChannelScreen({
activeChannel,
currentIdentity,
currentProfile,
onManageChannel,
onMarkChannelRead,
onTargetReached,
searchAnchor,
searchAnchorChannelId,
searchAnchorEvent,
onCloseForumPost,
onSelectForumPost,
selectedForumPostId,
targetForumReplyId,
targetMessageEvent,
targetMessageId,
}: ChannelScreenProps) {
const queryClient = useQueryClient();
const { markChannelRead, openChannelManagement } = useAppShell();
const [isMembersSidebarOpen, setIsMembersSidebarOpen] = React.useState(false);
const [replyTargetId, setReplyTargetId] = React.useState<string | null>(null);
const [editTargetId, setEditTargetId] = React.useState<string | null>(null);
@@ -98,8 +96,8 @@ export function ChannelScreen({
return;
}
onMarkChannelRead(activeChannelId, activeReadAt);
}, [activeChannelId, activeReadAt, onMarkChannelRead]);
markChannelRead(activeChannelId, activeReadAt);
}, [activeChannelId, activeReadAt, markChannelRead]);
const { activeChannelTitle, activeDmPresenceStatus } = useActiveChannelHeader(
activeChannel,
@@ -116,21 +114,12 @@ export function ChannelScreen({
const resolvedMessages = React.useMemo(() => {
const currentMessages = messagesQuery.data ?? [];
if (
!activeChannel ||
!searchAnchorEvent ||
searchAnchorChannelId !== activeChannel.id
) {
if (!activeChannel || !targetMessageEvent) {
return currentMessages;
}
return mergeMessages(currentMessages, searchAnchorEvent);
}, [
activeChannel,
messagesQuery.data,
searchAnchorChannelId,
searchAnchorEvent,
]);
return mergeMessages(currentMessages, targetMessageEvent);
}, [activeChannel, messagesQuery.data, targetMessageEvent]);
const messageAuthorPubkeys = React.useMemo(
() => collectMessageAuthorPubkeys(resolvedMessages),
[resolvedMessages],
@@ -190,24 +179,6 @@ export function ChannelScreen({
timelineMessages.find((message) => message.id === editTargetId) ?? null,
[editTargetId, timelineMessages],
);
const forumTargetEventId =
activeChannel &&
activeChannel.channelType === "forum" &&
searchAnchorChannelId === activeChannel.id
? (searchAnchor?.eventId ?? null)
: null;
const forumTargetEventKind =
activeChannel &&
activeChannel.channelType === "forum" &&
searchAnchorChannelId === activeChannel.id
? (searchAnchor?.kind ?? null)
: null;
const forumTargetEvent =
activeChannel &&
activeChannel.channelType === "forum" &&
searchAnchorChannelId === activeChannel.id
? searchAnchorEvent
: null;
const {
handleCancelEdit,
@@ -372,7 +343,7 @@ export function ChannelScreen({
<ChannelMembersBar
channel={activeChannel}
currentPubkey={currentPubkey}
onManageChannel={onManageChannel}
onManageChannel={openChannelManagement}
onToggleMembers={() => setIsMembersSidebarOpen((prev) => !prev)}
/>
) : null
@@ -394,22 +365,18 @@ export function ChannelScreen({
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
{activeChannel ? (
activeChannel.channelType === "forum" ? (
<React.Suspense
fallback={<ViewLoadingFallback label="Loading forum..." />}
>
<React.Suspense fallback={<ViewLoadingFallback kind="forum" />}>
<ForumView
channel={activeChannel}
currentPubkey={currentPubkey}
onTargetReached={onTargetReached}
targetEvent={forumTargetEvent}
targetEventId={forumTargetEventId}
targetEventKind={forumTargetEventKind}
onClosePost={onCloseForumPost}
onSelectPost={onSelectForumPost}
selectedPostId={selectedForumPostId}
targetReplyId={targetForumReplyId}
/>
</React.Suspense>
) : (
<React.Suspense
fallback={<ViewLoadingFallback label="Loading channel..." />}
>
<React.Suspense fallback={<ViewLoadingFallback kind="channel" />}>
<ChannelPane
activeChannel={activeChannel}
currentPubkey={currentPubkey}
@@ -435,16 +402,11 @@ export function ChannelScreen({
onEditSave={handleEditSave}
onReply={handleReply}
onSend={handleSend}
onTargetReached={onTargetReached}
onToggleReaction={effectiveToggleReaction}
profiles={messageProfiles}
replyTargetId={replyTargetId}
replyTargetMessage={replyTargetMessage}
targetMessageId={
activeChannel && searchAnchor?.channelId === activeChannel.id
? searchAnchor.eventId
: null
}
targetMessageId={targetMessageId}
typingPubkeys={typingPubkeys}
/>
</React.Suspense>
@@ -267,7 +267,11 @@ export function ForumThreadPanel({
</Button>
</div>
<div className="flex-1 overflow-y-auto" ref={scrollRef}>
<div
className="flex-1 overflow-y-auto"
data-scroll-restoration-id={`forum-thread:${channelId}`}
ref={scrollRef}
>
{/* Original post */}
<div
className={cn(
+27 -52
View File
@@ -1,11 +1,9 @@
import { MessageSquareText } from "lucide-react";
import * as React from "react";
import { getThreadReference } from "@/features/messages/lib/threading";
import { useProfileQuery, useUsersBatchQuery } from "@/features/profile/hooks";
import { mergeCurrentProfileIntoLookup } from "@/features/profile/lib/identity";
import type { Channel, RelayEvent } from "@/shared/api/types";
import { KIND_FORUM_COMMENT, KIND_FORUM_POST } from "@/shared/constants/kinds";
import type { Channel } from "@/shared/api/types";
import { Skeleton } from "@/shared/ui/skeleton";
import {
@@ -23,10 +21,11 @@ import { ForumThreadPanel } from "./ForumThreadPanel";
type ForumViewProps = {
channel: Channel;
currentPubkey?: string;
onClosePost: () => void;
onSelectPost: (postId: string) => void;
onTargetReached?: (messageId: string) => void;
targetEvent: RelayEvent | null;
targetEventId: string | null;
targetEventKind: number | null;
selectedPostId: string | null;
targetReplyId: string | null;
};
function canDelete(postPubkey: string, currentPubkey?: string): boolean {
@@ -39,28 +38,26 @@ function canDelete(postPubkey: string, currentPubkey?: string): boolean {
export function ForumView({
channel,
currentPubkey,
onClosePost,
onSelectPost,
onTargetReached,
targetEvent,
targetEventId,
targetEventKind,
selectedPostId,
targetReplyId,
}: ForumViewProps) {
const [expandedPostId, setExpandedPostId] = React.useState<string | null>(
null,
);
const [isComposerOpen, setIsComposerOpen] = React.useState(false);
const profileQuery = useProfileQuery();
const postsQuery = useForumPostsQuery(channel);
const threadQuery = useForumThreadQuery(
expandedPostId ? channel.id : null,
expandedPostId,
selectedPostId ? channel.id : null,
selectedPostId,
);
const createPostMutation = useCreateForumPostMutation(channel);
const createReplyMutation = useCreateForumReplyMutation(channel);
const deletePostMutation = useDeleteForumPostMutation(channel);
const deleteReplyMutation = useDeleteForumReplyMutation(
channel,
expandedPostId,
selectedPostId,
);
const posts = postsQuery.data?.posts ?? [];
@@ -97,40 +94,18 @@ export function ForumView({
),
[profileQuery.data, profilesQuery.data?.profiles],
);
const targetThreadId = React.useMemo(() => {
if (!targetEventId) {
return null;
}
if (targetEventKind === KIND_FORUM_POST) {
return targetEventId;
}
if (!targetEvent || targetEvent.kind !== KIND_FORUM_COMMENT) {
return null;
}
const thread = getThreadReference(targetEvent.tags);
return thread.rootId ?? thread.parentId ?? null;
}, [targetEvent, targetEventId, targetEventKind]);
// Reset expanded post when channel changes
const previousChannelIdRef = React.useRef(channel.id);
if (previousChannelIdRef.current !== channel.id) {
previousChannelIdRef.current = channel.id;
setExpandedPostId(null);
setIsComposerOpen(false);
}
React.useEffect(() => {
if (!targetThreadId || expandedPostId === targetThreadId) {
if (previousChannelIdRef.current === channel.id) {
return;
}
setExpandedPostId(targetThreadId);
}, [expandedPostId, targetThreadId]);
previousChannelIdRef.current = channel.id;
setIsComposerOpen(false);
}, [channel.id]);
if (expandedPostId) {
if (selectedPostId) {
const threadPost = threadQuery.data?.post;
const canDeleteExpandedPost = threadPost
? canDelete(threadPost.pubkey, effectiveCurrentPubkey)
@@ -143,12 +118,9 @@ export function ForumView({
isDeletingPost={deletePostMutation.isPending}
isLoading={threadQuery.isLoading}
isSendingReply={createReplyMutation.isPending}
onBack={() => setExpandedPostId(null)}
onBack={onClosePost}
onDeletePost={(eventId) => {
deletePostMutation.mutate(
{ eventId },
{ onSuccess: () => setExpandedPostId(null) },
);
deletePostMutation.mutate({ eventId }, { onSuccess: onClosePost });
}}
onDeleteReply={(eventId) => {
deleteReplyMutation.mutate({ eventId });
@@ -157,13 +129,13 @@ export function ForumView({
onReply={(content, mentionPubkeys) => {
createReplyMutation.mutate({
content,
parentEventId: expandedPostId,
parentEventId: selectedPostId,
mentionPubkeys,
});
}}
onTargetReached={onTargetReached}
profiles={profiles}
targetEventId={targetEventId}
targetEventId={targetReplyId}
thread={threadQuery.data}
/>
);
@@ -208,7 +180,10 @@ export function ForumView({
</div>
{/* Post list */}
<div className="flex-1 overflow-y-auto">
<div
className="flex-1 overflow-y-auto"
data-scroll-restoration-id={`forum-list:${channel.id}`}
>
{postsQuery.isLoading ? (
<div className="space-y-3 p-4">
<Skeleton className="h-24 w-full rounded-xl" />
@@ -233,13 +208,13 @@ export function ForumView({
<ForumPostCard
canDelete={canDelete(post.pubkey, effectiveCurrentPubkey)}
currentPubkey={effectiveCurrentPubkey}
isActive={false}
isActive={selectedPostId === post.eventId}
isDeleting={
deletePostMutation.isPending &&
deletePostMutation.variables?.eventId === post.eventId
}
key={post.eventId}
onClick={() => setExpandedPostId(post.eventId)}
onClick={() => onSelectPost(post.eventId)}
onDelete={(eventId) => {
deletePostMutation.mutate({ eventId });
}}
@@ -102,6 +102,7 @@ export const MessageTimeline = React.memo(function MessageTimeline({
) : null}
<div
className="h-full overflow-y-auto overflow-x-hidden overscroll-contain px-4 py-3 [overflow-anchor:none] sm:px-6"
data-scroll-restoration-id="message-timeline"
data-testid="message-timeline"
onScroll={syncScrollState}
ref={scrollContainerRef}
@@ -25,6 +25,7 @@ import {
type WorkflowCardProps = {
workflow: Workflow;
channelName?: string;
isActive?: boolean;
onSelect: (workflowId: string) => void;
onTrigger: (workflowId: string) => void;
onEdit: (workflow: Workflow) => void;
@@ -51,6 +52,7 @@ function StatusBadge({ status }: { status: Workflow["status"] }) {
export function WorkflowCard({
workflow,
channelName,
isActive = false,
onSelect,
onTrigger,
onEdit,
@@ -63,7 +65,9 @@ export function WorkflowCard({
return (
<div
className="relative w-full rounded-lg border bg-card p-3 text-left transition-colors hover:bg-muted/50"
className={`relative w-full rounded-lg border bg-card p-3 text-left transition-colors hover:bg-muted/50 ${
isActive ? "border-primary/40 bg-primary/5 shadow-sm" : ""
}`}
data-testid={`workflow-card-${workflow.id}`}
>
<button
@@ -10,6 +10,7 @@ import {
import { WorkflowRunTrace } from "@/features/workflows/ui/WorkflowRunTrace";
import type { Workflow } from "@/shared/api/types";
import { Button } from "@/shared/ui/button";
import { Skeleton } from "@/shared/ui/skeleton";
import {
getWorkflowDescription,
getWorkflowDisplayStatus,
@@ -60,20 +61,28 @@ export function WorkflowDetailPanel({
<div className="flex items-center justify-between border-b px-4 py-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<h3 className="truncate text-sm font-semibold">
{workflow?.name ?? "Loading..."}
</h3>
{workflow ? (
<h3 className="truncate text-sm font-semibold">
{workflow.name}
</h3>
) : (
<Skeleton className="h-4 w-36" />
)}
{workflowStatus ? <RunStatusBadge status={workflowStatus} /> : null}
</div>
{workflowDescription ? (
<p className="mt-1 truncate text-xs text-muted-foreground">
{workflowDescription}
</p>
) : workflowQuery.isLoading ? (
<Skeleton className="mt-1 h-3 w-full max-w-64" />
) : null}
{triggerSummary ? (
<p className="mt-1 truncate text-xs text-muted-foreground">
{triggerSummary}
</p>
) : workflowQuery.isLoading ? (
<Skeleton className="mt-1 h-3 w-40" />
) : null}
</div>
<div className="flex items-center gap-1">
@@ -88,7 +97,7 @@ export function WorkflowDetailPanel({
</Button>
) : null}
<Button
disabled={triggerMutation.isPending}
disabled={triggerMutation.isPending || workflowQuery.isLoading}
onClick={() => void handleTrigger()}
size="sm"
variant="outline"
@@ -113,7 +122,10 @@ export function WorkflowDetailPanel({
</div>
) : null}
<div className="flex-1 overflow-y-auto">
<div
className="flex-1 overflow-y-auto"
data-scroll-restoration-id={`workflow-detail:${workflowId}`}
>
{workflow ? (
<div className="space-y-4 p-4">
<div>
@@ -234,8 +246,18 @@ export function WorkflowDetailPanel({
<p className="text-sm text-red-400">Failed to load workflow</p>
</div>
) : (
<div className="flex h-32 items-center justify-center">
<p className="text-sm text-muted-foreground">Loading...</p>
<div className="space-y-4 p-4">
<div>
<Skeleton className="mb-2 h-4 w-28" />
<Skeleton className="h-40 w-full rounded-xl" />
</div>
<div>
<Skeleton className="mb-2 h-4 w-24" />
<div className="space-y-2">
<Skeleton className="h-16 w-full rounded-xl" />
<Skeleton className="h-16 w-full rounded-xl" />
</div>
</div>
</div>
)}
</div>
@@ -11,9 +11,17 @@ const WorkflowsView = React.lazy(async () => {
type WorkflowsScreenProps = {
channels: Channel[];
onCloseWorkflow: () => void;
onSelectWorkflow: (workflowId: string) => void;
selectedWorkflowId: string | null;
};
export function WorkflowsScreen({ channels }: WorkflowsScreenProps) {
export function WorkflowsScreen({
channels,
onCloseWorkflow,
onSelectWorkflow,
selectedWorkflowId,
}: WorkflowsScreenProps) {
return (
<>
<ChatHeader
@@ -22,11 +30,14 @@ export function WorkflowsScreen({ channels }: WorkflowsScreenProps) {
title="Workflows"
/>
<React.Suspense
fallback={<ViewLoadingFallback label="Loading workflows..." />}
>
<React.Suspense fallback={<ViewLoadingFallback kind="workflows" />}>
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
<WorkflowsView channels={channels} />
<WorkflowsView
channels={channels}
onCloseWorkflow={onCloseWorkflow}
onSelectWorkflow={onSelectWorkflow}
selectedWorkflowId={selectedWorkflowId}
/>
</div>
</React.Suspense>
</>
@@ -14,9 +14,13 @@ import {
triggerWorkflow,
} from "@/shared/api/tauriWorkflows";
import { Button } from "@/shared/ui/button";
import { Skeleton } from "@/shared/ui/skeleton";
type WorkflowsViewProps = {
channels: Channel[];
onCloseWorkflow: () => void;
onSelectWorkflow: (workflowId: string) => void;
selectedWorkflowId: string | null;
};
type WorkflowWithChannel = {
@@ -30,14 +34,48 @@ type DialogState =
| { mode: "edit"; workflow: Workflow }
| { mode: "duplicate"; workflow: Workflow };
export function WorkflowsView({ channels }: WorkflowsViewProps) {
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}
>
<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">
<Skeleton className="h-5 w-44" />
<Skeleton className="h-5 w-16 rounded-full" />
</div>
<Skeleton className="h-4 w-full max-w-2xl" />
<div className="flex flex-wrap gap-2">
<Skeleton className="h-5 w-20 rounded-full" />
<Skeleton className="h-5 w-24 rounded-full" />
<Skeleton className="h-5 w-16 rounded-full" />
</div>
</div>
<div className="hidden shrink-0 gap-2 sm:flex">
<Skeleton className="h-8 w-8 rounded-lg" />
<Skeleton className="h-8 w-8 rounded-lg" />
</div>
</div>
</div>
))}
</div>
);
}
export function WorkflowsView({
channels,
onCloseWorkflow,
onSelectWorkflow,
selectedWorkflowId,
}: WorkflowsViewProps) {
const [dialogState, setDialogState] = React.useState<DialogState>({
mode: "closed",
});
const [deleteTarget, setDeleteTarget] = React.useState<Workflow | null>(null);
const [selectedWorkflowId, setSelectedWorkflowId] = React.useState<
string | null
>(null);
const queryClient = useQueryClient();
const memberChannels = channels.filter((c) => c.isMember);
@@ -77,9 +115,9 @@ export function WorkflowsView({ channels }: WorkflowsViewProps) {
const deleteMutation = useMutation({
mutationFn: (workflowId: string) => deleteWorkflow(workflowId),
onSuccess: (_data, workflowId) => {
setSelectedWorkflowId((current) =>
current === workflowId ? null : current,
);
if (selectedWorkflowId === workflowId) {
onCloseWorkflow();
}
void queryClient.invalidateQueries({
predicate: (query) =>
query.queryKey[0] === "workflows" ||
@@ -129,7 +167,10 @@ export function WorkflowsView({ channels }: WorkflowsViewProps) {
className="flex min-h-0 flex-1 overflow-hidden"
data-testid="workflows-view"
>
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto p-4">
<div
className="flex min-h-0 flex-1 flex-col overflow-y-auto p-4"
data-scroll-restoration-id="workflows-list"
>
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-2">
<h2 className="text-lg font-semibold">Workflows</h2>
@@ -152,11 +193,7 @@ export function WorkflowsView({ channels }: WorkflowsViewProps) {
</div>
{allWorkflowsQuery.isLoading ? (
<div className="flex flex-1 items-center justify-center">
<p className="text-sm text-muted-foreground">
Loading workflows...
</p>
</div>
<WorkflowsListSkeleton />
) : allWorkflowsQuery.isError ? (
<div className="flex flex-1 flex-col items-center justify-center gap-2 text-muted-foreground">
<p className="text-sm text-red-400">Failed to load workflows</p>
@@ -186,11 +223,12 @@ export function WorkflowsView({ channels }: WorkflowsViewProps) {
{allWorkflows.map(({ workflow, channelName }) => (
<WorkflowCard
channelName={channelName}
isActive={selectedWorkflowId === workflow.id}
key={workflow.id}
onDelete={handleDelete}
onDuplicate={handleDuplicate}
onEdit={handleEdit}
onSelect={setSelectedWorkflowId}
onSelect={onSelectWorkflow}
onTrigger={handleTrigger}
workflow={workflow}
/>
@@ -203,7 +241,7 @@ export function WorkflowsView({ channels }: WorkflowsViewProps) {
<div className="w-[400px] shrink-0">
<WorkflowDetailPanel
key={selectedWorkflowId}
onClose={() => setSelectedWorkflowId(null)}
onClose={onCloseWorkflow}
onEdit={handleEdit}
workflowId={selectedWorkflowId}
/>
+3
View File
@@ -0,0 +1,3 @@
declare module "jdenticon" {
export function toSvg(hashOrValue: string, size?: number): string;
}
@@ -4,28 +4,21 @@ import type { Channel } from "@/shared/api/types";
type ChannelNavigationContextValue = {
channels: Channel[];
onOpenChannel: (channelId: string) => void;
};
const ChannelNavigationContext =
React.createContext<ChannelNavigationContextValue>({
channels: [],
onOpenChannel: () => {},
});
export function ChannelNavigationProvider({
channels,
children,
onOpenChannel,
}: {
channels: Channel[];
children: React.ReactNode;
onOpenChannel: (channelId: string) => void;
}) {
const value = React.useMemo(
() => ({ channels, onOpenChannel }),
[channels, onOpenChannel],
);
const value = React.useMemo(() => ({ channels }), [channels]);
return (
<ChannelNavigationContext.Provider value={value}>
+253 -4
View File
@@ -1,11 +1,260 @@
import { Skeleton } from "@/shared/ui/skeleton";
type ViewLoadingFallbackKind = "agents" | "channel" | "forum" | "workflows";
type ViewLoadingFallbackProps = {
label: string;
includeHeader?: boolean;
kind: ViewLoadingFallbackKind;
};
export function ViewLoadingFallback({ label }: ViewLoadingFallbackProps) {
function LoadingHeaderSkeleton() {
return (
<div className="flex min-h-0 flex-1 items-center justify-center px-6 py-8">
<p className="text-sm text-muted-foreground">{label}</p>
<header
className="flex min-w-0 items-center gap-3 border-b border-border/80 bg-background px-4 pb-3 pt-8 sm:px-6"
data-tauri-drag-region
>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2">
<Skeleton className="h-5 w-5 rounded-sm" />
<Skeleton className="h-6 w-36 max-w-[50vw]" />
</div>
<Skeleton className="mt-2 h-4 w-full max-w-2xl" />
</div>
<div className="hidden shrink-0 items-center gap-2 sm:flex">
<Skeleton className="h-8 w-8 rounded-lg" />
<Skeleton className="h-8 w-8 rounded-lg" />
</div>
</header>
);
}
function MessageRowsSkeleton() {
return (
<>
{["first", "second", "third", "fourth", "fifth"].map((row) => (
<div className="flex gap-3" key={row}>
<Skeleton className="h-8 w-8 shrink-0 rounded-lg" />
<div className="min-w-0 flex-1 space-y-2 pt-0.5">
<Skeleton className="h-3.5 w-40 max-w-[40%]" />
<Skeleton className="h-4 w-full max-w-3xl" />
<Skeleton className="h-4 w-full max-w-xl" />
</div>
</div>
))}
</>
);
}
function AgentsLoadingBody() {
return (
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-y-auto p-4">
<div className="space-y-6">
<section className="space-y-4">
<div className="flex items-center justify-between gap-3">
<div className="space-y-2">
<Skeleton className="h-5 w-28" />
<Skeleton className="h-4 w-72 max-w-full" />
</div>
<Skeleton className="h-8 w-8 rounded-lg" />
</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}
>
<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">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-3 w-16 rounded-full" />
</div>
</div>
</div>
))}
</div>
</section>
<section className="space-y-4">
<div className="space-y-2">
<Skeleton className="h-5 w-24" />
<Skeleton className="h-4 w-80 max-w-full" />
</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}
>
<div className="space-y-3">
<div className="flex items-center justify-between gap-3">
<div className="space-y-2">
<Skeleton className="h-4 w-28" />
<Skeleton className="h-3 w-20 rounded-full" />
</div>
<Skeleton className="h-7 w-16 rounded-full" />
</div>
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-4/5" />
</div>
</div>
))}
</div>
</section>
<section className="space-y-4">
<div className="space-y-2">
<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">
{["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"
key={row}
>
<Skeleton className="h-4 w-28" />
<Skeleton className="h-5 w-16 rounded-full" />
<Skeleton className="h-4 w-24" />
<Skeleton className="ml-auto h-8 w-24 rounded-lg" />
</div>
))}
</div>
</section>
</div>
</div>
);
}
function WorkflowsLoadingBody() {
return (
<div className="flex min-h-0 min-w-0 flex-1 overflow-hidden">
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-y-auto p-4">
<div className="mb-4 flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<Skeleton className="h-6 w-28" />
<Skeleton className="h-8 w-8 rounded-lg" />
</div>
<Skeleton className="h-9 w-36 rounded-lg" />
</div>
<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}
>
<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">
<Skeleton className="h-5 w-44" />
<Skeleton className="h-5 w-16 rounded-full" />
</div>
<Skeleton className="h-4 w-full max-w-2xl" />
<div className="flex flex-wrap gap-2">
<Skeleton className="h-5 w-20 rounded-full" />
<Skeleton className="h-5 w-24 rounded-full" />
<Skeleton className="h-5 w-16 rounded-full" />
</div>
</div>
<div className="hidden shrink-0 gap-2 sm:flex">
<Skeleton className="h-8 w-8 rounded-lg" />
<Skeleton className="h-8 w-8 rounded-lg" />
</div>
</div>
</div>
))}
</div>
</div>
<div className="hidden w-[400px] shrink-0 border-l border-border/60 bg-background/70 lg:block">
<div className="space-y-4 p-4">
<div className="space-y-2">
<Skeleton className="h-5 w-40" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-4/5" />
</div>
<Skeleton className="h-40 w-full rounded-xl" />
<div className="space-y-2">
<Skeleton className="h-4 w-28" />
<Skeleton className="h-16 w-full rounded-xl" />
<Skeleton className="h-16 w-full rounded-xl" />
</div>
</div>
</div>
</div>
);
}
function ChannelLoadingBody() {
return (
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
<div className="flex-1 overflow-y-auto px-4 py-3 sm:px-6">
<div className="mx-auto flex w-full max-w-4xl flex-col gap-4">
<MessageRowsSkeleton />
</div>
</div>
<div className="border-t border-border/60 bg-background px-4 py-4 sm:px-6">
<div className="mx-auto w-full max-w-4xl space-y-3">
<Skeleton className="h-10 w-full rounded-2xl" />
<div className="flex items-center gap-2">
<Skeleton className="h-8 w-20 rounded-lg" />
<Skeleton className="h-8 w-16 rounded-lg" />
<Skeleton className="ml-auto h-8 w-24 rounded-lg" />
</div>
</div>
</div>
</div>
);
}
function ForumLoadingBody() {
return (
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
<div className="border-b border-border/60 p-4">
<Skeleton className="h-10 w-full rounded-xl" />
</div>
<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}
>
<div className="space-y-3">
<div className="flex items-center gap-2">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-5 w-16 rounded-full" />
</div>
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-5/6" />
<div className="flex items-center gap-3">
<Skeleton className="h-4 w-20" />
<Skeleton className="h-4 w-16" />
<Skeleton className="h-4 w-14" />
</div>
</div>
</div>
))}
</div>
</div>
</div>
);
}
export function ViewLoadingFallback({
includeHeader = false,
kind,
}: ViewLoadingFallbackProps) {
return (
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
{includeHeader ? <LoadingHeaderSkeleton /> : null}
{kind === "agents" ? <AgentsLoadingBody /> : null}
{kind === "workflows" ? <WorkflowsLoadingBody /> : null}
{kind === "channel" ? <ChannelLoadingBody /> : null}
{kind === "forum" ? <ForumLoadingBody /> : null}
</div>
);
}
+10 -5
View File
@@ -3,12 +3,13 @@ import ReactMarkdown, { type Components } from "react-markdown";
import remarkBreaks from "remark-breaks";
import remarkGfm from "remark-gfm";
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
import type { Channel } from "@/shared/api/types";
import { useChannelNavigation } from "@/shared/context/ChannelNavigationContext";
import { cn } from "@/shared/lib/cn";
import { rewriteRelayUrl } from "@/shared/lib/mediaUrl";
import remarkChannelLinks from "@/shared/lib/remarkChannelLinks";
import remarkMentions from "@/shared/lib/remarkMentions";
import { useChannelNavigation } from "@/shared/context/ChannelNavigationContext";
import type { Channel } from "@/shared/api/types";
type MarkdownProps = {
channelNames?: string[];
@@ -205,11 +206,15 @@ function MarkdownInner({
: compact
? "compact"
: "default";
const { channels, onOpenChannel } = useChannelNavigation();
const { channels } = useChannelNavigation();
const { goChannel } = useAppNavigation();
const components = React.useMemo(
() => createMarkdownComponents(variant, channels, onOpenChannel),
[variant, channels, onOpenChannel],
() =>
createMarkdownComponents(variant, channels, (channelId) => {
void goChannel(channelId);
}),
[goChannel, variant, channels],
);
// biome-ignore lint/suspicious/noExplicitAny: PluggableList type not directly importable
+25
View File
@@ -3260,6 +3260,16 @@ async function handleSearchMessages(
created_at: now - 90 * 60,
score: 5.8,
},
{
event_id: "mock-forum-release-reply",
content: "Looks good to me. We should ship it.",
kind: 45003,
pubkey: ALICE_PUBKEY,
channel_id: "a27e1ee9-76a6-5bdf-a5d5-1d85610dad11",
channel_name: "watercooler",
created_at: now - 80 * 60,
score: 5.2,
},
];
const hits = mockHits
@@ -3464,6 +3474,21 @@ async function handleGetEvent(
content: "Release checklist: async feedback thread.",
sig: "mocksig".repeat(20).slice(0, 128),
},
{
id: "mock-forum-release-reply",
pubkey: ALICE_PUBKEY,
created_at: Math.floor(Date.now() / 1000) - 80 * 60,
kind: 45003,
tags: buildReplyMessageTags(
"a27e1ee9-76a6-5bdf-a5d5-1d85610dad11",
ALICE_PUBKEY,
"mock-forum-release-thread",
"mock-forum-release-thread",
undefined,
),
content: "Looks good to me. We should ship it.",
sig: "mocksig".repeat(20).slice(0, 128),
},
];
const event = knownEvents.find((item) => item.id === args.eventId);
if (!event) {
@@ -94,6 +94,7 @@ test("joining a channel from browser adds it to the sidebar", async ({
// Dialog should close and navigate to the joined channel
await expect(page.getByTestId("channel-browser-dialog")).not.toBeVisible();
await expect(page).toHaveURL(/#\/channels\//);
await expect(page.getByTestId("chat-title")).toHaveText("design");
// Channel should now appear in the sidebar
@@ -112,6 +113,7 @@ test("clicking a joined channel in browser navigates to it", async ({
await page.getByTestId("browse-channel-general").click();
await expect(page.getByTestId("channel-browser-dialog")).not.toBeVisible();
await expect(page).toHaveURL(/#\/channels\//);
await expect(page.getByTestId("chat-title")).toHaveText("general");
});
+132
View File
@@ -0,0 +1,132 @@
import { expect, test } from "@playwright/test";
import { installMockBridge } from "../helpers/bridge";
const ENGINEERING_CHANNEL_ID = "1c7e1c02-87bb-5e88-b2da-5a7a9432d0c9";
const WATERCOLOR_CHANNEL_ID = "a27e1ee9-76a6-5bdf-a5d5-1d85610dad11";
const FORUM_POST_ID = "mock-forum-release-thread";
const FORUM_REPLY_ID = "mock-forum-release-reply";
test.beforeEach(async ({ page }) => {
await installMockBridge(page);
});
async function navigateToWorkflows(page: import("@playwright/test").Page) {
await page.goto("/");
await page.getByTestId("open-workflows-view").click();
await expect(page).toHaveURL(/#\/workflows$/);
await expect(page.getByTestId("workflows-view")).toBeVisible();
}
async function createWorkflow(
page: import("@playwright/test").Page,
name: string,
) {
await page.getByRole("button", { name: "Create Workflow" }).click();
const dialog = page.getByRole("dialog");
await expect(dialog).toBeVisible();
await dialog.getByLabel("Workflow name").fill(name);
await dialog.getByRole("button", { name: "Add step" }).click();
await dialog.getByRole("button", { name: "Create" }).click();
await expect(dialog).not.toBeVisible();
}
test("global back and forward move across channel routes", async ({ page }) => {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await page.getByTestId("channel-random").click();
await expect(page.getByTestId("chat-title")).toHaveText("random");
await page.getByTestId("global-back").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await page.getByTestId("global-forward").click();
await expect(page.getByTestId("chat-title")).toHaveText("random");
});
test("direct forum thread links close back to the forum route", async ({
page,
}) => {
await page.goto(
`/#/channels/${WATERCOLOR_CHANNEL_ID}/posts/${FORUM_POST_ID}`,
);
await expect(page.getByTestId("chat-title")).toHaveText("watercooler");
await expect(
page.getByRole("button", { name: "Back to posts" }),
).toBeVisible();
await page.getByRole("button", { name: "Back to posts" }).click();
await expect(page).toHaveURL(
/#\/channels\/a27e1ee9-76a6-5bdf-a5d5-1d85610dad11$/,
);
await expect(
page.getByText("Release checklist: async feedback thread."),
).toBeVisible();
});
test("direct workflow detail links close back to workflows", async ({
page,
}) => {
const workflowName = `workflow_nav_${Date.now()}`;
await navigateToWorkflows(page);
await createWorkflow(page, workflowName);
const workflowCard = page
.locator('[data-testid^="workflow-card-"]')
.filter({ hasText: workflowName })
.first();
const workflowTestId = await workflowCard.getAttribute("data-testid");
const workflowId = workflowTestId?.replace("workflow-card-", "");
expect(workflowId).toBeTruthy();
await page.goto(`/#/workflows/${workflowId}`);
await expect(page.getByTestId("workflow-detail-panel")).toBeVisible();
await page.getByRole("button", { name: "Close detail panel" }).click();
await expect(page).toHaveURL(/#\/workflows$/);
await expect(page.getByTestId("workflows-view")).toBeVisible();
});
test("forum reply deep links survive reload", async ({ page }) => {
await page.goto(
`/#/channels/${WATERCOLOR_CHANNEL_ID}/posts/${FORUM_POST_ID}?replyId=${FORUM_REPLY_ID}`,
);
await expect(page.getByTestId("chat-title")).toHaveText("watercooler");
await expect(
page.getByText("Looks good to me. We should ship it."),
).toBeVisible();
await page.reload();
await expect(page.getByTestId("chat-title")).toHaveText("watercooler");
await expect(
page.getByText("Looks good to me. We should ship it."),
).toBeVisible();
});
test("message deep links survive reload", async ({ page }) => {
await page.goto(
`/#/channels/${ENGINEERING_CHANNEL_ID}?messageId=mock-engineering-shipped`,
);
await expect(page.getByTestId("chat-title")).toHaveText("engineering");
await expect(page.getByTestId("message-timeline")).toContainText(
"Engineering shipped the desktop build.",
);
await page.reload();
await expect(page.getByTestId("chat-title")).toHaveText("engineering");
await expect(page.getByTestId("message-timeline")).toContainText(
"Engineering shipped the desktop build.",
);
});
+3
View File
@@ -157,6 +157,9 @@ test("opens relay-backed search from the sidebar and loads the exact result", as
.getByText("Engineering shipped the desktop build.")
.click();
await expect(page).toHaveURL(
/#\/channels\/1c7e1c02-87bb-5e88-b2da-5a7a9432d0c9\?messageId=mock-engineering-shipped$/,
);
await expect(page.getByTestId("chat-title")).toHaveText("engineering");
await expect(page.getByTestId("message-timeline")).toContainText(
"Engineering shipped the desktop build.",
+1
View File
@@ -9,6 +9,7 @@ test.beforeEach(async ({ page }) => {
async function navigateToWorkflows(page: import("@playwright/test").Page) {
await page.goto("/");
await page.getByTestId("open-workflows-view").click();
await expect(page).toHaveURL(/#\/workflows$/);
await expect(page.getByTestId("workflows-view")).toBeVisible();
}
+15 -1
View File
@@ -1,12 +1,26 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { tanstackRouter } from "@tanstack/router-plugin/vite";
// @ts-expect-error process is a nodejs global
const host = process.env.TAURI_DEV_HOST;
// https://vite.dev/config/
export default defineConfig(async () => ({
plugins: [react()],
plugins: [
tanstackRouter({
target: "react",
routesDirectory: "./src/app/routes",
generatedRouteTree: "./src/app/routeTree.gen.ts",
virtualRouteConfig: "./src/app/routes.ts",
quoteStyle: "double",
semicolons: true,
routeTreeFileHeader: [
"// biome-ignore-all lint: generated by TanStack Router",
],
}),
react(),
],
resolve: {
alias: {
"@": "/src",