@allbluecn/web-app 0.10.3 → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (246) hide show
  1. package/package.json +24 -11
  2. package/public/favicon-dark.svg +1 -0
  3. package/public/favicon.ico +0 -0
  4. package/public/favicon.svg +1 -0
  5. package/public/logo/allblue-logo-app-icon.svg +7 -0
  6. package/public/logo/allblue-logo-dark-square.svg +4 -0
  7. package/public/logo/allblue-logo-dark.png +0 -0
  8. package/public/logo/allblue-logo-dark.svg +4 -0
  9. package/public/logo/allblue-logo-light-square.svg +4 -0
  10. package/public/logo/allblue-logo-light.png +0 -0
  11. package/public/logo/allblue-logo-light.svg +4 -0
  12. package/src/__tests__/bench/baselines/format-timestamp.json +44 -0
  13. package/src/__tests__/bench/core-hot-path.bench.ts +71 -0
  14. package/src/__tests__/integration/db-crud.integration.test.ts +79 -0
  15. package/src/__tests__/integration/login-email-acceptance.test.ts +202 -0
  16. package/src/__tests__/integration/login-email-p2-acceptance.test.ts +142 -0
  17. package/src/__tests__/rejects-tothrow-regression.test.ts +31 -0
  18. package/src/__tests__/security/ai/prompt-injection.test.ts +105 -0
  19. package/src/__tests__/security/ssrf-url-whitelist.test.ts +54 -0
  20. package/src/__tests__/security/xss-sanitize.test.ts +99 -0
  21. package/src/__tests__/setup.ts +9 -0
  22. package/src/__tests__/visual/__screenshots__/theme-surface.vrt.test.tsx/theme-surface-dark-baseline-1-chromium-darwin.png +0 -0
  23. package/src/__tests__/visual/__screenshots__/theme-surface.vrt.test.tsx/theme-surface-light-baseline-1-chromium-darwin.png +0 -0
  24. package/src/__tests__/visual/__screenshots__/user-avatar.vrt.test.tsx/user-avatar-initial-fallback-baseline-1-chromium-darwin.png +0 -0
  25. package/src/__tests__/visual/theme-surface.vrt.test.tsx +54 -0
  26. package/src/__tests__/visual/user-avatar.vrt.test.tsx +32 -0
  27. package/src/components/auth/icon-input.tsx +9 -1
  28. package/src/components/auth/password-input.tsx +9 -1
  29. package/src/components/changelog/changelog-page.tsx +44 -1
  30. package/src/components/error/global-error-boundary.tsx +2 -2
  31. package/src/components/forms/app-field.tsx +3 -1
  32. package/src/components/knowledge/general-groups.ts +15 -0
  33. package/src/components/knowledge/kb-filter-popover.tsx +143 -0
  34. package/src/components/knowledge/knowledge-toolbar.tsx +17 -21
  35. package/src/components/knowledge-public/legal-compliance-editor.tsx +4 -2
  36. package/src/components/layout/sidebar-03/__tests__/nav-workspace.test.tsx +9 -6
  37. package/src/components/layout/sidebar-03/app-sidebar.tsx +4 -15
  38. package/src/components/layout/sidebar-03/nav-knowledge.tsx +19 -17
  39. package/src/components/layout/sidebar-03/nav-workspace.tsx +20 -22
  40. package/src/components/notifications/settings-notifications.tsx +41 -1
  41. package/src/components/settings/personal-team/account/settings-profile.tsx +68 -1
  42. package/src/components/shared-kit/avatars/notion-avatar.tsx +2 -1
  43. package/src/components/shared-kit/icons/__tests__/lucide-curated-icons.test.ts +2 -2
  44. package/src/components/shared-kit/icons/__tests__/lucide-icon-picker.test.tsx +3 -2
  45. package/src/components/shared-kit/icons/lucide-icon-picker/curated-icons.ts +5 -10
  46. package/src/components/shared-kit/icons/lucide-icon-picker/lucide-icon-by-name.tsx +3 -3
  47. package/src/components/shared-kit/theme/theme-surface.tsx +52 -0
  48. package/src/components/story/relations/general-groups.ts +1 -15
  49. package/src/components/story/relations/knowledge-select-dialog.tsx +13 -87
  50. package/src/components/story/story-list-view.tsx +7 -1
  51. package/src/components/tiptap/node/table-node/ui/table-move-row-column-button/use-table-move-row-column.ts +0 -2
  52. package/src/components/workspace/__tests__/activity/workspace-activity-card.test.tsx +36 -0
  53. package/src/components/workspace/__tests__/contribution/workspace-contribution-card.test.tsx +17 -0
  54. package/src/components/workspace/__tests__/dialogs/create-workspace-dialog.test.tsx +63 -0
  55. package/src/components/workspace/__tests__/dialogs/delete-workspace-options.test.tsx +89 -0
  56. package/src/components/workspace/__tests__/dialogs/edit-workspace-dialog.test.tsx +68 -0
  57. package/src/components/workspace/__tests__/header/workspace-header.test.tsx +40 -0
  58. package/src/components/workspace/__tests__/kb/workspace-kb-card.test.tsx +154 -0
  59. package/src/components/workspace/__tests__/members/workspace-member-item.test.tsx +24 -0
  60. package/src/components/workspace/__tests__/members/workspace-members-manager.test.tsx +51 -0
  61. package/src/components/workspace/__tests__/projects/workspace-project-list.test.tsx +46 -0
  62. package/src/components/workspace/{create-workspace-dialog.tsx → dialogs/create-workspace-dialog.tsx} +1 -1
  63. package/src/components/workspace/{edit-workspace-dialog.tsx → dialogs/edit-workspace-dialog.tsx} +1 -1
  64. package/src/components/workspace/kb/workspace-kb-card.tsx +594 -0
  65. package/src/components/workspace/kb/workspace-kb-manage-dialog.tsx +174 -0
  66. package/src/components/workspace/kb/workspace-quick-doc-dialog.tsx +431 -0
  67. package/src/components/workspace/{workspace-members-manager.tsx → members/workspace-members-manager.tsx} +78 -40
  68. package/src/lib/__tests__/edition-desktop.test.ts +23 -0
  69. package/src/lib/__tests__/msw-sample.test.ts +68 -0
  70. package/src/lib/__tests__/pgvector.test.ts +2 -2
  71. package/src/lib/app-version.ts +1 -1
  72. package/src/lib/auth/auth.config.ts +123 -99
  73. package/src/lib/changelog/sdk-versions.ts +21 -21
  74. package/src/lib/collections/knowledge/knowledge-bases-collection.ts +3 -1
  75. package/src/lib/collections/reference/workspaces-collection.ts +3 -1
  76. package/src/lib/edition-desktop.ts +22 -0
  77. package/src/lib/fetch-json.ts +23 -0
  78. package/src/{components/workspace/workspace-kb-card.tsx → lib/i18n/resolver.ts} +6 -12
  79. package/src/lib/sanitize.ts +14 -1
  80. package/src/lib/session.ts +10 -2
  81. package/src/lib/vector-store/__tests__/factory.test.ts +1 -1
  82. package/src/lib/vector-store/__tests__/sqlite-vec-store.test.ts +2 -2
  83. package/src/plugins/core/tags/__tests__/tags.test.ts +10 -10
  84. package/src/routeTree.gen.ts +114 -0
  85. package/src/routes/__root.tsx +20 -3
  86. package/src/routes/__tests__/-auth-schemas.test.ts +62 -0
  87. package/src/routes/__tests__/-edition-routing.test.tsx +18 -5
  88. package/src/routes/__tests__/route-tree-structure.test.ts +66 -0
  89. package/src/routes/_login/changelog/confirm/route.lazy.tsx +72 -0
  90. package/src/routes/_login/changelog/confirm/route.tsx +28 -0
  91. package/src/routes/_login/email-unsubscribe/route.lazy.tsx +76 -0
  92. package/src/routes/_login/email-unsubscribe/route.tsx +28 -0
  93. package/src/routes/_login/forgot-password/route.lazy.tsx +2 -1
  94. package/src/routes/_login/login/route.lazy.tsx +4 -2
  95. package/src/routes/_login/register/route.lazy.tsx +28 -17
  96. package/src/routes/_login/register/route.tsx +8 -1
  97. package/src/routes/_login/reset-password/route.lazy.tsx +4 -2
  98. package/src/routes/_login/schemas/-auth.ts +30 -7
  99. package/src/routes/_login/verify-email/route.lazy.tsx +82 -0
  100. package/src/routes/_login/verify-email/route.tsx +28 -0
  101. package/src/routes/_nav/knowledge/compliance/index.lazy.tsx +5 -2
  102. package/src/routes/_nav/knowledge/index.lazy.tsx +1 -1
  103. package/src/routes/_nav/knowledge/laws/index.lazy.tsx +5 -2
  104. package/src/routes/_nav/route.tsx +26 -0
  105. package/src/routes/_nav/workspaces/$workspaceId/route.lazy.tsx +13 -11
  106. package/src/routes/_nav/workspaces/$workspaceId/route.tsx +4 -2
  107. package/src/routes/_nav/workspaces/$workspaceId/settings/route.lazy.tsx +1 -1
  108. package/src/routes/_nav/workspaces/index.lazy.tsx +3 -3
  109. package/src/routes/_public/index/route.tsx +2 -1
  110. package/src/routes/api/auth/desktop-callback.ts +2 -2
  111. package/src/routes/api/billing-webhook.ts +4 -0
  112. package/src/routes/api/cron/changelog-release.ts +100 -0
  113. package/src/routes/api/cron/subscription-reminders.ts +110 -0
  114. package/src/routes/api.auth.$.ts +3 -5
  115. package/src/server/__tests__/agent-crud.test.ts +4 -4
  116. package/src/server/__tests__/auth-registration.test.ts +60 -0
  117. package/src/server/__tests__/chat-conversations.test.ts +1 -3
  118. package/src/server/__tests__/fetch-guard.test.ts +99 -0
  119. package/src/server/__tests__/permissions.test.ts +1 -3
  120. package/src/server/__tests__/resources.team-seats.test.ts +2 -0
  121. package/src/server/__tests__/skill-crud.test.ts +3 -3
  122. package/src/server/ai-gateway/__tests__/ai-output-guard.test.ts +95 -0
  123. package/src/server/ai-gateway/__tests__/kb-context-guard.test.ts +136 -0
  124. package/src/server/ai-gateway/__tests__/knowledge-search-fence.test.ts +240 -0
  125. package/src/server/ai-gateway/__tests__/llm-fence.test.ts +125 -0
  126. package/src/server/ai-gateway/__tests__/run-store.test.ts +52 -0
  127. package/src/server/ai-gateway/ai-output-guard.ts +77 -0
  128. package/src/server/ai-gateway/index.ts +5 -0
  129. package/src/server/ai-gateway/kb-context-guard.ts +46 -0
  130. package/src/server/ai-gateway/llm-fence.ts +82 -0
  131. package/src/server/ai-gateway/persistence/run-store.ts +19 -0
  132. package/src/server/ai-gateway/story-prompt.ts +4 -2
  133. package/src/server/ai-gateway/tools/definitions.ts +12 -6
  134. package/src/server/ai-gateway/tools/server.ts +97 -54
  135. package/src/server/auth-registration.ts +40 -0
  136. package/src/server/billing-email.ts +39 -0
  137. package/src/server/builtin-project.ts +21 -0
  138. package/src/server/email/__tests__/deliverability.test.ts +68 -0
  139. package/src/server/email/__tests__/index.test.ts +32 -0
  140. package/src/server/email/__tests__/outbox.test.tsx +129 -0
  141. package/src/server/email/__tests__/product-templates.test.tsx +106 -0
  142. package/src/server/email/__tests__/registry.test.tsx +50 -0
  143. package/src/server/email/deliverability.ts +57 -0
  144. package/src/server/email/drivers/console.ts +11 -0
  145. package/src/server/email/drivers/resend.ts +31 -0
  146. package/src/server/email/drivers/smtp.ts +27 -0
  147. package/src/server/email/index.ts +56 -0
  148. package/src/server/email/outbox.ts +141 -0
  149. package/src/server/email/preferences.ts +20 -0
  150. package/src/server/email/registry.ts +38 -0
  151. package/src/server/email/sender.ts +36 -13
  152. package/src/server/email/templates/_base/button.tsx +12 -0
  153. package/src/server/email/templates/_base/email-layout.tsx +117 -0
  154. package/src/server/email/templates/_base/theme-fonts.tsx +43 -0
  155. package/src/server/email/templates/_base/theme.ts +22 -0
  156. package/src/server/email/templates/auth/reset-password.tsx +58 -0
  157. package/src/server/email/templates/auth/verify-email.tsx +59 -0
  158. package/src/server/email/templates/auth/welcome.tsx +49 -0
  159. package/src/server/email/templates/billing/payment-receipt.tsx +51 -0
  160. package/src/server/email/templates/billing/subscription-canceled.tsx +51 -0
  161. package/src/server/email/templates/billing/subscription-ending.tsx +51 -0
  162. package/src/server/email/templates/notification/mirror.tsx +50 -0
  163. package/src/server/email/templates/product/changelog-confirm.tsx +53 -0
  164. package/src/server/email/templates/product/changelog-release.tsx +65 -0
  165. package/src/server/email/templates/team/apply-notify.tsx +53 -0
  166. package/src/server/email/templates/team/approved.tsx +53 -0
  167. package/src/server/email/templates/team/invite.tsx +57 -0
  168. package/src/server/fetch-guard.ts +170 -0
  169. package/src/server/middlewares/security-headers.ts +122 -0
  170. package/src/server/notifications/__tests__/email-mirror.test.ts +193 -0
  171. package/src/server/notifications/__tests__/verify-reminder.test.ts +94 -0
  172. package/src/server/notifications/notification-service.ts +71 -1
  173. package/src/server/notifications/verify-reminder.ts +55 -0
  174. package/src/server/oauth-config.ts +59 -0
  175. package/src/server/serverFns/__tests__/auth-server-schemas.test.ts +168 -0
  176. package/src/server/serverFns/__tests__/billing.test.ts +263 -0
  177. package/src/server/serverFns/__tests__/email-verification.test.ts +149 -0
  178. package/src/server/serverFns/__tests__/link-preview.test.ts +239 -0
  179. package/src/server/serverFns/__tests__/project-kb.test.ts +3 -3
  180. package/src/server/serverFns/__tests__/project-team-assign.test.ts +1 -1
  181. package/src/server/serverFns/__tests__/story-comments.test.ts +265 -0
  182. package/src/server/serverFns/__tests__/story-crud-archive-detail.test.ts +266 -0
  183. package/src/server/serverFns/__tests__/story-crud-labels-users.test.ts +227 -0
  184. package/src/server/serverFns/__tests__/story-crud.test.ts +526 -0
  185. package/src/server/serverFns/__tests__/story-links.test.ts +262 -0
  186. package/src/server/serverFns/__tests__/story-list-query.test.ts +259 -0
  187. package/src/server/serverFns/__tests__/story-reactions.test.ts +230 -0
  188. package/src/server/serverFns/__tests__/story-tree.test.ts +317 -0
  189. package/src/server/serverFns/__tests__/story-types.test.ts +294 -0
  190. package/src/server/serverFns/__tests__/team-accept.test.ts +6 -6
  191. package/src/server/serverFns/__tests__/team-invitation.test.ts +5 -5
  192. package/src/server/serverFns/__tests__/team-join-request.test.ts +8 -8
  193. package/src/server/serverFns/__tests__/update-seats.test.ts +4 -4
  194. package/src/server/serverFns/__tests__/workspace-kb-idor.test.ts +160 -0
  195. package/src/server/serverFns/__tests__/workspace-kb-utils.test.ts +54 -0
  196. package/src/server/serverFns/__tests__/workspace-kb.test.ts +348 -0
  197. package/src/server/serverFns/__tests__/workspace.test.ts +28 -8
  198. package/src/server/serverFns/auth/email-verification.ts +132 -0
  199. package/src/server/serverFns/auth/forgot-password.ts +22 -2
  200. package/src/server/serverFns/auth/providers.ts +4 -3
  201. package/src/server/serverFns/auth/register.ts +63 -19
  202. package/src/server/serverFns/auth/registration-mode.ts +23 -0
  203. package/src/server/serverFns/auth/reset-password.ts +1 -1
  204. package/src/server/serverFns/auth/{invitation.ts → verify-reminder.ts} +9 -26
  205. package/src/server/serverFns/changelog/__tests__/subscribe.test.ts +119 -0
  206. package/src/server/serverFns/changelog/subscribe.ts +89 -0
  207. package/src/server/serverFns/email-unsubscribe.ts +66 -0
  208. package/src/server/serverFns/iteration/__tests__/iteration-crud.test.ts +3 -5
  209. package/src/server/serverFns/iteration/__tests__/iteration-stories.test.ts +1 -1
  210. package/src/server/serverFns/iteration/__tests__/iteration-transfer.test.ts +2 -2
  211. package/src/server/serverFns/kb-doc-search.ts +111 -0
  212. package/src/server/serverFns/knowledge-public/compliance/create.ts +2 -1
  213. package/src/server/serverFns/knowledge-public/legal/create.ts +2 -1
  214. package/src/server/serverFns/notifications.ts +10 -0
  215. package/src/server/serverFns/story/__tests__/bulk.test.ts +1 -1
  216. package/src/server/serverFns/story/__tests__/label-suggest-fn.test.ts +2 -6
  217. package/src/server/serverFns/story/__tests__/story-assoc.test.ts +2 -4
  218. package/src/server/serverFns/story/__tests__/story-dependencies.test.ts +2 -2
  219. package/src/server/serverFns/story/__tests__/story-groups.test.ts +2 -2
  220. package/src/server/serverFns/story/kb-general-consts.ts +39 -0
  221. package/src/server/serverFns/story/kb-general.ts +30 -17
  222. package/src/server/serverFns/story/story-assoc.ts +13 -103
  223. package/src/server/serverFns/story/story-crud.ts +2 -5
  224. package/src/server/serverFns/story/story-groups.ts +4 -13
  225. package/src/server/serverFns/team.ts +50 -12
  226. package/src/server/serverFns/workspace-kb-utils.ts +138 -0
  227. package/src/server/serverFns/workspace-kb.ts +403 -0
  228. package/src/server/serverFns/workspace.ts +4 -1
  229. package/src/shared/ai-cli/__tests__/adapter.test.ts +67 -0
  230. package/src/shared/ai-cli/__tests__/cli-detector.test.ts +99 -0
  231. package/src/shared/ai-cli/adapter.ts +2 -2
  232. package/src/shared/ai-cli/cli-detector.ts +18 -9
  233. package/src/start.ts +2 -1
  234. package/src/styles/globals.css +114 -0
  235. package/src/types/jest-dom-vitest.d.ts +8 -7
  236. package/vite.shared.ts +105 -4
  237. package/public/file.svg +0 -1
  238. package/public/globe.svg +0 -1
  239. package/public/window.svg +0 -1
  240. package/src/lib/__tests__/cli-detector.test.ts +0 -80
  241. /package/src/components/workspace/{workspace-activity-card.tsx → activity/workspace-activity-card.tsx} +0 -0
  242. /package/src/components/workspace/{workspace-contribution-card.tsx → contribution/workspace-contribution-card.tsx} +0 -0
  243. /package/src/components/workspace/{delete-workspace-options.tsx → dialogs/delete-workspace-options.tsx} +0 -0
  244. /package/src/components/workspace/{workspace-header.tsx → header/workspace-header.tsx} +0 -0
  245. /package/src/components/workspace/{workspace-member-item.tsx → members/workspace-member-item.tsx} +0 -0
  246. /package/src/components/workspace/{workspace-project-list.tsx → projects/workspace-project-list.tsx} +0 -0
