@nextclaw/ui 0.13.12 → 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 (147) hide show
  1. package/CHANGELOG.md +19 -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-D-WYFmcG.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-IMD4W0jD.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-DzbTVebD.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-CJ6OPxAf.js → search-config-DEH80WkC.js} +1 -1
  41. package/dist/assets/{secrets-config-CYOHWL7P.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-panel-nav.tsx +45 -13
  54. package/src/features/chat/components/chat-session-workspace-panel.tsx +17 -8
  55. package/src/features/chat/components/conversation/chat-conversation-panel.test.tsx +69 -0
  56. package/src/features/chat/components/conversation/chat-conversation-panel.tsx +3 -0
  57. package/src/features/chat/components/providers/chat-presenter.provider.tsx +2 -0
  58. package/src/features/chat/managers/chat-session-list.manager.test.ts +4 -0
  59. package/src/features/chat/managers/chat-session-list.manager.ts +2 -0
  60. package/src/features/chat/managers/ncp-chat-thread.manager.test.ts +153 -25
  61. package/src/features/chat/managers/ncp-chat-thread.manager.ts +153 -7
  62. package/src/features/chat/stores/chat-thread.store.test.ts +148 -0
  63. package/src/features/chat/stores/chat-thread.store.ts +287 -9
  64. package/src/features/marketplace/components/marketplace-catalog-grid.tsx +19 -1
  65. package/src/features/marketplace/components/marketplace-external-skill-source-action.tsx +14 -0
  66. package/src/features/marketplace/components/marketplace-page-parts.tsx +10 -2
  67. package/src/features/marketplace/components/marketplace-page.test.tsx +37 -1
  68. package/src/features/marketplace/components/marketplace-page.tsx +38 -16
  69. package/src/features/marketplace/components/mcp/mcp-marketplace-page.tsx +1 -0
  70. package/src/features/marketplace/hooks/use-marketplace.test.tsx +109 -0
  71. package/src/features/marketplace/hooks/use-marketplace.ts +3 -5
  72. package/src/features/side-dock/components/side-dock.test.tsx +25 -0
  73. package/src/features/side-dock/components/side-dock.tsx +13 -0
  74. package/src/features/system-status/components/desktop-update-config.tsx +2 -1
  75. package/src/platforms/desktop/types/desktop-update.types.ts +9 -0
  76. package/src/shared/components/config/provider-form.tsx +2 -2
  77. package/src/shared/components/doc-browser/managers/doc-browser.manager.ts +39 -32
  78. package/src/shared/components/ui/action-link.tsx +23 -1
  79. package/src/shared/lib/host-capabilities/host-capability.manager.test.ts +50 -0
  80. package/src/shared/lib/host-capabilities/host-capability.manager.ts +62 -0
  81. package/src/shared/lib/host-capabilities/index.ts +1 -0
  82. package/src/shared/lib/i18n/README.md +16 -3
  83. package/src/shared/lib/i18n/index.ts +93 -426
  84. package/src/shared/lib/i18n/locales/en-US/agents.json +49 -0
  85. package/src/shared/lib/i18n/locales/en-US/channel-auth.json +51 -0
  86. package/src/shared/lib/i18n/locales/en-US/channels.json +50 -0
  87. package/src/shared/lib/i18n/locales/en-US/chat.json +181 -0
  88. package/src/shared/lib/i18n/locales/en-US/core.json +310 -0
  89. package/src/shared/lib/i18n/locales/en-US/cron.json +32 -0
  90. package/src/shared/lib/i18n/locales/en-US/desktop-update.json +90 -0
  91. package/src/shared/lib/i18n/locales/en-US/doc-browser.json +87 -0
  92. package/src/shared/lib/i18n/locales/en-US/marketplace.json +72 -0
  93. package/src/shared/lib/i18n/locales/en-US/path-picker.json +12 -0
  94. package/src/shared/lib/i18n/locales/en-US/pwa.json +26 -0
  95. package/src/shared/lib/i18n/locales/en-US/remote.json +168 -0
  96. package/src/shared/lib/i18n/locales/en-US/runtime-control.json +105 -0
  97. package/src/shared/lib/i18n/locales/en-US/search.json +28 -0
  98. package/src/shared/lib/i18n/locales/zh-CN/agents.json +49 -0
  99. package/src/shared/lib/i18n/locales/zh-CN/channel-auth.json +51 -0
  100. package/src/shared/lib/i18n/locales/zh-CN/channels.json +50 -0
  101. package/src/shared/lib/i18n/locales/zh-CN/chat.json +181 -0
  102. package/src/shared/lib/i18n/locales/zh-CN/core.json +310 -0
  103. package/src/shared/lib/i18n/locales/zh-CN/cron.json +32 -0
  104. package/src/shared/lib/i18n/locales/zh-CN/desktop-update.json +90 -0
  105. package/src/shared/lib/i18n/locales/zh-CN/doc-browser.json +87 -0
  106. package/src/shared/lib/i18n/locales/zh-CN/marketplace.json +72 -0
  107. package/src/shared/lib/i18n/locales/zh-CN/path-picker.json +12 -0
  108. package/src/shared/lib/i18n/locales/zh-CN/pwa.json +26 -0
  109. package/src/shared/lib/i18n/locales/zh-CN/remote.json +168 -0
  110. package/src/shared/lib/i18n/locales/zh-CN/runtime-control.json +105 -0
  111. package/src/shared/lib/i18n/locales/zh-CN/search.json +28 -0
  112. package/src/shared/lib/navigation-history/index.ts +1 -0
  113. package/src/shared/lib/navigation-history/navigation-history.utils.test.ts +60 -0
  114. package/src/shared/lib/navigation-history/navigation-history.utils.ts +105 -0
  115. package/dist/assets/app-presenter-provider-CDSjiqfE.js +0 -3
  116. package/dist/assets/arrow-left-Dd45Mvr1.js +0 -1
  117. package/dist/assets/chat-page-D1X8YNTC.js +0 -105
  118. package/dist/assets/desktop-update-config-DOi5R1fX.js +0 -1
  119. package/dist/assets/doc-browser-D5FJcAyz.js +0 -1
  120. package/dist/assets/index-Q891JiF_.css +0 -1
  121. package/dist/assets/index-ZDJP_uJc.js +0 -101
  122. package/dist/assets/loader-circle-Dz4FssYC.js +0 -1
  123. package/dist/assets/mcp-marketplace-page-LmMhKH5a.js +0 -1
  124. package/dist/assets/mcp-marketplace-page-RqISBSqO.js +0 -40
  125. package/dist/assets/model-config-JUsW1Khb.js +0 -1
  126. package/dist/assets/play-BziM-Y34.js +0 -1
  127. package/dist/assets/plus-DrpyArC8.js +0 -1
  128. package/dist/assets/providers-list-BsDpxNzy.js +0 -1
  129. package/dist/assets/remote-DjhVMpVd.js +0 -1
  130. package/dist/assets/rotate-cw-DMBnsYKm.js +0 -1
  131. package/dist/assets/search-CjHGYvTI.js +0 -1
  132. package/dist/assets/x-dpDISboO.js +0 -1
  133. package/src/shared/lib/i18n/agents.ts +0 -85
  134. package/src/shared/lib/i18n/channel-auth.constants.ts +0 -87
  135. package/src/shared/lib/i18n/channels.ts +0 -53
  136. package/src/shared/lib/i18n/chat-labels.utils.ts +0 -206
  137. package/src/shared/lib/i18n/cron-labels.utils.ts +0 -32
  138. package/src/shared/lib/i18n/desktop-update-labels.utils.ts +0 -165
  139. package/src/shared/lib/i18n/marketplace-labels.utils.ts +0 -78
  140. package/src/shared/lib/i18n/pwa.ts +0 -62
  141. package/src/shared/lib/i18n/remote.ts +0 -273
  142. package/src/shared/lib/i18n/runtime/doc-browser-labels.utils.ts +0 -120
  143. package/src/shared/lib/i18n/runtime/i18n.path-picker.ts +0 -12
  144. package/src/shared/lib/i18n/runtime-control-labels.utils.ts +0 -180
  145. package/src/shared/lib/i18n/search.ts +0 -37
  146. /package/dist/assets/{config-hints-Ceiol9x4.js → config-hints-fGnUjDe9.js} +0 -0
  147. /package/dist/assets/{provider-models-C_yOh6DE.js → provider-models-C9VRroK3.js} +0 -0
@@ -0,0 +1,148 @@
1
+ import { beforeEach, describe, expect, it } from 'vitest';
2
+ import { useChatThreadStore } from '@/features/chat/stores/chat-thread.store';
3
+
4
+ const chatThreadWorkspaceStorageKey = 'nextclaw.chat.workspace-panel.state';
5
+
6
+ function createLocalStoragePersistStorage() {
7
+ return {
8
+ getItem: (name: string) => JSON.parse(window.localStorage.getItem(name) ?? 'null'),
9
+ setItem: (name: string, value: unknown) => window.localStorage.setItem(name, JSON.stringify(value)),
10
+ removeItem: (name: string) => window.localStorage.removeItem(name)
11
+ };
12
+ }
13
+
14
+ function resetChatThreadStore() {
15
+ useChatThreadStore.setState({
16
+ snapshot: {
17
+ ...useChatThreadStore.getState().snapshot,
18
+ sessionKey: null,
19
+ messages: [],
20
+ isSending: false,
21
+ workspacePanelParentKey: null,
22
+ activeWorkspacePanelKind: null,
23
+ childSessionTabs: [],
24
+ activeChildSessionKey: null,
25
+ workspaceFileTabs: [],
26
+ activeWorkspaceFileKey: null,
27
+ workspaceNavigationHistory: [],
28
+ workspaceNavigationHistoryIndex: 0,
29
+ },
30
+ });
31
+ }
32
+
33
+ describe('chat thread workspace panel persistence', () => {
34
+ beforeEach(() => {
35
+ window.localStorage.clear();
36
+ useChatThreadStore.persist.setOptions({
37
+ storage: createLocalStoragePersistStorage(),
38
+ });
39
+ resetChatThreadStore();
40
+ window.localStorage.clear();
41
+ });
42
+
43
+ it('persists only the workspace panel continuity state', () => {
44
+ useChatThreadStore.getState().setSnapshot({
45
+ sessionKey: 'session-1',
46
+ isSending: true,
47
+ workspacePanelParentKey: 'session-1',
48
+ activeWorkspacePanelKind: 'file',
49
+ workspaceFileTabs: [
50
+ {
51
+ key: 'session-1::preview::README.md',
52
+ parentSessionKey: 'session-1',
53
+ path: 'README.md',
54
+ label: 'README.md',
55
+ viewMode: 'preview',
56
+ rawText: '# Hello',
57
+ fullLines: [],
58
+ },
59
+ ],
60
+ activeWorkspaceFileKey: 'session-1::preview::README.md',
61
+ workspaceNavigationHistory: [
62
+ { kind: 'child-session', key: 'child-session-1' },
63
+ { kind: 'file', key: 'session-1::preview::README.md' },
64
+ ],
65
+ workspaceNavigationHistoryIndex: 1,
66
+ });
67
+
68
+ const persisted = JSON.parse(
69
+ window.localStorage.getItem(chatThreadWorkspaceStorageKey) ?? '{}',
70
+ );
71
+
72
+ expect(persisted.state.snapshot).toMatchObject({
73
+ workspacePanelParentKey: 'session-1',
74
+ activeWorkspacePanelKind: 'file',
75
+ activeWorkspaceFileKey: 'session-1::preview::README.md',
76
+ workspaceNavigationHistory: [
77
+ { kind: 'child-session', key: 'child-session-1' },
78
+ { kind: 'file', key: 'session-1::preview::README.md' },
79
+ ],
80
+ workspaceNavigationHistoryIndex: 1,
81
+ });
82
+ expect(persisted.state.snapshot.sessionKey).toBeUndefined();
83
+ expect(persisted.state.snapshot.isSending).toBeUndefined();
84
+ expect(persisted.state.snapshot.workspaceFileTabs[0].rawText).toBe('# Hello');
85
+ expect(persisted.state.snapshot.workspaceFileTabs[0].fullLines).toBeUndefined();
86
+ });
87
+
88
+ it('hydrates the workspace panel state and repairs stale active file keys', async () => {
89
+ window.localStorage.setItem(
90
+ chatThreadWorkspaceStorageKey,
91
+ JSON.stringify({
92
+ state: {
93
+ snapshot: {
94
+ workspacePanelParentKey: 'session-1',
95
+ activeWorkspacePanelKind: 'file',
96
+ activeWorkspaceFileKey: 'missing-file',
97
+ activeChildSessionKey: 'child-session-1',
98
+ workspaceNavigationHistory: [
99
+ { kind: 'file', key: 'missing-file' },
100
+ { kind: 'file', key: 'session-1::preview::README.md' },
101
+ ],
102
+ workspaceNavigationHistoryIndex: 1,
103
+ workspaceFileTabs: [
104
+ {
105
+ key: 'session-1::preview::README.md',
106
+ parentSessionKey: 'session-1',
107
+ path: 'README.md',
108
+ label: 'README.md',
109
+ viewMode: 'preview',
110
+ rawText: '# Hello',
111
+ },
112
+ {
113
+ key: 'invalid',
114
+ parentSessionKey: 'session-1',
115
+ path: '',
116
+ viewMode: 'preview',
117
+ },
118
+ ],
119
+ },
120
+ },
121
+ version: 2,
122
+ }),
123
+ );
124
+
125
+ await useChatThreadStore.persist.rehydrate();
126
+
127
+ expect(useChatThreadStore.getState().snapshot).toMatchObject({
128
+ workspacePanelParentKey: 'session-1',
129
+ activeWorkspacePanelKind: 'file',
130
+ activeWorkspaceFileKey: 'session-1::preview::README.md',
131
+ activeChildSessionKey: 'child-session-1',
132
+ workspaceNavigationHistory: [
133
+ { kind: 'file', key: 'session-1::preview::README.md' },
134
+ ],
135
+ workspaceNavigationHistoryIndex: 0,
136
+ workspaceFileTabs: [
137
+ {
138
+ key: 'session-1::preview::README.md',
139
+ parentSessionKey: 'session-1',
140
+ path: 'README.md',
141
+ label: 'README.md',
142
+ viewMode: 'preview',
143
+ rawText: '# Hello',
144
+ },
145
+ ],
146
+ });
147
+ });
148
+ });
@@ -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[];
@@ -65,14 +79,43 @@ export type ChatThreadSnapshot = {
65
79
  activeChildSessionKey?: string | null;
66
80
  workspaceFileTabs: ChatWorkspaceFileTab[];
67
81
  activeWorkspaceFileKey?: string | null;
82
+ workspaceNavigationHistory: ChatWorkspaceNavigationEntry[];
83
+ workspaceNavigationHistoryIndex: number;
68
84
  contextWindow?: SessionContextWindowView | null;
69
85
  };
70
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
+
71
91
  type ChatThreadStore = {
72
92
  snapshot: ChatThreadSnapshot;
73
93
  setSnapshot: (patch: Partial<ChatThreadSnapshot>) => void;
74
94
  };
75
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
+
76
119
  const initialSnapshot: ChatThreadSnapshot = {
77
120
  isProviderStateResolved: false,
78
121
  modelOptions: [],
@@ -105,16 +148,251 @@ const initialSnapshot: ChatThreadSnapshot = {
105
148
  activeChildSessionKey: null,
106
149
  workspaceFileTabs: [],
107
150
  activeWorkspaceFileKey: null,
151
+ workspaceNavigationHistory: [],
152
+ workspaceNavigationHistoryIndex: 0,
108
153
  contextWindow: null
109
154
  };
110
155
 
111
- export const useChatThreadStore = create<ChatThreadStore>((set) => ({
112
- snapshot: initialSnapshot,
113
- setSnapshot: (patch) =>
114
- set((state) => ({
115
- snapshot: {
116
- ...state.snapshot,
117
- ...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
+ };
118
395
  }
119
- }))
120
- }));
396
+ }
397
+ )
398
+ );
@@ -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
  });