@nextclaw/ui 0.15.0 → 0.15.2

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 (191) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/dist/assets/{api-DLtsiBKm.js → api-B7Qigvtm.js} +2 -2
  3. package/dist/assets/{appearance-settings-page-CtkeYXux.js → appearance-settings-page-CuE0Iiqj.js} +1 -1
  4. package/dist/assets/{book-open-BQwWBDKb.js → book-open-Ba2f7YiG.js} +1 -1
  5. package/dist/assets/channels-list-page-CTsM6TcP.js +8 -0
  6. package/dist/assets/chat-page-C_5dcuE3.js +1 -0
  7. package/dist/assets/config-split-page-Fbb4PtTf.js +1 -0
  8. package/dist/assets/{confirm-dialog-iIJFFp2T.js → confirm-dialog-D2EG3nqE.js} +1 -1
  9. package/dist/assets/desktop-update-config-BGR3HUi1.js +1 -0
  10. package/dist/assets/{dist-D2eZC3OR.js → dist-7JTjycxE.js} +1 -1
  11. package/dist/assets/{dist-CBxWOazz.js → dist-wXLhk4hl.js} +1 -1
  12. package/dist/assets/doc-browser-bHyXnxlj.js +1 -0
  13. package/dist/assets/doc-browser-context-CYAGm5d5.js +1 -0
  14. package/dist/assets/doc-browser-gTrQ3jCF.js +1 -0
  15. package/dist/assets/{es2015-DhQo_oS6.js → es2015-j1SRCIX5.js} +1 -1
  16. package/dist/assets/{external-link-CM5dnUI6.js → external-link-DxuPaDoK.js} +1 -1
  17. package/dist/assets/form-actions-BztL7P_T.js +1 -0
  18. package/dist/assets/{i18n-provider-di1waTGX.js → i18n-provider-B4oMxOFx.js} +1 -1
  19. package/dist/assets/index-CvrvblgX.css +1 -0
  20. package/dist/assets/index-Obd4JYU2.js +104 -0
  21. package/dist/assets/loader-circle-CdzgK4eH.js +1 -0
  22. package/dist/assets/{mcp-marketplace-page-C_MVQUbT.js → mcp-marketplace-page-Bt7CuOoq.js} +2 -2
  23. package/dist/assets/mcp-marketplace-page-Ck3t78_n.js +1 -0
  24. package/dist/assets/model-config-page-DeKk9gvE.js +1 -0
  25. package/dist/assets/plus-Bx7wQbhG.js +1 -0
  26. package/dist/assets/{provider-scoped-model-input-g1WkGiJ8.js → provider-scoped-model-input-DsH2LF1q.js} +1 -1
  27. package/dist/assets/providers-config-page-BFnGp68F.js +1 -0
  28. package/dist/assets/{react-DjpzoZXB.js → react-UirzUuST.js} +1 -1
  29. package/dist/assets/{refresh-cw-DaTv3zxE.js → refresh-cw-eT8BF-8B.js} +1 -1
  30. package/dist/assets/remote-CDkHZtA8.js +1 -0
  31. package/dist/assets/rotate-cw-1JqxHTuw.js +1 -0
  32. package/dist/assets/runtime-config-page-BelAqiNG.js +1 -0
  33. package/dist/assets/{save-BZof1uie.js → save-Cb2g0BOi.js} +1 -1
  34. package/dist/assets/{search-MxAarS_g.js → search-BcHjwdsb.js} +1 -1
  35. package/dist/assets/search-config-page-zPm6--Ig.js +1 -0
  36. package/dist/assets/secrets-config-page-Cxyp-KuQ.js +3 -0
  37. package/dist/assets/security-config-B_gCq9fn.js +1 -0
  38. package/dist/assets/{select-D7GcNP0z.js → select-CdSEOwVM.js} +1 -1
  39. package/dist/assets/{setting-row-CuozlnJd.js → setting-row-CTAtGexr.js} +1 -1
  40. package/dist/assets/settings-2-2DWauOjY.js +1 -0
  41. package/dist/assets/{side-dock-BcLau5S_.js → side-dock-DPvakJ0a.js} +2 -2
  42. package/dist/assets/{skeleton-shfmXuYp.js → skeleton-P5oM14E1.js} +1 -1
  43. package/dist/assets/{switch-C1BWMU5c.js → switch-BU_UEULb.js} +1 -1
  44. package/dist/assets/{tag-chip-DCUrQUCP.js → tag-chip-D-WsKoT1.js} +1 -1
  45. package/dist/assets/{tooltip-DfAHHA76.js → tooltip-CjkOaHW7.js} +1 -1
  46. package/dist/assets/{use-config-DAdbT5QJ.js → use-config--hx5oxQn.js} +1 -1
  47. package/dist/assets/{use-confirm-dialog-eWcJ6V-s.js → use-confirm-dialog-CD9a_SK1.js} +1 -1
  48. package/dist/assets/x-DT5x45JU.js +1 -0
  49. package/dist/index.html +38 -30
  50. package/index.html +12 -4
  51. package/package.json +9 -9
  52. package/src/app/components/layout/__tests__/sidebar.layout.test.tsx +29 -1
  53. package/src/app/components/layout/sidebar-items.tsx +43 -4
  54. package/src/app/components/layout/sidebar.tsx +83 -61
  55. package/src/app/configs/app-navigation.config.ts +49 -0
  56. package/src/app/styles/design-system.css +82 -0
  57. package/src/features/agents/components/__tests__/agents-page.test.tsx +135 -2
  58. package/src/features/agents/components/agent-advanced-config-fields.tsx +70 -0
  59. package/src/features/agents/components/agent-details-dialog.tsx +398 -0
  60. package/src/features/agents/components/agent-dialogs.tsx +20 -47
  61. package/src/features/agents/components/agents-page.tsx +76 -10
  62. package/src/features/agents/types/agent-form.types.ts +21 -0
  63. package/src/features/agents/utils/agent-form.utils.ts +39 -0
  64. package/src/features/channels/components/config/channel-form.tsx +14 -11
  65. package/src/features/chat/components/conversation/__tests__/chat-conversation-header-section.test.tsx +172 -1
  66. package/src/features/chat/components/conversation/chat-conversation-header-section.tsx +11 -1
  67. package/src/features/chat/components/conversation/chat-conversation-header.tsx +7 -3
  68. package/src/features/chat/components/conversation/chat-conversation-panel.tsx +1 -1
  69. package/src/features/chat/components/layout/__tests__/chat-sidebar-toolbar.test.tsx +48 -0
  70. package/src/features/chat/components/layout/__tests__/chat-sidebar-utility-menu.test.tsx +55 -0
  71. package/src/features/chat/components/layout/chat-sidebar-desktop-layout.tsx +0 -3
  72. package/src/features/chat/components/layout/chat-sidebar-toolbar.tsx +3 -4
  73. package/src/features/chat/components/layout/chat-sidebar-utility-menu.tsx +29 -30
  74. package/src/features/chat/components/layout/chat-sidebar.tsx +21 -7
  75. package/src/features/chat/features/conversation/components/__tests__/session-conversation-area.test.tsx +18 -3
  76. package/src/features/chat/features/conversation/components/session-conversation-input.tsx +2 -1
  77. package/src/features/chat/features/message/components/__tests__/chat-inline-panel-app-card.test.tsx +22 -1
  78. package/src/features/chat/features/message/components/__tests__/chat-message-list.container.test.tsx +1 -1
  79. package/src/features/chat/features/message/components/chat-inline-panel-app-card.tsx +10 -5
  80. package/src/features/chat/features/message/utils/chat-inline-panel-app-card.utils.ts +0 -11
  81. package/src/features/chat/features/ncp/hooks/__tests__/use-ncp-agent-runtime.test.tsx +23 -1
  82. package/src/features/chat/features/ncp/hooks/__tests__/use-ncp-session-list-view.test.tsx +74 -0
  83. package/src/features/chat/features/ncp/hooks/use-ncp-session-list-view.ts +3 -2
  84. package/src/features/chat/features/session/components/__tests__/chat-sidebar-project-groups.test.tsx +116 -0
  85. package/src/features/chat/features/session/components/chat-sidebar-project-groups.tsx +108 -46
  86. package/src/features/chat/features/session/components/chat-sidebar-session-entry.tsx +6 -0
  87. package/src/features/chat/features/session/components/chat-sidebar-session-item.tsx +48 -24
  88. package/src/features/chat/features/session/components/chat-sidebar-session-list.tsx +0 -3
  89. package/src/features/chat/features/session/components/session-header/chat-session-header-actions.tsx +0 -4
  90. package/src/features/chat/features/session/components/session-header/chat-session-title-switcher.tsx +294 -0
  91. package/src/features/chat/features/session/utils/__tests__/chat-session-display.utils.test.ts +15 -1
  92. package/src/features/chat/features/session/utils/__tests__/ncp-session-adapter.utils.test.ts +24 -4
  93. package/src/features/chat/features/session/utils/chat-session-display.utils.ts +3 -0
  94. package/src/features/chat/features/session/utils/chat-sidebar-session-groups.utils.ts +25 -3
  95. package/src/features/chat/features/session/utils/ncp-session-adapter.utils.ts +1 -0
  96. package/src/features/chat/features/welcome/components/chat-welcome-capability-grid.tsx +9 -4
  97. package/src/features/chat/features/welcome/components/chat-welcome.tsx +0 -5
  98. package/src/features/chat/features/workspace/components/__tests__/chat-session-workspace-file-preview.test.tsx +193 -56
  99. package/src/features/chat/features/workspace/components/__tests__/chat-session-workspace-panel-content.test.tsx +1 -0
  100. package/src/features/chat/features/workspace/components/__tests__/chat-session-workspace-panel.test.tsx +144 -0
  101. package/src/features/chat/features/workspace/components/chat-session-workspace-directory-browser.tsx +99 -0
  102. package/src/features/chat/features/workspace/components/chat-session-workspace-file-breadcrumbs.tsx +3 -1
  103. package/src/features/chat/features/workspace/components/chat-session-workspace-file-preview.tsx +67 -14
  104. package/src/features/chat/features/workspace/components/chat-session-workspace-panel-content.tsx +3 -0
  105. package/src/features/chat/features/workspace/components/chat-session-workspace-panel-nav.tsx +44 -1
  106. package/src/features/chat/features/workspace/components/chat-session-workspace-panel.tsx +45 -3
  107. package/src/features/chat/features/workspace/utils/__tests__/chat-workspace-panel-view-model.utils.test.ts +46 -0
  108. package/src/features/chat/managers/__tests__/chat-session-list.manager.test.ts +33 -2
  109. package/src/features/chat/managers/__tests__/chat-thread.manager.test.ts +47 -0
  110. package/src/features/chat/managers/chat-session-list.manager.ts +32 -0
  111. package/src/features/chat/managers/chat-thread.manager.ts +8 -2
  112. package/src/features/chat/stores/chat-session-list.store.ts +28 -5
  113. package/src/features/marketplace/components/mcp/mcp-marketplace-page.tsx +5 -12
  114. package/src/features/panel-apps/components/panel-app-list-item.tsx +6 -6
  115. package/src/features/panel-apps/index.ts +1 -0
  116. package/src/features/panel-apps/utils/panel-app-doc-browser.utils.tsx +2 -10
  117. package/src/features/panel-apps/utils/panel-app-iframe.utils.ts +6 -0
  118. package/src/features/pwa/managers/__tests__/pwa-shell-theme.manager.test.ts +11 -3
  119. package/src/features/pwa/managers/pwa-shell-theme.manager.ts +3 -1
  120. package/src/features/service-apps/components/service-apps-panel.tsx +145 -35
  121. package/src/features/settings/components/config/provider-auth-section.tsx +1 -1
  122. package/src/features/settings/components/config/provider-form.tsx +11 -8
  123. package/src/features/settings/components/config/provider-models-section.tsx +7 -7
  124. package/src/features/settings/components/config/provider-pill-selector.tsx +1 -1
  125. package/src/features/settings/components/config/secrets-config-form.tsx +5 -4
  126. package/src/features/settings/pages/model-config-page.tsx +26 -25
  127. package/src/features/settings/pages/providers-config-page.tsx +10 -13
  128. package/src/features/settings/pages/search-config-page.tsx +8 -5
  129. package/src/features/system-status/components/__tests__/desktop-update-config.test.tsx +97 -5
  130. package/src/features/system-status/components/config/runtime-config-editor.tsx +5 -4
  131. package/src/features/system-status/components/desktop-update-config.tsx +91 -2
  132. package/src/features/system-status/utils/update-release-notes.utils.ts +94 -0
  133. package/src/index.css +18 -0
  134. package/src/shared/components/config-split-page.tsx +6 -6
  135. package/src/shared/components/doc-browser/__tests__/doc-browser-url.utils.test.ts +73 -0
  136. package/src/shared/components/doc-browser/__tests__/doc-browser.test.tsx +48 -0
  137. package/src/shared/components/doc-browser/doc-browser-context.tsx +4 -0
  138. package/src/shared/components/doc-browser/doc-browser-panel-parts.tsx +5 -1
  139. package/src/shared/components/doc-browser/doc-browser-renderer.types.ts +2 -1
  140. package/src/shared/components/doc-browser/doc-browser-tab-strip.tsx +10 -10
  141. package/src/shared/components/doc-browser/doc-browser.tsx +6 -4
  142. package/src/shared/components/doc-browser/utils/doc-browser-url.utils.ts +62 -4
  143. package/src/shared/components/resizable-right-panel/__tests__/resizable-right-panel.test.tsx +18 -0
  144. package/src/shared/components/resizable-right-panel/resizable-right-panel.tsx +28 -3
  145. package/src/shared/components/ui/actions/__tests__/icon-action-button.test.tsx +36 -0
  146. package/src/shared/components/ui/actions/form-actions.tsx +25 -0
  147. package/src/shared/components/ui/actions/icon-action-button.tsx +25 -2
  148. package/src/shared/components/ui/card.tsx +12 -4
  149. package/src/shared/components/ui/tab-strip/compact-tab-strip.tsx +2 -1
  150. package/src/shared/hooks/use-server-path-browse.ts +26 -4
  151. package/src/shared/hooks/use-server-path-read.ts +11 -1
  152. package/src/shared/lib/api/ncp-session.types.ts +1 -1
  153. package/src/shared/lib/api/types.ts +12 -3
  154. package/src/shared/lib/api/utils/server-path.utils.ts +1 -0
  155. package/src/shared/lib/i18n/locales/en-US/agents.json +41 -0
  156. package/src/shared/lib/i18n/locales/en-US/chat.json +19 -0
  157. package/src/shared/lib/i18n/locales/en-US/core.json +3 -0
  158. package/src/shared/lib/i18n/locales/en-US/desktop-update.json +8 -0
  159. package/src/shared/lib/i18n/locales/en-US/doc-browser.json +5 -0
  160. package/src/shared/lib/i18n/locales/zh-CN/agents.json +41 -0
  161. package/src/shared/lib/i18n/locales/zh-CN/chat.json +19 -0
  162. package/src/shared/lib/i18n/locales/zh-CN/core.json +3 -0
  163. package/src/shared/lib/i18n/locales/zh-CN/desktop-update.json +8 -0
  164. package/src/shared/lib/i18n/locales/zh-CN/doc-browser.json +5 -0
  165. package/src/shared/lib/session-project/workspace-file-breadcrumb.utils.ts +9 -5
  166. package/src/shared/lib/theme/index.test.ts +21 -0
  167. package/src/shared/lib/theme/index.ts +6 -4
  168. package/src/vite-env.d.ts +2 -0
  169. package/dist/assets/channels-list-page-Cv8o9Hxg.js +0 -8
  170. package/dist/assets/chat-page-BtA7H1Ea.js +0 -1
  171. package/dist/assets/config-split-page-EidRBpP2.js +0 -1
  172. package/dist/assets/desktop-update-config-C6n_TB1r.js +0 -1
  173. package/dist/assets/doc-browser-B76XOXmW.js +0 -1
  174. package/dist/assets/doc-browser-DBnj9UMB.js +0 -1
  175. package/dist/assets/doc-browser-context-Sv-044Cl.js +0 -1
  176. package/dist/assets/ellipsis-2uzhypw5.js +0 -1
  177. package/dist/assets/index-CBRP-7-a.css +0 -1
  178. package/dist/assets/index-Z1fYpbNd.js +0 -104
  179. package/dist/assets/loader-circle-BjmN5DeJ.js +0 -1
  180. package/dist/assets/mcp-marketplace-page-CoZIiD6G.js +0 -1
  181. package/dist/assets/model-config-page-Cdr9P6TQ.js +0 -1
  182. package/dist/assets/plus-3ztRZD_l.js +0 -1
  183. package/dist/assets/providers-config-page-CWlO7J4A.js +0 -1
  184. package/dist/assets/remote-CKWZfVVB.js +0 -1
  185. package/dist/assets/rotate-cw-CHcaRUDz.js +0 -1
  186. package/dist/assets/runtime-config-page-CfTqh1nv.js +0 -1
  187. package/dist/assets/search-config-page-Dm8daYny.js +0 -1
  188. package/dist/assets/secrets-config-page-D8AbcR05.js +0 -3
  189. package/dist/assets/security-config-Ds0LsJmo.js +0 -1
  190. package/dist/assets/x-DqL1mYLC.js +0 -1
  191. /package/dist/assets/{config-hints-fGnUjDe9.js → config-hints-C_iP1zl_.js} +0 -0
