@nextclaw/ui 0.13.11 → 0.13.13

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 (154) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/dist/assets/{api-DmxQnm30.js → api-D0hARyyl.js} +1 -1
  3. package/dist/assets/app-presenter-provider-C7EsUuEJ.js +3 -0
  4. package/dist/assets/arrow-left-CzSkR5L6.js +1 -0
  5. package/dist/assets/book-open-DmeQPqkd.js +1 -0
  6. package/dist/assets/{channels-list-page-bkKDqaO5.js → channels-list-page-B0yiiH01.js} +2 -2
  7. package/dist/assets/chat-page-ChPjx_X9.js +105 -0
  8. package/dist/assets/{config-split-page-CdJEkWxv.js → config-split-page-BzfZSyAr.js} +1 -1
  9. package/dist/assets/{confirm-dialog-BWRNsRgb.js → confirm-dialog-CTcaBp-8.js} +1 -1
  10. package/dist/assets/desktop-update-config-CExpdkqD.js +1 -0
  11. package/dist/assets/{dist-CyWdaJvr.js → dist-C59BKqbX.js} +1 -1
  12. package/dist/assets/{dist-DzM2ZEVN.js → dist-Di6EHp2x.js} +1 -1
  13. package/dist/assets/{doc-browser-DewXa6oX.js → doc-browser-EZbnEcdk.js} +1 -1
  14. package/dist/assets/doc-browser-XIwe1Tg-.js +1 -0
  15. package/dist/assets/{doc-browser-context-CpL2m4Sp.js → doc-browser-context-BzEA1lm1.js} +1 -1
  16. package/dist/assets/{ellipsis-BDgTYNDX.js → ellipsis-hXUNwi63.js} +1 -1
  17. package/dist/assets/{es2015-WqekV0OK.js → es2015-CJlVSTh-.js} +1 -1
  18. package/dist/assets/{external-link-BLjo8z2a.js → external-link-J0fCK-vu.js} +1 -1
  19. package/dist/assets/host-capabilities-DjiRQ0Q9.js +1 -0
  20. package/dist/assets/index-DfZTO6Pi.js +101 -0
  21. package/dist/assets/index-mIb_YMrc.css +1 -0
  22. package/dist/assets/{key-round-Du8x3sWa.js → key-round-BzYZ0Nno.js} +1 -1
  23. package/dist/assets/loader-circle-BbdIlBbQ.js +1 -0
  24. package/dist/assets/mcp-marketplace-page-L22aCzHX.js +40 -0
  25. package/dist/assets/mcp-marketplace-page-f80Jmv37.js +1 -0
  26. package/dist/assets/model-config-DkD7vSQT.js +1 -0
  27. package/dist/assets/{notice-card-3a7FmAz-.js → notice-card-CD9Ok62G.js} +1 -1
  28. package/dist/assets/play-DHuSeKs8.js +1 -0
  29. package/dist/assets/plus-DrV685KC.js +1 -0
  30. package/dist/assets/{popover-BlJLguiZ.js → popover-CoBu3mbV.js} +1 -1
  31. package/dist/assets/{provider-scoped-model-input-BgPTQa8H.js → provider-scoped-model-input-DZlmTCJU.js} +1 -1
  32. package/dist/assets/providers-list-CVkWiqIy.js +1 -0
  33. package/dist/assets/{react-BVjl1ZaK.js → react-C7HsxuKH.js} +1 -1
  34. package/dist/assets/{refresh-cw-DFR87zei.js → refresh-cw-CVo4nWpq.js} +1 -1
  35. package/dist/assets/remote-Bznw9iji.js +1 -0
  36. package/dist/assets/rotate-cw-CXDpRO6D.js +1 -0
  37. package/dist/assets/{runtime-config-page-DYH2ejUI.js → runtime-config-page-5IycbIxs.js} +1 -1
  38. package/dist/assets/{save-BDmIlHvN.js → save-Z1QTovIt.js} +1 -1
  39. package/dist/assets/search-8nY6PGm0.js +1 -0
  40. package/dist/assets/{search-config-BRsrQ4zK.js → search-config-DEH80WkC.js} +1 -1
  41. package/dist/assets/{secrets-config-eYdx5_AW.js → secrets-config-Gzo5nDJG.js} +1 -1
  42. package/dist/assets/{status-dot-SVPjIyG5.js → status-dot-C1-FKgvv.js} +1 -1
  43. package/dist/assets/{tabs-Czd2mj6z.js → tabs-BqkKwx1R.js} +1 -1
  44. package/dist/assets/{tabs-custom-Ix4nkp2h.js → tabs-custom-DOkaJZmt.js} +1 -1
  45. package/dist/assets/{tag-chip-Cdwb3JSH.js → tag-chip-3kpiMTST.js} +1 -1
  46. package/dist/assets/{tooltip-CKGxLGnG.js → tooltip-Bw-mXQAM.js} +1 -1
  47. package/dist/assets/{trash-2-yLA8BEcE.js → trash-2-CmIwAJIT.js} +1 -1
  48. package/dist/assets/x-CSRTOZnM.js +1 -0
  49. package/dist/index.html +27 -25
  50. package/package.json +8 -8
  51. package/src/features/account/components/account-panel.tsx +2 -2
  52. package/src/features/account/managers/account.manager.ts +6 -5
  53. package/src/features/chat/components/chat-session-workspace-file-preview.test.tsx +39 -0
  54. package/src/features/chat/components/chat-session-workspace-file-preview.tsx +11 -4
  55. package/src/features/chat/components/chat-session-workspace-panel-nav.tsx +45 -13
  56. package/src/features/chat/components/chat-session-workspace-panel.tsx +20 -8
  57. package/src/features/chat/components/conversation/chat-conversation-panel.test.tsx +73 -0
  58. package/src/features/chat/components/conversation/chat-conversation-panel.tsx +4 -0
  59. package/src/features/chat/components/providers/chat-presenter.provider.tsx +2 -0
  60. package/src/features/chat/hooks/use-ncp-chat-derived-state.ts +2 -0
  61. package/src/features/chat/managers/chat-session-list.manager.test.ts +4 -0
  62. package/src/features/chat/managers/chat-session-list.manager.ts +2 -0
  63. package/src/features/chat/managers/ncp-chat-thread.manager.test.ts +153 -25
  64. package/src/features/chat/managers/ncp-chat-thread.manager.ts +153 -7
  65. package/src/features/chat/pages/ncp-chat-page.tsx +4 -0
  66. package/src/features/chat/stores/chat-thread.store.test.ts +148 -0
  67. package/src/features/chat/stores/chat-thread.store.ts +289 -9
  68. package/src/features/chat/utils/ncp-session-adapter.utils.test.ts +21 -0
  69. package/src/features/chat/utils/ncp-session-adapter.utils.ts +6 -0
  70. package/src/features/marketplace/components/marketplace-catalog-grid.tsx +19 -1
  71. package/src/features/marketplace/components/marketplace-external-skill-source-action.tsx +14 -0
  72. package/src/features/marketplace/components/marketplace-page-parts.tsx +10 -2
  73. package/src/features/marketplace/components/marketplace-page.test.tsx +37 -1
  74. package/src/features/marketplace/components/marketplace-page.tsx +38 -16
  75. package/src/features/marketplace/components/mcp/mcp-marketplace-page.tsx +1 -0
  76. package/src/features/marketplace/hooks/use-marketplace.test.tsx +109 -0
  77. package/src/features/marketplace/hooks/use-marketplace.ts +3 -5
  78. package/src/features/side-dock/components/side-dock.test.tsx +25 -0
  79. package/src/features/side-dock/components/side-dock.tsx +13 -0
  80. package/src/features/system-status/components/desktop-update-config.tsx +2 -1
  81. package/src/platforms/desktop/types/desktop-update.types.ts +9 -0
  82. package/src/shared/components/config/provider-form.tsx +2 -2
  83. package/src/shared/components/doc-browser/managers/doc-browser.manager.ts +39 -32
  84. package/src/shared/components/ui/action-link.tsx +23 -1
  85. package/src/shared/lib/api/ncp-session.types.ts +1 -0
  86. package/src/shared/lib/host-capabilities/host-capability.manager.test.ts +50 -0
  87. package/src/shared/lib/host-capabilities/host-capability.manager.ts +62 -0
  88. package/src/shared/lib/host-capabilities/index.ts +1 -0
  89. package/src/shared/lib/i18n/README.md +16 -3
  90. package/src/shared/lib/i18n/index.ts +93 -426
  91. package/src/shared/lib/i18n/locales/en-US/agents.json +49 -0
  92. package/src/shared/lib/i18n/locales/en-US/channel-auth.json +51 -0
  93. package/src/shared/lib/i18n/locales/en-US/channels.json +50 -0
  94. package/src/shared/lib/i18n/locales/en-US/chat.json +181 -0
  95. package/src/shared/lib/i18n/locales/en-US/core.json +310 -0
  96. package/src/shared/lib/i18n/locales/en-US/cron.json +32 -0
  97. package/src/shared/lib/i18n/locales/en-US/desktop-update.json +90 -0
  98. package/src/shared/lib/i18n/locales/en-US/doc-browser.json +87 -0
  99. package/src/shared/lib/i18n/locales/en-US/marketplace.json +72 -0
  100. package/src/shared/lib/i18n/locales/en-US/path-picker.json +12 -0
  101. package/src/shared/lib/i18n/locales/en-US/pwa.json +26 -0
  102. package/src/shared/lib/i18n/locales/en-US/remote.json +168 -0
  103. package/src/shared/lib/i18n/locales/en-US/runtime-control.json +105 -0
  104. package/src/shared/lib/i18n/locales/en-US/search.json +28 -0
  105. package/src/shared/lib/i18n/locales/zh-CN/agents.json +49 -0
  106. package/src/shared/lib/i18n/locales/zh-CN/channel-auth.json +51 -0
  107. package/src/shared/lib/i18n/locales/zh-CN/channels.json +50 -0
  108. package/src/shared/lib/i18n/locales/zh-CN/chat.json +181 -0
  109. package/src/shared/lib/i18n/locales/zh-CN/core.json +310 -0
  110. package/src/shared/lib/i18n/locales/zh-CN/cron.json +32 -0
  111. package/src/shared/lib/i18n/locales/zh-CN/desktop-update.json +90 -0
  112. package/src/shared/lib/i18n/locales/zh-CN/doc-browser.json +87 -0
  113. package/src/shared/lib/i18n/locales/zh-CN/marketplace.json +72 -0
  114. package/src/shared/lib/i18n/locales/zh-CN/path-picker.json +12 -0
  115. package/src/shared/lib/i18n/locales/zh-CN/pwa.json +26 -0
  116. package/src/shared/lib/i18n/locales/zh-CN/remote.json +168 -0
  117. package/src/shared/lib/i18n/locales/zh-CN/runtime-control.json +105 -0
  118. package/src/shared/lib/i18n/locales/zh-CN/search.json +28 -0
  119. package/src/shared/lib/navigation-history/index.ts +1 -0
  120. package/src/shared/lib/navigation-history/navigation-history.utils.test.ts +60 -0
  121. package/src/shared/lib/navigation-history/navigation-history.utils.ts +105 -0
  122. package/dist/assets/app-presenter-provider-CDSjiqfE.js +0 -3
  123. package/dist/assets/arrow-left-Dd45Mvr1.js +0 -1
  124. package/dist/assets/chat-page-YKdeGoVx.js +0 -105
  125. package/dist/assets/desktop-update-config-CLXdVz9r.js +0 -1
  126. package/dist/assets/doc-browser-D5FJcAyz.js +0 -1
  127. package/dist/assets/index-DKflQCOL.js +0 -101
  128. package/dist/assets/index-Q891JiF_.css +0 -1
  129. package/dist/assets/loader-circle-Dz4FssYC.js +0 -1
  130. package/dist/assets/mcp-marketplace-page-BaBuHB2L.js +0 -40
  131. package/dist/assets/mcp-marketplace-page-ooEvG_bB.js +0 -1
  132. package/dist/assets/model-config-B-qg70S4.js +0 -1
  133. package/dist/assets/play-BziM-Y34.js +0 -1
  134. package/dist/assets/plus-DrpyArC8.js +0 -1
  135. package/dist/assets/providers-list-cBeeslbc.js +0 -1
  136. package/dist/assets/remote-DjhVMpVd.js +0 -1
  137. package/dist/assets/rotate-cw-DMBnsYKm.js +0 -1
  138. package/dist/assets/search-CjHGYvTI.js +0 -1
  139. package/dist/assets/x-dpDISboO.js +0 -1
  140. package/src/shared/lib/i18n/agents.ts +0 -85
  141. package/src/shared/lib/i18n/channel-auth.constants.ts +0 -87
  142. package/src/shared/lib/i18n/channels.ts +0 -53
  143. package/src/shared/lib/i18n/chat-labels.utils.ts +0 -206
  144. package/src/shared/lib/i18n/cron-labels.utils.ts +0 -32
  145. package/src/shared/lib/i18n/desktop-update-labels.utils.ts +0 -165
  146. package/src/shared/lib/i18n/marketplace-labels.utils.ts +0 -78
  147. package/src/shared/lib/i18n/pwa.ts +0 -62
  148. package/src/shared/lib/i18n/remote.ts +0 -273
  149. package/src/shared/lib/i18n/runtime/doc-browser-labels.utils.ts +0 -120
  150. package/src/shared/lib/i18n/runtime/i18n.path-picker.ts +0 -12
  151. package/src/shared/lib/i18n/runtime-control-labels.utils.ts +0 -180
  152. package/src/shared/lib/i18n/search.ts +0 -37
  153. /package/dist/assets/{config-hints-Ceiol9x4.js → config-hints-fGnUjDe9.js} +0 -0
  154. /package/dist/assets/{provider-models-C_yOh6DE.js → provider-models-C9VRroK3.js} +0 -0
