@nextclaw/ui 0.14.1 → 0.14.3

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 (224) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/dist/assets/{api-DPcvCLfa.js → api-BP_8IaxB.js} +7 -7
  3. package/dist/assets/channels-list-page-DEh5Jjqx.js +8 -0
  4. package/dist/assets/chat-page-BRB0N5WW.js +105 -0
  5. package/dist/assets/config-split-page-CRhlZ_pH.js +1 -0
  6. package/dist/assets/{confirm-dialog-CWh5FfK2.js → confirm-dialog-DsivFJ4r.js} +2 -2
  7. package/dist/assets/{desktop-update-config-DsRhBizi.js → desktop-update-config-B0w9p2ep.js} +1 -1
  8. package/dist/assets/{dist-DPrgcEC0.js → dist-CikkhOlq.js} +1 -1
  9. package/dist/assets/{dist-DsE4OsSS.js → dist-PVA_xL1D.js} +1 -1
  10. package/dist/assets/doc-browser-BoNJ7-tE.js +1 -0
  11. package/dist/assets/doc-browser-CdJ_d4ik.js +1 -0
  12. package/dist/assets/doc-browser-context-MTw4EvKd.js +1 -0
  13. package/dist/assets/{ellipsis-B81FStLm.js → ellipsis-y6vDtq4M.js} +1 -1
  14. package/dist/assets/{external-link-C_dmG-WJ.js → external-link-DVJIQf_G.js} +1 -1
  15. package/dist/assets/index-BiE-K8-q.css +1 -0
  16. package/dist/assets/index-D9BAE_L_.js +105 -0
  17. package/dist/assets/mcp-marketplace-page-B1SZNY5m.js +40 -0
  18. package/dist/assets/mcp-marketplace-page-Cq_Tw42T.js +1 -0
  19. package/dist/assets/middleware-CkFT3R4v.js +1 -0
  20. package/dist/assets/model-config-page-DotaTUHb.js +1 -0
  21. package/dist/assets/plus-BbZqquJe.js +1 -0
  22. package/dist/assets/provider-scoped-model-input-DsFRuWJH.js +1 -0
  23. package/dist/assets/providers-config-page-DW4tQSLN.js +1 -0
  24. package/dist/assets/{react-sZz5HSCm.js → react-VNqT5Zp7.js} +1 -1
  25. package/dist/assets/remote-B8TiUfwW.js +1 -0
  26. package/dist/assets/rotate-cw-DjHDTkCv.js +1 -0
  27. package/dist/assets/runtime-config-page-S6ErRHR8.js +1 -0
  28. package/dist/assets/{save-MzjoiHri.js → save-BOJ477H-.js} +1 -1
  29. package/dist/assets/{search-B29dE4GN.js → search-BEti5beA.js} +1 -1
  30. package/dist/assets/{search-config-page-Dl5sZzob.js → search-config-page-DGj5am03.js} +1 -1
  31. package/dist/assets/{secrets-config-page-C_l-IFCK.js → secrets-config-page--IzrIOid.js} +2 -2
  32. package/dist/assets/{select-BKmh05hd.js → select-hoj8dK4K.js} +2 -2
  33. package/dist/assets/{tag-chip-BS_7Ueom.js → tag-chip-T8-h-die.js} +1 -1
  34. package/dist/assets/use-config-CjRV2hwL.js +1 -0
  35. package/dist/assets/x-D9PXs_xP.js +1 -0
  36. package/dist/index.html +70 -19
  37. package/index.html +55 -5
  38. package/package.json +9 -9
  39. package/src/app/components/app-presenter-provider.tsx +11 -2
  40. package/src/app/components/layout/__tests__/app-layout.test.tsx +11 -2
  41. package/src/app/components/layout/__tests__/sidebar.layout.test.tsx +110 -79
  42. package/src/app/components/layout/app-layout.tsx +18 -4
  43. package/src/app/components/layout/header.tsx +3 -3
  44. package/src/app/components/layout/page-layout.tsx +2 -2
  45. package/src/app/components/layout/runtime-status-entry.tsx +7 -7
  46. package/src/app/components/layout/sidebar-items.tsx +194 -71
  47. package/src/app/components/layout/sidebar-rail.styles.ts +15 -0
  48. package/src/app/components/layout/sidebar.tsx +340 -83
  49. package/src/app/managers/viewport-layout.manager.ts +10 -0
  50. package/src/app/stores/viewport-layout.store.ts +39 -3
  51. package/src/app/styles/design-system.css +388 -26
  52. package/src/app/test/vitest-setup.ts +31 -0
  53. package/src/features/agents/components/__tests__/agents-page.test.tsx +62 -39
  54. package/src/features/agents/components/agents-page.tsx +10 -6
  55. package/src/features/chat/components/chat-sidebar-list-mode-switch.tsx +3 -3
  56. package/src/features/chat/components/conversation/__tests__/chat-conversation-header-section.test.tsx +145 -0
  57. package/src/features/chat/components/conversation/__tests__/chat-conversation-header.test.tsx +4 -2
  58. package/src/features/chat/components/conversation/__tests__/chat-conversation-panel.test.tsx +69 -762
  59. package/src/features/chat/components/conversation/__tests__/chat-conversation-workspace-section.test.tsx +132 -0
  60. package/src/features/chat/components/conversation/chat-conversation-content.tsx +22 -15
  61. package/src/features/chat/components/conversation/chat-conversation-header-section.tsx +15 -16
  62. package/src/features/chat/components/conversation/chat-conversation-header.tsx +7 -6
  63. package/src/features/chat/components/conversation/chat-conversation-panel.tsx +19 -19
  64. package/src/features/chat/components/conversation/chat-conversation-skeleton.tsx +13 -13
  65. package/src/features/chat/components/conversation/chat-conversation-workspace-section.tsx +10 -4
  66. package/src/features/chat/components/layout/__tests__/chat-sidebar-read-state.test.tsx +52 -11
  67. package/src/features/chat/components/layout/__tests__/chat-sidebar.test.tsx +18 -16
  68. package/src/features/chat/components/layout/chat-page-shell.tsx +1 -1
  69. package/src/features/chat/components/layout/chat-sidebar-desktop-layout.tsx +247 -0
  70. package/src/features/chat/components/layout/chat-sidebar-toolbar.tsx +93 -20
  71. package/src/features/chat/components/layout/chat-sidebar-utility-menu.tsx +93 -36
  72. package/src/features/chat/components/layout/chat-sidebar.tsx +60 -93
  73. package/src/features/chat/components/providers/chat-presenter.provider.tsx +0 -6
  74. package/src/features/chat/features/conversation/components/__tests__/session-conversation-area.test.tsx +225 -0
  75. package/src/features/chat/features/conversation/components/__tests__/session-conversation-input.streaming.test.tsx +199 -0
  76. package/src/features/chat/features/conversation/components/session-conversation-area.tsx +308 -0
  77. package/src/features/chat/features/conversation/components/session-conversation-input.tsx +439 -0
  78. package/src/features/chat/features/conversation/hooks/use-session-conversation-controller.ts +194 -0
  79. package/src/features/chat/features/conversation/hooks/use-session-conversation-input-attachments.ts +66 -0
  80. package/src/features/chat/features/{input/hooks/use-chat-input-bar-query-state.ts → conversation/hooks/use-session-conversation-input-query.ts} +51 -20
  81. package/src/features/chat/features/conversation/hooks/use-session-conversation-input-state.ts +281 -0
  82. package/src/features/chat/features/conversation/utils/session-conversation-input-toolbar.utils.ts +158 -0
  83. package/src/features/chat/features/input/hooks/__tests__/use-chat-input-surface-state.test.tsx +56 -0
  84. package/src/features/chat/features/input/hooks/use-chat-input-surface-state.ts +117 -0
  85. package/src/features/chat/features/input/input-surface-plugins/__tests__/panel-app-reference-plugin.test.ts +77 -0
  86. package/src/features/chat/features/input/input-surface-plugins/__tests__/slash-command-plugin.test.ts +108 -0
  87. package/src/features/chat/features/input/input-surface-plugins/chat-input-product-plugin-adapters.types.ts +10 -0
  88. package/src/features/chat/features/input/input-surface-plugins/input-surface-search.utils.ts +108 -0
  89. package/src/features/chat/features/input/input-surface-plugins/panel-app-reference-plugin.utils.ts +108 -0
  90. package/src/features/chat/features/input/input-surface-plugins/slash-command-plugin.utils.ts +161 -0
  91. package/src/features/chat/features/input/utils/__tests__/chat-composer-state.utils.test.ts +22 -1
  92. package/src/features/chat/features/input/utils/__tests__/chat-inline-token.utils.test.ts +26 -13
  93. package/src/features/chat/features/input/utils/__tests__/ncp-chat-input-availability.utils.test.ts +7 -28
  94. package/src/features/chat/features/input/utils/chat-composer-state.utils.ts +6 -0
  95. package/src/features/chat/features/input/utils/chat-inline-token.utils.ts +26 -51
  96. package/src/features/chat/features/input/utils/chat-input-bar.utils.ts +2 -0
  97. package/src/features/chat/features/input/utils/ncp-chat-input-availability.utils.ts +5 -6
  98. package/src/features/chat/features/message/components/__tests__/chat-message-list.container.test.tsx +47 -12
  99. package/src/features/chat/features/message/components/chat-inline-panel-app-card.tsx +7 -7
  100. package/src/features/chat/features/message/components/chat-message-list.container.tsx +99 -18
  101. package/src/features/chat/features/message/utils/__tests__/chat-message.utils.test.ts +31 -12
  102. package/src/features/chat/features/message/utils/chat-message-markdown-part.utils.ts +38 -0
  103. package/src/features/chat/features/message/utils/chat-message-part.utils.ts +1 -1
  104. package/src/features/chat/features/session/components/chat-sidebar-project-groups.tsx +7 -7
  105. package/src/features/chat/features/session/components/chat-sidebar-session-list.tsx +6 -6
  106. package/src/features/chat/features/session/components/session-context-icon.tsx +2 -2
  107. package/src/features/chat/features/session/components/session-header/__tests__/chat-session-project-badge.test.tsx +2 -2
  108. package/src/features/chat/features/session/components/session-header/chat-session-header-actions.tsx +1 -1
  109. package/src/features/chat/features/session/components/session-header/chat-session-header-menu-item.tsx +1 -1
  110. package/src/features/chat/features/session/components/session-header/chat-session-metadata-dialog.tsx +1 -1
  111. package/src/features/chat/features/session/components/session-header/chat-session-project-badge.tsx +3 -3
  112. package/src/features/chat/features/session/components/session-run-badge.tsx +1 -1
  113. package/src/features/chat/features/session/hooks/__tests__/use-chat-session-project.test.tsx +3 -30
  114. package/src/features/chat/features/session/hooks/use-chat-session-project.ts +0 -5
  115. package/src/features/chat/features/session/utils/chat-run-metadata.utils.ts +8 -0
  116. package/src/features/chat/features/session-type/components/__tests__/chat-session-type-menu.test.tsx +2 -2
  117. package/src/features/chat/features/session-type/components/__tests__/chat-session-type-option-item.test.tsx +2 -0
  118. package/src/features/chat/features/session-type/components/chat-session-type-menu.tsx +2 -2
  119. package/src/features/chat/features/session-type/components/chat-session-type-option-item.tsx +2 -2
  120. package/src/features/chat/features/session-type/hooks/use-chat-session-type-state.ts +10 -2
  121. package/src/features/chat/features/welcome/components/__tests__/chat-conversation-welcome.test.tsx +49 -31
  122. package/src/features/chat/features/welcome/components/__tests__/chat-welcome.test.tsx +2 -9
  123. package/src/features/chat/features/welcome/components/chat-conversation-welcome.tsx +21 -10
  124. package/src/features/chat/features/welcome/components/chat-welcome-agent-picker.tsx +3 -3
  125. package/src/features/chat/features/welcome/components/chat-welcome-capability-grid.tsx +4 -4
  126. package/src/features/chat/features/welcome/components/chat-welcome-project-picker.tsx +14 -19
  127. package/src/features/chat/features/welcome/components/chat-welcome-session-type-picker.tsx +5 -5
  128. package/src/features/chat/features/welcome/components/chat-welcome.tsx +6 -6
  129. package/src/features/chat/features/welcome/utils/__tests__/chat-welcome-project-options.utils.test.ts +6 -8
  130. package/src/features/chat/features/welcome/utils/chat-welcome-project-options.utils.ts +1 -16
  131. package/src/features/chat/features/workspace/components/__tests__/chat-session-workspace-panel-content.test.tsx +54 -0
  132. package/src/features/chat/features/workspace/components/chat-session-workspace-panel-content.tsx +44 -76
  133. package/src/features/chat/features/workspace/components/chat-session-workspace-panel-nav.tsx +5 -1
  134. package/src/features/chat/features/workspace/components/chat-session-workspace-panel.tsx +7 -1
  135. package/src/features/chat/features/workspace/hooks/use-chat-conversation-workspace-state.ts +40 -31
  136. package/src/features/chat/features/workspace/utils/__tests__/chat-workspace-panel-view-model.utils.test.ts +59 -1
  137. package/src/features/chat/features/workspace/utils/chat-thread-workspace-session.utils.ts +109 -0
  138. package/src/features/chat/features/workspace/utils/chat-workspace-panel-view-model.utils.ts +44 -4
  139. package/src/features/chat/index.ts +0 -1
  140. package/src/features/chat/managers/__tests__/chat-session-list.manager.test.ts +51 -99
  141. package/src/features/chat/managers/__tests__/chat-thread.manager.test.ts +135 -21
  142. package/src/features/chat/managers/chat-session-list.manager.ts +12 -43
  143. package/src/features/chat/managers/chat-thread.manager.ts +108 -34
  144. package/src/features/chat/pages/__tests__/ncp-chat-page.test.ts +48 -1
  145. package/src/features/chat/pages/__tests__/ncp-chat-page.test.tsx +72 -0
  146. package/src/features/chat/pages/ncp-chat-page.tsx +0 -122
  147. package/src/features/chat/presenters/chat.presenter.ts +2 -19
  148. package/src/features/chat/stores/__tests__/chat-thread.store.test.ts +31 -0
  149. package/src/features/chat/stores/chat-thread.store.ts +42 -26
  150. package/src/features/panel-apps/components/panel-app-list-item.tsx +8 -8
  151. package/src/features/panel-apps/components/panel-app-toolbar.tsx +4 -4
  152. package/src/features/panel-apps/components/panel-apps-list.tsx +20 -20
  153. package/src/features/panel-apps/hooks/use-panel-apps.ts +2 -1
  154. package/src/features/pwa/managers/__tests__/pwa-shell-theme.manager.test.ts +70 -6
  155. package/src/features/pwa/managers/pwa-shell-theme.manager.ts +10 -5
  156. package/src/features/service-apps/components/service-action-authorization-dialog.tsx +8 -8
  157. package/src/features/service-apps/components/service-apps-panel.tsx +24 -24
  158. package/src/features/settings/components/config/provider-advanced-settings-section.tsx +5 -5
  159. package/src/features/settings/components/config/provider-auth-section.tsx +7 -7
  160. package/src/features/settings/components/config/provider-form.tsx +10 -10
  161. package/src/features/settings/components/config/provider-models-section.tsx +16 -16
  162. package/src/features/settings/components/config/provider-pill-selector.tsx +1 -1
  163. package/src/features/settings/pages/providers-config-page.tsx +17 -17
  164. package/src/platforms/desktop/components/__tests__/desktop-app-shell.test.tsx +50 -10
  165. package/src/platforms/desktop/components/desktop-app-shell.tsx +34 -10
  166. package/src/platforms/desktop/components/desktop-window-chrome.tsx +41 -16
  167. package/src/shared/components/config-split-page.tsx +11 -11
  168. package/src/shared/components/doc-browser/doc-browser-home-page.tsx +2 -2
  169. package/src/shared/components/doc-browser/doc-browser-panel-parts.tsx +4 -4
  170. package/src/shared/components/doc-browser/doc-browser-tab-strip.tsx +4 -4
  171. package/src/shared/components/doc-browser/doc-browser.tsx +3 -3
  172. package/src/shared/components/resizable-right-panel/resizable-right-panel.tsx +1 -1
  173. package/src/shared/components/ui/actions/icon-action-button.tsx +1 -1
  174. package/src/shared/components/ui/button.tsx +4 -4
  175. package/src/shared/components/ui/card.tsx +4 -4
  176. package/src/shared/components/ui/dialog.tsx +5 -5
  177. package/src/shared/components/ui/input.tsx +1 -1
  178. package/src/shared/components/ui/label.tsx +1 -1
  179. package/src/shared/components/ui/popover.tsx +1 -1
  180. package/src/shared/components/ui/select.tsx +3 -3
  181. package/src/shared/components/ui/switch.tsx +3 -3
  182. package/src/shared/components/ui/tab-strip/__tests__/compact-tab-strip.test.tsx +24 -1
  183. package/src/shared/components/ui/tab-strip/compact-tab-strip.tsx +8 -4
  184. package/src/shared/components/ui/tabs.tsx +4 -4
  185. package/src/shared/components/ui/textarea.tsx +1 -1
  186. package/src/shared/lib/i18n/locales/en-US/chat.json +20 -2
  187. package/src/shared/lib/i18n/locales/en-US/core.json +9 -2
  188. package/src/shared/lib/i18n/locales/zh-CN/chat.json +20 -2
  189. package/src/shared/lib/i18n/locales/zh-CN/core.json +9 -2
  190. package/src/shared/lib/theme/index.ts +94 -52
  191. package/src/shared/lib/ui-document-title/__tests__/ui-document-title.test.ts +49 -0
  192. package/src/shared/lib/ui-document-title/index.ts +50 -21
  193. package/dist/assets/channels-list-page-D6jtziu4.js +0 -8
  194. package/dist/assets/chat-page-BBnNJoKG.js +0 -105
  195. package/dist/assets/config-split-page-BCJhqdK9.js +0 -1
  196. package/dist/assets/doc-browser-C8xOF_9F.js +0 -1
  197. package/dist/assets/doc-browser-DgQedeAn.js +0 -1
  198. package/dist/assets/doc-browser-context-B46tQGmO.js +0 -1
  199. package/dist/assets/index-7o1WdUbU.js +0 -103
  200. package/dist/assets/index-DQWmEWzk.css +0 -1
  201. package/dist/assets/mcp-marketplace-page-CWZN7ohK.js +0 -1
  202. package/dist/assets/mcp-marketplace-page-Dzull_4-.js +0 -40
  203. package/dist/assets/model-config-page-Cv0O3BIj.js +0 -1
  204. package/dist/assets/plus-Bnt56oqG.js +0 -1
  205. package/dist/assets/provider-scoped-model-input-Duuh7i3h.js +0 -1
  206. package/dist/assets/providers-config-page-CPDz0Lxc.js +0 -1
  207. package/dist/assets/remote-B0LMs8u7.js +0 -1
  208. package/dist/assets/rotate-cw-BfyG_KEF.js +0 -1
  209. package/dist/assets/runtime-config-page-q0fvv8ME.js +0 -1
  210. package/dist/assets/use-config-DZkBWIdL.js +0 -1
  211. package/dist/assets/x-CW2KO-Hz.js +0 -1
  212. package/src/features/chat/components/conversation/chat-conversation-alerts.tsx +0 -37
  213. package/src/features/chat/features/input/components/__tests__/chat-attachment-upload-limit.test.ts +0 -38
  214. package/src/features/chat/features/input/components/chat-input-bar.container.tsx +0 -496
  215. package/src/features/chat/features/message/utils/chat-message-inline-content.utils.ts +0 -95
  216. package/src/features/chat/managers/__tests__/chat-input.manager.test.ts +0 -448
  217. package/src/features/chat/managers/__tests__/chat-run-snapshot.manager.test.ts +0 -129
  218. package/src/features/chat/managers/__tests__/chat-run.manager.test.ts +0 -236
  219. package/src/features/chat/managers/__tests__/chat-session-preference-sync.manager.test.ts +0 -219
  220. package/src/features/chat/managers/chat-input.manager.ts +0 -430
  221. package/src/features/chat/managers/chat-run.manager.ts +0 -110
  222. package/src/features/chat/managers/chat-session-preference-sync.manager.ts +0 -142
  223. package/src/features/chat/stores/chat-input.store.ts +0 -104
  224. /package/dist/assets/{config-hints-CTqBnCDp.js → config-hints-BkYBc7z0.js} +0 -0
