@nextclaw/ui 0.15.5 → 0.15.7

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 (169) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/dist/assets/api-V8m62-13.js +13 -0
  3. package/dist/assets/app-presenter-provider-Bzj9h_ZS.js +106 -0
  4. package/dist/assets/{appearance-settings-page-80fLa4-9.js → appearance-settings-page-DwBwu1AU.js} +1 -1
  5. package/dist/assets/{book-open-CN7Shpij.js → book-open-DtVSr5O9.js} +1 -1
  6. package/dist/assets/{channels-list-page-R7M6pzp6.js → channels-list-page-CYIqMliJ.js} +2 -2
  7. package/dist/assets/{chat-page-152YuwfU.js → chat-page-BC0icTtI.js} +1 -1
  8. package/dist/assets/{config-split-page-BlfvfmTm.js → config-split-page-B88prd8s.js} +1 -1
  9. package/dist/assets/{confirm-dialog-Dt8Kdbgp.js → confirm-dialog-BB09IC2u.js} +1 -1
  10. package/dist/assets/desktop-update-config-B-q0OhxO.js +1 -0
  11. package/dist/assets/{dist-D5ycbwCo.js → dist-Bytser-a.js} +1 -1
  12. package/dist/assets/{doc-browser-C9u7N9vH.js → doc-browser-B1FXvZm6.js} +1 -1
  13. package/dist/assets/doc-browser-Bodhf0fj2.js +1 -0
  14. package/dist/assets/{doc-browser-context-CCdW9TJf.js → doc-browser-context-D5VLNoj3.js} +1 -1
  15. package/dist/assets/{download-CyPgglKr.js → download-CdO6NoHj.js} +1 -1
  16. package/dist/assets/{external-link-BahW1sl3.js → external-link-C5NFoh5I.js} +1 -1
  17. package/dist/assets/{eye-D8gGXMkD.js → eye-B51oNRRc.js} +1 -1
  18. package/dist/assets/{folder-DGWqFDvE.js → folder-DxH7Nef-.js} +1 -1
  19. package/dist/assets/{form-actions-D2imZLnU.js → form-actions-C9pUwtYV.js} +1 -1
  20. package/dist/assets/index-DtWf7kk9.css +1 -0
  21. package/dist/assets/index-UnY4HtVa.js +2 -0
  22. package/dist/assets/loader-circle-BRSfaztA.js +1 -0
  23. package/dist/assets/{logo-badge-CuPetu-v.js → logo-badge-D2vORX3a.js} +1 -1
  24. package/dist/assets/mcp-marketplace-page-CGVu7mfL.js +9 -0
  25. package/dist/assets/mcp-marketplace-page-DnV0e1JS.js +1 -0
  26. package/dist/assets/{model-config-page-DiJcvBML.js → model-config-page-DYpchGVE.js} +1 -1
  27. package/dist/assets/plus-BgMBV9HJ.js +1 -0
  28. package/dist/assets/{popover-Btp7KVc3.js → popover-CONujDHf.js} +1 -1
  29. package/dist/assets/{provider-scoped-model-input-DZMtoAJG.js → provider-scoped-model-input-DA3Ua9v8.js} +1 -1
  30. package/dist/assets/providers-config-page-DXsoyMrh.js +1 -0
  31. package/dist/assets/{react-BY-U3h3t.js → react-Io6Pfq8D.js} +1 -1
  32. package/dist/assets/{refresh-cw-Cme5qFej.js → refresh-cw-C6j2tjul.js} +1 -1
  33. package/dist/assets/remote-C1y3ARHu.js +1 -0
  34. package/dist/assets/rotate-cw-Bv2B6fAV.js +1 -0
  35. package/dist/assets/runtime-config-page-eYrrKBrA.js +1 -0
  36. package/dist/assets/{save-DNg2yJTS.js → save-CBBZVvZ-.js} +1 -1
  37. package/dist/assets/{search-3ykfrAFt.js → search-DFF842z2.js} +1 -1
  38. package/dist/assets/{search-config-page-DrVKa_Ls.js → search-config-page-DUyuwD7d.js} +1 -1
  39. package/dist/assets/{secrets-config-page-B39uQ5kF.js → secrets-config-page-DwzKQJ76.js} +2 -2
  40. package/dist/assets/security-config-D8me3pXl.js +1 -0
  41. package/dist/assets/{select-B5EecXJt.js → select-BiOpo2kV.js} +1 -1
  42. package/dist/assets/{setting-row-Qv1Ps79v.js → setting-row-C3WQXLtS.js} +1 -1
  43. package/dist/assets/{settings-2-D-3um9Az.js → settings-2-DyOvA_6K.js} +1 -1
  44. package/dist/assets/{skeleton-BgkzQd4U.js → skeleton-CQIAEbA4.js} +1 -1
  45. package/dist/assets/{tabs-BVD8gp3y.js → tabs-BTSENlI2.js} +1 -1
  46. package/dist/assets/{tag-chip-0qn-f3Bo.js → tag-chip-BG5JIGwY.js} +1 -1
  47. package/dist/assets/{trash-2-cnsMppRC.js → trash-2-CTIfwpt1.js} +1 -1
  48. package/dist/assets/use-config-CY4uF2Ou.js +1 -0
  49. package/dist/assets/{use-confirm-dialog-C_o0eQeH.js → use-confirm-dialog-Bj4u78F9.js} +1 -1
  50. package/dist/assets/{use-viewport-layout-BWav4KCL.js → use-viewport-layout-ChJUcSQ6.js} +1 -1
  51. package/dist/assets/x-C6cM8wkT.js +1 -0
  52. package/dist/index.html +30 -29
  53. package/index.html +1 -0
  54. package/package.json +8 -8
  55. package/src/features/chat/components/conversation/__tests__/chat-conversation-header-section.test.tsx +97 -3
  56. package/src/features/chat/components/conversation/chat-conversation-header-section.tsx +13 -2
  57. package/src/features/chat/components/layout/__tests__/chat-sidebar-test.utils.ts +22 -0
  58. package/src/features/chat/components/layout/__tests__/chat-sidebar.test.tsx +13 -24
  59. package/src/features/chat/components/layout/chat-sidebar-desktop-layout.tsx +16 -4
  60. package/src/features/chat/components/layout/chat-sidebar.tsx +38 -2
  61. package/src/features/chat/features/conversation/components/__tests__/session-conversation-area.test.tsx +1 -0
  62. package/src/features/chat/features/conversation/components/__tests__/session-conversation-input.streaming.test.tsx +70 -2
  63. package/src/features/chat/features/conversation/components/session-conversation-area.tsx +22 -0
  64. package/src/features/chat/features/conversation/components/session-conversation-input.tsx +8 -9
  65. package/src/features/chat/features/conversation/hooks/__tests__/use-session-conversation-input-state.test.tsx +55 -0
  66. package/src/features/chat/features/conversation/hooks/use-session-conversation-input-state.ts +12 -11
  67. package/src/features/chat/features/conversation/hooks/use-session-conversation-preference-actions.ts +94 -0
  68. package/src/features/chat/features/input/hooks/__tests__/use-chat-input-surface-state.test.tsx +82 -0
  69. package/src/features/chat/features/input/hooks/use-chat-input-surface-state.ts +187 -63
  70. package/src/features/chat/features/input/input-surface-plugins/__tests__/context-reference-plugin.test.ts +225 -0
  71. package/src/features/chat/features/input/input-surface-plugins/__tests__/slash-command-plugin.test.ts +23 -0
  72. package/src/features/chat/features/input/input-surface-plugins/chat-input-product-plugin-adapters.types.ts +6 -1
  73. package/src/features/chat/features/input/input-surface-plugins/context-reference-plugin.utils.ts +357 -0
  74. package/src/features/chat/features/input/input-surface-plugins/slash-command-plugin.utils.ts +4 -1
  75. package/src/features/chat/features/input/utils/__tests__/chat-composer-state.utils.test.ts +26 -0
  76. package/src/features/chat/features/input/utils/__tests__/chat-inline-token.utils.test.ts +67 -5
  77. package/src/features/chat/features/input/utils/chat-composer-state.utils.ts +4 -10
  78. package/src/features/chat/features/input/utils/chat-composer-token-protocol.utils.ts +29 -0
  79. package/src/features/chat/features/input/utils/chat-inline-token.utils.ts +83 -8
  80. package/src/features/chat/features/input/utils/chat-input-bar.utils.ts +1 -0
  81. package/src/features/chat/features/message/components/__tests__/chat-inline-file-preview.test.tsx +159 -0
  82. package/src/features/chat/features/message/components/__tests__/chat-message-list.container.test.tsx +92 -26
  83. package/src/features/chat/features/message/components/chat-inline-file-preview.tsx +90 -21
  84. package/src/features/chat/features/message/components/chat-message-list.container.tsx +24 -3
  85. package/src/features/chat/features/message/utils/chat-message-texts.utils.ts +1 -0
  86. package/src/features/chat/features/ncp/hooks/__tests__/use-hydrated-ncp-agent.test.tsx +96 -3
  87. package/src/features/chat/features/project/components/__tests__/chat-project-create-dialog.test.tsx +67 -0
  88. package/src/features/chat/features/project/components/chat-project-create-dialog.tsx +203 -0
  89. package/src/features/chat/features/session/components/session-header/__tests__/chat-session-header-actions.test.tsx +7 -7
  90. package/src/features/chat/features/session/components/session-header/__tests__/chat-session-project-dialog.test.tsx +47 -0
  91. package/src/features/chat/features/session/components/session-header/chat-session-header-actions.tsx +6 -3
  92. package/src/features/chat/features/session/components/session-header/chat-session-project-badge.tsx +3 -0
  93. package/src/features/chat/features/session/components/session-header/chat-session-project-dialog.tsx +3 -0
  94. package/src/features/chat/features/session/components/session-header/chat-session-switcher-groups.tsx +245 -0
  95. package/src/features/chat/features/session/components/session-header/chat-session-title-switcher.tsx +82 -128
  96. package/src/features/chat/features/session/utils/__tests__/chat-sidebar-session-groups.utils.test.ts +22 -0
  97. package/src/features/chat/features/session/utils/chat-run-metadata.utils.ts +6 -6
  98. package/src/features/chat/features/session/utils/chat-sidebar-session-groups.utils.ts +12 -1
  99. package/src/features/chat/features/welcome/components/chat-conversation-welcome.tsx +3 -0
  100. package/src/features/chat/features/welcome/components/chat-welcome.tsx +1 -0
  101. package/src/features/chat/features/welcome/utils/__tests__/chat-welcome-project-options.utils.test.ts +20 -0
  102. package/src/features/chat/features/welcome/utils/chat-welcome-project-options.utils.ts +15 -2
  103. package/src/features/chat/features/workspace/components/__tests__/chat-session-workspace-file-preview.test.tsx +5 -6
  104. package/src/features/chat/features/workspace/components/__tests__/workspace-file-content-preview.test.tsx +67 -0
  105. package/src/features/chat/features/workspace/components/chat-session-workspace-file-preview.tsx +9 -7
  106. package/src/features/chat/features/workspace/components/workspace-file-content-preview.tsx +95 -4
  107. package/src/features/chat/pages/__tests__/ncp-chat-page.test.ts +23 -0
  108. package/src/features/marketplace/components/__tests__/marketplace-page-detail.test.tsx +1 -0
  109. package/src/features/marketplace/components/__tests__/marketplace-page.test.tsx +62 -0
  110. package/src/features/marketplace/components/curated-shelves/__tests__/marketplace-curated-scene-route.test.tsx +1 -0
  111. package/src/features/marketplace/components/curated-shelves/marketplace-curated-shelves.tsx +2 -15
  112. package/src/features/marketplace/components/marketplace-page.tsx +10 -2
  113. package/src/features/marketplace/hooks/__tests__/use-marketplace.test.tsx +26 -1
  114. package/src/features/marketplace/hooks/use-marketplace-curated-scene-route.ts +11 -0
  115. package/src/features/marketplace/hooks/use-marketplace-list-model.ts +1 -1
  116. package/src/features/marketplace/hooks/use-marketplace.ts +28 -1
  117. package/src/features/settings/components/config/provider-form.tsx +0 -4
  118. package/src/features/settings/utils/__tests__/provider-form-support.utils.test.ts +7 -9
  119. package/src/features/settings/utils/provider-form-model.utils.ts +0 -4
  120. package/src/features/system-status/hooks/use-current-version-release-notes-link.ts +33 -0
  121. package/src/features/system-status/index.ts +9 -0
  122. package/src/features/system-status/utils/update-release-notes.utils.ts +44 -3
  123. package/src/index.css +10 -16
  124. package/src/shared/components/common/__tests__/brand-header.test.tsx +110 -3
  125. package/src/shared/components/common/brand-header.tsx +121 -35
  126. package/src/shared/components/path-picker/__tests__/server-path-picker-dialog.test.tsx +288 -38
  127. package/src/shared/components/path-picker/server-path-picker-dialog.tsx +290 -199
  128. package/src/shared/components/path-picker/server-path-picker-directory-list.tsx +103 -0
  129. package/src/shared/components/path-picker/server-path-picker-footer.tsx +58 -0
  130. package/src/shared/components/path-picker/server-path-picker-locations.tsx +148 -0
  131. package/src/shared/components/path-picker/server-path-picker-navigation-bar.tsx +198 -0
  132. package/src/shared/components/path-picker/server-path-picker-new-folder.tsx +59 -0
  133. package/src/shared/components/path-picker/server-path-picker-toolbar.tsx +25 -0
  134. package/src/shared/hooks/use-projects.ts +28 -0
  135. package/src/shared/hooks/use-server-path-create-directory.ts +6 -0
  136. package/src/shared/hooks/use-server-path-search.ts +40 -0
  137. package/src/shared/lib/api/index.ts +1 -0
  138. package/src/shared/lib/api/projects/project.utils.ts +14 -0
  139. package/src/shared/lib/api/types.ts +27 -0
  140. package/src/shared/lib/api/utils/server-path.utils.ts +21 -1
  141. package/src/shared/lib/i18n/locales/en-US/chat.json +40 -2
  142. package/src/shared/lib/i18n/locales/en-US/core.json +2 -3
  143. package/src/shared/lib/i18n/locales/en-US/desktop-update.json +5 -1
  144. package/src/shared/lib/i18n/locales/en-US/marketplace.json +1 -0
  145. package/src/shared/lib/i18n/locales/en-US/path-picker.json +25 -0
  146. package/src/shared/lib/i18n/locales/zh-CN/chat.json +40 -2
  147. package/src/shared/lib/i18n/locales/zh-CN/core.json +2 -3
  148. package/src/shared/lib/i18n/locales/zh-CN/desktop-update.json +5 -1
  149. package/src/shared/lib/i18n/locales/zh-CN/marketplace.json +1 -0
  150. package/src/shared/lib/i18n/locales/zh-CN/path-picker.json +25 -0
  151. package/dist/assets/api-IlX_CbwU.js +0 -13
  152. package/dist/assets/app-presenter-provider-Bbl_MVbg.js +0 -106
  153. package/dist/assets/desktop-update-config-DreMJu9z.js +0 -1
  154. package/dist/assets/doc-browser-DJQ5XPsi2.js +0 -1
  155. package/dist/assets/index-0av6WO9-.css +0 -1
  156. package/dist/assets/index-CT0iYlxV.js +0 -2
  157. package/dist/assets/loader-circle-DtKek-qG.js +0 -1
  158. package/dist/assets/mcp-marketplace-page-4qMwFDpz.js +0 -1
  159. package/dist/assets/mcp-marketplace-page-BbxN5aZF.js +0 -9
  160. package/dist/assets/plus-BwgE-Zwf.js +0 -1
  161. package/dist/assets/providers-config-page-DfiWveEF.js +0 -1
  162. package/dist/assets/remote-BaVECEZw.js +0 -1
  163. package/dist/assets/rotate-cw-CMRZxIkH.js +0 -1
  164. package/dist/assets/runtime-config-page-wygwvMB4.js +0 -1
  165. package/dist/assets/security-config-BPzet76z.js +0 -1
  166. package/dist/assets/use-config-D6yx0lb0.js +0 -1
  167. package/dist/assets/x-B3o1dWR5.js +0 -1
  168. package/src/features/chat/features/input/input-surface-plugins/__tests__/panel-app-reference-plugin.test.ts +0 -77
  169. package/src/features/chat/features/input/input-surface-plugins/panel-app-reference-plugin.utils.ts +0 -144