@@ -1,4 +1,5 @@
1
1
  import { create } from 'zustand';
2
+ import { createJSONStorage, persist } from 'zustand/middleware';
2
3
  import type { MutableRefObject } from 'react';
3
4
  import type { NcpMessage } from '@nextclaw/ncp';
4
5
  import type { ChatFileOperationLineViewModel } from '@nextclaw/agent-chat-ui';
@@ -33,6 +34,19 @@ export type ChatWorkspaceFileTab = {
33
34
  fullLines?: ChatFileOperationLineViewModel[];
34
35
  };
35
36
 
37
+ export type ChatWorkspaceNavigationEntry =
38
+ | {
39
+ kind: 'child-session';
40
+ key: string;
41
+ }
42
+ | {
43
+ kind: 'file';
44
+ key: string;
45
+ }
46
+ | {
47
+ kind: 'cron';
48
+ };
49
+
36
50
  export type ChatThreadSnapshot = {
37
51
  isProviderStateResolved: boolean;
38
52
  modelOptions: ChatModelOption[];
@@ -47,6 +61,7 @@ export type ChatThreadSnapshot = {
47
61
  availableAgents?: AgentProfileView[];
48
62
  sessionDisplayName?: string;
49
63
  sessionProjectRoot?: string | null;
64
+ sessionWorkingDir?: string | null;
50
65
  sessionProjectName?: string | null;
51
66
  canDeleteSession: boolean;
52
67
  isDeletePending: boolean;
@@ -64,14 +79,43 @@ export type ChatThreadSnapshot = {
64
79
  activeChildSessionKey?: string | null;
65
80
  workspaceFileTabs: ChatWorkspaceFileTab[];
66
81
  activeWorkspaceFileKey?: string | null;
82
+ workspaceNavigationHistory: ChatWorkspaceNavigationEntry[];
83
+ workspaceNavigationHistoryIndex: number;
67
84
  contextWindow?: SessionContextWindowView | null;
68
85
  };
69
86
 
87
+ const CHAT_THREAD_WORKSPACE_STORAGE_KEY = 'nextclaw.chat.workspace-panel.state';
88
+ const CHAT_THREAD_WORKSPACE_STORAGE_VERSION = 2;
89
+ const CHAT_THREAD_MAX_PERSISTED_WORKSPACE_FILE_TABS = 8;
90
+
70
91
  type ChatThreadStore = {
71
92
  snapshot: ChatThreadSnapshot;
72
93
  setSnapshot: (patch: Partial<ChatThreadSnapshot>) => void;
73
94
  };
74
95
 
96
+ type PersistedChatThreadStore = {
97
+ snapshot?: {
98
+ workspacePanelParentKey?: unknown;
99
+ activeWorkspacePanelKind?: unknown;
100
+ activeChildSessionKey?: unknown;
101
+ workspaceFileTabs?: unknown;
102
+ activeWorkspaceFileKey?: unknown;
103
+ workspaceNavigationHistory?: unknown;
104
+ workspaceNavigationHistoryIndex?: unknown;
105
+ };
106
+ };
107
+
108
+ type PersistedChatWorkspaceSnapshot = Pick<
109
+ ChatThreadSnapshot,
110
+ | 'workspacePanelParentKey'
111
+ | 'activeWorkspacePanelKind'
112
+ | 'activeChildSessionKey'
113
+ | 'workspaceFileTabs'
114
+ | 'activeWorkspaceFileKey'
115
+ | 'workspaceNavigationHistory'
116
+ | 'workspaceNavigationHistoryIndex'
117
+ >;
118
+
75
119
  const initialSnapshot: ChatThreadSnapshot = {
76
120
  isProviderStateResolved: false,
77
121
  modelOptions: [],
@@ -86,6 +130,7 @@ const initialSnapshot: ChatThreadSnapshot = {
86
130
  availableAgents: [],
87
131
  sessionDisplayName: undefined,
88
132
  sessionProjectRoot: null,
133
+ sessionWorkingDir: null,
89
134
  sessionProjectName: null,
90
135
  canDeleteSession: false,
91
136
  isDeletePending: false,
@@ -103,16 +148,251 @@ const initialSnapshot: ChatThreadSnapshot = {
103
148
  activeChildSessionKey: null,
104
149
  workspaceFileTabs: [],
105
150
  activeWorkspaceFileKey: null,
151
+ workspaceNavigationHistory: [],
152
+ workspaceNavigationHistoryIndex: 0,
106
153
  contextWindow: null
107
154
  };
108
155
 
109
- export const useChatThreadStore = create<ChatThreadStore>((set) => ({
110
- snapshot: initialSnapshot,
111
- setSnapshot: (patch) =>
112
- set((state) => ({
113
- snapshot: {
114
- ...state.snapshot,
115
- ...patch
156
+ function isRecord(value: unknown): value is Record<string, unknown> {
157
+ return !!value && typeof value === 'object';
158
+ }
159
+
160
+ function normalizeOptionalString(value: unknown): string | null {
161
+ return typeof value === 'string' && value.trim().length > 0
162
+ ? value.trim()
163
+ : null;
164
+ }
165
+
166
+ function normalizeOptionalNumber(value: unknown): number | null {
167
+ return typeof value === 'number' && Number.isFinite(value)
168
+ ? value
169
+ : null;
170
+ }
171
+
172
+ function normalizeHistoryIndex(value: unknown, maxIndex: number): number {
173
+ return typeof value === 'number' && Number.isInteger(value)
174
+ ? Math.min(Math.max(0, value), maxIndex)
175
+ : maxIndex;
176
+ }
177
+
178
+ function isWorkspacePanelKind(
179
+ value: unknown,
180
+ ): value is NonNullable<ChatThreadSnapshot['activeWorkspacePanelKind']> {
181
+ return value === 'child-session' || value === 'file' || value === 'cron';
182
+ }
183
+
184
+ function normalizePersistedWorkspaceNavigationEntry(
185
+ value: unknown,
186
+ ): ChatWorkspaceNavigationEntry | null {
187
+ if (!isRecord(value)) {
188
+ return null;
189
+ }
190
+ if (value.kind === 'cron') {
191
+ return { kind: 'cron' };
192
+ }
193
+ const key = normalizeOptionalString(value.key);
194
+ if (!key || (value.kind !== 'child-session' && value.kind !== 'file')) {
195
+ return null;
196
+ }
197
+ return {
198
+ kind: value.kind,
199
+ key,
200
+ };
201
+ }
202
+
203
+ function createWorkspaceNavigationEntryFromSnapshot({
204
+ activeChildSessionKey,
205
+ activeWorkspaceFileKey,
206
+ activeWorkspacePanelKind,
207
+ }: {
208
+ activeWorkspacePanelKind: ChatThreadSnapshot['activeWorkspacePanelKind'];
209
+ activeChildSessionKey: string | null;
210
+ activeWorkspaceFileKey: string | null;
211
+ }): ChatWorkspaceNavigationEntry | null {
212
+ if (activeWorkspacePanelKind === 'cron') {
213
+ return { kind: 'cron' };
214
+ }
215
+ if (activeWorkspacePanelKind === 'child-session' && activeChildSessionKey) {
216
+ return {
217
+ kind: 'child-session',
218
+ key: activeChildSessionKey,
219
+ };
220
+ }
221
+ if (activeWorkspacePanelKind === 'file' && activeWorkspaceFileKey) {
222
+ return {
223
+ kind: 'file',
224
+ key: activeWorkspaceFileKey,
225
+ };
226
+ }
227
+ return null;
228
+ }
229
+
230
+ function normalizeOptionalText(value: unknown): string | null {
231
+ return typeof value === 'string' ? value : null;
232
+ }
233
+
234
+ function normalizePersistedWorkspaceFileTab(
235
+ value: unknown,
236
+ ): ChatWorkspaceFileTab | null {
237
+ if (
238
+ !isRecord(value)
239
+ || typeof value.key !== 'string'
240
+ || typeof value.path !== 'string'
241
+ || (value.viewMode !== 'preview' && value.viewMode !== 'diff')
242
+ ) {
243
+ return null;
244
+ }
245
+ const key = value.key.trim();
246
+ const path = value.path.trim();
247
+ if (!key || !path) {
248
+ return null;
249
+ }
250
+ return {
251
+ key,
252
+ parentSessionKey: normalizeOptionalString(value.parentSessionKey),
253
+ path,
254
+ label: normalizeOptionalString(value.label),
255
+ viewMode: value.viewMode,
256
+ line: normalizeOptionalNumber(value.line),
257
+ column: normalizeOptionalNumber(value.column),
258
+ rawText: normalizeOptionalText(value.rawText),
259
+ beforeText: normalizeOptionalText(value.beforeText),
260
+ afterText: normalizeOptionalText(value.afterText),
261
+ patchText: normalizeOptionalText(value.patchText),
262
+ oldStartLine: normalizeOptionalNumber(value.oldStartLine),
263
+ newStartLine: normalizeOptionalNumber(value.newStartLine),
264
+ };
265
+ }
266
+
267
+ function toPersistedWorkspaceFileTab(
268
+ tab: ChatWorkspaceFileTab,
269
+ ): ChatWorkspaceFileTab {
270
+ return {
271
+ key: tab.key,
272
+ parentSessionKey: tab.parentSessionKey,
273
+ path: tab.path,
274
+ label: tab.label,
275
+ viewMode: tab.viewMode,
276
+ line: tab.line,
277
+ column: tab.column,
278
+ rawText: tab.rawText,
279
+ beforeText: tab.beforeText,
280
+ afterText: tab.afterText,
281
+ patchText: tab.patchText,
282
+ oldStartLine: tab.oldStartLine,
283
+ newStartLine: tab.newStartLine,
284
+ };
285
+ }
286
+
287
+ function normalizePersistedWorkspaceSnapshot(
288
+ value: unknown,
289
+ ): PersistedChatWorkspaceSnapshot | null {
290
+ if (!isRecord(value)) {
291
+ return null;
292
+ }
293
+ const workspaceFileTabs = Array.isArray(value.workspaceFileTabs)
294
+ ? value.workspaceFileTabs
295
+ .map(normalizePersistedWorkspaceFileTab)
296
+ .filter((tab): tab is ChatWorkspaceFileTab => tab !== null)
297
+ .slice(-CHAT_THREAD_MAX_PERSISTED_WORKSPACE_FILE_TABS)
298
+ : [];
299
+ const activeWorkspaceFileKey = normalizeOptionalString(value.activeWorkspaceFileKey);
300
+ const resolvedActiveWorkspaceFileKey =
301
+ activeWorkspaceFileKey && workspaceFileTabs.some((tab) => tab.key === activeWorkspaceFileKey)
302
+ ? activeWorkspaceFileKey
303
+ : workspaceFileTabs[0]?.key ?? null;
304
+ const activeWorkspacePanelKind = isWorkspacePanelKind(value.activeWorkspacePanelKind)
305
+ ? value.activeWorkspacePanelKind
306
+ : null;
307
+ const resolvedActiveWorkspacePanelKind =
308
+ activeWorkspacePanelKind === 'file' && !resolvedActiveWorkspaceFileKey
309
+ ? null
310
+ : activeWorkspacePanelKind;
311
+ const activeChildSessionKey = normalizeOptionalString(value.activeChildSessionKey);
312
+ const fallbackHistoryEntry = createWorkspaceNavigationEntryFromSnapshot({
313
+ activeWorkspacePanelKind: resolvedActiveWorkspacePanelKind,
314
+ activeChildSessionKey,
315
+ activeWorkspaceFileKey: resolvedActiveWorkspaceFileKey,
316
+ });
317
+ const normalizedNavigationHistory = Array.isArray(value.workspaceNavigationHistory)
318
+ ? value.workspaceNavigationHistory
319
+ .map(normalizePersistedWorkspaceNavigationEntry)
320
+ .filter((entry): entry is ChatWorkspaceNavigationEntry => entry !== null)
321
+ .filter((entry) =>
322
+ entry.kind !== 'file' || workspaceFileTabs.some((tab) => tab.key === entry.key),
323
+ )
324
+ : [];
325
+ const workspaceNavigationHistory =
326
+ normalizedNavigationHistory.length > 0
327
+ ? normalizedNavigationHistory
328
+ : fallbackHistoryEntry
329
+ ? [fallbackHistoryEntry]
330
+ : [];
331
+ const workspaceNavigationHistoryIndex =
332
+ workspaceNavigationHistory.length > 0
333
+ ? normalizeHistoryIndex(
334
+ value.workspaceNavigationHistoryIndex,
335
+ workspaceNavigationHistory.length - 1,
336
+ )
337
+ : 0;
338
+
339
+ return {
340
+ workspacePanelParentKey: normalizeOptionalString(value.workspacePanelParentKey),
341
+ activeWorkspacePanelKind: resolvedActiveWorkspacePanelKind,
342
+ activeChildSessionKey,
343
+ workspaceFileTabs,
344
+ activeWorkspaceFileKey: resolvedActiveWorkspaceFileKey,
345
+ workspaceNavigationHistory,
346
+ workspaceNavigationHistoryIndex,
347
+ };
348
+ }
349
+
350
+ export const useChatThreadStore = create<ChatThreadStore>()(
351
+ persist(
352
+ (set) => ({
353
+ snapshot: initialSnapshot,
354
+ setSnapshot: (patch) =>
355
+ set((state) => ({
356
+ snapshot: {
357
+ ...state.snapshot,
358
+ ...patch
359
+ }
360
+ }))
361
+ }),
362
+ {
363
+ name: CHAT_THREAD_WORKSPACE_STORAGE_KEY,
364
+ version: CHAT_THREAD_WORKSPACE_STORAGE_VERSION,
365
+ storage: createJSONStorage(() => window.localStorage),
366
+ partialize: (state): PersistedChatThreadStore => ({
367
+ snapshot: {
368
+ workspacePanelParentKey: state.snapshot.workspacePanelParentKey,
369
+ activeWorkspacePanelKind: state.snapshot.activeWorkspacePanelKind,
370
+ activeChildSessionKey: state.snapshot.activeChildSessionKey,
371
+ workspaceFileTabs: state.snapshot.workspaceFileTabs
372
+ .slice(-CHAT_THREAD_MAX_PERSISTED_WORKSPACE_FILE_TABS)
373
+ .map(toPersistedWorkspaceFileTab),
374
+ activeWorkspaceFileKey: state.snapshot.activeWorkspaceFileKey,
375
+ workspaceNavigationHistory: state.snapshot.workspaceNavigationHistory,
376
+ workspaceNavigationHistoryIndex: state.snapshot.workspaceNavigationHistoryIndex,
377
+ }
378
+ }),
379
+ merge: (persistedState, currentState) => {
380
+ const persistedSnapshot = isRecord(persistedState)
381
+ ? persistedState.snapshot
382
+ : null;
383
+ const workspaceSnapshot =
384
+ normalizePersistedWorkspaceSnapshot(persistedSnapshot);
385
+ if (!workspaceSnapshot) {
386
+ return currentState;
387
+ }
388
+ return {
389
+ ...currentState,
390
+ snapshot: {
391
+ ...currentState.snapshot,
392
+ ...workspaceSnapshot
393
+ }
394
+ };
116
395
  }
117
- }))
118
- }));
396
+ }
397
+ )
398
+ );
@@ -22,6 +22,7 @@ describe('adaptNcpSessionSummary', () => {
22
22
  createSummary({
23
23
  agentId: 'engineer',
24
24
  lastMessageAt: '2026-03-18T00:00:00.000Z',
25
+ workingDir: '/Users/demo/workspace/project-alpha',
25
26
  metadata: {
26
27
  label: 'NCP Planning Thread',
27
28
  model: 'openai/gpt-5',
@@ -40,6 +41,7 @@ describe('adaptNcpSessionSummary', () => {
40
41
  preferredModel: 'openai/gpt-5',
41
42
  preferredThinking: 'medium',
42
43
  projectRoot: '/Users/demo/workspace/project-alpha',
44
+ workingDir: '/Users/demo/workspace/project-alpha',
43
45
  projectName: 'project-alpha',
44
46
  lastMessageAt: '2026-03-18T00:00:00.000Z',
45
47
  readAt: '2026-03-17T23:59:00.000Z',
@@ -50,6 +52,25 @@ describe('adaptNcpSessionSummary', () => {
50
52
  });
51
53
  });
52
54
 
55
+ it('uses workingDir as the local file base even when projectRoot is absent', () => {
56
+ const adapted = adaptNcpSessionSummary(
57
+ createSummary({
58
+ workingDir: '/Users/demo/workspace',
59
+ metadata: {
60
+ label: 'Workspace Thread',
61
+ session_type: 'native',
62
+ },
63
+ }),
64
+ );
65
+
66
+ expect(adapted).toMatchObject({
67
+ key: 'ncp-session-1',
68
+ label: 'Workspace Thread',
69
+ workingDir: '/Users/demo/workspace',
70
+ });
71
+ expect(adapted.projectRoot).toBeUndefined();
72
+ });
73
+
53
74
  it('marks child sessions from parent_session_id metadata and keeps the request link', () => {
54
75
  const adapted = adaptNcpSessionSummary(
55
76
  createSummary({
@@ -120,6 +120,10 @@ function readNcpSessionProjectRoot(summary: NcpSessionSummaryView): string | nul
120
120
  return normalizeSessionProjectRootValue(metadata.project_root ?? metadata.projectRoot);
121
121
  }
122
122
 
123
+ function readNcpSessionWorkingDir(summary: NcpSessionSummaryView): string | null {
124
+ return readOptionalString(summary.workingDir);
125
+ }
126
+
123
127
  function readNcpSessionReadAt(summary: NcpSessionSummaryView): string | null {
124
128
  const metadata = readMetadata(summary);
125
129
  if (!metadata) {
@@ -308,6 +312,7 @@ export function adaptNcpSessionSummary(summary: NcpSessionSummaryView): SessionE
308
312
  const preferredModel = readNcpSessionPreferredModel(summary);
309
313
  const preferredThinking = readNcpSessionPreferredThinking(summary);
310
314
  const projectRoot = readNcpSessionProjectRoot(summary);
315
+ const workingDir = readNcpSessionWorkingDir(summary) ?? projectRoot;
311
316
  const readAt = readNcpSessionReadAt(summary);
312
317
  const lastMessageAt = readOptionalString(summary.lastMessageAt);
313
318
  const projectName = getSessionProjectName(projectRoot);
@@ -328,6 +333,7 @@ export function adaptNcpSessionSummary(summary: NcpSessionSummaryView): SessionE
328
333
  ...(preferredModel ? { preferredModel } : {}),
329
334
  ...(preferredThinking ? { preferredThinking } : {}),
330
335
  ...(projectRoot ? { projectRoot } : {}),
336
+ ...(workingDir ? { workingDir } : {}),
331
337
  ...(projectName ? { projectName } : {}),
332
338
  sessionType: readNcpSessionType(summary),
333
339
  sessionTypeMutable: false,
@@ -19,8 +19,10 @@ type MarketplaceCatalogGridProps = {
19
19
  scope: "all" | "installed";
20
20
  title: string;
21
21
  summary: string;
22
+ refreshingLabel: string;
22
23
  showTitle: boolean;
23
24
  showListSkeleton: boolean;
25
+ isRefreshing: boolean;
24
26
  skeletonCardCount: number;
25
27
  allItems: MarketplaceItemSummary[];
26
28
  installedEntries: InstalledRenderEntry[];
@@ -41,8 +43,10 @@ export function MarketplaceCatalogGrid(props: MarketplaceCatalogGridProps) {
41
43
  scope,
42
44
  title,
43
45
  summary,
46
+ refreshingLabel,
44
47
  showTitle,
45
48
  showListSkeleton,
49
+ isRefreshing,
46
50
  skeletonCardCount,
47
51
  allItems,
48
52
  installedEntries,
@@ -60,7 +64,21 @@ export function MarketplaceCatalogGrid(props: MarketplaceCatalogGridProps) {
60
64
  {showTitle && (
61
65
  <div className="flex items-center justify-between gap-3">
62
66
  <h3 className="text-[14px] font-semibold text-gray-950">{title}</h3>
63
- <span className="text-[12px] text-gray-500">{summary}</span>
67
+ <div className="flex items-center gap-2 text-[12px] text-gray-500">
68
+ {isRefreshing && (
69
+ <span className="font-medium text-primary">{refreshingLabel}</span>
70
+ )}
71
+ <span>{summary}</span>
72
+ </div>
73
+ </div>
74
+ )}
75
+
76
+ {isRefreshing && (
77
+ <div
78
+ data-testid="marketplace-list-refreshing"
79
+ className="h-0.5 overflow-hidden rounded-full bg-primary/10"
80
+ >
81
+ <div className="h-full w-1/3 animate-pulse rounded-full bg-primary" />
64
82
  </div>
65
83
  )}
66
84
 
@@ -0,0 +1,14 @@
1
+ import { ExternalActionLink } from "@/shared/components/ui/action-link";
2
+ import { hostCapabilityManager } from "@/shared/lib/host-capabilities";
3
+ import { t } from "@/shared/lib/i18n";
4
+
5
+ const SKILLHUB_URL = "https://skillhub.cn/";
6
+
7
+ export function MarketplaceExternalSkillSourceAction() {
8
+ return (
9
+ <ExternalActionLink
10
+ label={t("marketplaceExternalSkillSourceTitle")}
11
+ onClick={() => void hostCapabilityManager.openExternalUrl(SKILLHUB_URL)}
12
+ />
13
+ );
14
+ }
@@ -8,11 +8,12 @@ import {
8
8
  } from "@/shared/components/ui/select";
9
9
  import { Skeleton } from "@/shared/components/ui/skeleton";
10
10
  import { t } from "@/shared/lib/i18n";
11
- import { PackageSearch } from "lucide-react";
11
+ import { Loader2, PackageSearch } from "lucide-react";
12
12
  import type { Ref } from "react";
13
13
 
14
14
  export function FilterPanel({
15
15
  scope,
16
+ isRefreshing,
16
17
  searchText,
17
18
  searchPlaceholder,
18
19
  sort,
@@ -20,6 +21,7 @@ export function FilterPanel({
20
21
  onSortChange,
21
22
  }: {
22
23
  scope: "all" | "installed";
24
+ isRefreshing: boolean;
23
25
  searchText: string;
24
26
  searchPlaceholder: string;
25
27
  sort: MarketplaceSort;
@@ -35,8 +37,14 @@ export function FilterPanel({
35
37
  value={searchText}
36
38
  onChange={(event) => onSearchTextChange(event.target.value)}
37
39
  placeholder={searchPlaceholder}
38
- className="h-9 w-full rounded-xl border border-gray-200/80 pl-9 pr-3 text-sm focus:outline-none focus:ring-1 focus:ring-primary/40"
40
+ className="h-9 w-full rounded-xl border border-gray-200/80 pl-9 pr-9 text-sm focus:outline-none focus:ring-1 focus:ring-primary/40"
39
41
  />
42
+ {isRefreshing && (
43
+ <Loader2
44
+ className="absolute right-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 animate-spin text-primary"
45
+ aria-label={t("marketplaceRefreshingResults")}
46
+ />
47
+ )}
40
48
  </div>
41
49
 
42
50
  {scope === "all" && (
@@ -1,4 +1,4 @@
1
- import { render, screen } from "@testing-library/react";
1
+ import { fireEvent, render, screen } from "@testing-library/react";
2
2
  import { MarketplacePage } from "@/features/marketplace";
3
3
  import type {
4
4
  MarketplaceInstalledView,
@@ -10,8 +10,10 @@ type ItemsQueryState = {
10
10
  data?: MarketplaceListView;
11
11
  isLoading: boolean;
12
12
  isFetching: boolean;
13
+ isFetchingNextPage: boolean;
13
14
  isError: boolean;
14
15
  error: Error | null;
16
+ hasNextPage: boolean;
15
17
  };
16
18
 
17
19
  type InstalledQueryState = {
@@ -26,6 +28,7 @@ const mocks = vi.hoisted(() => ({
26
28
  navigate: vi.fn(),
27
29
  docOpen: vi.fn(),
28
30
  confirm: vi.fn(),
31
+ openExternalUrl: vi.fn(),
29
32
  routeParams: {} as { scene?: string },
30
33
  itemsQuery: null as unknown as ItemsQueryState,
31
34
  installedQuery: null as unknown as InstalledQueryState,
@@ -70,6 +73,12 @@ vi.mock("@/shared/hooks/use-confirm-dialog", () => ({
70
73
  }),
71
74
  }));
72
75
 
76
+ vi.mock("@/shared/lib/host-capabilities", () => ({
77
+ hostCapabilityManager: {
78
+ openExternalUrl: mocks.openExternalUrl,
79
+ },
80
+ }));
81
+
73
82
  vi.mock("@/features/marketplace/hooks/use-marketplace", () => ({
74
83
  useMarketplaceItems: () => mocks.itemsQuery,
75
84
  useMarketplaceSkillScenes: () => ({
@@ -121,8 +130,10 @@ function createItemsQuery(overrides: Partial<Record<string, unknown>> = {}) {
121
130
  data: undefined as MarketplaceListView | undefined,
122
131
  isLoading: false,
123
132
  isFetching: false,
133
+ isFetchingNextPage: false,
124
134
  isError: false,
125
135
  error: null,
136
+ hasNextPage: false,
126
137
  ...overrides,
127
138
  };
128
139
  }
@@ -150,6 +161,7 @@ describe("MarketplacePage", () => {
150
161
  mocks.navigate.mockReset();
151
162
  mocks.docOpen.mockReset();
152
163
  mocks.confirm.mockReset();
164
+ mocks.openExternalUrl.mockReset();
153
165
  mocks.routeParams = {};
154
166
  mocks.installMutation.mutateAsync.mockReset();
155
167
  mocks.manageMutation.mutate.mockReset();
@@ -198,6 +210,8 @@ describe("MarketplacePage", () => {
198
210
  render(<MarketplacePage forcedType="skills" />);
199
211
 
200
212
  expect(screen.queryByTestId("marketplace-list-skeleton")).toBeNull();
213
+ expect(screen.getByTestId("marketplace-list-refreshing")).toBeTruthy();
214
+ expect(screen.getAllByText("Updating...").length).toBeGreaterThan(0);
201
215
  expect(screen.getByText("Web Search")).toBeTruthy();
202
216
  });
203
217
 
@@ -240,4 +254,26 @@ describe("MarketplacePage", () => {
240
254
  expect(screen.getByText("Web Search")).toBeTruthy();
241
255
  expect(container.querySelector(".opacity-70")).toBeNull();
242
256
  });
257
+
258
+ it("opens SkillHub from the marketplace tab row", () => {
259
+ mocks.itemsQuery = createItemsQuery({
260
+ data: {
261
+ total: 1,
262
+ page: 1,
263
+ pageSize: 20,
264
+ totalPages: 1,
265
+ sort: "relevance",
266
+ items: [createMarketplaceItem()],
267
+ } satisfies MarketplaceListView,
268
+ });
269
+
270
+ render(<MarketplacePage forcedType="skills" />);
271
+
272
+ const openButton = screen.getByRole("button", {
273
+ name: /SkillHub/i,
274
+ });
275
+ fireEvent.click(openButton);
276
+
277
+ expect(mocks.openExternalUrl).toHaveBeenCalledWith("https://skillhub.cn/");
278
+ });
243
279
  });