@@ -36,14 +36,14 @@ const DialogContent = React.forwardRef<
36
36
  <DialogPrimitive.Content
37
37
  ref={ref}
38
38
  className={cn(
39
- "fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border border-gray-200/50 bg-white p-6 shadow-xl duration-base data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] rounded-2xl",
39
+ "fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 rounded-2xl border border-border bg-popover p-6 text-popover-foreground shadow-xl duration-base data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]",
40
40
  className
41
41
  )}
42
42
  {...props}
43
43
  >
44
44
  {children}
45
- <DialogPrimitive.Close className="absolute right-4 top-4 rounded-lg p-1 opacity-70 ring-offset-white transition-all duration-fast hover:opacity-100 hover:bg-gray-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 disabled:pointer-events-none">
46
- <X className="h-4 w-4 text-gray-500" />
45
+ <DialogPrimitive.Close className="absolute right-4 top-4 rounded-lg p-1 opacity-70 ring-offset-background transition-all duration-fast hover:bg-accent hover:text-accent-foreground hover:opacity-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 disabled:pointer-events-none">
46
+ <X className="h-4 w-4 text-muted-foreground" />
47
47
  <span className="sr-only">Close</span>
48
48
  </DialogPrimitive.Close>
49
49
  </DialogPrimitive.Content>
