@nextclaw/ui 0.15.5 → 0.15.6

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 (147) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/dist/assets/api-Dyb5rgh0.js +13 -0
  3. package/dist/assets/app-presenter-provider-7jV0nPiL.js +106 -0
  4. package/dist/assets/{appearance-settings-page-80fLa4-9.js → appearance-settings-page-DFm3BbEb.js} +1 -1
  5. package/dist/assets/{book-open-CN7Shpij.js → book-open-L6LCuqOr.js} +1 -1
  6. package/dist/assets/{channels-list-page-R7M6pzp6.js → channels-list-page-DOaPKNJA.js} +2 -2
  7. package/dist/assets/{chat-page-152YuwfU.js → chat-page-Cw9uDhD_.js} +1 -1
  8. package/dist/assets/{config-split-page-BlfvfmTm.js → config-split-page-CqlqjnCK.js} +1 -1
  9. package/dist/assets/{confirm-dialog-Dt8Kdbgp.js → confirm-dialog-bAp8NU93.js} +1 -1
  10. package/dist/assets/desktop-update-config-2SjMfCMl.js +1 -0
  11. package/dist/assets/{dist-D5ycbwCo.js → dist-CrtbGLlh.js} +1 -1
  12. package/dist/assets/doc-browser-A5IRyDXQ2.js +1 -0
  13. package/dist/assets/{doc-browser-C9u7N9vH.js → doc-browser-BmnQa30-.js} +1 -1
  14. package/dist/assets/{doc-browser-context-CCdW9TJf.js → doc-browser-context-BlpUUFrR.js} +1 -1
  15. package/dist/assets/{download-CyPgglKr.js → download-BhZZNXD8.js} +1 -1
  16. package/dist/assets/{external-link-BahW1sl3.js → external-link-B9Uoq5J7.js} +1 -1
  17. package/dist/assets/{eye-D8gGXMkD.js → eye-K5knauEM.js} +1 -1
  18. package/dist/assets/{folder-DGWqFDvE.js → folder-BIYZZWJv.js} +1 -1
  19. package/dist/assets/{form-actions-D2imZLnU.js → form-actions-CKprWFeM.js} +1 -1
  20. package/dist/assets/index-BVn702m4.css +1 -0
  21. package/dist/assets/{index-CT0iYlxV.js → index-CFxJ1Fkg.js} +2 -2
  22. package/dist/assets/loader-circle-Bj2RMrCR.js +1 -0
  23. package/dist/assets/{logo-badge-CuPetu-v.js → logo-badge-BQ-XFZod.js} +1 -1
  24. package/dist/assets/mcp-marketplace-page-BfA7A3n4.js +9 -0
  25. package/dist/assets/mcp-marketplace-page-CXHYO95j.js +1 -0
  26. package/dist/assets/{model-config-page-DiJcvBML.js → model-config-page-BbWbW_qM.js} +1 -1
  27. package/dist/assets/plus-TmFdwO3P.js +1 -0
  28. package/dist/assets/{popover-Btp7KVc3.js → popover-CX9zFsuc.js} +1 -1
  29. package/dist/assets/{provider-scoped-model-input-DZMtoAJG.js → provider-scoped-model-input-B-9_DtIP.js} +1 -1
  30. package/dist/assets/providers-config-page-xwIjGur2.js +1 -0
  31. package/dist/assets/{react-BY-U3h3t.js → react-DsgudwpT.js} +1 -1
  32. package/dist/assets/{refresh-cw-Cme5qFej.js → refresh-cw-kXdvbhFf.js} +1 -1
  33. package/dist/assets/remote-B9F2uCk9.js +1 -0
  34. package/dist/assets/rotate-cw-DEc6aihr.js +1 -0
  35. package/dist/assets/runtime-config-page-DTsi7czJ.js +1 -0
  36. package/dist/assets/{save-DNg2yJTS.js → save-C9qpz2_2.js} +1 -1
  37. package/dist/assets/{search-3ykfrAFt.js → search-_kEi29qo.js} +1 -1
  38. package/dist/assets/{search-config-page-DrVKa_Ls.js → search-config-page-H7ug8f4D.js} +1 -1
  39. package/dist/assets/{secrets-config-page-B39uQ5kF.js → secrets-config-page-C1_6rINu.js} +2 -2
  40. package/dist/assets/security-config-Cr2ERKLu.js +1 -0
  41. package/dist/assets/{select-B5EecXJt.js → select-MJKhKAWR.js} +1 -1
  42. package/dist/assets/{setting-row-Qv1Ps79v.js → setting-row-DA3tJuew.js} +1 -1
  43. package/dist/assets/{settings-2-D-3um9Az.js → settings-2-D_y7zXJz.js} +1 -1
  44. package/dist/assets/{skeleton-BgkzQd4U.js → skeleton-0uTLUhm9.js} +1 -1
  45. package/dist/assets/{tabs-BVD8gp3y.js → tabs-h44XPocd.js} +1 -1
  46. package/dist/assets/{tag-chip-0qn-f3Bo.js → tag-chip-bRZ04q3C.js} +1 -1
  47. package/dist/assets/{trash-2-cnsMppRC.js → trash-2-BRpSE-Wt.js} +1 -1
  48. package/dist/assets/use-config-CwDEAl6f.js +1 -0
  49. package/dist/assets/{use-confirm-dialog-C_o0eQeH.js → use-confirm-dialog-BbnWw-8c.js} +1 -1
  50. package/dist/assets/{use-viewport-layout-BWav4KCL.js → use-viewport-layout-CqgIMDQL.js} +1 -1
  51. package/dist/assets/x-DdEDL6ju.js +1 -0
  52. package/dist/index.html +29 -29
  53. package/package.json +8 -8
  54. package/src/features/chat/components/conversation/__tests__/chat-conversation-header-section.test.tsx +84 -1
  55. package/src/features/chat/components/conversation/chat-conversation-header-section.tsx +13 -2
  56. package/src/features/chat/components/layout/__tests__/chat-sidebar-test.utils.ts +22 -0
  57. package/src/features/chat/components/layout/__tests__/chat-sidebar.test.tsx +13 -24
  58. package/src/features/chat/components/layout/chat-sidebar-desktop-layout.tsx +16 -4
  59. package/src/features/chat/components/layout/chat-sidebar.tsx +38 -2
  60. package/src/features/chat/features/conversation/components/__tests__/session-conversation-area.test.tsx +1 -0
  61. package/src/features/chat/features/conversation/components/__tests__/session-conversation-input.streaming.test.tsx +52 -2
  62. package/src/features/chat/features/conversation/components/session-conversation-area.tsx +22 -0
  63. package/src/features/chat/features/conversation/components/session-conversation-input.tsx +8 -9
  64. package/src/features/chat/features/conversation/hooks/__tests__/use-session-conversation-input-state.test.tsx +55 -0
  65. package/src/features/chat/features/conversation/hooks/use-session-conversation-input-state.ts +12 -11
  66. package/src/features/chat/features/conversation/hooks/use-session-conversation-preference-actions.ts +94 -0
  67. package/src/features/chat/features/input/hooks/__tests__/use-chat-input-surface-state.test.tsx +37 -0
  68. package/src/features/chat/features/input/hooks/use-chat-input-surface-state.ts +140 -63
  69. package/src/features/chat/features/input/input-surface-plugins/__tests__/context-reference-plugin.test.ts +175 -0
  70. package/src/features/chat/features/input/input-surface-plugins/__tests__/slash-command-plugin.test.ts +23 -0
  71. package/src/features/chat/features/input/input-surface-plugins/chat-input-product-plugin-adapters.types.ts +8 -1
  72. package/src/features/chat/features/input/input-surface-plugins/context-reference-plugin.utils.ts +274 -0
  73. package/src/features/chat/features/input/input-surface-plugins/slash-command-plugin.utils.ts +4 -1
  74. package/src/features/chat/features/input/utils/__tests__/chat-composer-state.utils.test.ts +26 -0
  75. package/src/features/chat/features/input/utils/__tests__/chat-inline-token.utils.test.ts +50 -4
  76. package/src/features/chat/features/input/utils/chat-composer-state.utils.ts +4 -10
  77. package/src/features/chat/features/input/utils/chat-composer-token-protocol.utils.ts +29 -0
  78. package/src/features/chat/features/input/utils/chat-inline-token.utils.ts +66 -8
  79. package/src/features/chat/features/input/utils/chat-input-bar.utils.ts +1 -0
  80. package/src/features/chat/features/ncp/hooks/__tests__/use-hydrated-ncp-agent.test.tsx +41 -3
  81. package/src/features/chat/features/project/components/__tests__/chat-project-create-dialog.test.tsx +67 -0
  82. package/src/features/chat/features/project/components/chat-project-create-dialog.tsx +203 -0
  83. package/src/features/chat/features/session/components/session-header/__tests__/chat-session-header-actions.test.tsx +7 -7
  84. package/src/features/chat/features/session/components/session-header/__tests__/chat-session-project-dialog.test.tsx +47 -0
  85. package/src/features/chat/features/session/components/session-header/chat-session-header-actions.tsx +6 -3
  86. package/src/features/chat/features/session/components/session-header/chat-session-project-badge.tsx +3 -0
  87. package/src/features/chat/features/session/components/session-header/chat-session-project-dialog.tsx +3 -0
  88. package/src/features/chat/features/session/components/session-header/chat-session-switcher-groups.tsx +245 -0
  89. package/src/features/chat/features/session/components/session-header/chat-session-title-switcher.tsx +81 -127
  90. package/src/features/chat/features/session/utils/__tests__/chat-sidebar-session-groups.utils.test.ts +22 -0
  91. package/src/features/chat/features/session/utils/chat-run-metadata.utils.ts +6 -6
  92. package/src/features/chat/features/session/utils/chat-sidebar-session-groups.utils.ts +12 -1
  93. package/src/features/chat/features/welcome/components/chat-conversation-welcome.tsx +3 -0
  94. package/src/features/chat/features/welcome/components/chat-welcome.tsx +1 -0
  95. package/src/features/chat/features/welcome/utils/__tests__/chat-welcome-project-options.utils.test.ts +20 -0
  96. package/src/features/chat/features/welcome/utils/chat-welcome-project-options.utils.ts +15 -2
  97. package/src/features/chat/pages/__tests__/ncp-chat-page.test.ts +23 -0
  98. package/src/features/settings/components/config/provider-form.tsx +0 -4
  99. package/src/features/settings/utils/__tests__/provider-form-support.utils.test.ts +7 -9
  100. package/src/features/settings/utils/provider-form-model.utils.ts +0 -4
  101. package/src/features/system-status/hooks/use-current-version-release-notes-link.ts +33 -0
  102. package/src/features/system-status/index.ts +9 -0
  103. package/src/features/system-status/utils/update-release-notes.utils.ts +44 -3
  104. package/src/index.css +10 -16
  105. package/src/shared/components/common/__tests__/brand-header.test.tsx +110 -3
  106. package/src/shared/components/common/brand-header.tsx +121 -35
  107. package/src/shared/components/path-picker/__tests__/server-path-picker-dialog.test.tsx +288 -38
  108. package/src/shared/components/path-picker/server-path-picker-dialog.tsx +290 -199
  109. package/src/shared/components/path-picker/server-path-picker-directory-list.tsx +103 -0
  110. package/src/shared/components/path-picker/server-path-picker-footer.tsx +58 -0
  111. package/src/shared/components/path-picker/server-path-picker-locations.tsx +148 -0
  112. package/src/shared/components/path-picker/server-path-picker-navigation-bar.tsx +198 -0
  113. package/src/shared/components/path-picker/server-path-picker-new-folder.tsx +59 -0
  114. package/src/shared/components/path-picker/server-path-picker-toolbar.tsx +25 -0
  115. package/src/shared/hooks/use-projects.ts +28 -0
  116. package/src/shared/hooks/use-server-path-create-directory.ts +6 -0
  117. package/src/shared/hooks/use-server-path-search.ts +40 -0
  118. package/src/shared/lib/api/index.ts +1 -0
  119. package/src/shared/lib/api/projects/project.utils.ts +14 -0
  120. package/src/shared/lib/api/types.ts +27 -0
  121. package/src/shared/lib/api/utils/server-path.utils.ts +21 -1
  122. package/src/shared/lib/i18n/locales/en-US/chat.json +35 -2
  123. package/src/shared/lib/i18n/locales/en-US/core.json +2 -3
  124. package/src/shared/lib/i18n/locales/en-US/desktop-update.json +4 -0
  125. package/src/shared/lib/i18n/locales/en-US/path-picker.json +25 -0
  126. package/src/shared/lib/i18n/locales/zh-CN/chat.json +35 -2
  127. package/src/shared/lib/i18n/locales/zh-CN/core.json +2 -3
  128. package/src/shared/lib/i18n/locales/zh-CN/desktop-update.json +4 -0
  129. package/src/shared/lib/i18n/locales/zh-CN/path-picker.json +25 -0
  130. package/dist/assets/api-IlX_CbwU.js +0 -13
  131. package/dist/assets/app-presenter-provider-Bbl_MVbg.js +0 -106
  132. package/dist/assets/desktop-update-config-DreMJu9z.js +0 -1
  133. package/dist/assets/doc-browser-DJQ5XPsi2.js +0 -1
  134. package/dist/assets/index-0av6WO9-.css +0 -1
  135. package/dist/assets/loader-circle-DtKek-qG.js +0 -1
  136. package/dist/assets/mcp-marketplace-page-4qMwFDpz.js +0 -1
  137. package/dist/assets/mcp-marketplace-page-BbxN5aZF.js +0 -9
  138. package/dist/assets/plus-BwgE-Zwf.js +0 -1
  139. package/dist/assets/providers-config-page-DfiWveEF.js +0 -1
  140. package/dist/assets/remote-BaVECEZw.js +0 -1
  141. package/dist/assets/rotate-cw-CMRZxIkH.js +0 -1
  142. package/dist/assets/runtime-config-page-wygwvMB4.js +0 -1
  143. package/dist/assets/security-config-BPzet76z.js +0 -1
  144. package/dist/assets/use-config-D6yx0lb0.js +0 -1
  145. package/dist/assets/x-B3o1dWR5.js +0 -1
  146. package/src/features/chat/features/input/input-surface-plugins/__tests__/panel-app-reference-plugin.test.ts +0 -77
  147. package/src/features/chat/features/input/input-surface-plugins/panel-app-reference-plugin.utils.ts +0 -144
