@nextclaw/ui 0.15.26 → 0.15.27

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 (125) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/dist/assets/{api-CK3Fro7z.js → api-a_GaOFc7.js} +4 -4
  3. package/dist/assets/{appearance-settings-page-zqwWO8Dy.js → appearance-settings-page-DlGXLgff.js} +1 -1
  4. package/dist/assets/{channels-list-page-CVCZBlCL.js → channels-list-page-BEEk4Uc9.js} +2 -2
  5. package/dist/assets/{chat-page-D4wehRTz.js → chat-page-DUQXXqQm.js} +1 -1
  6. package/dist/assets/{config-split-page-CceNFvlY.js → config-split-page-ps1Aod3g.js} +1 -1
  7. package/dist/assets/{confirm-dialog-CWeNYWZY.js → confirm-dialog-DJSUNG33.js} +2 -2
  8. package/dist/assets/desktop-update-config--zagjHic.js +1 -0
  9. package/dist/assets/{dist-D9xtGgdL.js → dist-BkTYNzm_.js} +1 -1
  10. package/dist/assets/{dist-AYxkFx2B.js → dist-DkR7_Dpl.js} +1 -1
  11. package/dist/assets/doc-browser-DahzJEWj.js +1 -0
  12. package/dist/assets/doc-browser-Q67TOGVS.js +1 -0
  13. package/dist/assets/{doc-browser-context-Cb7Z0j90.js → doc-browser-context-BQim8SqD.js} +1 -1
  14. package/dist/assets/form-actions-B2-bpFie.js +1 -0
  15. package/dist/assets/index-Ba_o5fu9.js +109 -0
  16. package/dist/assets/index-DSLK7zgR.css +1 -0
  17. package/dist/assets/loader-circle-DyXZU92l.js +1 -0
  18. package/dist/assets/mcp-marketplace-page-B0dBE5CV.js +1 -0
  19. package/dist/assets/mcp-marketplace-page-w8panjZG.js +9 -0
  20. package/dist/assets/{model-config-page-DfOLyQqR.js → model-config-page-rrhFaque.js} +1 -1
  21. package/dist/assets/{navigation-link-MiiyoIjH.js → navigation-link-mpHFXwRr.js} +2 -2
  22. package/dist/assets/plus-Dhb4KuMH.js +1 -0
  23. package/dist/assets/{provider-scoped-model-input-DXzjr5l0.js → provider-scoped-model-input-CFsfWEt9.js} +1 -1
  24. package/dist/assets/{providers-config-page-CaO8WfZH.js → providers-config-page-D-TYFZsF.js} +1 -1
  25. package/dist/assets/react-BlXwvOLo.js +8 -0
  26. package/dist/assets/{refresh-cw-a2VVeeAF.js → refresh-cw-JFuyDj_P.js} +1 -1
  27. package/dist/assets/remote-BwVv_Qq-.js +1 -0
  28. package/dist/assets/rotate-cw-DjbbiIW_.js +1 -0
  29. package/dist/assets/{runtime-config-page-BuA4A02z.js → runtime-config-page-Br9j9Vxe.js} +1 -1
  30. package/dist/assets/{save-CGm23Yww.js → save-BL8P8CLX.js} +1 -1
  31. package/dist/assets/{search-CH2t--sH.js → search-bZL75Pgc.js} +1 -1
  32. package/dist/assets/{search-config-page-BAmYqoAz.js → search-config-page-B3fvzMd1.js} +1 -1
  33. package/dist/assets/{secrets-config-page-BlPOuql_.js → secrets-config-page-ClwTxtXd.js} +2 -2
  34. package/dist/assets/security-config-DmJw9QZA.js +1 -0
  35. package/dist/assets/{select-DnxxDpcV.js → select-rUZ8nIZu.js} +2 -2
  36. package/dist/assets/{settings-2-agnMYc8a.js → settings-2-BI4SCkxQ.js} +1 -1
  37. package/dist/assets/skeleton-CVmEPG43.js +1 -0
  38. package/dist/assets/{tag-chip-CPOc8b2N.js → tag-chip-BdI_yIOA.js} +1 -1
  39. package/dist/assets/x-BcEayhV6.js +1 -0
  40. package/dist/index.html +19 -19
  41. package/package.json +7 -7
  42. package/src/app/components/layout/app-layout.tsx +38 -2
  43. package/src/app/hooks/__tests__/use-app-event-consumers.test.ts +20 -0
  44. package/src/app/hooks/use-app-event-consumers.ts +21 -0
  45. package/src/app/presenters/app.presenter.ts +6 -0
  46. package/src/features/apps/components/__tests__/app-packages-panel.test.tsx +92 -14
  47. package/src/features/apps/components/__tests__/apps-panel.test.tsx +58 -0
  48. package/src/features/apps/components/app-artwork.tsx +49 -0
  49. package/src/features/apps/components/app-marketplace-catalog.tsx +332 -0
  50. package/src/features/apps/components/app-marketplace-cover.tsx +64 -0
  51. package/src/features/apps/components/app-marketplace-detail.tsx +161 -0
  52. package/src/features/apps/components/app-marketplace-dialog.tsx +107 -217
  53. package/src/features/apps/components/app-marketplace-operation.tsx +84 -0
  54. package/src/features/apps/components/app-package-card.tsx +201 -52
  55. package/src/features/apps/components/app-packages-panel.tsx +98 -26
  56. package/src/features/apps/components/apps-panel.tsx +28 -27
  57. package/src/features/apps/hooks/use-app-marketplace.test.ts +144 -0
  58. package/src/features/apps/hooks/use-app-marketplace.ts +55 -85
  59. package/src/features/apps/hooks/use-app-packages.ts +59 -4
  60. package/src/features/apps/index.ts +1 -0
  61. package/src/features/apps/managers/__tests__/app-package-operation-settlement.manager.test.ts +81 -0
  62. package/src/features/apps/managers/app-package-operation-settlement.manager.ts +80 -0
  63. package/src/features/apps/services/app-marketplace.service.ts +223 -0
  64. package/src/features/apps/types/app-marketplace.types.ts +75 -0
  65. package/src/features/chat/features/conversation/components/__tests__/session-conversation-input.streaming.test.tsx +64 -3
  66. package/src/features/chat/features/conversation/components/session-queued-input-rows.tsx +24 -14
  67. package/src/features/chat/features/conversation/hooks/__tests__/use-session-conversation-controller.test.tsx +53 -0
  68. package/src/features/chat/features/conversation/hooks/__tests__/use-session-run-queue.test.tsx +18 -0
  69. package/src/features/chat/features/conversation/hooks/use-chat-composer-reference-intent.ts +4 -1
  70. package/src/features/chat/features/conversation/hooks/use-session-conversation-controller.ts +126 -32
  71. package/src/features/chat/features/conversation/hooks/use-session-run-queue.ts +12 -2
  72. package/src/features/chat/features/input/utils/__tests__/chat-inline-token.utils.test.ts +31 -0
  73. package/src/features/chat/features/input/utils/chat-composer-token-protocol.utils.ts +5 -0
  74. package/src/features/chat/features/input/utils/chat-inline-token-composer.utils.ts +14 -0
  75. package/src/features/chat/features/input/utils/chat-inline-token.utils.ts +25 -0
  76. package/src/features/chat/features/message/hooks/use-chat-inline-token-actions.ts +8 -1
  77. package/src/features/chat/features/workspace/components/__tests__/chat-session-workspace-panel-content.test.tsx +77 -20
  78. package/src/features/chat/features/workspace/components/chat-session-workspace-directory-browser.tsx +56 -50
  79. package/src/features/chat/features/workspace/components/project-files/workspace-directory-tree.tsx +33 -11
  80. package/src/features/chat/features/workspace/components/project-files/workspace-project-tree-view.tsx +93 -0
  81. package/src/features/chat/features/workspace/stores/__tests__/workspace-project-tree.store.test.ts +38 -0
  82. package/src/features/chat/features/workspace/stores/workspace-project-tree.store.ts +160 -0
  83. package/src/features/chat/index.ts +1 -0
  84. package/src/features/chat/managers/__tests__/chat-composer-intent.manager.test.ts +29 -0
  85. package/src/features/chat/managers/chat-composer-intent.manager.ts +29 -2
  86. package/src/features/chat/presenters/chat.presenter.ts +2 -2
  87. package/src/features/panel-apps/components/__tests__/panel-apps-list.test.tsx +1 -1
  88. package/src/features/panel-apps/components/panel-apps-list.tsx +1 -6
  89. package/src/features/right-panel-resources/utils/__tests__/right-panel-resource-uri.utils.test.ts +34 -0
  90. package/src/features/right-panel-resources/utils/right-panel-resource-uri.utils.ts +29 -0
  91. package/src/features/service-apps/components/service-apps-panel.tsx +3 -17
  92. package/src/platforms/desktop/components/desktop-app-shell.tsx +5 -0
  93. package/src/platforms/mobile/components/mobile-app-shell.tsx +9 -1
  94. package/src/shared/components/actions/__tests__/navigation-link.test.tsx +12 -0
  95. package/src/shared/components/actions/navigation-link.tsx +6 -3
  96. package/src/shared/components/doc-browser/__tests__/doc-browser.test.tsx +34 -0
  97. package/src/shared/components/doc-browser/doc-browser-tab-strip.tsx +5 -0
  98. package/src/shared/components/doc-browser/doc-browser.tsx +11 -1
  99. package/src/shared/components/doc-browser/managers/doc-browser.manager.ts +13 -0
  100. package/src/shared/components/ui/tabs.tsx +24 -1
  101. package/src/shared/hooks/use-server-path-watch.ts +42 -0
  102. package/src/shared/lib/api/server-path/server-path.types.ts +10 -0
  103. package/src/shared/lib/api/types.ts +3 -0
  104. package/src/shared/lib/api/utils/server-path.utils.ts +10 -0
  105. package/src/shared/lib/i18n/locales/en-US/chat.json +1 -0
  106. package/src/shared/lib/i18n/locales/en-US/doc-browser.json +49 -7
  107. package/src/shared/lib/i18n/locales/zh-CN/chat.json +1 -0
  108. package/src/shared/lib/i18n/locales/zh-CN/doc-browser.json +49 -7
  109. package/vite.config.ts +32 -1
  110. package/dist/assets/desktop-update-config-Dirc3bpt.js +0 -1
  111. package/dist/assets/doc-browser-4Sud6dWw.js +0 -1
  112. package/dist/assets/doc-browser-D4x_yH5F.js +0 -1
  113. package/dist/assets/form-actions-B2q4jgjE.js +0 -1
  114. package/dist/assets/index-CdyTsSBu.css +0 -1
  115. package/dist/assets/index-Das_bw2h.js +0 -107
  116. package/dist/assets/loader-circle-CpmBdaon.js +0 -1
  117. package/dist/assets/mcp-marketplace-page-CdOY3Sop.js +0 -1
  118. package/dist/assets/mcp-marketplace-page-DvjrfqEt.js +0 -9
  119. package/dist/assets/plus-C8FkjpSZ.js +0 -1
  120. package/dist/assets/react-C8XFFvuz.js +0 -8
  121. package/dist/assets/remote-BJHXa4mB.js +0 -1
  122. package/dist/assets/rotate-cw-DJ7UF3F2.js +0 -1
  123. package/dist/assets/security-config-CbDiY2xr.js +0 -1
  124. package/dist/assets/skeleton-XpqeWL62.js +0 -1
  125. package/dist/assets/x-odTHAtLZ.js +0 -1