@@ -86,7 +86,7 @@ const DialogTitle = React.forwardRef<
86
86
  <DialogPrimitive.Title
87
87
  ref={ref}
88
88
  className={cn(
89
- "text-lg font-semibold leading-tight tracking-tight text-gray-900",
89
+ "text-lg font-semibold leading-tight tracking-tight text-foreground",
90
90
  className
91
91
  )}
92
92
  {...props}
@@ -100,7 +100,7 @@ const DialogDescription = React.forwardRef<
100
100
  >(({ className, ...props }, ref) => (
101
101
  <DialogPrimitive.Description
102
102
  ref={ref}
103
- className={cn("text-sm text-gray-500 leading-relaxed", className)}
103
+ className={cn("text-sm text-muted-foreground leading-relaxed", className)}
104
104
  {...props}
105
105
  />
106
106
  ))
@@ -9,7 +9,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
9
9
  <input
10
10
  type={type}
11
11
  className={cn(
12
- 'flex h-9 w-full rounded-xl border border-gray-200/80 bg-white px-3.5 py-2 text-sm text-gray-900 file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-gray-300 placeholder:font-normal focus:outline-none focus:ring-1 focus:ring-primary/40 focus:border-primary/40 transition-colors disabled:cursor-not-allowed disabled:opacity-50',
12
+ 'flex h-9 w-full rounded-xl border border-border bg-card px-3.5 py-2 text-sm text-foreground file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground/55 placeholder:font-normal focus:outline-none focus:ring-1 focus:ring-primary/40 focus:border-primary/50 transition-colors disabled:cursor-not-allowed disabled:opacity-50',
13
13
  className
14
14
  )}