@@ -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} />;
@@ -5,6 +5,9 @@ import { setLanguage } from '@/shared/lib/i18n';
5
5
 
6
6
  const mocks = vi.hoisted(() => ({
7
7
  useServerPathBrowse: vi.fn(),
8
+ useServerPathCreateDirectory: vi.fn(),
9
+ createDirectory: vi.fn(),
10
+ resetCreateDirectory: vi.fn(),
8
11
  refetch: vi.fn(),
9
12
  }));
10
13
 
@@ -12,21 +15,75 @@ vi.mock('@/shared/hooks/use-server-path-browse', () => ({
12
15
  useServerPathBrowse: mocks.useServerPathBrowse,
13
16
  }));
14
17
 
18
+ vi.mock('@/shared/hooks/use-server-path-create-directory', () => ({
19
+ useServerPathCreateDirectory: mocks.useServerPathCreateDirectory,
20
+ }));
21
+
22
+ type PickerOverrides = {
23
+ currentPath?: string;
24
+ defaultWorkspacePath?: string;
25
+ description?: string;
26
+ open?: boolean;
27
+ };
28
+
29
+ const createPickerElement = (overrides?: PickerOverrides) => (
30
+ <ServerPathPickerDialog
31
+ open={overrides?.open ?? true}
32
+ currentPath={overrides?.currentPath ?? '/workspace'}
33
+ defaultWorkspacePath={overrides?.defaultWorkspacePath}
34
+ isSaving={false}
35
+ onOpenChange={() => {}}
36
+ onConfirm={() => {}}
37
+ title="选择目录"
38
+ description={overrides?.description ?? '选择一个目录'}
39
+ pathLabel="目录"
40
+ confirmLabel="确定"
41
+ />
42
+ );
43
+
44
+ const renderPicker = (overrides?: PickerOverrides) => render(createPickerElement(overrides));
45
+
15
46
  describe('ServerPathPickerDialog', () => {
16
47
  beforeEach(() => {
17
48
  setLanguage('zh');
18
49
  mocks.refetch.mockReset();
50
+ mocks.refetch.mockResolvedValue(undefined);
51
+ mocks.createDirectory.mockReset();
52
+ mocks.createDirectory.mockResolvedValue({ path: '/workspace/new-folder' });
53
+ mocks.resetCreateDirectory.mockReset();
54
+ mocks.useServerPathCreateDirectory.mockReturnValue({
55
+ mutateAsync: mocks.createDirectory,
56
+ reset: mocks.resetCreateDirectory,
57
+ isPending: false,
58
+ error: null,
59
+ });
19
60
  mocks.useServerPathBrowse.mockReturnValue({
20
61
  data: {
21
62
  currentPath: '/workspace',
22
63
  homePath: '/Users/peiwang',
23
64
  parentPath: '/',
24
- breadcrumbs: [
25
- { label: 'workspace', path: '/workspace' },
26
- ],
65
+ breadcrumbs: [{ label: 'workspace', path: '/workspace' }],
27
66
  entries: [
28
- { name: 'playground', path: '/workspace/playground' },
29
- { name: 'nextbot', path: '/workspace/nextbot' },
67
+ {
68
+ name: 'playground',
69
+ path: '/workspace/playground',
70
+ kind: 'directory',
71
+ hidden: false,
72
+ },
73
+ {
74
+ name: 'nextbot',
75
+ path: '/workspace/nextbot',
76
+ kind: 'directory',
77
+ hidden: false,
78
+ },
79
+ ],
80
+ locations: [
81
+ { kind: 'desktop', path: '/Users/peiwang/Desktop' },
82
+ { kind: 'documents', path: '/Users/peiwang/Documents' },
83
+ { kind: 'downloads', path: '/Users/peiwang/Downloads' },
84
+ { kind: 'icloud-drive', path: '/Users/peiwang/Library/Mobile Documents/com~apple~CloudDocs' },
85
+ { kind: 'applications', path: '/Applications' },
86
+ { kind: 'volumes', path: '/Volumes' },
30
87
  ],
31
88
  },
32
89
  isLoading: false,
@@ -37,55 +94,248 @@ describe('ServerPathPickerDialog', () => {
37
94
 
38
95
  it('filters entries inside the current directory', async () => {
39
96
  const user = userEvent.setup();
40
-
41
- render(
42
- <ServerPathPickerDialog
43
- open
44
- currentPath="/workspace"
45
- isSaving={false}
46
- onOpenChange={() => {}}
47
- onConfirm={() => {}}
48
- title="选择目录"
49
- description="选择一个目录"
50
- pathLabel="目录"
51
- confirmLabel="确定"
52
- />
53
- );
97
+ renderPicker();
54
98
 
55
99
  expect(screen.getByText('playground')).toBeTruthy();
56
100
  expect(screen.getByText('nextbot')).toBeTruthy();
57
101
 
58
- await user.type(screen.getByPlaceholderText('搜索当前目录'), 'play');
102
+ await user.type(screen.getAllByPlaceholderText('搜索当前目录')[0], 'play');
59
103
 
60
104
  expect(screen.getByText('playground')).toBeTruthy();
61
105
  expect(screen.queryByText('nextbot')).toBeNull();
62
106
  });
63
107
 
64
- it('shows the empty-search hint and keeps the list region scroll-contained', async () => {
108
+ it('shows server-backed Mac locations and opens sidebar locations with one click', async () => {
65
109
  const user = userEvent.setup();
66
- render(
67
- <ServerPathPickerDialog
68
- open
69
- currentPath="/workspace"
70
- isSaving={false}
71
- onOpenChange={() => {}}
72
- onConfirm={() => {}}
73
- title="选择目录"
74
- description="选择一个目录"
75
- pathLabel="目录"
76
- confirmLabel="确定"
77
- />
110
+ renderPicker();
111
+
112
+ expect(screen.getByRole('button', { name: '桌面' })).toBeTruthy();
113
+ expect(screen.getByRole('button', { name: '文稿' })).toBeTruthy();
114
+ expect(screen.getByRole('button', { name: '下载' })).toBeTruthy();
115
+ expect(screen.getByRole('button', { name: 'iCloud 云盘' })).toBeTruthy();
116
+ expect(screen.getByRole('button', { name: '应用程序' })).toBeTruthy();
117
+ expect(screen.getByRole('button', { name: '磁盘与卷' })).toBeTruthy();
118
+
119
+ await user.click(screen.getByRole('button', { name: '下载' }));
120
+
121
+ expect(mocks.useServerPathBrowse).toHaveBeenLastCalledWith({
122
+ path: '/Users/peiwang/Downloads',
123
+ enabled: true,
124
+ });
125
+ });
126
+
127
+ it('keeps the explicit current path when an older home browse result is still visible', () => {
128
+ mocks.useServerPathBrowse.mockReturnValue({
129
+ data: {
130
+ currentPath: '/Users/peiwang',
131
+ homePath: '/Users/peiwang',
132
+ parentPath: '/Users',
133
+ breadcrumbs: [
134
+ { label: 'Users', path: '/Users' },
135
+ { label: 'peiwang', path: '/Users/peiwang' },
136
+ ],
137
+ entries: [],
138
+ },
139
+ isLoading: false,
140
+ error: null,
141
+ refetch: mocks.refetch,
142
+ });
143
+
144
+ renderPicker({ currentPath: '/Users/peiwang/.nextclaw/workspace' });
145
+
146
+ expect((screen.getByLabelText('目录') as HTMLInputElement).value).toBe(
147
+ '/Users/peiwang/.nextclaw/workspace',
78
148
  );
149
+ expect(screen.getByText('/Users/peiwang/.nextclaw/workspace')).toBeTruthy();
150
+ });
151
+
152
+ it('starts at the workdir and keeps the canonical address and selected folder aligned', () => {
153
+ mocks.useServerPathBrowse.mockReturnValue({
154
+ data: {
155
+ currentPath: '/Users/peiwang/.nextclaw/workspace',
156
+ homePath: '/Users/peiwang',
157
+ parentPath: '/Users/peiwang/.nextclaw',
158
+ breadcrumbs: [
159
+ { label: '/', path: '/' },
160
+ { label: 'Users', path: '/Users' },
161
+ { label: 'peiwang', path: '/Users/peiwang' },
162
+ { label: '.nextclaw', path: '/Users/peiwang/.nextclaw' },
163
+ { label: 'workspace', path: '/Users/peiwang/.nextclaw/workspace' },
164
+ ],
165
+ entries: [],
166
+ },
167
+ isLoading: false,
168
+ error: null,
169
+ refetch: mocks.refetch,
170
+ });
79
171
 
80
- await user.type(screen.getByPlaceholderText('搜索当前目录'), 'missing');
172
+ renderPicker({
173
+ currentPath: '',
174
+ defaultWorkspacePath: '~/.nextclaw/workspace',
175
+ description: undefined,
176
+ });
81
177
 
82
- expect(screen.getByText('当前目录下没有匹配结果。')).toBeTruthy();
178
+ expect(mocks.useServerPathBrowse).toHaveBeenLastCalledWith({
179
+ path: '~/.nextclaw/workspace',
180
+ enabled: true,
181
+ });
182
+ expect((screen.getByLabelText('目录') as HTMLInputElement).value).toBe(
183
+ '/Users/peiwang/.nextclaw/workspace',
184
+ );
185
+ expect(screen.getByRole('button', { name: 'NextClaw 工作区' })).toBeTruthy();
186
+ expect(screen.getByText('workspace')).toBeTruthy();
187
+ });
188
+
189
+ it('normalizes a home-relative workdir with Windows separators', () => {
190
+ mocks.useServerPathBrowse.mockReturnValue({
191
+ data: {
192
+ currentPath: 'C:\\Users\\alice\\.nextclaw\\workspace',
193
+ homePath: 'C:\\Users\\alice',
194
+ parentPath: 'C:\\Users\\alice\\.nextclaw',
195
+ breadcrumbs: [
196
+ { label: 'C:', path: 'C:\\' },
197
+ { label: 'Users', path: 'C:\\Users' },
198
+ { label: 'alice', path: 'C:\\Users\\alice' },
199
+ { label: '.nextclaw', path: 'C:\\Users\\alice\\.nextclaw' },
200
+ { label: 'workspace', path: 'C:\\Users\\alice\\.nextclaw\\workspace' },
201
+ ],
202
+ entries: [],
203
+ locations: [],
204
+ },
205
+ isLoading: false,
206
+ error: null,
207
+ refetch: mocks.refetch,
208
+ });
209
+
210
+ renderPicker({ currentPath: '', defaultWorkspacePath: '~/.nextclaw/workspace' });
211
+
212
+ expect((screen.getByLabelText('目录') as HTMLInputElement).value).toBe(
213
+ 'C:\\Users\\alice\\.nextclaw\\workspace',
214
+ );
215
+ expect(screen.getByText('workspace')).toBeTruthy();
216
+ });
217
+
218
+ it('creates a folder in the current directory and selects it without entering it', async () => {
219
+ const user = userEvent.setup();
220
+ renderPicker();
221
+
222
+ await user.click(screen.getByRole('button', { name: '新建文件夹' }));
223
+ await user.type(screen.getByLabelText('文件夹名称'), 'new-folder');
224
+ await user.click(screen.getByRole('button', { name: '创建' }));
225
+
226
+ expect(mocks.createDirectory).toHaveBeenCalledWith({
227
+ parentPath: '/workspace',
228
+ name: 'new-folder',
229
+ });
230
+ expect(mocks.refetch).toHaveBeenCalledOnce();
231
+ expect((screen.getByLabelText('目录') as HTMLInputElement).value).toBe(
232
+ '/workspace/new-folder',
233
+ );
234
+ expect((screen.getByRole('button', { name: '后退' }) as HTMLButtonElement).disabled).toBe(
235
+ true,
236
+ );
237
+ });
238
+
239
+ it('uses single click to select and double click to enter a folder', async () => {
240
+ const user = userEvent.setup();
241
+ renderPicker();
242
+ const playground = screen.getByRole('option', { name: /playground/ });
243
+
244
+ expect(screen.getByText('单击选择,双击进入')).toBeTruthy();
245
+
246
+ await user.click(playground);
247
+
248
+ expect((screen.getByLabelText('目录') as HTMLInputElement).value).toBe(
249
+ '/workspace/playground',
250
+ );
251
+ expect(mocks.useServerPathBrowse).toHaveBeenLastCalledWith({
252
+ path: '/workspace',
253
+ enabled: true,
254
+ });
255
+
256
+ await user.dblClick(playground);
257
+
258
+ expect(mocks.useServerPathBrowse).toHaveBeenLastCalledWith({
259
+ path: '/workspace/playground',
260
+ enabled: true,
261
+ });
262
+ expect((screen.getByRole('button', { name: '后退' }) as HTMLButtonElement).disabled).toBe(
263
+ false,
264
+ );
265
+ });
266
+
267
+ it('starts a fresh selection session when the dialog is reopened', async () => {
268
+ const user = userEvent.setup();
269
+ const view = renderPicker();
83
270
 
271
+ await user.click(screen.getByRole('option', { name: /playground/ }));
272
+ expect((screen.getByLabelText('目录') as HTMLInputElement).value).toBe(
273
+ '/workspace/playground',
274
+ );
275
+
276
+ view.rerender(createPickerElement({ open: false }));
277
+ view.rerender(createPickerElement());
278
+
279
+ expect((screen.getByLabelText('目录') as HTMLInputElement).value).toBe('/workspace');
280
+ expect((screen.getByRole('button', { name: '后退' }) as HTMLButtonElement).disabled).toBe(
281
+ true,
282
+ );
283
+ });
284
+
285
+ it('opens an editable address and records navigation history', async () => {
286
+ const user = userEvent.setup();
287
+ renderPicker();
288
+
289
+ await user.click(screen.getByRole('button', { name: '编辑地址' }));
290
+ const addressInput = screen.getByLabelText('地址');
291
+ await user.clear(addressInput);
292
+ await user.type(addressInput, '/workspace/playground{Enter}');
293
+
294
+ expect((screen.getByLabelText('目录') as HTMLInputElement).value).toBe(
295
+ '/workspace/playground',
296
+ );
297
+ expect((screen.getByRole('button', { name: '后退' }) as HTMLButtonElement).disabled).toBe(
298
+ false,
299
+ );
300
+
301
+ await user.click(screen.getByRole('button', { name: '后退' }));
302
+ expect((screen.getByLabelText('目录') as HTMLInputElement).value).toBe('/workspace');
303
+ expect((screen.getByRole('button', { name: '前进' }) as HTMLButtonElement).disabled).toBe(
304
+ false,
305
+ );
306
+ });
307
+
308
+ it('supports arrow selection and Enter navigation from the directory list', async () => {
309
+ const user = userEvent.setup();
310
+ renderPicker();
311
+ const playground = screen.getByRole('option', { name: /playground/ });
312
+
313
+ await user.click(playground);
314
+ await user.keyboard('{ArrowDown}');
315
+
316
+ expect((screen.getByLabelText('目录') as HTMLInputElement).value).toBe(
317
+ '/workspace/nextbot',
318
+ );
319
+ await user.keyboard('{Enter}');
320
+ expect(mocks.useServerPathBrowse).toHaveBeenLastCalledWith({
321
+ path: '/workspace/nextbot',
322
+ enabled: true,
323
+ });
324
+ });
325
+
326
+ it('shows the empty-search hint and keeps the list region scroll-contained', async () => {
327
+ const user = userEvent.setup();
328
+ renderPicker();
329
+
330
+ await user.type(screen.getAllByPlaceholderText('搜索当前目录')[0], 'missing');
331
+
332
+ expect(screen.getByText('当前目录下没有匹配结果。')).toBeTruthy();
84
333
  const dialog = screen.getByRole('dialog');
85
334
  expect(dialog.className).toContain('overflow-hidden');
86
- expect(dialog.className).toContain('sm:h-[42rem]');
87
-
88
- const scrollArea = screen.getByText('当前目录下没有匹配结果。').closest('.overflow-auto');
335
+ expect(dialog.className).toContain('sm:h-[44rem]');
336
+ const scrollArea = screen
337
+ .getByText('当前目录下没有匹配结果。')
338
+ .closest('.overflow-auto');
89
339
  expect(scrollArea?.className).toContain('flex-1');
90
340
  expect(scrollArea?.className).toContain('min-h-0');
91
341
  });