@@ -99,7 +99,7 @@ export function ProviderModelsSection(props: ProviderModelsSectionProps) {
99
99
  ) : null}
100
100
 
101
101
  {models.length === 0 ? (
102
- <div className="rounded-xl border border-dashed border-border bg-muted/60 px-4 py-6 text-center">
102
+ <div className="rounded-xl border border-dashed border-border/65 bg-muted/30 px-4 py-6 text-center">
103
103
  <p className="text-sm text-muted-foreground">{t('providerModelsEmptyShort')}</p>
104
104
  {!showModelInput ? (
105
105
  <button
@@ -123,14 +123,14 @@ export function ProviderModelsSection(props: ProviderModelsSectionProps) {
123
123
  return (
124
124
  <div
125
125
  key={modelName}
126
- className="group inline-flex max-w-full items-center gap-1 rounded-full border border-border bg-card px-3 py-1.5"
126
+ className="group inline-flex max-w-full items-center gap-1 rounded-full border border-border/55 bg-muted/45 px-3 py-1.5"
127
127
  >
128
128
  <span className="max-w-[140px] truncate text-sm text-foreground sm:max-w-[220px]">{modelName}</span>
129
129
  <Popover>
130
130
  <PopoverTrigger asChild>
131
131
  <button
132
132
  type="button"
133
- className="inline-flex h-5 w-5 items-center justify-center rounded-full text-muted-foreground/70 opacity-100 transition-opacity hover:bg-accent hover:text-accent-foreground md:opacity-0 md:group-hover:opacity-100 md:group-focus-within:opacity-100"
133
+ className="inline-flex h-5 w-5 items-center justify-center rounded-full text-muted-foreground/70 opacity-100 transition-opacity hover:bg-muted hover:text-foreground md:opacity-0 md:group-hover:opacity-100 md:group-focus-within:opacity-100"
134
134
  aria-label={t('providerModelThinkingTitle')}
135
135
  title={t('providerModelThinkingTitle')}
136
136
  >
@@ -138,7 +138,7 @@ export function ProviderModelsSection(props: ProviderModelsSectionProps) {
138
138
  </button>
139
139
  </PopoverTrigger>
140
140
  <PopoverContent className="w-80 space-y-3">
141
- <div className="flex items-center justify-between gap-3 rounded-lg border border-border bg-muted/60 px-3 py-2">
141
+ <div className="flex items-center justify-between gap-3 rounded-lg bg-muted/50 px-3 py-2">
142
142
  <div className="min-w-0">
143
143
  <p className="text-xs font-semibold text-foreground">{t('providerModelVisionTitle')}</p>
144
144
  <p className="text-xs text-muted-foreground">{t('providerModelVisionHint')}</p>
@@ -159,8 +159,8 @@ export function ProviderModelsSection(props: ProviderModelsSectionProps) {
159
159
  onClick={() => onToggleModelThinkingLevel(modelName, level)}
160
160
  className={`rounded-full border px-2.5 py-1 text-xs font-medium transition-colors ${
161
161
  selected
162
- ? 'border-primary bg-primary text-white'
163
- : 'border-border bg-card text-muted-foreground hover:border-primary/40 hover:bg-accent/70 hover:text-accent-foreground'
162
+ ? 'border-foreground/15 bg-foreground text-background'
163
+ : 'border-border/55 bg-muted/50 text-muted-foreground hover:border-border hover:bg-muted hover:text-foreground'
164
164
  }`}
165
165
  >
166
166
  {formatThinkingLevelLabel(level)}
@@ -198,7 +198,7 @@ export function ProviderModelsSection(props: ProviderModelsSectionProps) {
198
198
  <button
199
199
  type="button"
200
200
  onClick={() => onRemoveModel(modelName)}
201
- className="inline-flex h-5 w-5 items-center justify-center rounded-full text-muted-foreground/70 opacity-100 transition-opacity hover:bg-accent hover:text-accent-foreground md:opacity-0 md:group-hover:opacity-100 md:group-focus-within:opacity-100"
201
+ className="inline-flex h-5 w-5 items-center justify-center rounded-full text-muted-foreground/70 opacity-100 transition-opacity hover:bg-muted hover:text-foreground md:opacity-0 md:group-hover:opacity-100 md:group-focus-within:opacity-100"
202
202
  aria-label={t('remove')}
203
203
  >
204
204
  <X className="h-3 w-3" />
@@ -16,7 +16,7 @@ export function ProviderPillSelector(props: ProviderPillSelectorProps) {
16
16
  type="button"
17
17
  onClick={() => onChange(option.value)}
18
18
  aria-pressed={selected}
19
- className={`rounded-full border px-3 py-1.5 text-xs font-medium transition-colors ${selected ? 'border-primary bg-primary text-primary-foreground shadow-sm' : 'border-border bg-card text-muted-foreground hover:border-primary/40 hover:bg-accent/70 hover:text-accent-foreground'}`}
19
+ className={`rounded-full border px-3 py-1.5 text-xs font-medium transition-colors ${selected ? 'border-foreground/15 bg-foreground text-background' : 'border-border/55 bg-muted/50 text-muted-foreground hover:border-border hover:bg-muted hover:text-foreground'}`}
20
20
  >
21
21
  {option.label}
22
22
  </button>
@@ -1,6 +1,7 @@
1
1
  import { useMemo, useState } from 'react';
2
2
  import { Plus, Save, Trash2 } from 'lucide-react';
3
3
  import { Button } from '@/shared/components/ui/button';
4
+ import { FormActions } from '@/shared/components/ui/actions/form-actions';
4
5
  import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/shared/components/ui/card';
5
6
  import { Input } from '@/shared/components/ui/input';
6
7
  import { Label } from '@/shared/components/ui/label';
@@ -272,12 +273,12 @@ export function SecretsConfigForm(props: {
272
273
  onRemove={(index) => setState((prev) => ({ ...prev, refs: prev.refs.filter((_, cursor) => cursor !== index) }))}
273
274
  onAdd={() => setState((prev) => ({ ...prev, refs: [...prev.refs, createRefRow()] }))}
274
275
  />
275
- <div className="flex justify-end">
276
- <Button type="button" onClick={handleSave} disabled={isPending}>
277
- <Save className="mr-2 h-4 w-4" />
276
+ <FormActions>
277
+ <Button type="button" size="sm" onClick={handleSave} disabled={isPending}>
278
+ <Save className="mr-1.5 h-3.5 w-3.5" />
278
279
  {isPending ? t('saving') : t('save')}
279
280
  </Button>
280
- </div>
281
+ </FormActions>
281
282
  </PageLayout>
282
283
  );
283
284
  }
@@ -3,13 +3,14 @@ import { Card } from '@/shared/components/ui/card';
3
3
  import { Input } from '@/shared/components/ui/input';
4
4
  import { Label } from '@/shared/components/ui/label';
5
5
  import { Skeleton } from '@/shared/components/ui/skeleton';
6
+ import { FormActions } from '@/shared/components/ui/actions/form-actions';
6
7
  import { ProviderScopedModelInput } from '@/shared/components/common/provider-scoped-model-input';
7
8
  import { useConfig, useConfigSchema, useProviderTemplates, useProviders, useUpdateModel } from '@/shared/hooks/use-config';
8
9
  import { hintForPath } from '@/shared/lib/config-hints';
9
10
  import { t } from '@/shared/lib/i18n';
10
11
  import { buildProviderModelCatalog } from '@/shared/lib/provider-models';
11
12
  import { PageLayout, PageHeader } from '@/app/components/layout/page-layout';
12
- import { DOCS_DEFAULT_BASE_URL } from '@/shared/components/doc-browser/doc-browser-context';
13
+ import { getDocsUrl } from '@/shared/components/doc-browser/doc-browser-context';
13
14
  import { BookOpen, Folder, Loader2, Sparkles } from 'lucide-react';
14
15
  import { useMemo, useState } from 'react';
15
16
 
@@ -37,48 +38,48 @@ function ModelConfigForm(props: {
37
38
  event.preventDefault();
38
39
  onSubmit({ model, workspace });
39
40
  }}
40
- className="space-y-8"
41
+ className="space-y-5"
41
42
  >
42
- <div className="grid grid-cols-1 gap-8 md:grid-cols-2">
43
- <div className="rounded-2xl border border-gray-200 bg-white p-8 shadow-card">
44
- <div className="mb-8 flex items-center gap-4">
45
- <div className="flex h-10 w-10 items-center justify-center rounded-xl bg-primary text-white">
46
- <Sparkles className="h-5 w-5" />
43
+ <div className="grid grid-cols-1 gap-5 md:grid-cols-2">
44
+ <div className="rounded-2xl border border-border/75 bg-card p-6">
45
+ <div className="mb-6 flex items-center gap-3">
46
+ <div className="flex h-9 w-9 items-center justify-center rounded-lg bg-muted text-muted-foreground">
47
+ <Sparkles className="h-4 w-4" />
47
48
  </div>
48
- <h3 className="text-lg font-bold text-gray-900">{t('defaultModel')}</h3>
49
+ <h3 className="text-base font-semibold text-foreground">{t('defaultModel')}</h3>
49
50
  </div>
50
51
  <div className="space-y-2">
51
- <Label htmlFor="model" className="text-xs font-semibold uppercase tracking-wider text-gray-500">
52
+ <Label htmlFor="model" className="text-xs font-medium text-muted-foreground">
52
53
  {modelLabel}
53
54
  </Label>
54
55
  <ProviderScopedModelInput id="model" value={model} onChange={setModel} providerCatalog={providerCatalog} modelPlaceholder={modelPlaceholder} />
55
- <p className="text-xs text-gray-400">{modelHelpText}</p>
56
- <a href={`${DOCS_DEFAULT_BASE_URL}/guide/model-selection`} className="inline-flex items-center gap-1.5 text-xs text-primary transition-colors hover:text-primary-hover">
56
+ <p className="text-xs text-muted-foreground/80">{modelHelpText}</p>
57
+ <a href={getDocsUrl('/guide/model-selection')} className="inline-flex items-center gap-1.5 text-xs text-primary transition-colors hover:text-primary-hover">
57
58
  <BookOpen className="h-3.5 w-3.5" />
58
59
  {t('channelsGuideTitle')}
59
60
  </a>
60
61
  </div>
61
62
  </div>
62
- <div className="rounded-2xl border border-gray-200 bg-white p-8 shadow-card">
63
- <div className="mb-8 flex items-center gap-4">
64
- <div className="flex h-10 w-10 items-center justify-center rounded-xl bg-primary text-white">
65
- <Folder className="h-5 w-5" />
63
+ <div className="rounded-2xl border border-border/75 bg-card p-6">
64
+ <div className="mb-6 flex items-center gap-3">
65
+ <div className="flex h-9 w-9 items-center justify-center rounded-lg bg-muted text-muted-foreground">
66
+ <Folder className="h-4 w-4" />
66
67
  </div>
67
- <h3 className="text-lg font-bold text-gray-900">{t('workspace')}</h3>
68
+ <h3 className="text-base font-semibold text-foreground">{t('workspace')}</h3>
68
69
  </div>
69
70
  <div className="space-y-2">
70
- <Label htmlFor="workspace" className="text-xs font-semibold uppercase tracking-wider text-gray-500">
71
+ <Label htmlFor="workspace" className="text-xs font-medium text-muted-foreground">
71
72
  {workspaceLabel}
72
73
  </Label>
73
74
  <Input id="workspace" value={workspace} onChange={(event) => setWorkspace(event.target.value)} placeholder={workspacePlaceholder} className="rounded-xl" />
74
75
  </div>
75
76
  </div>
76
77
  </div>
77
- <div className="flex justify-end pt-4">
78
- <Button type="submit" disabled={isPending} size="lg">
79
- {isPending ? <Loader2 className="h-5 w-5 animate-spin" /> : t('saveChanges')}
78
+ <FormActions>
79
+ <Button type="submit" disabled={isPending} size="sm">
80
+ {isPending ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : t('saveChanges')}
80
81
  </Button>
81
- </div>
82
+ </FormActions>
82
83
  </form>
83
84
  );
84
85
  }
@@ -108,9 +109,9 @@ export function ModelConfigPage() {
108
109
  <Skeleton className="h-8 w-32" />
109
110
  <Skeleton className="h-4 w-48" />
110
111
  </div>
111
- <Card className="rounded-2xl border-gray-200 p-6">
112
- <div className="mb-6 flex items-center gap-4">
113
- <Skeleton className="h-12 w-12 rounded-xl" />
112
+ <Card hover={false} className="rounded-2xl border-border/75 p-6 shadow-none">
113
+ <div className="mb-6 flex items-center gap-3">
114
+ <Skeleton className="h-9 w-9 rounded-lg" />
114
115
  <div className="space-y-2">
115
116
  <Skeleton className="h-5 w-24" />
116
117
  <Skeleton className="h-3 w-32" />
@@ -119,7 +120,7 @@ export function ModelConfigPage() {
119
120
  <Skeleton className="mb-2 h-4 w-20" />
120
121
  <Skeleton className="h-10 w-full rounded-xl" />
121
122
  </Card>
122
- <Card className="rounded-2xl border-gray-200 p-6">
123
+ <Card hover={false} className="rounded-2xl border-border/75 p-6 shadow-none">
123
124
  <Skeleton className="mb-2 h-5 w-24" />
124
125
  <Skeleton className="h-10 w-full rounded-xl" />
125
126
  </Card>
@@ -137,7 +137,7 @@ export function ProvidersConfigPage() {
137
137
  <div className="grid max-h-[24rem] grid-cols-2 gap-2 overflow-y-auto sm:grid-cols-3">
138
138
  <button
139
139
  type="button"
140
- className="flex min-h-20 w-full flex-col items-start gap-2 rounded-lg border border-dashed border-border bg-card px-3 py-2 text-left transition-colors hover:border-primary/40 hover:bg-accent/70"
140
+ className="flex min-h-20 w-full flex-col items-start gap-2 rounded-lg border border-dashed border-border/70 bg-muted/20 px-3 py-2 text-left transition-colors hover:border-border hover:bg-muted/45"
141
141
  onClick={async () => {
142
142
  try {
143
143
  const result = await createProvider.mutateAsync({ data: { providerType: null } });
@@ -150,7 +150,7 @@ export function ProvidersConfigPage() {
150
150
  }
151
151
  }}
152
152
  >
153
- <LogoBadge name="custom" className="h-8 w-8 rounded-lg border border-border bg-background" />
153
+ <LogoBadge name="custom" className="h-8 w-8 rounded-lg border border-border/60 bg-muted/50" />
154
154
  <span className="line-clamp-2 min-w-0 text-xs font-semibold leading-4 text-foreground">
155
155
  {t('providerAddCustom')}
156
156
  </span>
@@ -159,7 +159,7 @@ export function ProvidersConfigPage() {
159
159
  <button
160
160
  key={template.providerType}
161
161
  type="button"
162
- className="flex min-h-20 w-full flex-col items-start gap-2 rounded-lg border border-border bg-card px-3 py-2 text-left transition-colors hover:border-primary/40 hover:bg-accent/70"
162
+ className="flex min-h-20 w-full flex-col items-start gap-2 rounded-lg border border-border/55 bg-muted/15 px-3 py-2 text-left transition-colors hover:border-border/80 hover:bg-muted/40"
163
163
  onClick={async () => {
164
164
  try {
165
165
  const result = await createProvider.mutateAsync({ data: { providerType: template.providerType } });
@@ -175,7 +175,7 @@ export function ProvidersConfigPage() {
175
175
  <LogoBadge
176
176
  name={template.providerType}
177
177
  src={template.logo ? `/logos/${template.logo}` : null}
178
- className="h-8 w-8 rounded-lg border border-border bg-background"
178
+ className="h-8 w-8 rounded-lg border border-border/55 bg-background"
179
179
  imgClassName="h-4 w-4 object-contain"
180
180
  />
181
181
  <span className="line-clamp-2 min-w-0 text-xs font-semibold leading-4 text-foreground">
@@ -221,10 +221,10 @@ export function ProvidersConfigPage() {
221
221
  role="button"
222
222
  tabIndex={0}
223
223
  className={cn(
224
- 'w-full rounded-xl border p-2.5 text-left transition-all',
224
+ 'group w-full rounded-xl border p-2.5 text-left transition-colors',
225
225
  resolvedSelectedProvider === provider.providerId
226
- ? 'border-primary/35 bg-primary-50/45 text-foreground shadow-sm'
227
- : 'border-border bg-card text-muted-foreground hover:border-primary/35 hover:bg-accent/70 hover:text-accent-foreground',
226
+ ? 'border-border/70 bg-muted/65 text-foreground'
227
+ : 'border-transparent bg-transparent text-muted-foreground hover:border-border/50 hover:bg-muted/40 hover:text-foreground',
228
228
  )}
229
229
  >
230
230
  <div className="relative min-h-10">
@@ -232,10 +232,7 @@ export function ProvidersConfigPage() {
232
232
  <LogoBadge
233
233
  name={provider.providerType ?? provider.providerId}
234
234
  src={template?.logo ? `/logos/${template.logo}` : null}
235
- className={cn(
236
- 'h-10 w-10 rounded-lg border',
237
- isReady ? 'border-primary/35 bg-background' : 'border-border bg-background',
238
- )}
235
+ className="h-10 w-10 rounded-lg border border-border/55 bg-muted/40"
239
236
  imgClassName="h-5 w-5 object-contain"
240
237
  fallback={<span className="text-sm font-semibold uppercase text-muted-foreground">{provider.providerId[0]}</span>}
241
238
  />
@@ -247,7 +244,7 @@ export function ProvidersConfigPage() {
247
244
  </p>
248
245
  </div>
249
246
  </div>
250
- <div className="absolute right-0 top-0 flex items-center gap-1.5">
247
+ <div className="absolute right-0 top-0 flex items-center gap-1.5 opacity-70 transition-opacity group-hover:opacity-100">
251
248
  <div onClick={(event) => event.stopPropagation()}>
252
249
  <Switch
253
250
  checked={isEnabled}
@@ -268,7 +265,7 @@ export function ProvidersConfigPage() {
268
265
  <PopoverTrigger asChild>
269
266
  <button
270
267
  type="button"
271
- className="inline-flex h-7 w-7 items-center justify-center rounded-lg text-muted-foreground/70 hover:bg-accent hover:text-accent-foreground"
268
+ className="inline-flex h-7 w-7 items-center justify-center rounded-lg text-muted-foreground/70 hover:bg-muted hover:text-foreground"
272
269
  onClick={(event) => event.stopPropagation()}
273
270
  title={t('more')}
274
271
  >
@@ -2,6 +2,7 @@ import { useState } from "react";
2
2
  import { ExternalLink, KeyRound, Search as SearchIcon } from "lucide-react";
3
3
  import { PageHeader, PageLayout } from "@/app/components/layout/page-layout";
4
4
  import { Button } from "@/shared/components/ui/button";
5
+ import { FormActions } from "@/shared/components/ui/actions/form-actions";
5
6
  import { Input } from "@/shared/components/ui/input";
6
7
  import { Label } from "@/shared/components/ui/label";
7
8
  import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/components/ui/select";
@@ -456,11 +457,13 @@ function SearchConfigForm(props: {
456
457
  />
457
458
  </ConfigSplitPaneBody>
458
459
 
459
- <ConfigSplitPaneFooter className="flex justify-end px-6 py-4">
460
- <Button type="submit" disabled={isPending}>
461
- <KeyRound className="mr-2 h-4 w-4" />
462
- {isPending ? t("saving") : t("saveChanges")}
463
- </Button>
460
+ <ConfigSplitPaneFooter>
461
+ <FormActions>
462
+ <Button type="submit" size="sm" disabled={isPending}>
463
+ <KeyRound className="mr-1.5 h-3.5 w-3.5" />
464
+ {isPending ? t("saving") : t("saveChanges")}
465
+ </Button>
466
+ </FormActions>
464
467
  </ConfigSplitPaneFooter>
465
468
  </form>
466
469
  </ConfigSplitDetailPane>
@@ -1,6 +1,7 @@
1
1
  import { render, screen } from '@testing-library/react';
2
2
  import userEvent from '@testing-library/user-event';
3
- import { beforeEach, describe, expect, it, vi } from 'vitest';
3
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
4
5
  import { useRuntimeUpdateStore } from '@/features/system-status';
5
6
  import { DesktopUpdateConfig } from '@/features/system-status/components/desktop-update-config';
6
7
  import { setLanguage } from '@/shared/lib/i18n';
@@ -26,6 +27,22 @@ vi.mock('@/features/system-status', async () => {
26
27
  };
27
28
  });
28
29
 
30
+ function renderDesktopUpdateConfig() {
31
+ const queryClient = new QueryClient({
32
+ defaultOptions: {
33
+ queries: {
34
+ gcTime: 0,
35
+ retry: false
36
+ }
37
+ }
38
+ });
39
+ return render(
40
+ <QueryClientProvider client={queryClient}>
41
+ <DesktopUpdateConfig />
42
+ </QueryClientProvider>
43
+ );
44
+ }
45
+
29
46
  describe('DesktopUpdateConfig', () => {
30
47
  beforeEach(() => {
31
48
  setLanguage('zh');
@@ -60,7 +77,7 @@ describe('DesktopUpdateConfig', () => {
60
77
  availableVersion: '0.18.2-beta.1',
61
78
  downloadedVersion: null,
62
79
  minimumHostVersion: null,
63
- releaseNotesUrl: 'https://example.com/release-notes',
80
+ releaseNotesUrl: null,
64
81
  lastCheckedAt: '2026-04-13T12:00:00.000Z',
65
82
  progress: null,
66
83
  canAutoDownload: false,
@@ -77,8 +94,12 @@ describe('DesktopUpdateConfig', () => {
77
94
  });
78
95
  });
79
96
 
97
+ afterEach(() => {
98
+ vi.unstubAllGlobals();
99
+ });
100
+
80
101
  it('renders current channel information and beta guidance', () => {
81
- render(<DesktopUpdateConfig />);
102
+ renderDesktopUpdateConfig();
82
103
 
83
104
  expect(mocks.start).not.toHaveBeenCalled();
84
105
  expect(mocks.stop).not.toHaveBeenCalled();
@@ -93,7 +114,7 @@ describe('DesktopUpdateConfig', () => {
93
114
  it('sends the newly selected release channel to the desktop update manager', async () => {
94
115
  const user = userEvent.setup();
95
116
 
96
- render(<DesktopUpdateConfig />);
117
+ renderDesktopUpdateConfig();
97
118
 
98
119
  await user.click(screen.getByRole('combobox'));
99
120
  await user.click(screen.getByRole('option', { name: 'Stable' }));
@@ -118,9 +139,80 @@ describe('DesktopUpdateConfig', () => {
118
139
  : null
119
140
  }));
120
141
 
121
- render(<DesktopUpdateConfig />);
142
+ renderDesktopUpdateConfig();
122
143
 
123
144
  expect(screen.getByText('正在下载 50%')).toBeTruthy();
124
145
  expect(screen.getByText('50 B / 100 B')).toBeTruthy();
125
146
  });
147
+
148
+ it('loads structured release notes from the docs JSON endpoint', async () => {
149
+ const fetchMock = vi.fn(async () => new Response(JSON.stringify({
150
+ schemaVersion: 1,
151
+ product: 'NextClaw',
152
+ version: '0.22.0',
153
+ sections: [
154
+ {
155
+ kind: 'feature',
156
+ title: {
157
+ 'zh-CN': '功能',
158
+ 'en-US': 'Features'
159
+ },
160
+ items: [
161
+ {
162
+ title: {
163
+ 'zh-CN': '更新前查看内容',
164
+ 'en-US': 'Review before updating'
165
+ },
166
+ body: {
167
+ 'zh-CN': '更新提示可以直接展示本版本的结构化说明。',
168
+ 'en-US': 'The update prompt can show structured notes for this version.'
169
+ }
170
+ }
171
+ ]
172
+ },
173
+ {
174
+ kind: 'fix',
175
+ title: {
176
+ 'zh-CN': '修复',
177
+ 'en-US': 'Fixes'
178
+ },
179
+ items: [
180
+ {
181
+ title: {
182
+ 'zh-CN': '附件发送修复',
183
+ 'en-US': 'Attachment delivery fix'
184
+ }
185
+ }
186
+ ]
187
+ }
188
+ ]
189
+ }), {
190
+ headers: { 'content-type': 'application/json' },
191
+ status: 200
192
+ }));
193
+ vi.stubGlobal('fetch', fetchMock);
194
+ useRuntimeUpdateStore.setState((state) => ({
195
+ ...state,
196
+ snapshot: state.snapshot
197
+ ? {
198
+ ...state.snapshot,
199
+ status: 'update-available',
200
+ availableVersion: '0.22.0',
201
+ releaseNotesUrl: 'https://docs.nextclaw.io/zh/notes/2026-07-05-nextclaw-v0-22-0'
202
+ }
203
+ : null
204
+ }));
205
+
206
+ renderDesktopUpdateConfig();
207
+
208
+ expect(await screen.findByText('更新前查看内容')).toBeTruthy();
209
+ expect(screen.getByText('修复')).toBeTruthy();
210
+ expect(fetchMock).toHaveBeenCalledWith(
211
+ 'https://docs.nextclaw.io/release-notes/nextclaw-v0.22.0.json',
212
+ expect.objectContaining({
213
+ cache: 'no-store',
214
+ headers: { accept: 'application/json' }
215
+ })
216
+ );
217
+ });
126
218
  });
@@ -2,6 +2,7 @@ import { useMemo, useState } from 'react';
2
2
  import { Save } from 'lucide-react';
3
3
  import type { ConfigView, RuntimeConfigUpdate } from '@/shared/lib/api';
4
4
  import { Button } from '@/shared/components/ui/button';
5
+ import { FormActions } from '@/shared/components/ui/actions/form-actions';
5
6
  import { PageLayout } from '@/app/components/layout/page-layout';
6
7
  import { t } from '@/shared/lib/i18n';
7
8
  import { RuntimeConfigOverview } from '@/features/system-status/components/config/runtime-config-overview';
@@ -111,12 +112,12 @@ export function RuntimeConfigEditor({
111
112
  onRemoveBinding={(index) => setBindings((previous) => previous.filter((_, cursor) => cursor !== index))}
112
113
  onAddBinding={() => setBindings((previous) => [...previous, createEmptyRuntimeBinding()])}
113
114
  />
114
- <div className="flex justify-end">
115
- <Button type="button" onClick={handleSave} disabled={updateRuntime.isPending}>
116
- <Save className="h-4 w-4 mr-2" />
115
+ <FormActions>
116
+ <Button type="button" size="sm" onClick={handleSave} disabled={updateRuntime.isPending}>
117
+ <Save className="mr-1.5 h-3.5 w-3.5" />
117
118
  {updateRuntime.isPending ? t('saving') : t('saveRuntimeSettings')}
118
119
  </Button>
119
- </div>
120
+ </FormActions>
120
121
  </PageLayout>
121
122
  );
122
123
  }
@@ -1,4 +1,5 @@
1
1
  import type { UpdateSnapshot } from '@nextclaw/shared';
2
+ import { useQuery } from '@tanstack/react-query';
2
3
  import { runtimeUpdateManager, useRuntimeUpdateStore } from '@/features/system-status';
3
4
  import { Button } from '@/shared/components/ui/button';
4
5
  import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/shared/components/ui/card';
@@ -6,9 +7,16 @@ import { Label } from '@/shared/components/ui/label';
6
7
  import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/components/ui/select';
7
8
  import { Switch } from '@/shared/components/ui/switch';
8
9
  import { PageHeader, PageLayout } from '@/app/components/layout/page-layout';
9
- import { formatDateTime, t } from '@/shared/lib/i18n';
10
+ import { formatDateTime, getLanguage, t } from '@/shared/lib/i18n';
10
11
  import { cn } from '@/shared/lib/utils';
11
12
  import { hostCapabilityManager } from '@/shared/lib/host-capabilities';
13
+ import {
14
+ fetchReleaseNotesData,
15
+ readReleaseNotesText,
16
+ resolveReleaseNotesDataUrl,
17
+ type ReleaseNotesLocale,
18
+ type ReleaseNotesSection
19
+ } from '@/features/system-status/utils/update-release-notes.utils';
12
20
  import { Download, ExternalLink, RefreshCw, RotateCw } from 'lucide-react';
13
21
 
14
22
  const STATUS_LABEL_KEYS: Record<string, string> = {
@@ -21,6 +29,13 @@ const STATUS_LABEL_KEYS: Record<string, string> = {
21
29
  failed: 'desktopUpdatesStatusFailed',
22
30
  };
23
31
 
32
+ const RELEASE_NOTES_KIND_LABEL_KEYS: Record<string, string> = {
33
+ feature: 'desktopUpdatesReleaseNotesFeature',
34
+ enhancement: 'desktopUpdatesReleaseNotesEnhancement',
35
+ fix: 'desktopUpdatesReleaseNotesFix',
36
+ compatibility: 'desktopUpdatesReleaseNotesCompatibility'
37
+ };
38
+
24
39
  function StatusBadge({ status }: { status: string }) {
25
40
  return <span className={cn('inline-flex rounded-full px-3 py-1 text-xs font-medium ring-1', getStatusTone(status))}>{getStatusLabel(status)}</span>;
26
41
  }
@@ -117,6 +132,80 @@ function getStatusTone(status: string): string {
117
132
  return 'bg-gray-100 text-gray-700 ring-gray-200';
118
133
  }
119
134
 
135
+ function getReleaseNotesLocale(): ReleaseNotesLocale {
136
+ return getLanguage() === 'zh' ? 'zh-CN' : 'en-US';
137
+ }
138
+
139
+ function getReleaseNotesSectionTitle(section: ReleaseNotesSection, locale: ReleaseNotesLocale): string {
140
+ const title = readReleaseNotesText(section.title, locale);
141
+ return title ?? t(RELEASE_NOTES_KIND_LABEL_KEYS[section.kind] ?? 'desktopUpdatesReleaseNotesEnhancement');
142
+ }
143
+
144
+ function ReleaseNotesPreview({ snapshot }: { snapshot: UpdateSnapshot }) {
145
+ const dataUrl = resolveReleaseNotesDataUrl(snapshot);
146
+ const locale = getReleaseNotesLocale();
147
+ const releaseNotesQuery = useQuery({
148
+ queryKey: ['runtime-release-notes', dataUrl],
149
+ queryFn: async () => await fetchReleaseNotesData(dataUrl ?? ''),
150
+ enabled: Boolean(dataUrl),
151
+ retry: false,
152
+ staleTime: 5 * 60 * 1000
153
+ });
154
+ const payload = releaseNotesQuery.data;
155
+ const title = readReleaseNotesText(payload?.title, locale) ?? t('desktopUpdatesReleaseNotesPreviewTitle');
156
+ const summary = readReleaseNotesText(payload?.summary, locale) ?? t('desktopUpdatesReleaseNotesPreviewDescription');
157
+
158
+ if (!snapshot.releaseNotesUrl) {
159
+ return null;
160
+ }
161
+
162
+ return (
163
+ <Card>
164
+ <CardHeader>
165
+ <div className="flex flex-wrap items-start justify-between gap-3">
166
+ <div className="space-y-1">
167
+ <CardTitle>{title}</CardTitle>
168
+ <CardDescription>{summary}</CardDescription>
169
+ </div>
170
+ <Button variant="ghost" onClick={() => void hostCapabilityManager.openExternalUrl(snapshot.releaseNotesUrl ?? '')}>
171
+ <ExternalLink className="mr-2 h-4 w-4" />
172
+ {t('desktopUpdatesReleaseNotes')}
173
+ </Button>
174
+ </div>
175
+ </CardHeader>
176
+ <CardContent className="space-y-4">
177
+ {releaseNotesQuery.isLoading ? (
178
+ <p className="text-sm text-gray-500">{t('desktopUpdatesReleaseNotesPreviewLoading')}</p>
179
+ ) : null}
180
+ {!dataUrl || releaseNotesQuery.isError ? (
181
+ <p className="text-sm text-gray-500">{t('desktopUpdatesReleaseNotesPreviewUnavailable')}</p>
182
+ ) : null}
183
+ {payload ? (
184
+ <div className="grid gap-4 md:grid-cols-2">
185
+ {payload.sections.map((section) => (
186
+ <div key={section.kind} className="rounded-xl border border-gray-200 bg-gray-50/60 p-4">
187
+ <p className="text-sm font-semibold text-gray-900">{getReleaseNotesSectionTitle(section, locale)}</p>
188
+ <ul className="mt-3 space-y-3">
189
+ {section.items.map((item, index) => {
190
+ const itemTitle = readReleaseNotesText(item.title, locale);
191
+ const itemBody = readReleaseNotesText(item.body, locale);
192
+ return (
193
+ <li key={`${itemTitle ?? section.kind}-${index}`} className="space-y-1">
194
+ {itemTitle ? <p className="text-sm font-medium text-gray-800">{itemTitle}</p> : null}
195
+ {itemBody ? <p className="text-sm leading-5 text-gray-600">{itemBody}</p> : null}
196
+ </li>
197
+ );
198
+ })}
199
+ </ul>
200
+ </div>
201
+ ))}
202
+ </div>
203
+ ) : null}
204
+ </CardContent>
205
+ </Card>
206
+ );
207
+ }
208
+
120
209
  function RuntimeUpdateUnavailableState() {
121
210
  return (
122
211
  <PageLayout className="space-y-6">
@@ -192,6 +281,7 @@ export function DesktopUpdateConfig() {
192
281
  {snapshot.errorMessage && snapshot.status !== 'blocked' ? <div className="rounded-2xl border border-red-200 bg-red-50/70 p-4 text-sm text-red-700">{snapshot.errorMessage}</div> : null}
193
282
  </CardContent>
194
283
  </Card>
284
+ <ReleaseNotesPreview snapshot={snapshot} />
195
285
  <Card>
196
286
  <CardHeader>
197
287
  <CardTitle>{t('desktopUpdatesPreferencesTitle')}</CardTitle>
@@ -250,7 +340,6 @@ export function DesktopUpdateConfig() {
250
340
  <RotateCw className={cn('mr-2 h-4 w-4', isApplying && 'animate-spin')} />
251
341
  {t('runtimeUpdatesApplyNow')}
252
342
  </Button>
253
- {snapshot.releaseNotesUrl ? <Button variant="ghost" onClick={() => void hostCapabilityManager.openExternalUrl(snapshot.releaseNotesUrl ?? '')}><ExternalLink className="mr-2 h-4 w-4" />{t('desktopUpdatesReleaseNotes')}</Button> : null}
254
343
  </CardContent>
255
344
  </Card>
256
345
  </PageLayout>