15
15
  ref={ref}
@@ -8,7 +8,7 @@ const Label = React.forwardRef<HTMLLabelElement, LabelProps>(
8
8
  <label
9
9
  ref={ref}
10
10
  className={cn(
11
- 'text-sm font-medium leading-none text-gray-700 peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
11
+ 'text-sm font-medium leading-none text-foreground peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
12
12
  className
13
13
  )}
14
14
  {...props}
@@ -28,7 +28,7 @@ const PopoverContent = React.forwardRef<
28
28
  align={align}
29
29
  collisionPadding={collisionPadding}
30
30
  className={cn(
31
- 'z-[var(--z-popover,50)] w-72 overflow-x-hidden overflow-y-auto rounded-2xl border border-gray-200/50 bg-white p-4 shadow-lg animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
31
+ 'z-[var(--z-popover,50)] w-72 overflow-x-hidden overflow-y-auto rounded-2xl border border-border bg-popover p-4 text-popover-foreground shadow-lg animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
32
32
  className
33
33
  )}
34
34
  style={{ maxHeight: POPOVER_CONTENT_MAX_HEIGHT, ...style }}
@@ -25,7 +25,7 @@ const SelectTrigger = React.forwardRef<
25
25
  <SelectPrimitive.Trigger
26
26
  ref={ref}
27
27
  className={cn(
28
- "flex h-9 w-full items-center justify-between whitespace-nowrap rounded-xl border border-gray-200/80 bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-primary/40 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1 bg-white",
28
+ "flex h-9 w-full items-center justify-between whitespace-nowrap rounded-xl border border-border bg-card px-3 py-2 text-sm text-foreground shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-primary/40 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
29
29
  className
30
30
  )}
31
31
  {...props}
@@ -78,7 +78,7 @@ const SelectContent = React.forwardRef<
78
78
  <SelectPrimitive.Content
79
79
  ref={ref}
80
80
  className={cn(
81
- "relative z-[var(--z-popover,50)] flex max-h-96 min-w-[8rem] flex-col overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 bg-white",
81
+ "relative z-[var(--z-popover,50)] flex max-h-96 min-w-[8rem] flex-col overflow-hidden rounded-md border border-border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
82
82
  position === "popper" &&
83
83
  "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
84
84
  className
@@ -119,7 +119,7 @@ const SelectItem = React.forwardRef<
119
119
  <SelectPrimitive.Item
120
120
  ref={ref}
121
121
  className={cn(
122
- "relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-gray-100 focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 hover:bg-gray-100",
122
+ "relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 hover:bg-accent",
123
123
  className
124
124
  )}
125
125
  {...props}
@@ -16,8 +16,8 @@ const Switch = React.forwardRef<HTMLButtonElement, SwitchProps>(
16
16
  aria-checked={checked}
17
17
  ref={ref}
18
18
  className={cn(
19
- 'peer inline-flex h-[22px] w-10 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors duration-fast focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-50',
20
- checked ? 'bg-primary' : 'bg-gray-200/80 hover:bg-gray-300/80',
19
+ 'peer inline-flex h-[22px] w-10 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors duration-fast focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50',
20
+ checked ? 'bg-primary' : 'bg-muted hover:bg-accent',
21
21
  className
22
22
  )}
23
23
  onClick={() => onCheckedChange?.(!checked)}
@@ -26,7 +26,7 @@ const Switch = React.forwardRef<HTMLButtonElement, SwitchProps>(
26
26
  <span
27
27
  data-state={checked ? 'checked' : 'unchecked'}
28
28
  className={cn(
29
- 'pointer-events-none block h-5 w-5 rounded-full bg-white shadow-md ring-0 transition-transform duration-fast',
29
+ 'pointer-events-none block h-5 w-5 rounded-full bg-card shadow-md ring-0 transition-transform duration-fast',
30
30
  checked ? 'translate-x-5' : 'translate-x-0',
31
31
  thumbClassName
32
32
  )}
@@ -1,4 +1,4 @@
1
- import { render, screen } from "@testing-library/react";
1
+ import { fireEvent, render, screen } from "@testing-library/react";
2
2
  import { afterEach, describe, expect, it, vi } from "vitest";
3
3
  import {
4
4
  CompactTabStrip,
@@ -49,4 +49,27 @@ describe("CompactTabStrip", () => {
49
49
  inline: "nearest",
50
50
  });
51
51
  });
52
+
53
+ it("selects a tab when clicking its leading icon", () => {
54
+ const onSelect = vi.fn();
55
+
56
+ render(
57
+ <CompactTabStrip
58
+ tabs={[
59
+ {
60
+ key: "child-session",
61
+ label: "Child session",
62
+ active: false,
63
+ leadingIcon: <span data-testid="child-tab-icon" />,
64
+ onSelect,
65
+ },
66
+ ]}
67
+ actions={[]}
68
+ />,
69
+ );
70
+
71
+ fireEvent.click(screen.getByTestId("child-tab-icon"));
72
+
73
+ expect(onSelect).toHaveBeenCalledTimes(1);
74
+ });
52
75
  });
@@ -65,6 +65,9 @@ function CompactTabLabelButton({
65
65
  labelClassName?: string;
66
66
  tab: CompactTabStripTab;
67
67
  }) {
68
+ const leadingIcon = tab.onClose && tab.closePlacement === "leading-hover"
69
+ ? null
70
+ : tab.leadingIcon;
68
71
  const button = (
69
72
  <button
70
73
  type="button"
@@ -72,6 +75,11 @@ function CompactTabLabelButton({
72
75
  aria-label={tab.label}
73
76
  className="flex min-w-0 flex-1 items-center gap-1.5 text-left"
74
77
  >
78
+ {leadingIcon ? (
79
+ <span className="inline-flex shrink-0 items-center justify-center">
80
+ {leadingIcon}
81
+ </span>
82
+ ) : null}
75
83
  <span className={cn("min-w-0 truncate text-[12px] font-medium", labelClassName)}>
76
84
  {tab.label}
77
85
  </span>
@@ -136,10 +144,6 @@ function CompactTabItem({
136
144
  </span>
137
145
  <X className="hidden h-3.5 w-3.5 group-hover:block" />
138
146
  </button>
139
- ) : tab.leadingIcon ? (
140
- <span className="inline-flex shrink-0 items-center justify-center">
141
- {tab.leadingIcon}
142
- </span>
143
147
  ) : null}
144
148
  <CompactTabLabelButton tab={tab} labelClassName={labelClassName} />
145
149
  {tab.onClose && tab.closePlacement !== "leading-hover" ? (
@@ -42,7 +42,7 @@ export function TabsList({ children, className }: TabsListProps) {
42
42
  return (
43
43
  <div
44
44
  className={cn(
45
- 'inline-flex h-9 items-center justify-center rounded-xl bg-gray-100/80 p-1 text-gray-500',
45
+ 'inline-flex h-9 items-center justify-center rounded-xl bg-muted p-1 text-muted-foreground',
46
46
  className
47
47
  )}
48
48
  >
@@ -64,10 +64,10 @@ export function TabsTrigger({ value, children, className }: TabsTriggerProps) {
64
64
  aria-pressed={isActive}
65
65
  data-state={isActive ? 'active' : 'inactive'}
66
66
  className={cn(
67
- 'inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-[13px] font-medium ring-offset-white transition-all duration-fast focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
67
+ 'inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-[13px] font-medium ring-offset-background transition-all duration-fast focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
68
68
  isActive
69
- ? 'bg-white text-gray-900 shadow-sm'
70
- : 'hover:bg-white/50 hover:text-gray-800 text-gray-600',
69
+ ? 'bg-card text-foreground shadow-sm'
70
+ : 'text-muted-foreground hover:bg-accent hover:text-accent-foreground',
71
71
  className
72
72
  )}
73
73
  >
@@ -7,7 +7,7 @@ export const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
7
7
  ({ className, ...props }, ref) => (
8
8
  <textarea
9
9
  className={cn(
10
- "flex min-h-28 w-full rounded-xl border border-gray-200/80 bg-white px-3.5 py-2.5 text-sm text-gray-900 placeholder:text-gray-300 placeholder:font-normal focus:outline-none focus:ring-1 focus:ring-primary/40 focus:border-primary/40 transition-colors disabled:cursor-not-allowed disabled:opacity-50",
10
+ "flex min-h-28 w-full rounded-xl border border-border bg-card px-3.5 py-2.5 text-sm text-foreground placeholder:text-muted-foreground/55 placeholder:font-normal focus:outline-none focus:ring-1 focus:ring-primary/40 focus:border-primary/50 transition-colors disabled:cursor-not-allowed disabled:opacity-50",
11
11
  className,
12
12
  )}
13
13
  ref={ref}
@@ -39,6 +39,8 @@
39
39
  "chatWorkspaceBack": "Back in workspace",
40
40
  "chatWorkspaceForward": "Forward in workspace",
41
41
  "chatWorkspaceClosePanel": "Close workspace panel",
42
+ "chatWorkspaceSideChatDraftTitle": "Side chat",
43
+ "chatWorkspaceSideChatDraftSubtitle": "This draft will inherit the parent session context when you send the first message.",
42
44
  "chatWorkspaceChildSessions": "Child sessions",
43
45
  "chatWorkspaceOpenFiles": "Open files",
44
46
  "chatWorkspacePreview": "Preview",
@@ -55,9 +57,10 @@
55
57
  "chatTyping": "Agent is thinking...",
56
58
  "chatRuntimeInitializing": "Chat is still initializing. You can keep typing and send once startup finishes.",
57
59
  "chatRuntimeInitializationFailed": "Chat startup failed. Please retry in a moment or inspect the service logs.",
58
- "chatInputPlaceholder": "Type a message, type / to select skills, Enter to send, Shift + Enter for newline",
60
+ "chatInputPlaceholder": "Type a message, / for skills, @ for panel apps, Enter to send, Shift + Enter for newline",
59
61
  "chatInputPlaceholderCompact": "Message NextClaw...",
60
62
  "chatInputHint": "Multi-turn context is preserved in the current session.",
63
+ "chatSlashSection": "Slash menu",
61
64
  "chatSlashSectionCommands": "Commands",
62
65
  "chatSlashSectionSkills": "Skills",
63
66
  "chatSlashTypeCommand": "Command",
@@ -67,8 +70,20 @@
67
70
  "chatSlashLoading": "Loading commands and skills…",
68
71
  "chatSlashNoResult": "No matches",
69
72
  "chatSlashHint": "Type / to access commands and skills",
70
- "chatSlashCommandHint": "Press Enter to insert command, then add args and send.",
73
+ "chatSlashCommandHint": "Press Enter to run this command.",
74
+ "chatSlashCommandSideChatTitle": "Side chat",
75
+ "chatSlashCommandSideChatDescription": "Open a draft side conversation that inherits this session context.",
76
+ "chatSlashCommandSideChatDetail": "Creates the child session only after your first message.",
71
77
  "chatSlashSkillHint": "Press Enter to add this skill for the next turn.",
78
+ "chatPanelAppReferenceLoading": "Loading panel apps...",
79
+ "chatPanelAppReferenceSection": "Panel Apps",
80
+ "chatPanelAppReferenceNoResult": "No matching panel apps",
81
+ "chatPanelAppReferenceHint": "Type @ to reference a panel app",
82
+ "chatPanelAppReferenceItemHint": "Press Enter to reference this panel app.",
83
+ "chatPanelAppReferenceType": "Panel App",
84
+ "chatPanelAppReferenceAppId": "App ID",
85
+ "chatPanelAppReferenceFile": "File",
86
+ "chatPanelAppReferenceNoDescription": "No panel app description",
72
87
  "chatSkillScopeBuiltin": "Built-in",
73
88
  "chatSkillScopeProject": "Project",
74
89
  "chatSkillScopeWorkspace": "Workspace",
@@ -98,6 +113,9 @@
98
113
  "chatContextCompactionCoveredMessages": "Covered messages",
99
114
  "chatContextCompactionOriginalTokens": "Estimated before compaction",
100
115
  "chatContextCompactionProjectedTokens": "Estimated after compaction",
116
+ "chatContextInheritanceInherited": "Inherited parent session context",
117
+ "chatContextInheritanceSourceSession": "Source session",
118
+ "chatContextInheritanceMessages": "Inherited messages",
101
119
  "chatSending": "Sending...",
102
120
  "chatQueueSend": "Queue",
103
121
  "chatQueuedHintPrefix": "Queued",
@@ -12,6 +12,8 @@
12
12
  "advanced": "Advanced",
13
13
  "settings": "Settings",
14
14
  "settingsMenu": "Settings menu",
15
+ "sidebarCollapse": "Collapse sidebar",
16
+ "sidebarExpand": "Expand sidebar",
15
17
  "security": "Security",
16
18
  "backToMain": "Back to Main",
17
19
  "enabled": "Enabled",
@@ -37,8 +39,13 @@
37
39
  "noneOption": "None",
38
40
  "language": "Language",
39
41
  "theme": "Theme",
40
- "themeWarm": "Warm",
41
- "themeCool": "Cool",
42
+ "themeNatural": "Natural",
43
+ "themeMinimal": "Minimal",
44
+ "themeWarm": "Paper",
45
+ "themeCool": "Mist",
46
+ "themeDawn": "Dawn",
47
+ "themeGraphite": "Graphite",
48
+ "themeProbe": "Probe",
42
49
  "isRequired": "is required",
43
50
  "duplicate": "duplicate",
44
51
  "notFound": "not found",
@@ -39,6 +39,8 @@
39
39
  "chatWorkspaceBack": "工作区后退",
40
40
  "chatWorkspaceForward": "工作区前进",
41
41
  "chatWorkspaceClosePanel": "关闭工作区侧栏",
42
+ "chatWorkspaceSideChatDraftTitle": "Side chat",
43
+ "chatWorkspaceSideChatDraftSubtitle": "发送第一条消息后,会继承父会话上下文并创建真实 child 会话。",
42
44
  "chatWorkspaceChildSessions": "子会话",
43
45
  "chatWorkspaceOpenFiles": "打开的文件",
44
46
  "chatWorkspacePreview": "预览",
@@ -55,9 +57,10 @@
55
57
  "chatTyping": "Agent 正在思考...",
56
58
  "chatRuntimeInitializing": "聊天能力正在初始化。你可以先输入内容,完成后即可发送。",
57
59
  "chatRuntimeInitializationFailed": "聊天能力启动失败,请稍后重试或检查服务日志。",
58
- "chatInputPlaceholder": "输入消息,输入 / 选择技能,Enter 发送,Shift + Enter 换行",
60
+ "chatInputPlaceholder": "输入消息,/ 选技能,@ 引用面板应用,Enter 发送,Shift + Enter 换行",
59
61
  "chatInputPlaceholderCompact": "发消息...",
60
62
  "chatInputHint": "支持多轮上下文,默认走当前会话。",
63
+ "chatSlashSection": "斜杠菜单",
61
64
  "chatSlashSectionCommands": "命令",
62
65
  "chatSlashSectionSkills": "技能",
63
66
  "chatSlashTypeCommand": "命令",
@@ -67,8 +70,20 @@
67
70
  "chatSlashLoading": "加载命令与技能中…",
68
71
  "chatSlashNoResult": "无匹配项",
69
72
  "chatSlashHint": "输入 / 触发命令或技能选择",
70
- "chatSlashCommandHint": "回车插入命令,继续输入参数后发送。",
73
+ "chatSlashCommandHint": "回车执行这个命令。",
74
+ "chatSlashCommandSideChatTitle": "Side chat",
75
+ "chatSlashCommandSideChatDescription": "打开一个继承当前会话上下文的右侧草稿会话。",
76
+ "chatSlashCommandSideChatDetail": "只有发送第一条消息后才会创建真实 child 会话。",
71
77
  "chatSlashSkillHint": "回车把该技能加入本轮请求。",
78
+ "chatPanelAppReferenceLoading": "加载面板应用中…",
79
+ "chatPanelAppReferenceSection": "面板应用",
80
+ "chatPanelAppReferenceNoResult": "没有匹配的面板应用",
81
+ "chatPanelAppReferenceHint": "输入 @ 引用面板应用",
82
+ "chatPanelAppReferenceItemHint": "回车引用这个面板应用。",
83
+ "chatPanelAppReferenceType": "面板应用",
84
+ "chatPanelAppReferenceAppId": "应用 ID",
85
+ "chatPanelAppReferenceFile": "文件",
86
+ "chatPanelAppReferenceNoDescription": "暂无面板应用描述",
72
87
  "chatSkillScopeBuiltin": "内建",
73
88
  "chatSkillScopeProject": "项目",
74
89
  "chatSkillScopeWorkspace": "工作区",
@@ -98,6 +113,9 @@
98
113
  "chatContextCompactionCoveredMessages": "覆盖消息数",
99
114
  "chatContextCompactionOriginalTokens": "压缩前估算",
100
115
  "chatContextCompactionProjectedTokens": "压缩后估算",
116
+ "chatContextInheritanceInherited": "已继承父会话上下文",
117
+ "chatContextInheritanceSourceSession": "来源会话",
118
+ "chatContextInheritanceMessages": "继承消息数",
101
119
  "chatSending": "发送中...",
102
120
  "chatQueueSend": "排队发送",
103
121
  "chatQueuedHintPrefix": "当前有",
@@ -12,6 +12,8 @@
12
12
  "advanced": "高级",
13
13
  "settings": "设置",
14
14
  "settingsMenu": "设置菜单",
15
+ "sidebarCollapse": "收起侧边栏",
16
+ "sidebarExpand": "展开侧边栏",
15
17
  "security": "安全",
16
18
  "backToMain": "返回主界面",
17
19
  "enabled": "启用",
@@ -37,8 +39,13 @@
37
39
  "noneOption": "无",
38
40
  "language": "语言",
39
41
  "theme": "主题",
40
- "themeWarm": "暖色",
41
- "themeCool": "冷色",
42
+ "themeNatural": "自然",
43
+ "themeMinimal": "简白",
44
+ "themeWarm": "纸暖",
45
+ "themeCool": "雾蓝",
46
+ "themeDawn": "晨砂",
47
+ "themeGraphite": "石墨",
48
+ "themeProbe": "探针",
42
49
  "isRequired": "必填",
43
50
  "duplicate": "重复",
44
51
  "notFound": "未找到",
@@ -1,80 +1,122 @@
1
- export type UiTheme = 'warm' | 'cool';
1
+ export type UiTheme = 'natural' | 'minimal' | 'warm' | 'cool' | 'dawn' | 'graphite' | 'probe';
2
2
 
3
3
  const THEME_STORAGE_KEY = 'nextclaw.ui.theme';
4
+ const DEFAULT_THEME: UiTheme = 'natural';
5
+ const THEME_VALUES: readonly UiTheme[] = ['natural', 'minimal', 'warm', 'cool', 'dawn', 'graphite', 'probe'];
4
6
 
5
7
  export const THEME_OPTIONS: Array<{ value: UiTheme; labelKey: string }> = [
8
+ { value: 'natural', labelKey: 'themeNatural' },
9
+ { value: 'minimal', labelKey: 'themeMinimal' },
6
10
  { value: 'warm', labelKey: 'themeWarm' },
7
- { value: 'cool', labelKey: 'themeCool' }
11
+ { value: 'cool', labelKey: 'themeCool' },
12
+ { value: 'dawn', labelKey: 'themeDawn' },
13
+ { value: 'graphite', labelKey: 'themeGraphite' },
14
+ { value: 'probe', labelKey: 'themeProbe' }
8
15
  ];
9
16
 
10
- let activeTheme: UiTheme = 'warm';
11
- let initialized = false;
12
- const listeners = new Set<(theme: UiTheme) => void>();
13
-
14
- function isTheme(value: unknown): value is UiTheme {
15
- return value === 'warm' || value === 'cool';
16
- }
17
+ export function normalizeTheme(value: unknown): UiTheme | null {
18
+ if (typeof value !== 'string') {
19
+ return null;
20
+ }
17
21
 
18
- function applyThemeAttribute(theme: UiTheme): void {
19
- if (typeof document === 'undefined') {
20
- return;
22
+ if ((THEME_VALUES as readonly string[]).includes(value)) {
23
+ return value as UiTheme;
21
24
  }
22
- document.documentElement.setAttribute('data-theme', theme);
25
+
26
+ return value === 'leaf' ? 'warm' : null;
23
27
  }
24
28
 
25
- export function resolveInitialTheme(): UiTheme {
26
- if (typeof window === 'undefined') {
27
- return 'warm';
28
- }
29
+ class UiThemeOwner {
30
+ private activeTheme: UiTheme = DEFAULT_THEME;
31
+ private initialized = false;
32
+ private readonly listeners = new Set<(theme: UiTheme) => void>();
29
33
 
30
- try {
31
- const saved = window.localStorage.getItem(THEME_STORAGE_KEY);
32
- if (isTheme(saved)) {
33
- return saved;
34
+ resolveInitialTheme = (): UiTheme => {
35
+ if (typeof window === 'undefined') {
36
+ return DEFAULT_THEME;
34
37
  }
35
- } catch {
36
- // ignore storage failures
37
- }
38
38
 
39
- return 'warm';
40
- }
39
+ try {
40
+ const saved = window.localStorage.getItem(THEME_STORAGE_KEY);
41
+ const theme = normalizeTheme(saved);
42
+ if (theme) {
43
+ return theme;
44
+ }
45
+ } catch {
46
+ // ignore storage failures
47
+ }
41
48
 
42
- export function initializeTheme(): UiTheme {
43
- if (!initialized) {
44
- activeTheme = resolveInitialTheme();
45
- applyThemeAttribute(activeTheme);
46
- initialized = true;
47
- }
48
- return activeTheme;
49
- }
49
+ return DEFAULT_THEME;
50
+ };
50
51
 
51
- export function getTheme(): UiTheme {
52
- return initialized ? activeTheme : initializeTheme();
53
- }
52
+ initializeTheme = (): UiTheme => {
53
+ if (!this.initialized) {
54
+ this.activeTheme = this.resolveInitialTheme();
55
+ this.applyThemeAttribute(this.activeTheme);
56
+ this.initialized = true;
57
+ }
58
+ return this.activeTheme;
59
+ };
54
60
 
55
- export function setTheme(theme: UiTheme): void {
56
- initializeTheme();
57
- if (theme === activeTheme) {
58
- return;
59
- }
61
+ getTheme = (): UiTheme => this.initialized ? this.activeTheme : this.initializeTheme();
60
62
 
61
- activeTheme = theme;
62
- applyThemeAttribute(activeTheme);
63
+ setTheme = (theme: UiTheme): void => {
64
+ this.initializeTheme();
65
+ if (theme === this.activeTheme) {
66
+ return;
67
+ }
68
+
69
+ this.activeTheme = theme;
70
+ this.applyThemeAttribute(this.activeTheme);
71
+ this.saveTheme(this.activeTheme);
72
+ this.listeners.forEach((listener) => listener(this.activeTheme));
73
+ };
74
+
75
+ subscribeThemeChange = (listener: (theme: UiTheme) => void): (() => void) => {
76
+ this.listeners.add(listener);
77
+ return () => {
78
+ this.listeners.delete(listener);
79
+ };
80
+ };
81
+
82
+ private applyThemeAttribute = (theme: UiTheme): void => {
83
+ if (typeof document === 'undefined') {
84
+ return;
85
+ }
86
+ document.documentElement.setAttribute('data-theme', theme);
87
+ };
88
+
89
+ private saveTheme = (theme: UiTheme): void => {
90
+ if (typeof window === 'undefined') {
91
+ return;
92
+ }
63
93
 
64
- if (typeof window !== 'undefined') {
65
94
  try {
66
- window.localStorage.setItem(THEME_STORAGE_KEY, activeTheme);
95
+ window.localStorage.setItem(THEME_STORAGE_KEY, theme);
67
96
  } catch {
68
97
  // ignore storage failures
69
98
  }
70
- }
99
+ };
100
+ }
101
+
102
+ const uiThemeOwner = new UiThemeOwner();
71
103
 
72
- listeners.forEach((listener) => listener(activeTheme));
104
+ export function resolveInitialTheme(): UiTheme {
105
+ return uiThemeOwner.resolveInitialTheme();
106
+ }
107
+
108
+ export function initializeTheme(): UiTheme {
109
+ return uiThemeOwner.initializeTheme();
110
+ }
111
+
112
+ export function getTheme(): UiTheme {
113
+ return uiThemeOwner.getTheme();
114
+ }
115
+
116
+ export function setTheme(theme: UiTheme): void {
117
+ uiThemeOwner.setTheme(theme);
73
118
  }
74
119
 
75
120
  export function subscribeThemeChange(listener: (theme: UiTheme) => void): () => void {
76
- listeners.add(listener);
77
- return () => {
78
- listeners.delete(listener);
79
- };
121
+ return uiThemeOwner.subscribeThemeChange(listener);
80
122
  }
@@ -0,0 +1,49 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { setLanguage } from "@/shared/lib/i18n";
3
+ import { resolveUiDocumentTitle } from "@/shared/lib/ui-document-title";
4
+
5
+ type TestLocation = Pick<Location, "host" | "hostname" | "port">;
6
+
7
+ function testLocation(host: string, hostname: string, port = ""): TestLocation {
8
+ return { host, hostname, port };
9
+ }
10
+
11
+ describe("resolveUiDocumentTitle", () => {
12
+ it("keeps the previous title shape when the browser location is unavailable", () => {
13
+ setLanguage("en");
14
+
15
+ expect(resolveUiDocumentTitle("/chat")).toBe("NextClaw - Chat");
16
+ });
17
+
18
+ it("uses only the port for localhost instances", () => {
19
+ setLanguage("en");
20
+
21
+ expect(
22
+ resolveUiDocumentTitle(
23
+ "/chat",
24
+ testLocation("localhost:5173", "localhost", "5173"),
25
+ ),
26
+ ).toBe("NextClaw 5173 - Chat");
27
+ });
28
+
29
+ it("uses only the port for loopback IP instances", () => {
30
+ setLanguage("en");
31
+
32
+ expect(
33
+ resolveUiDocumentTitle(
34
+ "/model",
35
+ testLocation("127.0.0.1:3000", "127.0.0.1", "3000"),
36
+ ),
37
+ ).toBe("NextClaw 3000 - Model Configuration");
38
+ });
39
+
40
+ it("uses the full host for non-local instances", () => {
41
+ setLanguage("en");
42
+ const title = resolveUiDocumentTitle(
43
+ "/providers",
44
+ testLocation("nextclaw.example.com:8443", "nextclaw.example.com", "8443"),
45
+ );
46
+
47
+ expect(title).toBe("NextClaw nextclaw.example.com:8443 - AI Providers");
48
+ });
49
+ });