@@ -4,11 +4,20 @@ export type ReleaseNotesLocale = 'zh-CN' | 'en-US';
4
4
 
5
5
  export type ReleaseNotesText = Partial<Record<ReleaseNotesLocale, string>>;
6
6
 
7
+ export type ReleaseNotesLink = {
8
+ url: string;
9
+ versionLabel: string;
10
+ };
11
+
7
12
  export type ReleaseNotesItem = {
8
13
  title: ReleaseNotesText;
9
14
  body?: ReleaseNotesText;
10
15
  };
11
16
 
17
+ export type ReleaseNotesLinks = {
18
+ html?: ReleaseNotesText;
19
+ };
20
+
12
21
  export type ReleaseNotesSection = {
13
22
  kind: string;
14
23
  title?: ReleaseNotesText;
@@ -18,9 +27,12 @@ export type ReleaseNotesSection = {
18
27
  export type ReleaseNotesPayload = {
19
28
  title?: ReleaseNotesText;
20
29
  summary?: ReleaseNotesText;
30
+ links?: ReleaseNotesLinks;
21
31
  sections: ReleaseNotesSection[];
22
32
  };
23
33
 
34
+ const DEFAULT_RELEASE_NOTES_BASE_URL = 'https://docs.nextclaw.io/';
35
+
24
36
  export function readReleaseNotesText(
25
37
  value: ReleaseNotesText | undefined,
26
38
  locale: ReleaseNotesLocale
@@ -33,17 +45,40 @@ export function readReleaseNotesText(
33
45
  export function resolveReleaseNotesDataUrl(snapshot: UpdateSnapshot): string | null {
34
46
  const releaseNotesUrl = snapshot.releaseNotesUrl?.trim();
35
47
  const version = (snapshot.availableVersion ?? snapshot.downloadedVersion)?.trim();
36
- if (!releaseNotesUrl || !version || !/^[0-9A-Za-z.-]+$/.test(version)) {
48
+ if (!releaseNotesUrl) {
37
49
  return null;
38
50
  }
51
+ return resolveVersionReleaseNotesDataUrl(version, releaseNotesUrl);
52
+ }
39
53
 
54
+ export function resolveVersionReleaseNotesDataUrl(version: string | null | undefined, baseUrl = DEFAULT_RELEASE_NOTES_BASE_URL): string | null {
55
+ const normalizedVersion = version?.trim();
56
+ if (!normalizedVersion || !/^[0-9A-Za-z.-]+$/.test(normalizedVersion)) {
57
+ return null;
58
+ }
40
59
  try {
41
- return new URL(`/release-notes/nextclaw-v${version}.json`, releaseNotesUrl).toString();
60
+ return new URL(`/release-notes/nextclaw-v${normalizedVersion}.json`, baseUrl).toString();
42
61
  } catch {
43
62
  return null;
44
63
  }
45
64
  }
46
65
 
66
+ export function resolveReleaseNotesHtmlUrl(payload: ReleaseNotesPayload | undefined, locale: ReleaseNotesLocale): string | null {
67
+ return readReleaseNotesText(payload?.links?.html, locale);
68
+ }
69
+
70
+ export function resolveUpdateReleaseNotesLink(snapshot: UpdateSnapshot): ReleaseNotesLink | null {
71
+ const url = snapshot.releaseNotesUrl?.trim();
72
+ const version = (snapshot.downloadedVersion ?? snapshot.availableVersion)?.trim();
73
+ if (!url || !version) {
74
+ return null;
75
+ }
76
+ return {
77
+ url,
78
+ versionLabel: `v${version}`
79
+ };
80
+ }
81
+
47
82
  function isRecord(value: unknown): value is Record<string, unknown> {
48
83
  return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
49
84
  }
@@ -70,12 +105,18 @@ function isReleaseNotesSection(value: unknown): value is ReleaseNotesSection {
70
105
  && (value.title === undefined || isReleaseNotesText(value.title));
71
106
  }
72
107
 
108
+ function isReleaseNotesLinks(value: unknown): value is ReleaseNotesLinks {
109
+ return isRecord(value)
110
+ && (value.html === undefined || isReleaseNotesText(value.html));
111
+ }
112
+
73
113
  function isReleaseNotesPayload(value: unknown): value is ReleaseNotesPayload {
74
114
  return isRecord(value)
75
115
  && Array.isArray(value.sections)
76
116
  && value.sections.every(isReleaseNotesSection)
77
117
  && (value.title === undefined || isReleaseNotesText(value.title))
78
- && (value.summary === undefined || isReleaseNotesText(value.summary));
118
+ && (value.summary === undefined || isReleaseNotesText(value.summary))
119
+ && (value.links === undefined || isReleaseNotesLinks(value.links));
79
120
  }
80
121
 
81
122
  export async function fetchReleaseNotesData(url: string): Promise<ReleaseNotesPayload> {
package/src/index.css CHANGED
@@ -719,8 +719,9 @@
719
719
  --md-table-head-bg: hsl(var(--muted) / 0.84);
720
720
  }
721
721
 
722
+ /* The root flow owns external spacing; individual blocks only own their surface. */
722
723
  .chat-markdown > * + * {
723
- margin-top: 0.72rem;
724
+ margin-block-start: 0.75rem;
724
725
  }
725
726
 
726
727
  .chat-markdown p,
@@ -729,21 +730,19 @@
729
730
  color: var(--md-text);
730
731
  }
731
732
 
732
- .chat-markdown h1,
733
- .chat-markdown h2,
734
- .chat-markdown h3,
735
- .chat-markdown h4,
736
- .chat-markdown h5,
737
- .chat-markdown h6 {
738
- margin: 0.2rem 0 0;
739
- padding-bottom: 0.5em;
733
+ .chat-markdown :is(h1, h2, h3, h4, h5, h6) {
734
+ margin: 0;
740
735
  font-weight: 700;
741
736
  line-height: 1.35;
742
737
  letter-spacing: -0.01em;
743
738
  }
744
739
 
745
- .chat-markdown :is(h1, h2, h3, h4, h5, h6) + * {
746
- margin-top: 0;
740
+ .chat-markdown > :is(h1, h2, h3, h4, h5, h6):not(:first-child) {
741
+ margin-block-start: 1.25rem;
742
+ }
743
+
744
+ .chat-markdown > :is(h1, h2, h3, h4, h5, h6) + * {
745
+ margin-block-start: 0.5rem;
747
746
  }
748
747
 
749
748
  .chat-markdown h1 {
@@ -766,7 +765,6 @@
766
765
 
767
766
  .chat-markdown ul,
768
767
  .chat-markdown ol {
769
- margin: 0.35rem 0;
770
768
  padding-left: 1.5rem;
771
769
  list-style-position: outside;
772
770
  }
@@ -839,7 +837,6 @@
839
837
  .chat-markdown hr {
840
838
  border: 0;
841
839
  border-top: 1px solid var(--md-border);
842
- margin: 0.92rem 0;
843
840
  }
844
841
 
845
842
  .chat-markdown a {
@@ -874,7 +871,6 @@
874
871
  }
875
872
 
876
873
  .chat-codeblock {
877
- margin: 0.2rem 0 0.1rem;
878
874
  border: 1px solid var(--md-code-toolbar-border);
879
875
  border-radius: 0.5rem;
880
876
  overflow: hidden;
@@ -1299,7 +1295,6 @@
1299
1295
  }
1300
1296
 
1301
1297
  .chat-table-wrap {
1302
- margin: 0.15rem 0;
1303
1298
  border: 1px solid var(--md-border);
1304
1299
  border-radius: 0.72rem;
1305
1300
  overflow-x: auto;
@@ -1378,7 +1373,6 @@
1378
1373
  }
1379
1374
 
1380
1375
  .chat-markdown blockquote {
1381
- margin: 0.2rem 0;
1382
1376
  border-left: 3px solid var(--md-blockquote-border);
1383
1377
  border-radius: 0 0.55rem 0.55rem 0;
1384
1378
  padding: 0.45rem 0.72rem;
@@ -10,7 +10,9 @@ import { setLanguage } from '@/shared/lib/i18n';
10
10
 
11
11
  const mocks = vi.hoisted(() => ({
12
12
  applyDownloadedUpdate: vi.fn(),
13
- downloadUpdate: vi.fn()
13
+ downloadUpdate: vi.fn(),
14
+ fetchReleaseNotes: vi.fn(),
15
+ openExternalUrl: vi.fn()
14
16
  }));
15
17
 
16
18
  vi.mock('@/features/system-status', async () => {
@@ -25,7 +27,30 @@ vi.mock('@/features/system-status', async () => {
25
27
  };
26
28
  });
27
29
 
28
- function renderBrandHeader() {
30
+ vi.mock('@/shared/lib/host-capabilities', () => ({
31
+ hostCapabilityManager: {
32
+ openExternalUrl: mocks.openExternalUrl,
33
+ },
34
+ }));
35
+
36
+ function createReleaseNotesResponse(urls?: { zh?: string; en?: string }): Response {
37
+ return new Response(JSON.stringify({
38
+ links: urls
39
+ ? {
40
+ html: {
41
+ ...(urls.zh ? { 'zh-CN': urls.zh } : {}),
42
+ ...(urls.en ? { 'en-US': urls.en } : {})
43
+ }
44
+ }
45
+ : undefined,
46
+ sections: []
47
+ }), {
48
+ headers: { 'content-type': 'application/json' },
49
+ status: 200
50
+ });
51
+ }
52
+
53
+ function renderBrandHeader(options: { productVersion?: string } = {}) {
29
54
  const queryClient = new QueryClient({
30
55
  defaultOptions: {
31
56
  queries: {
@@ -35,7 +60,7 @@ function renderBrandHeader() {
35
60
  });
36
61
  queryClient.setQueryData(['app-meta'], {
37
62
  name: 'NextClaw',
38
- productVersion: '0.18.11'
63
+ productVersion: options.productVersion ?? '0.18.11'
39
64
  });
40
65
  return render(
41
66
  <QueryClientProvider client={queryClient}>
@@ -57,6 +82,10 @@ describe('BrandHeader', () => {
57
82
  });
58
83
  mocks.applyDownloadedUpdate.mockReset();
59
84
  mocks.downloadUpdate.mockReset();
85
+ mocks.fetchReleaseNotes.mockReset();
86
+ mocks.fetchReleaseNotes.mockResolvedValue(createReleaseNotesResponse());
87
+ mocks.openExternalUrl.mockReset();
88
+ vi.stubGlobal('fetch', mocks.fetchReleaseNotes);
60
89
  });
61
90
 
62
91
  it('shows update progress next to the product version', async () => {
@@ -105,6 +134,84 @@ describe('BrandHeader', () => {
105
134
  expect(screen.queryByRole('button', { name: '更新' })).toBeNull();
106
135
  });
107
136
 
137
+ it('opens release notes from the current version tooltip when that version has docs notes', async () => {
138
+ const user = userEvent.setup();
139
+ mocks.fetchReleaseNotes.mockResolvedValueOnce(createReleaseNotesResponse({
140
+ zh: 'https://docs.nextclaw.io/zh/notes/2026-07-15-nextclaw-v0-23-0',
141
+ en: 'https://docs.nextclaw.io/en/notes/2026-07-15-nextclaw-v0-23-0'
142
+ }));
143
+
144
+ renderBrandHeader({ productVersion: '0.23.0' });
145
+
146
+ const version = await screen.findByRole('button', {
147
+ name: '当前版本 v0.23.0,查看 v0.23.0 更新说明'
148
+ });
149
+
150
+ await user.hover(version);
151
+ expect((await screen.findByRole('tooltip')).textContent).toContain('点击查看 v0.23.0 更新说明');
152
+
153
+ await user.click(version);
154
+
155
+ expect(mocks.fetchReleaseNotes).toHaveBeenCalledWith(
156
+ 'https://docs.nextclaw.io/release-notes/nextclaw-v0.23.0.json',
157
+ expect.objectContaining({ cache: 'no-store' })
158
+ );
159
+ expect(mocks.openExternalUrl).toHaveBeenCalledWith('https://docs.nextclaw.io/zh/notes/2026-07-15-nextclaw-v0-23-0');
160
+ });
161
+
162
+ it('keeps update target release notes available without replacing the current version link', async () => {
163
+ const user = userEvent.setup();
164
+ mocks.fetchReleaseNotes.mockResolvedValueOnce(createReleaseNotesResponse({
165
+ zh: 'https://docs.nextclaw.io/zh/notes/2026-07-14-nextclaw-v0-18-11'
166
+ }));
167
+ useRuntimeUpdateStore.setState({
168
+ supported: true,
169
+ initialized: true,
170
+ busyAction: null,
171
+ snapshot: {
172
+ status: 'update-available',
173
+ installationKind: 'npm-runtime-bundle',
174
+ channel: 'stable',
175
+ hostVersion: '0.18.11',
176
+ currentVersion: '0.18.11',
177
+ availableVersion: '0.18.12',
178
+ downloadedVersion: null,
179
+ minimumHostVersion: null,
180
+ releaseNotesUrl: 'https://docs.nextclaw.io/zh/notes/2026-07-15-nextclaw-v0-18-12',
181
+ lastCheckedAt: null,
182
+ progress: null,
183
+ canAutoDownload: true,
184
+ canApplyInApp: false,
185
+ requiresRestart: false,
186
+ blockReason: null,
187
+ recoveryCommand: null,
188
+ errorMessage: null,
189
+ preferences: {
190
+ automaticChecks: true,
191
+ autoDownload: true
192
+ }
193
+ }
194
+ });
195
+
196
+ renderBrandHeader();
197
+
198
+ const currentVersion = await screen.findByRole('button', {
199
+ name: '当前版本 v0.18.11,查看 v0.18.11 更新说明'
200
+ });
201
+ await user.click(currentVersion);
202
+ expect(mocks.openExternalUrl).toHaveBeenCalledWith('https://docs.nextclaw.io/zh/notes/2026-07-14-nextclaw-v0-18-11');
203
+
204
+ await user.click(screen.getByRole('button', { name: '下载' }));
205
+ expect(mocks.downloadUpdate).toHaveBeenCalledTimes(1);
206
+
207
+ const updateReleaseNotes = screen.getByRole('button', { name: '查看 v0.18.12 更新说明' });
208
+ await user.hover(updateReleaseNotes);
209
+ expect((await screen.findByRole('tooltip')).textContent).toBe('查看 v0.18.12 更新说明');
210
+
211
+ await user.click(updateReleaseNotes);
212
+ expect(mocks.openExternalUrl).toHaveBeenCalledWith('https://docs.nextclaw.io/zh/notes/2026-07-15-nextclaw-v0-18-12');
213
+ });
214
+
108
215
  it('applies the downloaded update from the version-adjacent update button', async () => {
109
216
  const user = userEvent.setup();
110
217
  useRuntimeUpdateStore.setState({
@@ -1,11 +1,20 @@
1
1
  import type { UpdateSnapshot } from '@nextclaw/shared';
2
- import { runtimeUpdateManager, useRuntimeUpdateStore } from '@/features/system-status';
2
+ import {
3
+ runtimeUpdateManager,
4
+ resolveUpdateReleaseNotesLink,
5
+ useCurrentVersionReleaseNotesLink,
6
+ useRuntimeUpdateStore,
7
+ type ReleaseNotesLink,
8
+ type RuntimeUpdateBusyAction
9
+ } from '@/features/system-status';
3
10
  import { useAppMeta } from '@/shared/hooks/use-app-meta';
4
11
  import type { ReactNode } from 'react';
5
12
  import { RuntimeStatusEntry } from '@/app/components/layout/runtime-status-entry';
6
13
  import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/shared/components/ui/tooltip';
14
+ import { hostCapabilityManager } from '@/shared/lib/host-capabilities';
7
15
  import { t } from '@/shared/lib/i18n';
8
16
  import { cn } from '@/shared/lib/utils';
17
+ import { ExternalLink } from 'lucide-react';
9
18
 
10
19
  type BrandHeaderProps = {
11
20
  className?: string;
@@ -15,9 +24,11 @@ type BrandHeaderProps = {
15
24
 
16
25
  export function BrandHeader({ className, density = 'sidebar', suffix }: BrandHeaderProps) {
17
26
  const { data } = useAppMeta();
27
+ const { supported, busyAction, snapshot } = useRuntimeUpdateStore();
18
28
  const productName = data?.name ?? 'NextClaw';
19
29
  const productVersion = data?.productVersion?.trim();
20
30
  const versionLabel = productVersion ? `v${productVersion}` : null;
31
+ const releaseNotesLink = useCurrentVersionReleaseNotesLink(productVersion);
21
32
  const resolvedSuffix = suffix ?? <RuntimeStatusEntry />;
22
33
  const shouldReserveMacWindowControls = typeof window !== 'undefined' && window.nextclawDesktop?.platform === 'darwin';
23
34
  const isChromeDensity = density === 'chrome';
@@ -42,9 +53,9 @@ export function BrandHeader({ className, density = 'sidebar', suffix }: BrandHea
42
53
  >
43
54
  {productName}
44
55
  </span>
45
- {versionLabel ? <BrandVersionLabel versionLabel={versionLabel} density={density} /> : null}
56
+ {versionLabel ? <BrandVersionLabel versionLabel={versionLabel} density={density} releaseNotesLink={releaseNotesLink} /> : null}
46
57
  </div>
47
- <RuntimeUpdateInlineStatus />
58
+ <RuntimeUpdateInlineStatus supported={supported} busyAction={busyAction} snapshot={snapshot} />
48
59
  {resolvedSuffix ? <span className="inline-flex items-center shrink-0">{resolvedSuffix}</span> : null}
49
60
  </div>
50
61
  </div>
@@ -54,68 +65,143 @@ export function BrandHeader({ className, density = 'sidebar', suffix }: BrandHea
54
65
  function BrandVersionLabel({
55
66
  versionLabel,
56
67
  density,
68
+ releaseNotesLink,
57
69
  }: {
58
70
  versionLabel: string;
59
71
  density: BrandHeaderProps['density'];
72
+ releaseNotesLink: ReleaseNotesLink | null;
60
73
  }) {
61
74
  const isChromeDensity = density === 'chrome';
75
+ const triggerClassName = cn(
76
+ 'block min-w-0 flex-1 truncate font-medium text-gray-500 outline-none',
77
+ isChromeDensity ? 'text-[12px]' : 'text-[12px]',
78
+ releaseNotesLink && 'cursor-pointer rounded-sm transition-colors hover:text-gray-700 hover:underline focus-visible:ring-2 focus-visible:ring-gray-300'
79
+ );
80
+ const tooltip = releaseNotesLink
81
+ ? [
82
+ t('desktopUpdatesVersionTooltipCurrent').replace('{version}', versionLabel),
83
+ t('desktopUpdatesVersionTooltipReleaseNotes').replace('{version}', releaseNotesLink.versionLabel)
84
+ ].join('\n')
85
+ : versionLabel;
86
+ const trigger = releaseNotesLink ? (
87
+ <button
88
+ type="button"
89
+ aria-label={t('desktopUpdatesVersionReleaseNotesLabel').replace('{currentVersion}', versionLabel).replace('{releaseVersion}', releaseNotesLink.versionLabel)}
90
+ className={cn(triggerClassName, 'border-0 bg-transparent p-0 text-left')}
91
+ onClick={() => void hostCapabilityManager.openExternalUrl(releaseNotesLink.url)}
92
+ >
93
+ {versionLabel}
94
+ </button>
95
+ ) : (
96
+ <span tabIndex={0} aria-label={versionLabel} className={triggerClassName}>
97
+ {versionLabel}
98
+ </span>
99
+ );
62
100
 
63
101
  return (
64
102
  <TooltipProvider delayDuration={250}>
65
103
  <Tooltip>
66
- <TooltipTrigger asChild>
67
- <span
68
- tabIndex={0}
69
- aria-label={versionLabel}
70
- className={cn(
71
- 'block min-w-0 flex-1 truncate font-medium text-gray-500 outline-none',
72
- isChromeDensity ? 'text-[12px]' : 'text-[12px]',
73
- )}
74
- >
75
- {versionLabel}
76
- </span>
77
- </TooltipTrigger>
78
- <TooltipContent side="bottom" className="text-xs">{versionLabel}</TooltipContent>
104
+ <TooltipTrigger asChild>{trigger}</TooltipTrigger>
105
+ <TooltipContent side="bottom" className="max-w-72 whitespace-pre-line text-xs leading-relaxed">{tooltip}</TooltipContent>
79
106
  </Tooltip>
80
107
  </TooltipProvider>
81
108
  );
82
109
  }
83
110
 
84
- function RuntimeUpdateInlineStatus() {
85
- const { supported, busyAction, snapshot } = useRuntimeUpdateStore();
111
+ function RuntimeUpdateInlineStatus({
112
+ supported,
113
+ busyAction,
114
+ snapshot,
115
+ }: {
116
+ supported: boolean;
117
+ busyAction: RuntimeUpdateBusyAction;
118
+ snapshot: UpdateSnapshot | null;
119
+ }) {
86
120
  if (!supported || !snapshot) {
87
121
  return null;
88
122
  }
89
- if (snapshot.status === 'downloading' || snapshot.status === 'blocked' || snapshot.status === 'failed') {
123
+ const releaseNotesLink = resolveUpdateReleaseNotesLink(snapshot);
124
+ if (snapshot.status === 'downloading') {
125
+ return (
126
+ <RuntimeUpdateInlineGroup>
127
+ <RuntimeUpdateInlineBadge snapshot={snapshot} />
128
+ {releaseNotesLink ? <RuntimeUpdateReleaseNotesButton link={releaseNotesLink} tone="amber" /> : null}
129
+ </RuntimeUpdateInlineGroup>
130
+ );
131
+ }
132
+ if (snapshot.status === 'blocked' || snapshot.status === 'failed') {
90
133
  return <RuntimeUpdateInlineBadge snapshot={snapshot} />;
91
134
  }
92
135
  if (snapshot.status === 'downloaded') {
93
136
  return (
94
- <button
95
- type="button"
96
- className="inline-flex h-5 shrink-0 items-center rounded-full bg-emerald-50 px-2 text-[11px] font-semibold leading-none text-emerald-700 ring-1 ring-emerald-100 transition-colors hover:bg-emerald-100 disabled:opacity-70"
97
- disabled={busyAction === 'applying'}
98
- onClick={() => void runtimeUpdateManager.applyDownloadedUpdate()}
99
- >
100
- {busyAction === 'applying' ? t('desktopUpdatesInlineApplying') : t('desktopUpdatesInlineReady')}
101
- </button>
137
+ <RuntimeUpdateInlineGroup>
138
+ <button
139
+ type="button"
140
+ className="inline-flex h-5 shrink-0 items-center rounded-full bg-emerald-50 px-2 text-[11px] font-semibold leading-none text-emerald-700 ring-1 ring-emerald-100 transition-colors hover:bg-emerald-100 disabled:opacity-70"
141
+ disabled={busyAction === 'applying'}
142
+ onClick={() => void runtimeUpdateManager.applyDownloadedUpdate()}
143
+ >
144
+ {busyAction === 'applying' ? t('desktopUpdatesInlineApplying') : t('desktopUpdatesInlineReady')}
145
+ </button>
146
+ {releaseNotesLink ? <RuntimeUpdateReleaseNotesButton link={releaseNotesLink} tone="emerald" /> : null}
147
+ </RuntimeUpdateInlineGroup>
102
148
  );
103
149
  }
104
150
  if (snapshot.status === 'update-available') {
105
151
  return (
106
- <button
107
- type="button"
108
- className="inline-flex h-5 shrink-0 items-center rounded-full bg-amber-50 px-2 text-[11px] font-semibold leading-none text-amber-700 ring-1 ring-amber-100 transition-colors hover:bg-amber-100 disabled:opacity-70"
109
- disabled={busyAction === 'downloading'}
110
- onClick={() => void runtimeUpdateManager.downloadUpdate()}
111
- >
112
- {busyAction === 'downloading' ? t('desktopUpdatesInlineDownloading') : t('desktopUpdatesInlineDownload')}
113
- </button>
152
+ <RuntimeUpdateInlineGroup>
153
+ <button
154
+ type="button"
155
+ className="inline-flex h-5 shrink-0 items-center rounded-full bg-amber-50 px-2 text-[11px] font-semibold leading-none text-amber-700 ring-1 ring-amber-100 transition-colors hover:bg-amber-100 disabled:opacity-70"
156
+ disabled={busyAction === 'downloading'}
157
+ onClick={() => void runtimeUpdateManager.downloadUpdate()}
158
+ >
159
+ {busyAction === 'downloading' ? t('desktopUpdatesInlineDownloading') : t('desktopUpdatesInlineDownload')}
160
+ </button>
161
+ {releaseNotesLink ? <RuntimeUpdateReleaseNotesButton link={releaseNotesLink} tone="amber" /> : null}
162
+ </RuntimeUpdateInlineGroup>
114
163
  );
115
164
  }
116
165
  return null;
117
166
  }
118
167
 
168
+ function RuntimeUpdateInlineGroup({ children }: { children: ReactNode }) {
169
+ return <span className="inline-flex shrink-0 items-center gap-1">{children}</span>;
170
+ }
171
+
172
+ function RuntimeUpdateReleaseNotesButton({
173
+ link,
174
+ tone,
175
+ }: {
176
+ link: ReleaseNotesLink;
177
+ tone: 'amber' | 'emerald';
178
+ }) {
179
+ const label = t('desktopUpdatesUpdateReleaseNotesLabel').replace('{version}', link.versionLabel);
180
+ const className = cn(
181
+ 'inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-[11px] ring-1 transition-colors focus-visible:outline-none focus-visible:ring-2',
182
+ tone === 'emerald'
183
+ ? 'bg-emerald-50 text-emerald-700 ring-emerald-100 hover:bg-emerald-100 focus-visible:ring-emerald-300'
184
+ : 'bg-amber-50 text-amber-700 ring-amber-100 hover:bg-amber-100 focus-visible:ring-amber-300'
185
+ );
186
+ return (
187
+ <TooltipProvider delayDuration={250}>
188
+ <Tooltip>
189
+ <TooltipTrigger asChild>
190
+ <button
191
+ type="button"
192
+ aria-label={label}
193
+ className={className}
194
+ onClick={() => void hostCapabilityManager.openExternalUrl(link.url)}
195
+ >
196
+ <ExternalLink className="h-3 w-3" aria-hidden="true" />
197
+ </button>
198
+ </TooltipTrigger>
199
+ <TooltipContent side="bottom" className="text-xs">{label}</TooltipContent>
200
+ </Tooltip>
201
+ </TooltipProvider>
202
+ );
203
+ }
204
+
119
205
  function RuntimeUpdateInlineBadge({ snapshot }: { snapshot: UpdateSnapshot }) {
120
206
  if (snapshot.status === 'blocked' || snapshot.status === 'failed') {
121
207
  return <RuntimeUpdateIssueIcon snapshot={snapshot} />;