@@ -1,4 +1,5 @@
1
- import { useState, type MouseEventHandler, type ReactNode } from 'react';
1
+ import { useMemo, type ReactNode } from 'react';
2
+ import { useQueryClient } from '@tanstack/react-query';
2
3
  import type { ChatFileOpenActionViewModel } from '@nextclaw/agent-chat-ui';
3
4
  import { ChevronsUp, Copy, FilePlus2, FolderPlus, LocateFixed, Upload, RefreshCw } from 'lucide-react';
4
5
  import { toast } from 'sonner';
@@ -10,6 +11,10 @@ import {
10
11
  } from '@/features/chat/features/workspace/components/project-files/workspace-directory-tree';
11
12
  import { WorkspaceNewFolderTreeItem } from '@/features/chat/features/workspace/components/project-files/workspace-new-folder-tree-item';
12
13
  import { WorkspaceProjectFilesToolbar } from '@/features/chat/features/workspace/components/project-files/workspace-project-files-toolbar';
14
+ import {
15
+ isWorkspaceProjectBrowseQuery,
16
+ WorkspaceProjectTree,
17
+ } from '@/features/chat/features/workspace/components/project-files/workspace-project-tree-view';
13
18
  import {
14
19
  useWorkspaceProjectFilesController,
15
20
  type RefreshDirectory,
@@ -18,10 +23,15 @@ import {
18
23
  } from '@/features/chat/features/workspace/hooks/use-workspace-file-actions';
19
24
  import { ContextMenu, type ContextMenuGroup } from '@/shared/components/ui/context-menu/context-menu';
20
25
  import { useConfirmDialog } from '@/shared/hooks/use-confirm-dialog';
26
+ import { useServerPathWatch } from '@/shared/hooks/use-server-path-watch';
21
27
  import type { useServerPathBrowse } from '@/shared/hooks/use-server-path-browse';
22
28
  import type { ServerPathEntryView } from '@/shared/lib/api';
23
29
  import { hostCapabilityManager } from '@/shared/lib/host-capabilities';
24
30
  import { t } from '@/shared/lib/i18n';
31
+ import {
32
+ normalizeWorkspaceProjectTreeRootKey,
33
+ useWorkspaceProjectTreeStore,
34
+ } from '@/features/chat/features/workspace/stores/workspace-project-tree.store';
25
35
 
26
36
  type ChatSessionWorkspaceDirectoryBrowserProps = {
27
37
  activeRelativePath?: string | null;
@@ -32,6 +42,8 @@ type ChatSessionWorkspaceDirectoryBrowserProps = {
32
42
  showRoot?: boolean;
33
43
  };
34
44
 
45
+ const EMPTY_EXPANDED_PATHS: readonly string[] = [];
46
+
35
47
  function readPathName(path: string): string {
36
48
  return path.split(/[\\/]/).filter(Boolean).pop() ?? path;
37
49
  }
@@ -39,7 +51,6 @@ function readPathName(path: string): string {
39
51
  function WorkspaceDirectoryEntries({
40
52
  activeRelativePath,
41
53
  busy,
42
- collapseVersion,
43
54
  createTarget,
44
55
  entries,
45
56
  fallbackParentTarget,
@@ -60,10 +71,10 @@ function WorkspaceDirectoryEntries({
60
71
  revealPath,
61
72
  selectedPath,
62
73
  showRoot,
74
+ treeRootKey,
63
75
  }: {
64
76
  activeRelativePath: string | null;
65
77
  busy: boolean;
66
- collapseVersion: number;
67
78
  createTarget: WorkspaceDirectoryActionTarget | null;
68
79
  entries: readonly ServerPathEntryView[];
69
80
  fallbackParentTarget: WorkspaceDirectoryActionTarget;
@@ -84,6 +95,7 @@ function WorkspaceDirectoryEntries({
84
95
  revealPath: string | null;
85
96
  selectedPath: string | null;
86
97
  showRoot: boolean;
98
+ treeRootKey: string | null;
87
99
  }) {
88
100
  if (entries.length === 0) {
89
101
  return <div className="px-4 py-8 text-center text-xs text-gray-400">{t('chatWorkspaceDirectoryEmpty')}</div>;
@@ -94,7 +106,6 @@ function WorkspaceDirectoryEntries({
94
106
  key={entry.path}
95
107
  browseParentRefresh={refresh}
96
108
  busy={busy}
97
- collapseVersion={collapseVersion}
98
109
  createTarget={createTarget}
99
110
  entry={entry}
100
111
  level={showRoot ? 1 : 0}
@@ -113,6 +124,7 @@ function WorkspaceDirectoryEntries({
113
124
  renderCreateFolder={renderCreateFolder}
114
125
  revealPath={revealPath}
115
126
  selectedPath={selectedPath}
127
+ treeRootKey={treeRootKey}
116
128
  />
117
129
  ));
118
130
  }
@@ -207,45 +219,6 @@ function buildRootContextMenuGroups({
207
219
  ];
208
220
  }
209
221
 
210
- function WorkspaceProjectTree({
211
- children,
212
- currentPath,
213
- onBlankContext,
214
- onContextMenu,
215
- rootDraft,
216
- showRoot,
217
- }: {
218
- children: ReactNode;
219
- currentPath: string | null;
220
- onBlankContext: () => void;
221
- onContextMenu?: MouseEventHandler<HTMLDivElement>;
222
- rootDraft: ReactNode;
223
- showRoot: boolean;
224
- }) {
225
- return (
226
- <div
227
- data-testid="workspace-directory-browser"
228
- role="tree"
229
- tabIndex={-1}
230
- aria-label={t('chatWorkspaceProjectFiles')}
231
- className="min-h-0 flex-1 overflow-auto py-1 custom-scrollbar"
232
- onContextMenu={(event) => {
233
- if (!(event.target as HTMLElement).closest('[data-workspace-tree-entry]')) onBlankContext();
234
- onContextMenu?.(event);
235
- }}
236
- >
237
- {showRoot && currentPath ? (
238
- <>
239
- {rootDraft}
240
- {children}
241
- </>
242
- ) : (
243
- children
244
- )}
245
- </div>
246
- );
247
- }
248
-
249
222
  function WorkspaceDirectoryBrowserReady({
250
223
  activeRelativePath,
251
224
  browseQuery,
@@ -271,6 +244,7 @@ function WorkspaceDirectoryBrowserReady({
271
244
  rootPath: string | null;
272
245
  showRoot: boolean;
273
246
  }) {
247
+ const queryClient = useQueryClient();
274
248
  const {
275
249
  cancelCreateFolder,
276
250
  clearSelection,
@@ -298,8 +272,15 @@ function WorkspaceDirectoryBrowserReady({
298
272
  selectedPath,
299
273
  } = controller.state;
300
274
  const busy = pendingAction !== null;
301
- const [collapseVersion, setCollapseVersion] = useState(0);
302
- const rootRefresh = () => browseQuery.refetch();
275
+ const treeRootKey = normalizeWorkspaceProjectTreeRootKey(rootPath);
276
+ const collapseAll = useWorkspaceProjectTreeStore((state) => state.collapseAll);
277
+ const rootRefresh = () =>
278
+ rootPath
279
+ ? queryClient.refetchQueries({
280
+ predicate: (query) => isWorkspaceProjectBrowseQuery(query.queryKey, rootPath),
281
+ type: 'active',
282
+ })
283
+ : browseQuery.refetch();
303
284
  const rootTarget: WorkspaceDirectoryActionTarget | null = rootPath
304
285
  ? { label: rootLabel, path: rootPath, refresh: rootRefresh }
305
286
  : null;
@@ -328,7 +309,6 @@ function WorkspaceDirectoryBrowserReady({
328
309
  <WorkspaceDirectoryEntries
329
310
  activeRelativePath={activeRelativePath}
330
311
  busy={busy}
331
- collapseVersion={collapseVersion}
332
312
  createTarget={createTarget}
333
313
  entries={entries}
334
314
  fallbackParentTarget={fallbackParentTarget}
@@ -349,6 +329,7 @@ function WorkspaceDirectoryBrowserReady({
349
329
  revealPath={revealPath}
350
330
  selectedPath={selectedPath}
351
331
  showRoot={false}
332
+ treeRootKey={treeRootKey || null}
352
333
  />
353
334
  );
354
335
  const tree = (
@@ -364,10 +345,12 @@ function WorkspaceDirectoryBrowserReady({
364
345
  const rootMenuGroups = rootTarget
365
346
  ? buildRootContextMenuGroups({
366
347
  busy,
367
- onCollapseAll: () => setCollapseVersion((value) => value + 1),
348
+ onCollapseAll: () => {
349
+ if (treeRootKey) collapseAll(treeRootKey);
350
+ },
368
351
  onCreateFile: () => openCreateFile(rootTarget),
369
352
  onCreateFolder: () => openCreateFolder(rootTarget),
370
- onRefresh: () => void browseQuery.refetch(),
353
+ onRefresh: () => void rootRefresh(),
371
354
  onRevealPath,
372
355
  onUpload: () => requestUpload(rootTarget),
373
356
  target: rootTarget,
@@ -385,10 +368,12 @@ function WorkspaceDirectoryBrowserReady({
385
368
  <WorkspaceProjectFilesToolbar
386
369
  disabled={busy}
387
370
  rootLabel={rootLabel}
388
- onCollapseAll={() => setCollapseVersion((value) => value + 1)}
371
+ onCollapseAll={() => {
372
+ if (treeRootKey) collapseAll(treeRootKey);
373
+ }}
389
374
  onNewFile={() => openCreateFile(toolbarTarget)}
390
375
  onNewFolder={() => openCreateFolder(toolbarTarget)}
391
- onRefresh={() => void browseQuery.refetch()}
376
+ onRefresh={() => void rootRefresh()}
392
377
  />
393
378
  ) : null}
394
379
  {rootPath ? (
@@ -427,6 +412,27 @@ export function ChatSessionWorkspaceDirectoryBrowser({
427
412
  const entries = browseQuery.data?.entries ?? [];
428
413
  const errorMessage = readBrowseError(browseQuery);
429
414
  const rootPath = showRoot ? (browseQuery.data?.currentPath ?? null) : null;
415
+ const treeRootKey = normalizeWorkspaceProjectTreeRootKey(rootPath);
416
+ const expandedPaths = useWorkspaceProjectTreeStore((state) =>
417
+ treeRootKey ? (state.trees[treeRootKey]?.expandedPaths ?? EMPTY_EXPANDED_PATHS) : EMPTY_EXPANDED_PATHS,
418
+ );
419
+ const watchedDirectories = useMemo(() => {
420
+ if (!rootPath) return [];
421
+ const separator = rootPath.includes('\\') && !rootPath.includes('/') ? '\\' : '/';
422
+ const normalizedRoot = rootPath.replace(/[\\/]+$/g, '');
423
+ const expandedPathSet = new Set(expandedPaths);
424
+ const visibleExpandedPaths = expandedPaths.filter((relativePath) => {
425
+ const segments = relativePath.split('/');
426
+ return segments.slice(0, -1).every((_, index) => expandedPathSet.has(segments.slice(0, index + 1).join('/')));
427
+ });
428
+ return [
429
+ rootPath,
430
+ ...visibleExpandedPaths.map(
431
+ (relativePath) => `${normalizedRoot}${separator}${relativePath.replace(/[\\/]+/g, separator)}`,
432
+ ),
433
+ ];
434
+ }, [expandedPaths, rootPath]);
435
+ useServerPathWatch(watchedDirectories);
430
436
  const rootLabel = rootPath ? readPathName(rootPath) : '';
431
437
  const controller = useWorkspaceProjectFilesController({
432
438
  activeRelativePath,
@@ -1,4 +1,4 @@
1
- import { useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from 'react';
1
+ import { useCallback, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from 'react';
2
2
  import type { ChatFileOpenActionViewModel } from '@nextclaw/agent-chat-ui';
3
3
  import {
4
4
  ChevronDown,
@@ -26,13 +26,16 @@ import { buildServerPathContentUrl, type ServerPathEntryView } from '@/shared/li
26
26
  import { t } from '@/shared/lib/i18n';
27
27
  import { cn } from '@/shared/lib/utils';
28
28
  import { buildWorkspaceEntryContextMenuGroups } from '@/features/chat/features/workspace/components/project-files/workspace-directory-entry-menu';
29
+ import {
30
+ normalizeWorkspaceProjectTreeRelativePath,
31
+ useWorkspaceProjectTreeStore,
32
+ } from '@/features/chat/features/workspace/stores/workspace-project-tree.store';
29
33
 
30
34
  export { copyWorkspacePath } from '@/features/chat/features/workspace/components/project-files/workspace-directory-entry-menu';
31
35
 
32
36
  type WorkspaceDirectoryTreeActionProps = {
33
37
  activeRelativePath: string | null;
34
38
  busy: boolean;
35
- collapseVersion: number;
36
39
  createTarget: WorkspaceDirectoryActionTarget | null;
37
40
  onAddToChat?: (entry: ServerPathEntryView, relativePath: string) => void;
38
41
  onCreateFolder?: (target: WorkspaceDirectoryActionTarget) => void;
@@ -45,6 +48,7 @@ type WorkspaceDirectoryTreeActionProps = {
45
48
  renderCreateFolder: (level: number) => ReactNode;
46
49
  revealPath: string | null;
47
50
  selectedPath: string | null;
51
+ treeRootKey: string | null;
48
52
  };
49
53
 
50
54
  function buildEntryLabel(entry: ServerPathEntryView): string {
@@ -206,11 +210,31 @@ function isWorkspaceTreeEntryExpanded(
206
210
  );
207
211
  }
208
212
 
213
+ function useWorkspaceTreeEntryExpansion(treeRootKey: string | null, relativePath: string | null) {
214
+ const [locallyExpanded, setLocallyExpanded] = useState(false);
215
+ const relativePathKey = normalizeWorkspaceProjectTreeRelativePath(relativePath);
216
+ const persistedExpanded = useWorkspaceProjectTreeStore((state) =>
217
+ Boolean(treeRootKey && relativePathKey && state.trees[treeRootKey]?.expandedPaths.includes(relativePathKey)),
218
+ );
219
+ const setDirectoryExpanded = useWorkspaceProjectTreeStore((state) => state.setDirectoryExpanded);
220
+ const manuallyExpanded = treeRootKey && relativePathKey ? persistedExpanded : locallyExpanded;
221
+ const updateExpanded = useCallback(
222
+ (expanded: boolean) => {
223
+ if (treeRootKey && relativePathKey) {
224
+ setDirectoryExpanded(treeRootKey, relativePathKey, expanded);
225
+ return;
226
+ }
227
+ setLocallyExpanded(expanded);
228
+ },
229
+ [relativePathKey, setDirectoryExpanded, treeRootKey],
230
+ );
231
+ return { manuallyExpanded, updateExpanded };
232
+ }
233
+
209
234
  export function WorkspaceDirectoryTreeEntry({
210
235
  activeRelativePath,
211
236
  browseParentRefresh,
212
237
  busy,
213
- collapseVersion,
214
238
  createTarget,
215
239
  entry,
216
240
  level,
@@ -229,6 +253,7 @@ export function WorkspaceDirectoryTreeEntry({
229
253
  renderCreateFolder,
230
254
  revealPath,
231
255
  selectedPath,
256
+ treeRootKey,
232
257
  }: WorkspaceDirectoryTreeActionProps & {
233
258
  browseParentRefresh: RefreshDirectory;
234
259
  entry: ServerPathEntryView;
@@ -241,7 +266,7 @@ export function WorkspaceDirectoryTreeEntry({
241
266
  const rowRef = useRef<HTMLDivElement | null>(null);
242
267
  const entryButtonRef = useRef<HTMLButtonElement | null>(null);
243
268
  const isDirectory = entry.kind === 'directory';
244
- const [manuallyExpanded, setIsExpanded] = useState(false);
269
+ const { manuallyExpanded, updateExpanded } = useWorkspaceTreeEntryExpansion(treeRootKey, relativePath);
245
270
  const isExpanded = isWorkspaceTreeEntryExpanded(
246
271
  manuallyExpanded,
247
272
  isDirectory,
@@ -254,9 +279,6 @@ export function WorkspaceDirectoryTreeEntry({
254
279
  onRename,
255
280
  refresh: browseParentRefresh,
256
281
  });
257
- useEffect(() => {
258
- if (collapseVersion > 0) setIsExpanded(false);
259
- }, [collapseVersion]);
260
282
  const browseQuery = useServerPathBrowse({
261
283
  path: entry.path,
262
284
  includeFiles: true,
@@ -271,10 +293,10 @@ export function WorkspaceDirectoryTreeEntry({
271
293
  label: entry.name,
272
294
  path: entry.path,
273
295
  refresh: () => refreshDirectory(),
274
- expand: () => setIsExpanded(true),
296
+ expand: () => updateExpanded(true),
275
297
  }
276
298
  : parentCreateTarget,
277
- [entry.name, entry.path, isDirectory, parentCreateTarget, refreshDirectory],
299
+ [entry.name, entry.path, isDirectory, parentCreateTarget, refreshDirectory, updateExpanded],
278
300
  );
279
301
  const selection = useMemo<WorkspaceTreeSelection>(
280
302
  () => ({ path: entry.path, createTarget: ownCreateTarget }),
@@ -301,7 +323,7 @@ export function WorkspaceDirectoryTreeEntry({
301
323
 
302
324
  const activateEntry = () => {
303
325
  onSelect(selection);
304
- if (isDirectory) setIsExpanded(!isExpanded);
326
+ if (isDirectory) updateExpanded(!isExpanded);
305
327
  else onFileOpen({ path: entry.path, label: entry.name, viewMode: 'preview' });
306
328
  };
307
329
 
@@ -433,7 +455,6 @@ export function WorkspaceDirectoryTreeEntry({
433
455
  activeRelativePath={activeRelativePath}
434
456
  browseQuery={browseQuery}
435
457
  busy={busy}
436
- collapseVersion={collapseVersion}
437
458
  createTarget={createTarget}
438
459
  level={level}
439
460
  onAddToChat={onAddToChat}
@@ -451,6 +472,7 @@ export function WorkspaceDirectoryTreeEntry({
451
472
  renderCreateFolder={renderCreateFolder}
452
473
  revealPath={revealPath}
453
474
  selectedPath={selectedPath}
475
+ treeRootKey={treeRootKey}
454
476
  />
455
477
  </div>
456
478
  ) : null}
@@ -0,0 +1,93 @@
1
+ import { useEffect, useLayoutEffect, useRef, type MouseEventHandler, type ReactNode } from 'react';
2
+ import type { QueryKey } from '@tanstack/react-query';
3
+ import {
4
+ normalizeWorkspaceProjectTreeRootKey,
5
+ useWorkspaceProjectTreeStore,
6
+ } from '@/features/chat/features/workspace/stores/workspace-project-tree.store';
7
+ import { t } from '@/shared/lib/i18n';
8
+
9
+ function normalizeComparablePath(path: string): string {
10
+ const normalized = path.trim().replace(/\\/g, '/').replace(/\/+$/g, '') || '/';
11
+ return /^[a-z]:/i.test(normalized) ? normalized.toLowerCase() : normalized;
12
+ }
13
+
14
+ export function isWorkspaceProjectBrowseQuery(queryKey: QueryKey, rootPath: string): boolean {
15
+ if (queryKey[0] !== 'server-path-browse' || queryKey[3] !== true || typeof queryKey[1] !== 'string') {
16
+ return false;
17
+ }
18
+ const root = normalizeComparablePath(rootPath);
19
+ const candidate = normalizeComparablePath(queryKey[1]);
20
+ return candidate === root || (root === '/' ? candidate.startsWith('/') : candidate.startsWith(`${root}/`));
21
+ }
22
+
23
+ export function WorkspaceProjectTree({
24
+ children,
25
+ currentPath,
26
+ onBlankContext,
27
+ onContextMenu,
28
+ rootDraft,
29
+ showRoot,
30
+ }: {
31
+ children: ReactNode;
32
+ currentPath: string | null;
33
+ onBlankContext: () => void;
34
+ onContextMenu?: MouseEventHandler<HTMLDivElement>;
35
+ rootDraft: ReactNode;
36
+ showRoot: boolean;
37
+ }) {
38
+ const treeRef = useRef<HTMLDivElement | null>(null);
39
+ const pendingScrollTopRef = useRef<number | null>(null);
40
+ const scrollPersistTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
41
+ const treeRootKey = normalizeWorkspaceProjectTreeRootKey(currentPath);
42
+ const savedScrollTop = useWorkspaceProjectTreeStore((state) =>
43
+ treeRootKey ? (state.trees[treeRootKey]?.scrollTop ?? 0) : 0,
44
+ );
45
+ const setScrollTop = useWorkspaceProjectTreeStore((state) => state.setScrollTop);
46
+
47
+ useLayoutEffect(() => {
48
+ if (treeRef.current && treeRootKey) treeRef.current.scrollTop = savedScrollTop;
49
+ }, [savedScrollTop, treeRootKey]);
50
+
51
+ useEffect(
52
+ () => () => {
53
+ if (scrollPersistTimerRef.current) clearTimeout(scrollPersistTimerRef.current);
54
+ if (treeRootKey && pendingScrollTopRef.current !== null) {
55
+ setScrollTop(treeRootKey, pendingScrollTopRef.current);
56
+ }
57
+ },
58
+ [setScrollTop, treeRootKey],
59
+ );
60
+
61
+ return (
62
+ <div
63
+ ref={treeRef}
64
+ data-testid="workspace-directory-browser"
65
+ role="tree"
66
+ tabIndex={-1}
67
+ aria-label={t('chatWorkspaceProjectFiles')}
68
+ className="min-h-0 flex-1 overflow-auto py-1 custom-scrollbar"
69
+ onContextMenu={(event) => {
70
+ if (!(event.target as HTMLElement).closest('[data-workspace-tree-entry]')) onBlankContext();
71
+ onContextMenu?.(event);
72
+ }}
73
+ onScroll={(event) => {
74
+ if (!treeRootKey) return;
75
+ pendingScrollTopRef.current = event.currentTarget.scrollTop;
76
+ if (scrollPersistTimerRef.current) clearTimeout(scrollPersistTimerRef.current);
77
+ scrollPersistTimerRef.current = setTimeout(() => {
78
+ scrollPersistTimerRef.current = null;
79
+ if (pendingScrollTopRef.current !== null) setScrollTop(treeRootKey, pendingScrollTopRef.current);
80
+ }, 120);
81
+ }}
82
+ >
83
+ {showRoot && currentPath ? (
84
+ <>
85
+ {rootDraft}
86
+ {children}
87
+ </>
88
+ ) : (
89
+ children
90
+ )}
91
+ </div>
92
+ );
93
+ }
@@ -0,0 +1,38 @@
1
+ import { beforeEach, describe, expect, it } from 'vitest';
2
+ import { useWorkspaceProjectTreeStore } from '@/features/chat/features/workspace/stores/workspace-project-tree.store';
3
+
4
+ const ROOT = '/Users/peiwang/Projects/nextbot';
5
+
6
+ describe('workspace project tree store', () => {
7
+ beforeEach(() => {
8
+ window.localStorage.clear();
9
+ useWorkspaceProjectTreeStore.setState({ trees: {} });
10
+ });
11
+
12
+ it('keeps expansion and scroll view state isolated by project root', () => {
13
+ const store = useWorkspaceProjectTreeStore.getState();
14
+ store.setDirectoryExpanded(ROOT, 'src/features', true);
15
+ store.setScrollTop(ROOT, 128.4);
16
+ store.setDirectoryExpanded('/tmp/another-project', 'docs', true);
17
+
18
+ expect(useWorkspaceProjectTreeStore.getState().trees[ROOT]).toMatchObject({
19
+ expandedPaths: ['src/features'],
20
+ scrollTop: 128,
21
+ });
22
+ expect(useWorkspaceProjectTreeStore.getState().trees['/tmp/another-project']).toMatchObject({
23
+ expandedPaths: ['docs'],
24
+ scrollTop: 0,
25
+ });
26
+ });
27
+
28
+ it('normalizes paths and collapses only the requested project', () => {
29
+ const store = useWorkspaceProjectTreeStore.getState();
30
+ store.setDirectoryExpanded(`${ROOT}/`, '/src\\features/', true);
31
+ store.setDirectoryExpanded(ROOT, 'src/features', true);
32
+ store.setDirectoryExpanded('/tmp/another-project', 'docs', true);
33
+ useWorkspaceProjectTreeStore.getState().collapseAll(ROOT);
34
+
35
+ expect(useWorkspaceProjectTreeStore.getState().trees[ROOT]?.expandedPaths).toEqual([]);
36
+ expect(useWorkspaceProjectTreeStore.getState().trees['/tmp/another-project']?.expandedPaths).toEqual(['docs']);
37
+ });
38
+ });
@@ -0,0 +1,160 @@
1
+ import { create } from 'zustand';
2
+ import { createJSONStorage, persist } from 'zustand/middleware';
3
+
4
+ const WORKSPACE_PROJECT_TREE_STORAGE_KEY = 'nextclaw.chat.workspace-project-tree.state';
5
+ const WORKSPACE_PROJECT_TREE_STORAGE_VERSION = 1;
6
+ const MAX_PERSISTED_PROJECT_TREES = 12;
7
+ const MAX_PERSISTED_EXPANDED_PATHS = 256;
8
+ const MAX_PERSISTED_SCROLL_TOP = 10_000_000;
9
+
10
+ export type WorkspaceProjectTreeViewState = {
11
+ expandedPaths: string[];
12
+ scrollTop: number;
13
+ updatedAt: number;
14
+ };
15
+
16
+ type WorkspaceProjectTreeStore = {
17
+ trees: Record<string, WorkspaceProjectTreeViewState>;
18
+ collapseAll: (rootPath: string) => void;
19
+ setDirectoryExpanded: (rootPath: string, relativePath: string, expanded: boolean) => void;
20
+ setScrollTop: (rootPath: string, scrollTop: number) => void;
21
+ };
22
+
23
+ type PersistedWorkspaceProjectTreeStore = Pick<WorkspaceProjectTreeStore, 'trees'>;
24
+
25
+ function isRecord(value: unknown): value is Record<string, unknown> {
26
+ return !!value && typeof value === 'object';
27
+ }
28
+
29
+ export function normalizeWorkspaceProjectTreeRootKey(path: string | null | undefined): string {
30
+ const normalized = path?.trim().replace(/\\/g, '/').replace(/\/+$/g, '') ?? '';
31
+ return normalized || (path?.trim() === '/' ? '/' : '');
32
+ }
33
+
34
+ export function normalizeWorkspaceProjectTreeRelativePath(path: string | null | undefined): string {
35
+ return path?.trim().replace(/\\/g, '/').replace(/^\/+|\/+$/g, '').replace(/\/{2,}/g, '/') ?? '';
36
+ }
37
+
38
+ function normalizeScrollTop(value: unknown): number {
39
+ if (typeof value !== 'number' || !Number.isFinite(value)) return 0;
40
+ return Math.min(Math.max(0, Math.round(value)), MAX_PERSISTED_SCROLL_TOP);
41
+ }
42
+
43
+ function normalizeUpdatedAt(value: unknown): number {
44
+ return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : 0;
45
+ }
46
+
47
+ function normalizeExpandedPaths(value: unknown): string[] {
48
+ if (!Array.isArray(value)) return [];
49
+ return Array.from(
50
+ new Set(
51
+ value
52
+ .map((path) => (typeof path === 'string' ? normalizeWorkspaceProjectTreeRelativePath(path) : ''))
53
+ .filter(Boolean),
54
+ ),
55
+ ).slice(0, MAX_PERSISTED_EXPANDED_PATHS);
56
+ }
57
+
58
+ function normalizePersistedTrees(value: unknown): Record<string, WorkspaceProjectTreeViewState> {
59
+ if (!isRecord(value)) return {};
60
+ return Object.entries(value)
61
+ .map(([rootPath, tree]) => {
62
+ const rootKey = normalizeWorkspaceProjectTreeRootKey(rootPath);
63
+ if (!rootKey || !isRecord(tree)) return null;
64
+ return [
65
+ rootKey,
66
+ {
67
+ expandedPaths: normalizeExpandedPaths(tree.expandedPaths),
68
+ scrollTop: normalizeScrollTop(tree.scrollTop),
69
+ updatedAt: normalizeUpdatedAt(tree.updatedAt),
70
+ },
71
+ ] as const;
72
+ })
73
+ .filter((entry): entry is readonly [string, WorkspaceProjectTreeViewState] => entry !== null)
74
+ .sort((left, right) => right[1].updatedAt - left[1].updatedAt)
75
+ .slice(0, MAX_PERSISTED_PROJECT_TREES)
76
+ .reduce<Record<string, WorkspaceProjectTreeViewState>>((trees, [rootKey, tree]) => {
77
+ trees[rootKey] = tree;
78
+ return trees;
79
+ }, {});
80
+ }
81
+
82
+ function retainRecentTrees(
83
+ trees: Record<string, WorkspaceProjectTreeViewState>,
84
+ ): Record<string, WorkspaceProjectTreeViewState> {
85
+ return normalizePersistedTrees(trees);
86
+ }
87
+
88
+ function createTreeViewState(): WorkspaceProjectTreeViewState {
89
+ return { expandedPaths: [], scrollTop: 0, updatedAt: Date.now() };
90
+ }
91
+
92
+ export const useWorkspaceProjectTreeStore = create<WorkspaceProjectTreeStore>()(
93
+ persist(
94
+ (set) => ({
95
+ trees: {},
96
+ collapseAll: (rootPath) => {
97
+ const rootKey = normalizeWorkspaceProjectTreeRootKey(rootPath);
98
+ if (!rootKey) return;
99
+ set((state) => {
100
+ const current = state.trees[rootKey];
101
+ if (!current || current.expandedPaths.length === 0) return state;
102
+ return {
103
+ trees: retainRecentTrees({
104
+ ...state.trees,
105
+ [rootKey]: { ...current, expandedPaths: [], updatedAt: Date.now() },
106
+ }),
107
+ };
108
+ });
109
+ },
110
+ setDirectoryExpanded: (rootPath, relativePath, expanded) => {
111
+ const rootKey = normalizeWorkspaceProjectTreeRootKey(rootPath);
112
+ const pathKey = normalizeWorkspaceProjectTreeRelativePath(relativePath);
113
+ if (!rootKey || !pathKey) return;
114
+ set((state) => {
115
+ const current = state.trees[rootKey] ?? createTreeViewState();
116
+ const expandedPaths = expanded
117
+ ? Array.from(new Set([...current.expandedPaths, pathKey])).slice(0, MAX_PERSISTED_EXPANDED_PATHS)
118
+ : current.expandedPaths.filter((path) => path !== pathKey);
119
+ if (
120
+ expandedPaths.length === current.expandedPaths.length &&
121
+ expandedPaths.every((path, index) => path === current.expandedPaths[index])
122
+ ) {
123
+ return state;
124
+ }
125
+ return {
126
+ trees: retainRecentTrees({
127
+ ...state.trees,
128
+ [rootKey]: { ...current, expandedPaths, updatedAt: Date.now() },
129
+ }),
130
+ };
131
+ });
132
+ },
133
+ setScrollTop: (rootPath, scrollTop) => {
134
+ const rootKey = normalizeWorkspaceProjectTreeRootKey(rootPath);
135
+ if (!rootKey) return;
136
+ const normalizedScrollTop = normalizeScrollTop(scrollTop);
137
+ set((state) => {
138
+ const current = state.trees[rootKey] ?? createTreeViewState();
139
+ if (current.scrollTop === normalizedScrollTop) return state;
140
+ return {
141
+ trees: retainRecentTrees({
142
+ ...state.trees,
143
+ [rootKey]: { ...current, scrollTop: normalizedScrollTop, updatedAt: Date.now() },
144
+ }),
145
+ };
146
+ });
147
+ },
148
+ }),
149
+ {
150
+ name: WORKSPACE_PROJECT_TREE_STORAGE_KEY,
151
+ version: WORKSPACE_PROJECT_TREE_STORAGE_VERSION,
152
+ storage: createJSONStorage(() => window.localStorage),
153
+ partialize: (state): PersistedWorkspaceProjectTreeStore => ({ trees: state.trees }),
154
+ merge: (persistedState, currentState) => ({
155
+ ...currentState,
156
+ trees: normalizePersistedTrees(isRecord(persistedState) ? persistedState.trees : null),
157
+ }),
158
+ },
159
+ ),
160
+ );
@@ -4,6 +4,7 @@ export { ChatPresenterProvider } from "./components/providers/chat-presenter.pro
4
4
  export { usePresenter } from "./components/providers/chat-presenter.provider";
5
5
  export { ChatPresenter } from "./presenters/chat.presenter";
6
6
  export { ChatDraftIntentManager } from "./managers/chat-draft-intent.manager";
7
+ export { ChatComposerIntentManager } from "./managers/chat-composer-intent.manager";
7
8
  export { ChatCompletionNotificationManager } from "./managers/chat-completion-notification.manager";
8
9
  export { buildSessionPath, CHAT_DRAFT_SESSION_PATH } from "./features/session/utils/chat-session-route.utils";
9
10
  export { useChatSessionListStore } from "./stores/chat-session-list.store";
@@ -152,4 +152,33 @@ describe('ChatComposerIntentManager', () => {
152
152
  }),
153
153
  );
154
154
  });
155
+
156
+ it('publishes an immutable UI resource reference to the targeted composer', () => {
157
+ const manager = new ChatComposerIntentManager();
158
+ const listener = vi.fn();
159
+ manager.subscribe('session-1', listener);
160
+ const reference = {
161
+ uri: 'nextclaw://panel-app/task-board',
162
+ resourceKind: 'panel-app',
163
+ title: 'Task board',
164
+ currentUrl: '/api/panel-apps/task-board/content',
165
+ contentParams: { boardId: 'today' },
166
+ };
167
+
168
+ manager.requestUiResourceReference({
169
+ targetSessionKey: 'session-1',
170
+ reference,
171
+ });
172
+ reference.contentParams.boardId = 'mutated';
173
+
174
+ expect(listener).toHaveBeenCalledWith(expect.objectContaining({
175
+ kind: 'ui_resource',
176
+ targetSessionKey: 'session-1',
177
+ tokenKey: 'nextclaw://panel-app/task-board',
178
+ label: 'Task board',
179
+ reference: expect.objectContaining({
180
+ contentParams: { boardId: 'today' },
181
+ }),
182
+ }));
183
+ });
155
184
  });