@@ -0,0 +1,54 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // AllBlue - 理想之海
3
+ // Copyright (C) 2026 AllBlue Contributors
4
+ //
5
+ // This program is free software: you can redistribute it and/or modify
6
+ // it under the terms of the GNU Affero General Public License as published by
7
+ // the Free Software Foundation, either version 3 of the License, or
8
+ // (at your option) any later version.
9
+ //
10
+ // This program is distributed in the hope that it will be useful,
11
+ // but WITHOUT ANY WARRANTY; without even the implied warranty of
12
+ // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
+ // GNU Affero General Public License for more details.
14
+ //
15
+ // You should have received a copy of the GNU Affero General Public License
16
+ // along with this program. If not, see <https://www.gnu.org/licenses/>.
17
+
18
+ // VRT 视觉回归:主题表面聚合组件的亮色/暗色渲染基线。
19
+ // 基线由 darwin 本机生成并提交入库;本机即门禁环境(同一台机器生成+对比)。
20
+
21
+ import { expect, test } from 'vitest'
22
+ import { page } from 'vitest/browser'
23
+ import { render } from 'vitest-browser-react'
24
+ import { ThemeProvider } from 'next-themes'
25
+ import { TooltipProvider } from '@/components/ui/tooltip'
26
+ import { ThemeSurface } from '@/components/shared-kit/theme/theme-surface'
27
+
28
+ test('theme surface light baseline', async () => {
29
+ await render(
30
+ <ThemeProvider forcedTheme="light">
31
+ <TooltipProvider>
32
+ <ThemeSurface />
33
+ </TooltipProvider>
34
+ </ThemeProvider>,
35
+ )
36
+ await expect(page.getByTestId('theme-surface')).toMatchScreenshot({
37
+ comparatorName: 'pixelmatch',
38
+ comparatorOptions: { threshold: 0.2, allowedMismatchedPixelRatio: 0.01 },
39
+ })
40
+ })
41
+
42
+ test('theme surface dark baseline', async () => {
43
+ await render(
44
+ <ThemeProvider forcedTheme="dark">
45
+ <TooltipProvider>
46
+ <ThemeSurface />
47
+ </TooltipProvider>
48
+ </ThemeProvider>,
49
+ )
50
+ await expect(page.getByTestId('theme-surface')).toMatchScreenshot({
51
+ comparatorName: 'pixelmatch',
52
+ comparatorOptions: { threshold: 0.2, allowedMismatchedPixelRatio: 0.01 },
53
+ })
54
+ })
@@ -0,0 +1,32 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // AllBlue - 理想之海
3
+ // Copyright (C) 2026 AllBlue Contributors
4
+ //
5
+ // This program is free software: you can redistribute it and/or modify
6
+ // it under the terms of the GNU Affero General Public License as published by
7
+ // the Free Software Foundation, either version 3 of the License, or
8
+ // (at your option) any later version.
9
+ //
10
+ // This program is distributed in the hope that it will be useful,
11
+ // but WITHOUT ANY WARRANTY; without even the implied warranty of
12
+ // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
+ // GNU Affero General Public License for more details.
14
+ //
15
+ // You should have received a copy of the GNU Affero General Public License
16
+ // along with this program. If not, see <https://www.gnu.org/licenses/>.
17
+
18
+ // VRT 视觉回归:UserAvatar 首字母 fallback 渲染基线。
19
+
20
+ import { expect, test } from 'vitest'
21
+ import { page } from 'vitest/browser'
22
+ import { render } from 'vitest-browser-react'
23
+ import { UserAvatar } from '@/components/shared-kit'
24
+
25
+ test('user avatar initial fallback baseline', async () => {
26
+ const screen = await render(<UserAvatar name="张三" size="lg" />)
27
+ const root = screen.container.firstChild as HTMLElement
28
+ await expect(page.elementLocator(root)).toMatchScreenshot({
29
+ comparatorName: 'pixelmatch',
30
+ comparatorOptions: { threshold: 0.2, allowedMismatchedPixelRatio: 0.01 },
31
+ })
32
+ })
@@ -22,9 +22,10 @@ import type { LucideIcon } from 'lucide-react'
22
22
  interface IconInputProps extends React.InputHTMLAttributes<HTMLInputElement> {
23
23
  icon: LucideIcon
24
24
  label: string
25
+ error?: string | null
25
26
  }
26
27
 
27
- export function IconInput({ icon: Icon, label, id, className, ...props }: IconInputProps) {
28
+ export function IconInput({ icon: Icon, label, id, className, error, ...props }: IconInputProps) {
28
29
  const inputId = id || label.toLowerCase()
29
30
 
30
31
  return (
@@ -34,12 +35,19 @@ export function IconInput({ icon: Icon, label, id, className, ...props }: IconIn
34
35
  <Input
35
36
  id={inputId}
36
37
  className={`peer ps-9 ${className || ''}`}
38
+ aria-invalid={!!error}
39
+ aria-describedby={error ? `${inputId}-error` : undefined}
37
40
  {...props}
38
41
  />
39
42
  <div className="pointer-events-none absolute inset-y-0 start-0 flex items-center justify-center ps-3 text-muted-foreground">
40
43
  <Icon size={16} />
41
44
  </div>
42
45
  </div>
46
+ {error ? (
47
+ <p id={`${inputId}-error`} className="text-xs text-destructive mt-1">
48
+ {error}
49
+ </p>
50
+ ) : null}
43
51
  </div>
44
52
  )
45
53
  }
@@ -23,9 +23,10 @@ import { m } from "@/paraglide/messages.js";
23
23
 
24
24
  interface PasswordInputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'type'> {
25
25
  label: string
26
+ error?: string | null
26
27
  }
27
28
 
28
- export function PasswordInput({ label, id, className, ...props }: PasswordInputProps) {
29
+ export function PasswordInput({ label, id, className, error, ...props }: PasswordInputProps) {
29
30
  const inputId = id || label.toLowerCase()
30
31
  const [isVisible, setIsVisible] = useState(false)
31
32
 
@@ -37,6 +38,8 @@ export function PasswordInput({ label, id, className, ...props }: PasswordInputP
37
38
  id={inputId}
38
39
  className={`ps-9 pe-9 ${className || ''}`}
39
40
  type={isVisible ? 'text' : 'password'}
41
+ aria-invalid={!!error}
42
+ aria-describedby={error ? `${inputId}-error` : undefined}
40
43
  {...props}
41
44
  />
42
45
  <div className="pointer-events-none absolute inset-y-0 start-0 flex items-center justify-center ps-3 text-muted-foreground">
@@ -61,6 +64,11 @@ export function PasswordInput({ label, id, className, ...props }: PasswordInputP
61
64
  )}
62
65
  </button>
63
66
  </div>
67
+ {error ? (
68
+ <p id={`${inputId}-error`} className="text-xs text-destructive mt-1">
69
+ {error}
70
+ </p>
71
+ ) : null}
64
72
  </div>
65
73
  )
66
74
  }
@@ -23,6 +23,8 @@ import { Route } from '@/routes/_nav/changelog/route'
23
23
  import type { DeveloperLogEntry } from '@allbluecn/kernel'
24
24
  import { SDK_GROUPS } from '@/lib/changelog/sdk-versions'
25
25
  import { APP_FULL_VERSION, APP_UPDATE_DATE_DISPLAY } from '@/lib/app-version'
26
+ import { subscribeChangelogFn } from '@/server/serverFns/changelog/subscribe'
27
+ import { toast } from '@/components/ui/sonner'
26
28
  import { m } from "@/paraglide/messages.js";
27
29
 
28
30
  const TAG_KEYS = ['all', 'feat', 'fix', 'improvement', 'design', 'refactor', 'breaking', 'docs', 'security', 'perf']
@@ -51,10 +53,39 @@ const REQUIRES_ACTION_TAGS = new Set(['breaking'])
51
53
  function ChangelogSidebar() {
52
54
  const [activeGroup, setActiveGroup] = useState<string | null>(null)
53
55
  const [isOverflowing, setIsOverflowing] = useState(false)
56
+ const [email, setEmail] = useState('')
57
+ const [subscribing, setSubscribing] = useState(false)
54
58
  const outerRef = useRef<HTMLDivElement>(null)
55
59
  const isOverflowingRef = useRef(false)
56
60
  const expandedHeightRef = useRef(0)
57
61
 
62
+ const handleSubscribe = async () => {
63
+ const trimmed = email.trim()
64
+ if (!trimmed || subscribing) return
65
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmed)) {
66
+ toast.error(m.common_changelog_subscribeInvalidEmail())
67
+ return
68
+ }
69
+ setSubscribing(true)
70
+ try {
71
+ const result = await subscribeChangelogFn({ data: { email: trimmed } })
72
+ if (result.status === 'already') {
73
+ toast.info(m.common_changelog_subscribeAlready())
74
+ } else {
75
+ toast.success(m.common_changelog_subscribeCheckEmail())
76
+ }
77
+ setEmail('')
78
+ } catch (err) {
79
+ toast.error(
80
+ err instanceof Error && /email/i.test(err.message)
81
+ ? m.common_changelog_subscribeInvalidEmail()
82
+ : m.common_changelog_subscribeFailed(),
83
+ )
84
+ } finally {
85
+ setSubscribing(false)
86
+ }
87
+ }
88
+
58
89
  const checkOverflow = useCallback(() => {
59
90
  if (!outerRef.current) return
60
91
 
@@ -146,10 +177,22 @@ function ChangelogSidebar() {
146
177
  <div className="mt-3 flex gap-1.5">
147
178
  <input
148
179
  type="email"
180
+ value={email}
181
+ onChange={(e) => setEmail(e.target.value)}
182
+ onKeyDown={(e) => {
183
+ if (e.key === 'Enter') {
184
+ e.preventDefault()
185
+ handleSubscribe()
186
+ }
187
+ }}
149
188
  placeholder={m.common_changelog_subscribe_placeholder()}
150
189
  className="flex-1 min-w-0 h-8 rounded-md border border-border bg-background px-3 text-xs placeholder:text-muted-foreground/70 focus:outline-none focus:ring-2 focus:ring-primary/20 transition-shadow"
151
190
  />
152
- <button className="h-8 px-3 rounded-md bg-primary text-primary-foreground text-xs font-medium hover:bg-primary/90 transition-colors shrink-0">
191
+ <button
192
+ onClick={handleSubscribe}
193
+ disabled={subscribing || !email.trim()}
194
+ className="h-8 px-3 rounded-md bg-primary text-primary-foreground text-xs font-medium hover:bg-primary/90 transition-colors shrink-0 disabled:opacity-50 disabled:pointer-events-none"
195
+ >
153
196
  {m.common_changelog_subscribe_submit()}
154
197
  </button>
155
198
  </div>
@@ -17,7 +17,7 @@
17
17
 
18
18
  import { Link, useLocation } from '@tanstack/react-router'
19
19
  import { useMemo } from 'react'
20
- import { LogIn, FileQuestionIcon } from 'lucide-react'
20
+ import { LogIn, CircleAlert } from 'lucide-react'
21
21
  import { isUnauthorizedError } from '@/lib/auth/anonymous-safe'
22
22
  import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from '@/components/ui/empty'
23
23
  import { Button } from '@/components/ui/button'
@@ -41,7 +41,7 @@ function GlobalErrorBoundaryInner({ error, info }: { error: unknown; info?: { co
41
41
  <Empty>
42
42
  <EmptyHeader>
43
43
  <EmptyMedia variant="icon">
44
- {unauthorized ? <LogIn /> : <FileQuestionIcon />}
44
+ {unauthorized ? <LogIn className="size-5" /> : <CircleAlert className="size-5" />}
45
45
  </EmptyMedia>
46
46
  <EmptyTitle>
47
47
  {unauthorized ? m.common_errorPage_loginRequired() : m.common_errorPage_error()}
@@ -37,6 +37,7 @@ export interface AppFieldProps<TValue> {
37
37
  description?: string
38
38
  validators?: AppFieldValidators<TValue>
39
39
  children?: (field: AnyFieldApi) => ReactNode
40
+ renderError?: boolean
40
41
  }
41
42
 
42
43
  export function AppField<TValue>({
@@ -46,6 +47,7 @@ export function AppField<TValue>({
46
47
  description,
47
48
  validators,
48
49
  children,
50
+ renderError = true,
49
51
  }: AppFieldProps<TValue>) {
50
52
  return (
51
53
  <Field>
@@ -57,7 +59,7 @@ export function AppField<TValue>({
57
59
  children={(field) => (
58
60
  <>
59
61
  {children ? children(field) : <DefaultInput field={field} />}
60
- <AppFieldError field={field} />
62
+ {renderError && <AppFieldError field={field} />}
61
63
  </>
62
64
  )}
63
65
  />
@@ -0,0 +1,15 @@
1
+ import { m } from '@/paraglide/messages.js'
2
+
3
+ const GROUP_LABELS: Record<string, () => string> = {
4
+ legal: m.story_knowledgeDialog_groupLegal,
5
+ compliance: m.story_knowledgeDialog_groupCompliance,
6
+ dictionary: m.story_knowledgeDialog_groupDictionary,
7
+ bookmark: m.story_knowledgeDialog_groupBookmark,
8
+ summary: m.story_knowledgeDialog_groupSummary,
9
+ }
10
+
11
+ /** 通用知识库分组 key → 标签(未知 key 原样返回) */
12
+ export function generalGroupLabel(group: string): string {
13
+ const labelFn = GROUP_LABELS[group]
14
+ return labelFn ? labelFn() : group
15
+ }
@@ -0,0 +1,143 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // AllBlue - 理想之海
3
+ // Copyright (C) 2026 AllBlue Contributors
4
+ //
5
+ // This program is free software: you can redistribute it and/or modify
6
+ // it under the terms of the GNU Affero General Public License as published by
7
+ // the Free Software Foundation, either version 3 of the License, or
8
+ // (at your option) any later version.
9
+ //
10
+ // This program is distributed in the hope that it will be useful,
11
+ // but WITHOUT ANY WARRANTY; without even the implied warranty of
12
+ // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
+ // GNU Affero General Public License for more details.
14
+ //
15
+ // You should have received a copy of the GNU Affero General Public License
16
+ // along with this program. If not, see <https://www.gnu.org/licenses/>.
17
+
18
+ import * as React from "react";
19
+ import { BookOpenText, Bookmark, BookMarked, Check, ChevronsUpDown, Scale, ShieldCheck, Sprout } from "lucide-react";
20
+ import { Button } from "@/components/ui/button";
21
+ import { Input } from "@/components/ui/input";
22
+ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
23
+ import { LucideIconByName } from "@/components/shared-kit/icons/lucide-icon-picker/lucide-icon-by-name";
24
+ import { generalGroupLabel } from "./general-groups";
25
+ import { m } from "@/paraglide/messages.js";
26
+
27
+ export type KbFilter = { type: "kb"; id: string } | { type: "general"; group: string } | null;
28
+
29
+ const GENERAL_GROUP_ICONS: Record<string, React.ComponentType<{ className?: string }>> = {
30
+ legal: Scale,
31
+ compliance: ShieldCheck,
32
+ dictionary: BookMarked,
33
+ bookmark: Bookmark,
34
+ summary: Sprout,
35
+ };
36
+
37
+ const GENERAL_GROUP_META = [
38
+ { key: "legal" },
39
+ { key: "compliance" },
40
+ { key: "dictionary" },
41
+ { key: "bookmark" },
42
+ { key: "summary" },
43
+ ] as const;
44
+
45
+ interface KbFilterPopoverProps {
46
+ search: string;
47
+ onSearchChange: (q: string) => void;
48
+ filter: KbFilter;
49
+ onFilterChange: (f: KbFilter) => void;
50
+ userKbs: Array<{ id: string; name: string; icon?: string | null }>;
51
+ /** 文案覆盖(缺省用 workspace_kb_* 通用文案);story 弹窗传入保持原文案零变化 */
52
+ searchPlaceholder?: string;
53
+ allLabel?: string;
54
+ userKbsLabel?: string;
55
+ generalKbsLabel?: string;
56
+ }
57
+
58
+ /** 搜索框 + 知识库筛选下拉(用户 team 库 + 通用知识库两组)。story 关联弹窗与工作空间快捷文档弹窗共用。 */
59
+ export function KbFilterPopover({
60
+ search, onSearchChange, filter, onFilterChange, userKbs,
61
+ searchPlaceholder, allLabel, userKbsLabel, generalKbsLabel,
62
+ }: KbFilterPopoverProps) {
63
+ const [open, setOpen] = React.useState(false);
64
+ const activeKb = filter?.type === "kb" ? userKbs.find((k) => k.id === filter.id) : undefined;
65
+
66
+ const select = (f: KbFilter) => {
67
+ onFilterChange(f);
68
+ setOpen(false);
69
+ };
70
+
71
+ return (
72
+ <div className="flex items-center gap-2">
73
+ <Input
74
+ placeholder={searchPlaceholder ?? m.workspace_kb_searchPlaceholder()}
75
+ value={search}
76
+ onChange={(e) => onSearchChange(e.target.value)}
77
+ className="h-8"
78
+ />
79
+ <Popover open={open} onOpenChange={setOpen}>
80
+ <PopoverTrigger asChild>
81
+ <Button variant="outline" size="sm" className="h-8 shrink-0 gap-1">
82
+ <BookOpenText className="size-3.5" />
83
+ {activeKb
84
+ ? activeKb.name
85
+ : filter?.type === "general"
86
+ ? generalGroupLabel(filter.group)
87
+ : (allLabel ?? m.workspace_kb_allKbs())}
88
+ <ChevronsUpDown className="size-3.5 opacity-50" />
89
+ </Button>
90
+ </PopoverTrigger>
91
+ <PopoverContent align="end" className="w-60 p-1">
92
+ <button
93
+ type="button"
94
+ className="mt-1 flex w-full items-center justify-between rounded-sm px-2 py-1.5 text-sm hover:bg-muted"
95
+ onClick={() => select(null)}
96
+ >
97
+ {allLabel ?? m.workspace_kb_allKbs()}
98
+ {filter === null && <Check className="size-3.5" />}
99
+ </button>
100
+ <div className="border-t px-2 pt-3 text-xs font-medium text-muted-foreground">
101
+ {userKbsLabel ?? m.workspace_kb_userKbs()}
102
+ </div>
103
+ {userKbs.map((kb) => (
104
+ <button
105
+ key={kb.id}
106
+ type="button"
107
+ className="flex w-full items-center justify-between rounded-sm px-2 py-1.5 text-sm hover:bg-muted"
108
+ onClick={() => select({ type: "kb", id: kb.id })}
109
+ >
110
+ <span className="flex min-w-0 items-center gap-2">
111
+ {kb.icon && (
112
+ <LucideIconByName name={kb.icon} size={14} className="shrink-0 text-muted-foreground" />
113
+ )}
114
+ <span className="truncate">{kb.name}</span>
115
+ </span>
116
+ {filter?.type === "kb" && filter.id === kb.id && <Check className="size-3.5" />}
117
+ </button>
118
+ ))}
119
+ <div className="border-t px-2 pt-3 text-xs font-medium text-muted-foreground">
120
+ {generalKbsLabel ?? m.workspace_kb_generalKbs()}
121
+ </div>
122
+ {GENERAL_GROUP_META.map((group) => {
123
+ const Icon = GENERAL_GROUP_ICONS[group.key];
124
+ return (
125
+ <button
126
+ key={group.key}
127
+ type="button"
128
+ className="flex w-full items-center justify-between rounded-sm px-2 py-1.5 text-sm hover:bg-muted"
129
+ onClick={() => select({ type: "general", group: group.key })}
130
+ >
131
+ <span className="flex min-w-0 items-center gap-2">
132
+ <Icon className="size-3.5 shrink-0 text-muted-foreground" />
133
+ <span className="truncate">{generalGroupLabel(group.key)}</span>
134
+ </span>
135
+ {filter?.type === "general" && filter.group === group.key && <Check className="size-3.5" />}
136
+ </button>
137
+ );
138
+ })}
139
+ </PopoverContent>
140
+ </Popover>
141
+ </div>
142
+ );
143
+ }
@@ -339,8 +339,8 @@ export function KnowledgeToolbar({ kb, onUpdate, breadcrumb, docType, treeCollap
339
339
  >
340
340
  {kb.name}
341
341
  </BreadcrumbLink>
342
- <BreadcrumbSeparator />
343
342
  </BreadcrumbItem>
343
+ <BreadcrumbSeparator />
344
344
  <BreadcrumbItem>
345
345
  <DropdownMenu>
346
346
  <DropdownMenuTrigger className="flex items-center text-sm text-muted-foreground hover:text-foreground">
@@ -357,8 +357,8 @@ export function KnowledgeToolbar({ kb, onUpdate, breadcrumb, docType, treeCollap
357
357
  ))}
358
358
  </DropdownMenuContent>
359
359
  </DropdownMenu>
360
- <BreadcrumbSeparator />
361
360
  </BreadcrumbItem>
361
+ <BreadcrumbSeparator />
362
362
  {breadcrumb.slice(-2).map((item, index, arr) => {
363
363
  const isLast = index === arr.length - 1
364
364
  return (
@@ -368,16 +368,14 @@ export function KnowledgeToolbar({ kb, onUpdate, breadcrumb, docType, treeCollap
368
368
  {item.title}
369
369
  </BreadcrumbPage>
370
370
  ) : (
371
- <>
372
- <BreadcrumbLink
373
- className="text-sm font-semibold cursor-pointer hover:text-foreground"
374
- onClick={() => onNavigate?.(item.id)}
375
- >
376
- {item.title}
377
- </BreadcrumbLink>
378
- <BreadcrumbSeparator />
379
- </>
371
+ <BreadcrumbLink
372
+ className="text-sm font-semibold cursor-pointer hover:text-foreground"
373
+ onClick={() => onNavigate?.(item.id)}
374
+ >
375
+ {item.title}
376
+ </BreadcrumbLink>
380
377
  )}
378
+ {!isLast && <BreadcrumbSeparator />}
381
379
  </BreadcrumbItem>
382
380
  )
383
381
  })}
@@ -391,8 +389,8 @@ export function KnowledgeToolbar({ kb, onUpdate, breadcrumb, docType, treeCollap
391
389
  >
392
390
  {kb.name}
393
391
  </BreadcrumbLink>
394
- <BreadcrumbSeparator />
395
392
  </BreadcrumbItem>
393
+ <BreadcrumbSeparator />
396
394
  {breadcrumb.map((item, index) => {
397
395
  const isLast = index === breadcrumb.length - 1
398
396
  return (
@@ -402,16 +400,14 @@ export function KnowledgeToolbar({ kb, onUpdate, breadcrumb, docType, treeCollap
402
400
  {item.title}
403
401
  </BreadcrumbPage>
404
402
  ) : (
405
- <>
406
- <BreadcrumbLink
407
- className="text-sm font-semibold cursor-pointer hover:text-foreground"
408
- onClick={() => onNavigate?.(item.id)}
409
- >
410
- {item.title}
411
- </BreadcrumbLink>
412
- <BreadcrumbSeparator />
413
- </>
403
+ <BreadcrumbLink
404
+ className="text-sm font-semibold cursor-pointer hover:text-foreground"
405
+ onClick={() => onNavigate?.(item.id)}
406
+ >
407
+ {item.title}
408
+ </BreadcrumbLink>
414
409
  )}
410
+ {!isLast && <BreadcrumbSeparator />}
415
411
  </BreadcrumbItem>
416
412
  )
417
413
  })}
@@ -197,8 +197,9 @@ export function LegalComplianceEditor({ type, editingId }: Props) {
197
197
  } else {
198
198
  await updateComplianceFn({ data: payload })
199
199
  }
200
+ qc.invalidateQueries({ queryKey: [type] })
200
201
  },
201
- [editingId, type],
202
+ [editingId, type, qc],
202
203
  )
203
204
 
204
205
  const handlePropertyChange = useCallback((key: string, value: unknown) => {
@@ -237,9 +238,10 @@ export function LegalComplianceEditor({ type, editingId }: Props) {
237
238
  } else {
238
239
  await updateComplianceFn({ data: payload })
239
240
  }
241
+ qc.invalidateQueries({ queryKey: [type] })
240
242
  },
241
243
  }),
242
- [editingId, type, saveField],
244
+ [editingId, type, saveField, qc],
243
245
  )
244
246
 
245
247
  if (!record) {
@@ -4,7 +4,7 @@ import { overwriteGetLocale } from "@/paraglide/runtime.js";
4
4
 
5
5
  overwriteGetLocale(() => "zh");
6
6
 
7
- const mockUseLiveQuery = vi.fn();
7
+ const mockUseQuery = vi.fn();
8
8
  const mockUseLocation = vi.fn();
9
9
  const mockUseSidebar = vi.fn();
10
10
 
@@ -13,10 +13,12 @@ vi.mock("@tanstack/react-router", () => ({
13
13
  <a href={to}>{children}</a>
14
14
  ),
15
15
  useLocation: () => mockUseLocation(),
16
+ useRouter: () => ({ navigate: vi.fn() }),
16
17
  }));
17
18
 
18
- vi.mock("@tanstack/react-db", () => ({
19
- useLiveQuery: (...args: any[]) => mockUseLiveQuery(...args),
19
+ vi.mock("@tanstack/react-query", () => ({
20
+ useQuery: (...args: any[]) => mockUseQuery(...args),
21
+ useQueryClient: () => ({ invalidateQueries: vi.fn() }),
20
22
  }));
21
23
 
22
24
  vi.mock("@/components/ui/sidebar", () => ({
@@ -52,10 +54,11 @@ vi.mock("@/lib/utils", () => ({
52
54
  }));
53
55
 
54
56
  vi.mock("@/lib/collections/reference/workspaces-collection", () => ({
55
- workspacesCollection: { id: "workspaces" },
57
+ WORKSPACES_QUERY_KEY: ["workspaces"],
56
58
  }));
57
59
 
58
60
  vi.mock("@/server/serverFns/workspace", () => ({
61
+ listPinnedWorkspacesBriefFn: vi.fn().mockResolvedValue([]),
59
62
  toggleWorkspacePinFn: vi.fn().mockResolvedValue({ success: true }),
60
63
  }));
61
64
 
@@ -68,7 +71,7 @@ beforeEach(() => {
68
71
  cleanup();
69
72
  mockUseLocation.mockReturnValue({ pathname: "/workspaces" });
70
73
  mockUseSidebar.mockReturnValue({ state: "expanded" });
71
- mockUseLiveQuery.mockReturnValue({
74
+ mockUseQuery.mockReturnValue({
72
75
  data: [
73
76
  { id: "ws1", name: "工作空间 A", icon: "folder" },
74
77
  { id: "ws2", name: "工作空间 B", icon: "folder-kanban" },
@@ -103,7 +106,7 @@ describe("NavWorkspace", () => {
103
106
  });
104
107
 
105
108
  it("空 collection 时不渲染工作空间列表项", async () => {
106
- mockUseLiveQuery.mockReturnValue({ data: [] });
109
+ mockUseQuery.mockReturnValue({ data: [] });
107
110
  render(<NavWorkspace />);
108
111
  await waitFor(() => {
109
112
  expect(screen.queryByTestId("project-icon")).not.toBeInTheDocument();
@@ -24,7 +24,6 @@ import {
24
24
  useSidebar,
25
25
  } from "@/components/ui/sidebar";
26
26
  import { cn } from "@/lib/utils";
27
- import { m } from "motion/react";
28
27
  import DashboardNavigation from "@/components/layout/sidebar-03/nav-main";
29
28
  import { NotificationsBell } from "@/components/layout/sidebar-03/nav-notifications";
30
29
  import { NavAnyHub } from "@/components/layout/sidebar-03/nav-anyhub";
@@ -57,15 +56,10 @@ export function DashboardSidebar() {
57
56
  {isCollapsed ? (
58
57
  <div className="flex flex-col items-center gap-2">
59
58
  <LogoIcon className="h-6 w-6" />
60
- <m.div
61
- className="flex flex-col-reverse items-center gap-1"
62
- initial={{ opacity: 0 }}
63
- animate={{ opacity: 1 }}
64
- transition={{ duration: 0.8 }}
65
- >
59
+ <div className="flex flex-col-reverse items-center gap-1">
66
60
  <NotificationsBell />
67
61
  <SidebarTrigger />
68
- </m.div>
62
+ </div>
69
63
  </div>
70
64
  ) : (
71
65
  <>
@@ -78,17 +72,12 @@ export function DashboardSidebar() {
78
72
  {/* <Badge className="h-4 text-[10px] relative -top-4 -left-9 ">v0</Badge> */}
79
73
  </div>
80
74
  </a>
81
- <m.div
82
- className="flex items-center gap-2"
83
- initial={{ opacity: 0 }}
84
- animate={{ opacity: 1 }}
85
- transition={{ duration: 0.8 }}
86
- >
75
+ <div className="flex items-center gap-2">
87
76
  <NotificationsBell />
88
77
  <AnimatedThemeToggler />
89
78
 
90
79
  <SidebarTrigger />
91
- </m.div>
80
+ </div>
92
81
  </>
93
82
  )}
94
83
  </SidebarHeader>