@nextclaw/ui 0.15.24 → 0.15.25

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 (94) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/dist/assets/{api-BxPNDp30.js → api-FK7tikI1.js} +3 -3
  3. package/dist/assets/appearance-settings-page-BYbvl2wj.js +1 -0
  4. package/dist/assets/{channels-list-page-DRaNWAxC.js → channels-list-page-CocdcIko.js} +2 -2
  5. package/dist/assets/chat-page-C5E-LLVd.js +1 -0
  6. package/dist/assets/{config-split-page-BVWVDbvG.js → config-split-page-CA_a0AJ5.js} +1 -1
  7. package/dist/assets/{confirm-dialog-mA2iVzxh.js → confirm-dialog-CxZfenjK.js} +1 -1
  8. package/dist/assets/{desktop-update-config-DyQb1LXr.js → desktop-update-config-BEKC8Ly9.js} +1 -1
  9. package/dist/assets/{dist-B4Pf5X3Q.js → dist-BH7W5yCi.js} +1 -1
  10. package/dist/assets/{dist-DHEWkGWX.js → dist-CaYdVVeO.js} +1 -1
  11. package/dist/assets/doc-browser-Cl2ldE3f.js +1 -0
  12. package/dist/assets/doc-browser-DXCSd63E.js +1 -0
  13. package/dist/assets/{doc-browser-context-CuGI6PQc.js → doc-browser-context-FDPs3gJf.js} +1 -1
  14. package/dist/assets/{form-actions-BOfIyG3O.js → form-actions-0c0aw_bl.js} +1 -1
  15. package/dist/assets/i18n-provider-DkITJNz8.js +1 -0
  16. package/dist/assets/index-CQ2LcI2p.css +1 -0
  17. package/dist/assets/index-CrdfvBVg.js +107 -0
  18. package/dist/assets/loader-circle-DHtUxCIA.js +1 -0
  19. package/dist/assets/{mcp-marketplace-page-ofDqbOIF.js → mcp-marketplace-page-Bc_jI5iH.js} +2 -2
  20. package/dist/assets/mcp-marketplace-page-CzCieD9r.js +1 -0
  21. package/dist/assets/{model-config-page-D73Tu3jW.js → model-config-page-C4jw-pcj.js} +1 -1
  22. package/dist/assets/{navigation-link-CAFOcXNg.js → navigation-link-CVy0_kVl.js} +1 -1
  23. package/dist/assets/plus-CG1RwZQq.js +1 -0
  24. package/dist/assets/{provider-scoped-model-input-BaJJJYMX.js → provider-scoped-model-input-CmH4BFvc.js} +1 -1
  25. package/dist/assets/{providers-config-page-BL6IRVBm.js → providers-config-page-CJQjwzaw.js} +1 -1
  26. package/dist/assets/{react-BC_orpAj.js → react-DjFBVA0n.js} +1 -1
  27. package/dist/assets/{refresh-cw-CWDp_JnN.js → refresh-cw-j2bl79Ea.js} +1 -1
  28. package/dist/assets/remote-BIlpD3yL.js +1 -0
  29. package/dist/assets/rotate-cw-bKG8zH8M.js +1 -0
  30. package/dist/assets/runtime-config-page-B_x2Glz5.js +1 -0
  31. package/dist/assets/{save-Cnr71JON.js → save-BKNrtK81.js} +1 -1
  32. package/dist/assets/{search-CCX11X2m.js → search-CkOm65W_.js} +1 -1
  33. package/dist/assets/{search-config-page-BB6d5gT7.js → search-config-page-BaIijtmO.js} +1 -1
  34. package/dist/assets/{secrets-config-page-DYsDeT9K.js → secrets-config-page-CJjtdFAl.js} +2 -2
  35. package/dist/assets/security-config-DB8apj09.js +1 -0
  36. package/dist/assets/{select-DvwBu99K.js → select-Ea7ffKCE.js} +1 -1
  37. package/dist/assets/{settings-2-Dmv6zMne.js → settings-2-CtyN3m_V.js} +1 -1
  38. package/dist/assets/skeleton-Bt6zxcAB.js +1 -0
  39. package/dist/assets/{tag-chip-gMPvx7n_.js → tag-chip-C9l4xsKp.js} +1 -1
  40. package/dist/assets/x-CowtCu9Z.js +1 -0
  41. package/dist/index.html +20 -20
  42. package/package.json +8 -8
  43. package/src/features/chat/features/conversation/components/__tests__/session-conversation-area.test.tsx +30 -1
  44. package/src/features/chat/features/conversation/components/session-conversation-area.tsx +1 -1
  45. package/src/features/chat/features/ncp/hooks/__tests__/use-hydrated-ncp-agent.test.tsx +40 -3
  46. package/src/features/chat/features/workspace/components/__tests__/chat-session-workspace-file-preview.selection.test.tsx +66 -0
  47. package/src/features/chat/features/workspace/components/__tests__/chat-session-workspace-file-preview.test.tsx +13 -1
  48. package/src/features/chat/features/workspace/components/__tests__/chat-session-workspace-panel-content.test.tsx +573 -90
  49. package/src/features/chat/features/workspace/components/chat-session-workspace-directory-browser.tsx +389 -288
  50. package/src/features/chat/features/workspace/components/chat-session-workspace-file-breadcrumbs.tsx +54 -35
  51. package/src/features/chat/features/workspace/components/chat-session-workspace-file-preview.tsx +79 -148
  52. package/src/features/chat/features/workspace/components/chat-session-workspace-panel-content.tsx +149 -94
  53. package/src/features/chat/features/workspace/components/project-files/__tests__/workspace-directory-entry-menu.test.tsx +50 -0
  54. package/src/features/chat/features/workspace/components/project-files/workspace-directory-entry-menu.tsx +209 -0
  55. package/src/features/chat/features/workspace/components/project-files/workspace-directory-tree.tsx +411 -0
  56. package/src/features/chat/features/workspace/components/project-files/workspace-new-folder-tree-item.tsx +112 -0
  57. package/src/features/chat/features/workspace/components/project-files/workspace-project-files-toolbar.tsx +64 -0
  58. package/src/features/chat/features/workspace/hooks/use-workspace-explorer-layout.ts +88 -0
  59. package/src/features/chat/features/workspace/hooks/use-workspace-file-actions.ts +335 -0
  60. package/src/features/chat/features/workspace/utils/chat-workspace-file-path-mutation.utils.ts +58 -0
  61. package/src/features/chat/features/workspace/utils/chat-workspace-file-tab.utils.ts +23 -22
  62. package/src/features/chat/features/workspace/utils/chat-workspace-panel-layout.utils.ts +13 -4
  63. package/src/features/chat/managers/__tests__/chat-composer-intent.manager.test.ts +86 -60
  64. package/src/features/chat/managers/__tests__/chat-thread.manager.test.ts +103 -99
  65. package/src/features/chat/managers/chat-composer-intent.manager.ts +37 -33
  66. package/src/features/chat/managers/chat-thread.manager.ts +132 -128
  67. package/src/features/chat/stores/__tests__/chat-thread.store.test.ts +20 -40
  68. package/src/features/chat/stores/chat-thread.store.ts +47 -74
  69. package/src/shared/components/resizable-right-panel/__tests__/resizable-right-panel.test.tsx +35 -38
  70. package/src/shared/components/resizable-right-panel/resizable-right-panel.tsx +25 -19
  71. package/src/shared/components/ui/context-menu/__tests__/context-menu.test.tsx +60 -34
  72. package/src/shared/components/ui/context-menu/context-menu.tsx +95 -68
  73. package/src/shared/lib/api/managers/client.manager.ts +31 -17
  74. package/src/shared/lib/api/server-path/server-path.types.ts +66 -0
  75. package/src/shared/lib/api/types.ts +145 -116
  76. package/src/shared/lib/api/utils/server-path.utils.ts +35 -11
  77. package/src/shared/lib/i18n/locales/en-US/chat.json +42 -0
  78. package/src/shared/lib/i18n/locales/zh-CN/chat.json +42 -0
  79. package/dist/assets/appearance-settings-page-DpVJjvkB.js +0 -1
  80. package/dist/assets/chat-page-BuFDmG5_.js +0 -1
  81. package/dist/assets/doc-browser-CtO_HtRy.js +0 -1
  82. package/dist/assets/doc-browser-eMUagyBV.js +0 -1
  83. package/dist/assets/index-BjBdWHV1.js +0 -107
  84. package/dist/assets/index-DmpmjAGQ.css +0 -1
  85. package/dist/assets/loader-circle-Df8wF2Pr.js +0 -1
  86. package/dist/assets/mcp-marketplace-page-DI49IKxp.js +0 -1
  87. package/dist/assets/plus-DVU5RLCS.js +0 -1
  88. package/dist/assets/remote-T24O58lH.js +0 -1
  89. package/dist/assets/rotate-cw-BqhwSV5p.js +0 -1
  90. package/dist/assets/runtime-config-page-CXUx017t.js +0 -1
  91. package/dist/assets/security-config-CVPgqL7G.js +0 -1
  92. package/dist/assets/skeleton-C5tWata3.js +0 -1
  93. package/dist/assets/use-confirm-dialog-D_3-QpZU.js +0 -1
  94. package/dist/assets/x-BEWSqeux.js +0 -1
@@ -1,83 +1,200 @@
1
- import { fireEvent, render, screen } from "@testing-library/react";
2
- import userEvent from "@testing-library/user-event";
3
- import { expect, it, vi } from "vitest";
4
- import { ChatSessionWorkspacePanelContent } from "@/features/chat/features/workspace/components/chat-session-workspace-panel-content";
5
- import type { ResolvedChildSessionTab } from "@/features/chat/features/ncp/hooks/use-ncp-child-session-tabs-view";
1
+ import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
2
+ import userEvent from '@testing-library/user-event';
3
+ import { beforeEach, expect, it, vi } from 'vitest';
4
+ import { ChatSessionWorkspacePanelContent } from '@/features/chat/features/workspace/components/chat-session-workspace-panel-content';
5
+ import type { ResolvedChildSessionTab } from '@/features/chat/features/ncp/hooks/use-ncp-child-session-tabs-view';
6
+ import { useChatThreadStore } from '@/features/chat/stores/chat-thread.store';
7
+ import { CHAT_WORKSPACE_EXPLORER_DEFAULT_WIDTH } from '@/features/chat/features/workspace/utils/chat-workspace-panel-layout.utils';
6
8
 
7
9
  const mocks = vi.hoisted(() => ({
10
+ browseEntries: [
11
+ {
12
+ name: 'src',
13
+ path: '/Users/peiwang/Projects/nextbot/src',
14
+ kind: 'directory' as const,
15
+ hidden: false,
16
+ },
17
+ {
18
+ name: 'package.json',
19
+ path: '/private/resolved-worktree/package.json',
20
+ kind: 'file' as const,
21
+ hidden: false,
22
+ },
23
+ ],
8
24
  materializeSideChatDraft: vi.fn(),
25
+ createServerPathDirectory: vi.fn(),
26
+ createServerPathFile: vi.fn(),
27
+ deleteServerPathEntry: vi.fn(),
28
+ renameServerPathEntry: vi.fn(),
29
+ uploadServerPathFiles: vi.fn(),
30
+ refetchBrowse: vi.fn(),
31
+ requestDirectoryReference: vi.fn(),
9
32
  requestFileReference: vi.fn(),
10
33
  openChildSessions: vi.fn(),
11
34
  openFilePreview: vi.fn(),
12
35
  openProjectFiles: vi.fn(),
13
36
  openSessionCronPanel: vi.fn(),
37
+ removeWorkspacePath: vi.fn(),
38
+ renameWorkspacePath: vi.fn(),
14
39
  selectChildSessionDetail: vi.fn(),
40
+ setWorkspaceExplorerWidth: vi.fn(),
41
+ scrollIntoView: vi.fn(),
15
42
  }));
16
43
 
17
- vi.mock("@/features/chat/components/providers/chat-presenter.provider", () => ({
44
+ function firePointerEvent(
45
+ target: Window | Document | Node | Element,
46
+ type: string,
47
+ point: { clientX: number; pointerId?: number },
48
+ ) {
49
+ const event = new Event(type, { bubbles: true });
50
+ Object.defineProperties(event, {
51
+ clientX: { value: point.clientX },
52
+ pointerId: { value: point.pointerId ?? 1 },
53
+ });
54
+ fireEvent(target, event);
55
+ }
56
+
57
+ vi.mock('@/features/chat/components/providers/chat-presenter.provider', () => ({
18
58
  usePresenter: () => ({
19
59
  chatThreadManager: {
20
60
  ...mocks,
21
61
  },
22
62
  chatComposerIntentManager: {
63
+ requestDirectoryReference: mocks.requestDirectoryReference,
23
64
  requestFileReference: mocks.requestFileReference,
24
65
  },
25
66
  }),
26
67
  }));
27
68
 
28
- vi.mock("@/features/chat/features/conversation/components/session-conversation-area", () => ({
69
+ vi.mock('@/shared/lib/api', async (importOriginal) => ({
70
+ ...(await importOriginal()),
71
+ createServerPathDirectory: mocks.createServerPathDirectory,
72
+ createServerPathFile: mocks.createServerPathFile,
73
+ deleteServerPathEntry: mocks.deleteServerPathEntry,
74
+ renameServerPathEntry: mocks.renameServerPathEntry,
75
+ uploadServerPathFiles: mocks.uploadServerPathFiles,
76
+ }));
77
+
78
+ vi.mock('@/features/chat/features/conversation/components/session-conversation-area', () => ({
29
79
  SessionConversationArea: () => <div data-testid="session-conversation-area" />,
30
80
  }));
31
81
 
32
- vi.mock("@/shared/hooks/use-server-path-browse", () => ({
33
- useServerPathBrowse: () => ({
82
+ vi.mock('@/shared/hooks/use-server-path-browse', () => ({
83
+ useServerPathBrowse: ({ path }: { path?: string | null }) => ({
34
84
  isLoading: false,
35
85
  error: null,
36
- data: {
37
- currentPath: "/Users/peiwang/Projects/nextbot",
38
- parentPath: "/Users/peiwang/Projects",
39
- homePath: "/Users/peiwang",
40
- breadcrumbs: [],
41
- entries: [
42
- {
43
- name: "src",
44
- path: "/Users/peiwang/Projects/nextbot/src",
45
- kind: "directory",
46
- hidden: false,
47
- },
48
- {
49
- name: "package.json",
50
- path: "/private/resolved-worktree/package.json",
51
- kind: "file",
52
- hidden: false,
86
+ data: path?.endsWith('package.json')
87
+ ? undefined
88
+ : {
89
+ currentPath: '/Users/peiwang/Projects/nextbot',
90
+ parentPath: '/Users/peiwang/Projects',
91
+ homePath: '/Users/peiwang',
92
+ breadcrumbs: [],
93
+ entries: mocks.browseEntries,
53
94
  },
54
- ],
95
+ refetch: mocks.refetchBrowse,
96
+ }),
97
+ }));
98
+
99
+ vi.mock('@/shared/hooks/use-server-path-read', () => ({
100
+ useServerPathRead: ({ path }: { path: string }) => ({
101
+ isLoading: false,
102
+ error: null,
103
+ data: {
104
+ kind: 'text',
105
+ languageHint: 'json',
106
+ resolvedPath: path,
107
+ startLine: 1,
108
+ text: '{}',
109
+ truncated: false,
55
110
  },
56
111
  }),
57
112
  }));
58
113
 
114
+ beforeEach(() => {
115
+ vi.clearAllMocks();
116
+ HTMLElement.prototype.getBoundingClientRect = () => ({
117
+ bottom: 600,
118
+ height: 600,
119
+ left: 0,
120
+ right: 800,
121
+ top: 0,
122
+ width: 800,
123
+ x: 0,
124
+ y: 0,
125
+ toJSON: () => ({}),
126
+ });
127
+ mocks.browseEntries.splice(
128
+ 0,
129
+ mocks.browseEntries.length,
130
+ {
131
+ name: 'src',
132
+ path: '/Users/peiwang/Projects/nextbot/src',
133
+ kind: 'directory',
134
+ hidden: false,
135
+ },
136
+ {
137
+ name: 'package.json',
138
+ path: '/private/resolved-worktree/package.json',
139
+ kind: 'file',
140
+ hidden: false,
141
+ },
142
+ );
143
+ HTMLElement.prototype.scrollIntoView = mocks.scrollIntoView;
144
+ mocks.createServerPathDirectory.mockResolvedValue({
145
+ path: '/Users/peiwang/Projects/nextbot/new-folder',
146
+ });
147
+ mocks.createServerPathFile.mockResolvedValue({
148
+ path: '/Users/peiwang/Projects/nextbot/new-file.md',
149
+ });
150
+ mocks.renameServerPathEntry.mockResolvedValue({
151
+ path: '/Users/peiwang/Projects/nextbot/renamed.md',
152
+ oldPath: '/Users/peiwang/Projects/nextbot/package.json',
153
+ name: 'renamed.md',
154
+ kind: 'file',
155
+ });
156
+ mocks.deleteServerPathEntry.mockResolvedValue({
157
+ path: '/private/resolved-worktree/package.json',
158
+ kind: 'file',
159
+ });
160
+ mocks.uploadServerPathFiles.mockResolvedValue({
161
+ files: [
162
+ {
163
+ name: 'brief.md',
164
+ path: '/Users/peiwang/Projects/nextbot/brief.md',
165
+ sizeBytes: 5,
166
+ },
167
+ ],
168
+ overwritten: false,
169
+ });
170
+ mocks.refetchBrowse.mockResolvedValue(undefined);
171
+ useChatThreadStore.getState().setSnapshot({
172
+ workspaceExplorerWidth: CHAT_WORKSPACE_EXPLORER_DEFAULT_WIDTH,
173
+ });
174
+ });
175
+
59
176
  function createChildTab(): ResolvedChildSessionTab {
60
177
  return {
61
- sessionKey: "child-1",
62
- parentSessionKey: "parent-1",
63
- title: "Child title",
64
- agentId: "agent-1",
178
+ sessionKey: 'child-1',
179
+ parentSessionKey: 'parent-1',
180
+ title: 'Child title',
181
+ agentId: 'agent-1',
65
182
  updatedAt: null,
66
183
  lastMessageAt: null,
67
184
  readAt: null,
68
185
  runStatus: undefined,
69
- sessionTypeLabel: "原生",
70
- preferredModel: "minimax/MiniMax-M3",
71
- projectName: "nextbot",
72
- projectRoot: "/Users/peiwang/Projects/nextbot",
186
+ sessionTypeLabel: '原生',
187
+ preferredModel: 'minimax/MiniMax-M3',
188
+ projectName: 'nextbot',
189
+ projectRoot: '/Users/peiwang/Projects/nextbot',
73
190
  };
74
191
  }
75
192
 
76
- it("shows compact child session metadata without repeating the tab title", () => {
193
+ it('shows compact child session metadata without repeating the tab title', () => {
77
194
  render(
78
195
  <ChatSessionWorkspacePanelContent
79
196
  activeSelection={{
80
- kind: "child-session",
197
+ kind: 'child-session',
81
198
  tab: createChildTab(),
82
199
  }}
83
200
  childSessionTabs={[createChildTab()]}
@@ -89,20 +206,20 @@ it("shows compact child session metadata without repeating the tab title", () =>
89
206
  />,
90
207
  );
91
208
 
92
- expect(screen.queryByText("Child title")).toBeNull();
93
- expect(screen.getByText("原生")).toBeTruthy();
94
- expect(screen.getByText("minimax/MiniMax-M3")).toBeTruthy();
95
- expect(screen.getByText("nextbot")).toBeTruthy();
96
- expect(screen.getByTitle("/Users/peiwang/Projects/nextbot")).toBeTruthy();
209
+ expect(screen.queryByText('Child title')).toBeNull();
210
+ expect(screen.getByText('原生')).toBeTruthy();
211
+ expect(screen.getByText('minimax/MiniMax-M3')).toBeTruthy();
212
+ expect(screen.getByText('nextbot')).toBeTruthy();
213
+ expect(screen.getByTitle('/Users/peiwang/Projects/nextbot')).toBeTruthy();
97
214
  });
98
215
 
99
- it("shows all session workspace entries in the overview", async () => {
216
+ it('shows all session workspace entries in the overview', async () => {
100
217
  const user = userEvent.setup();
101
218
  const childTab = createChildTab();
102
219
 
103
220
  render(
104
221
  <ChatSessionWorkspacePanelContent
105
- activeSelection={{ kind: "overview" }}
222
+ activeSelection={{ kind: 'overview' }}
106
223
  childSessionTabs={[childTab]}
107
224
  filePreviewRefreshVersion={0}
108
225
  sessionKey="parent-1"
@@ -112,11 +229,11 @@ it("shows all session workspace entries in the overview", async () => {
112
229
  />,
113
230
  );
114
231
 
115
- expect(screen.getByText("Overview")).toBeTruthy();
116
- const childSessionsButton = screen.getByRole("button", {
232
+ expect(screen.getByText('Overview')).toBeTruthy();
233
+ const childSessionsButton = screen.getByRole('button', {
117
234
  name: /Child sessions/,
118
235
  });
119
- const cronJobsButton = screen.getByRole("button", {
236
+ const cronJobsButton = screen.getByRole('button', {
120
237
  name: /Scheduled tasks/,
121
238
  });
122
239
 
@@ -125,17 +242,17 @@ it("shows all session workspace entries in the overview", async () => {
125
242
 
126
243
  await user.click(childSessionsButton);
127
244
  await user.click(cronJobsButton);
128
- await user.click(screen.getByRole("button", { name: /Project files/ }));
245
+ await user.click(screen.getByRole('button', { name: /Project files/ }));
129
246
 
130
- expect(mocks.openChildSessions).toHaveBeenCalledWith("parent-1");
131
- expect(mocks.openSessionCronPanel).toHaveBeenCalledWith("parent-1");
132
- expect(mocks.openProjectFiles).toHaveBeenCalledWith("parent-1");
247
+ expect(mocks.openChildSessions).toHaveBeenCalledWith('parent-1');
248
+ expect(mocks.openSessionCronPanel).toHaveBeenCalledWith('parent-1');
249
+ expect(mocks.openProjectFiles).toHaveBeenCalledWith('parent-1');
133
250
  });
134
251
 
135
- it("shows an empty child sessions page instead of disabling the entry", () => {
252
+ it('shows an empty child sessions page instead of disabling the entry', () => {
136
253
  render(
137
254
  <ChatSessionWorkspacePanelContent
138
- activeSelection={{ kind: "child-sessions" }}
255
+ activeSelection={{ kind: 'child-sessions' }}
139
256
  childSessionTabs={[]}
140
257
  filePreviewRefreshVersion={0}
141
258
  sessionKey="parent-1"
@@ -145,13 +262,13 @@ it("shows an empty child sessions page instead of disabling the entry", () => {
145
262
  />,
146
263
  );
147
264
 
148
- expect(screen.getByText("No child sessions yet.")).toBeTruthy();
265
+ expect(screen.getByText('No child sessions yet.')).toBeTruthy();
149
266
  });
150
267
 
151
- it("shows the selected session project as a hierarchical file tree", () => {
268
+ it('shows the selected session project as a hierarchical file tree', () => {
152
269
  render(
153
270
  <ChatSessionWorkspacePanelContent
154
- activeSelection={{ kind: "project-files" }}
271
+ activeSelection={{ kind: 'project-files' }}
155
272
  childSessionTabs={[]}
156
273
  filePreviewRefreshVersion={0}
157
274
  sessionKey="parent-1"
@@ -161,20 +278,18 @@ it("shows the selected session project as a hierarchical file tree", () => {
161
278
  />,
162
279
  );
163
280
 
164
- expect(screen.getByRole("tree", { name: "Project files" })).toBeTruthy();
165
- expect(screen.getByText("nextbot")).toBeTruthy();
166
- expect(
167
- screen.getByRole("treeitem", { name: "Open directory: src" }),
168
- ).toBeTruthy();
281
+ expect(screen.getByRole('tree', { name: 'Project files' })).toBeTruthy();
282
+ expect(screen.getByText('nextbot')).toBeTruthy();
283
+ expect(screen.getByRole('treeitem', { name: 'Open directory: src' })).toBeTruthy();
169
284
  expect(document.querySelector('[data-file-type-icon="npm"]')).toBeTruthy();
170
285
  });
171
286
 
172
- it("adds a project file to the targeted chat from its context menu", async () => {
287
+ it('adds a project file to the targeted chat from its context menu', async () => {
173
288
  const user = userEvent.setup();
174
289
  mocks.requestFileReference.mockReset();
175
290
  render(
176
291
  <ChatSessionWorkspacePanelContent
177
- activeSelection={{ kind: "project-files" }}
292
+ activeSelection={{ kind: 'project-files' }}
178
293
  childSessionTabs={[]}
179
294
  filePreviewRefreshVersion={0}
180
295
  sessionKey={null}
@@ -184,53 +299,421 @@ it("adds a project file to the targeted chat from its context menu", async () =>
184
299
  />,
185
300
  );
186
301
 
187
- fireEvent.contextMenu(
188
- screen.getByRole("button", { name: "package.json" }),
189
- );
302
+ fireEvent.contextMenu(screen.getByRole('button', { name: 'package.json' }));
190
303
 
191
- expect(screen.getByRole("menuitem", { name: "Copy path" })).toBeTruthy();
192
- expect(screen.getByRole("menuitem", { name: "Copy relative path" })).toBeTruthy();
193
- await user.click(screen.getByRole("menuitem", { name: "Add to chat" }));
304
+ expect(screen.getByRole('menuitem', { name: 'Copy path' })).toBeTruthy();
305
+ expect(screen.getByRole('menuitem', { name: 'Copy relative path' })).toBeTruthy();
306
+ await user.click(screen.getByRole('menuitem', { name: 'Add to chat' }));
194
307
 
195
308
  expect(mocks.requestFileReference).toHaveBeenCalledWith({
196
309
  targetSessionKey: null,
197
- tokenKey: "package.json",
198
- label: "package.json",
310
+ tokenKey: 'package.json',
311
+ label: 'package.json',
312
+ });
313
+ });
314
+
315
+ it('adds a project folder to chat with the directory reference protocol', async () => {
316
+ const user = userEvent.setup();
317
+ render(
318
+ <ChatSessionWorkspacePanelContent
319
+ activeSelection={{ kind: 'project-files' }}
320
+ childSessionTabs={[]}
321
+ filePreviewRefreshVersion={0}
322
+ sessionKey="parent-1"
323
+ sessionCronJobs={[]}
324
+ sessionProjectRoot="/Users/peiwang/Projects/nextbot"
325
+ sessionWorkingDir="/Users/peiwang/Projects/nextbot"
326
+ />,
327
+ );
328
+
329
+ fireEvent.contextMenu(screen.getByRole('button', { name: 'src' }));
330
+ await user.click(screen.getByRole('menuitem', { name: 'Add to chat' }));
331
+
332
+ expect(mocks.requestDirectoryReference).toHaveBeenCalledWith({
333
+ targetSessionKey: 'parent-1',
334
+ tokenKey: 'src',
335
+ label: 'src',
336
+ });
337
+ });
338
+
339
+ it('keeps the five Explorer actions distinct and exposes upload from the root menu', async () => {
340
+ const user = userEvent.setup();
341
+ render(
342
+ <ChatSessionWorkspacePanelContent
343
+ activeSelection={{ kind: 'project-files' }}
344
+ childSessionTabs={[]}
345
+ filePreviewRefreshVersion={0}
346
+ sessionKey="parent-1"
347
+ sessionCronJobs={[]}
348
+ sessionProjectRoot="/Users/peiwang/Projects/nextbot"
349
+ sessionWorkingDir="/Users/peiwang/Projects/nextbot"
350
+ />,
351
+ );
352
+
353
+ for (const name of ['New file', 'New folder', 'Refresh project files', 'Collapse all', 'More project file actions']) {
354
+ expect(screen.getByRole('button', { name })).toBeTruthy();
355
+ }
356
+ expect(screen.getByRole('button', { name: 'More actions for package.json' })).toBeTruthy();
357
+
358
+ await user.click(screen.getByRole('button', { name: 'More project file actions' }));
359
+ expect(screen.getByRole('menuitem', { name: 'Upload files here' })).toBeTruthy();
360
+ await user.keyboard('{Escape}');
361
+
362
+ fireEvent.contextMenu(screen.getByRole('button', { name: 'package.json' }));
363
+ const fileMenu = screen.getByRole('menu', {
364
+ name: 'Project file actions',
365
+ });
366
+ for (const name of ['Open', 'Add to chat', 'Download', 'Copy path', 'Copy relative path', 'Rename', 'Delete']) {
367
+ expect(within(fileMenu).getByRole('menuitem', { name })).toBeTruthy();
368
+ }
369
+ expect(within(fileMenu).getAllByRole('group')).toHaveLength(5);
370
+ const download = screen.getByRole('menuitem', { name: 'Download' });
371
+ expect(download.tagName).toBe('A');
372
+ expect(download.getAttribute('download')).toBe('package.json');
373
+ expect(download.getAttribute('href')).toContain(
374
+ '/api/server-paths/content?path=%2Fprivate%2Fresolved-worktree%2Fpackage.json',
375
+ );
376
+ });
377
+
378
+ it('creates a project file inline from the distinct toolbar action', async () => {
379
+ const user = userEvent.setup();
380
+ render(
381
+ <ChatSessionWorkspacePanelContent
382
+ activeSelection={{ kind: 'project-files' }}
383
+ childSessionTabs={[]}
384
+ filePreviewRefreshVersion={0}
385
+ sessionKey="parent-1"
386
+ sessionCronJobs={[]}
387
+ sessionProjectRoot="/Users/peiwang/Projects/nextbot"
388
+ sessionWorkingDir="/Users/peiwang/Projects/nextbot"
389
+ />,
390
+ );
391
+
392
+ await user.click(screen.getByRole('button', { name: 'New file' }));
393
+ const input = screen.getByRole('textbox', { name: 'File name' });
394
+ await user.type(input, 'notes.md');
395
+ await user.click(screen.getByRole('button', { name: 'Create' }));
396
+
397
+ await waitFor(() => {
398
+ expect(mocks.createServerPathFile).toHaveBeenCalledWith({
399
+ basePath: '/Users/peiwang/Projects/nextbot',
400
+ parentPath: '/Users/peiwang/Projects/nextbot',
401
+ name: 'notes.md',
402
+ });
403
+ });
404
+ });
405
+
406
+ it('renames a project file inline and synchronizes open workspace tabs', async () => {
407
+ const user = userEvent.setup();
408
+ render(
409
+ <ChatSessionWorkspacePanelContent
410
+ activeSelection={{ kind: 'project-files' }}
411
+ childSessionTabs={[]}
412
+ filePreviewRefreshVersion={0}
413
+ sessionKey="parent-1"
414
+ sessionCronJobs={[]}
415
+ sessionProjectRoot="/Users/peiwang/Projects/nextbot"
416
+ sessionWorkingDir="/Users/peiwang/Projects/nextbot"
417
+ />,
418
+ );
419
+
420
+ fireEvent.contextMenu(screen.getByRole('button', { name: 'package.json' }));
421
+ await user.click(screen.getByRole('menuitem', { name: 'Rename' }));
422
+ const input = screen.getByRole('textbox', { name: 'New name' });
423
+ await user.clear(input);
424
+ await user.type(input, 'renamed.md');
425
+ await user.click(screen.getByRole('button', { name: 'Rename' }));
426
+
427
+ await waitFor(() => {
428
+ expect(mocks.renameServerPathEntry).toHaveBeenCalledWith({
429
+ basePath: '/Users/peiwang/Projects/nextbot',
430
+ path: '/private/resolved-worktree/package.json',
431
+ name: 'renamed.md',
432
+ });
433
+ expect(mocks.renameWorkspacePath).toHaveBeenCalledWith({
434
+ previousPath: '/Users/peiwang/Projects/nextbot/package.json',
435
+ path: '/Users/peiwang/Projects/nextbot/renamed.md',
436
+ label: 'renamed.md',
437
+ });
199
438
  });
200
439
  });
201
440
 
202
- it("preserves project tree expansion and scroll while visiting another workspace tab", async () => {
441
+ it('creates a project folder from the root toolbar and refreshes the tree', async () => {
442
+ const user = userEvent.setup();
443
+ mocks.createServerPathDirectory.mockImplementationOnce(async () => {
444
+ const path = '/Users/peiwang/Projects/nextbot/research';
445
+ mocks.browseEntries.push({
446
+ name: 'research',
447
+ path,
448
+ kind: 'directory',
449
+ hidden: false,
450
+ });
451
+ return { path };
452
+ });
453
+ render(
454
+ <ChatSessionWorkspacePanelContent
455
+ activeSelection={{ kind: 'project-files' }}
456
+ childSessionTabs={[]}
457
+ filePreviewRefreshVersion={0}
458
+ sessionKey="parent-1"
459
+ sessionCronJobs={[]}
460
+ sessionProjectRoot="/Users/peiwang/Projects/nextbot"
461
+ sessionWorkingDir="/Users/peiwang/Projects/nextbot"
462
+ />,
463
+ );
464
+
465
+ await user.click(screen.getByRole('button', { name: 'New folder' }));
466
+ expect(document.activeElement).toBe(screen.getByRole('textbox', { name: 'Folder name' }));
467
+ expect(mocks.scrollIntoView).toHaveBeenCalledWith({ block: 'nearest' });
468
+ await user.type(screen.getByRole('textbox', { name: 'Folder name' }), 'research');
469
+ await user.click(screen.getByRole('button', { name: 'Create' }));
470
+
471
+ await waitFor(() => {
472
+ expect(mocks.createServerPathDirectory).toHaveBeenCalledWith({
473
+ basePath: '/Users/peiwang/Projects/nextbot',
474
+ parentPath: '/Users/peiwang/Projects/nextbot',
475
+ name: 'research',
476
+ });
477
+ expect(mocks.refetchBrowse).toHaveBeenCalled();
478
+ expect(document.activeElement).toBe(screen.getByRole('button', { name: 'research' }));
479
+ expect(mocks.scrollIntoView).toHaveBeenCalledTimes(2);
480
+ });
481
+ });
482
+
483
+ it('uses the selected directory as the toolbar target and creates inline', async () => {
484
+ const user = userEvent.setup();
485
+ render(
486
+ <ChatSessionWorkspacePanelContent
487
+ activeSelection={{ kind: 'project-files' }}
488
+ childSessionTabs={[]}
489
+ filePreviewRefreshVersion={0}
490
+ sessionKey="parent-1"
491
+ sessionCronJobs={[]}
492
+ sessionProjectRoot="/Users/peiwang/Projects/nextbot"
493
+ sessionWorkingDir="/Users/peiwang/Projects/nextbot"
494
+ />,
495
+ );
496
+
497
+ await user.click(screen.getByRole('button', { name: 'src' }));
498
+ await user.click(screen.getByRole('button', { name: 'New folder' }));
499
+
500
+ const input = screen.getByRole('textbox', { name: 'Folder name' });
501
+ expect(input.closest('[role="group"]')?.parentElement?.getAttribute('aria-label')).toBe('Open directory: src');
502
+ await user.type(input, 'nested');
503
+ await user.click(screen.getByRole('button', { name: 'Create' }));
504
+
505
+ await waitFor(() => {
506
+ expect(mocks.createServerPathDirectory).toHaveBeenCalledWith({
507
+ basePath: '/Users/peiwang/Projects/nextbot',
508
+ parentPath: '/Users/peiwang/Projects/nextbot/src',
509
+ name: 'nested',
510
+ });
511
+ });
512
+ });
513
+
514
+ it('opens root actions from blank tree space and reveals an inline draft', async () => {
515
+ const user = userEvent.setup();
516
+ render(
517
+ <ChatSessionWorkspacePanelContent
518
+ activeSelection={{ kind: 'project-files' }}
519
+ childSessionTabs={[]}
520
+ filePreviewRefreshVersion={0}
521
+ sessionKey="parent-1"
522
+ sessionCronJobs={[]}
523
+ sessionProjectRoot="/Users/peiwang/Projects/nextbot"
524
+ sessionWorkingDir="/Users/peiwang/Projects/nextbot"
525
+ />,
526
+ );
527
+
528
+ fireEvent.contextMenu(screen.getByRole('tree', { name: 'Project files' }));
529
+ expect(screen.getByRole('menu', { name: 'Project root actions' })).toBeTruthy();
530
+ expect(screen.getByRole('menuitem', { name: 'Upload files here' })).toBeTruthy();
531
+ await user.click(screen.getByRole('menuitem', { name: 'New folder' }));
532
+
533
+ const input = screen.getByRole('textbox', { name: 'Folder name' });
534
+ expect(document.activeElement).toBe(input);
535
+ expect(mocks.scrollIntoView).toHaveBeenCalledWith({ block: 'nearest' });
536
+ });
537
+
538
+ it('uploads multiple project files from the root actions menu', async () => {
539
+ const user = userEvent.setup();
540
+ render(
541
+ <ChatSessionWorkspacePanelContent
542
+ activeSelection={{ kind: 'project-files' }}
543
+ childSessionTabs={[]}
544
+ filePreviewRefreshVersion={0}
545
+ sessionKey="parent-1"
546
+ sessionCronJobs={[]}
547
+ sessionProjectRoot="/Users/peiwang/Projects/nextbot"
548
+ sessionWorkingDir="/Users/peiwang/Projects/nextbot"
549
+ />,
550
+ );
551
+
552
+ await user.click(screen.getByRole('button', { name: 'More project file actions' }));
553
+ await user.click(screen.getByRole('menuitem', { name: 'Upload files here' }));
554
+ const file = new File(['brief'], 'brief.md', { type: 'text/markdown' });
555
+ fireEvent.change(screen.getByLabelText('Upload files'), {
556
+ target: { files: [file] },
557
+ });
558
+
559
+ await waitFor(() => {
560
+ expect(mocks.uploadServerPathFiles).toHaveBeenCalledWith({
561
+ basePath: '/Users/peiwang/Projects/nextbot',
562
+ targetPath: '/Users/peiwang/Projects/nextbot',
563
+ files: [file],
564
+ overwrite: undefined,
565
+ });
566
+ expect(mocks.refetchBrowse).toHaveBeenCalled();
567
+ });
568
+ });
569
+
570
+ it('requires destructive confirmation before deleting a project file', async () => {
571
+ const user = userEvent.setup();
572
+ render(
573
+ <ChatSessionWorkspacePanelContent
574
+ activeSelection={{ kind: 'project-files' }}
575
+ childSessionTabs={[]}
576
+ filePreviewRefreshVersion={0}
577
+ sessionKey="parent-1"
578
+ sessionCronJobs={[]}
579
+ sessionProjectRoot="/Users/peiwang/Projects/nextbot"
580
+ sessionWorkingDir="/Users/peiwang/Projects/nextbot"
581
+ />,
582
+ );
583
+
584
+ fireEvent.contextMenu(screen.getByRole('button', { name: 'package.json' }));
585
+ await user.click(screen.getByRole('menuitem', { name: 'Delete' }));
586
+ expect(mocks.deleteServerPathEntry).not.toHaveBeenCalled();
587
+ expect(screen.getByText('Delete package.json?')).toBeTruthy();
588
+
589
+ await user.click(screen.getByRole('button', { name: 'Delete' }));
590
+ await waitFor(() => {
591
+ expect(mocks.deleteServerPathEntry).toHaveBeenCalledWith({
592
+ basePath: '/Users/peiwang/Projects/nextbot',
593
+ path: '/private/resolved-worktree/package.json',
594
+ });
595
+ expect(mocks.removeWorkspacePath).toHaveBeenCalledWith('/private/resolved-worktree/package.json');
596
+ expect(mocks.refetchBrowse).toHaveBeenCalled();
597
+ });
598
+ });
599
+
600
+ it('preserves project tree expansion and scroll while visiting another workspace tab', async () => {
203
601
  const user = userEvent.setup();
204
602
  const projectFilesProps = {
205
- activeSelection: { kind: "project-files" as const },
603
+ activeSelection: { kind: 'project-files' as const },
206
604
  childSessionTabs: [],
207
605
  filePreviewRefreshVersion: 0,
208
- sessionKey: "parent-1",
606
+ sessionKey: 'parent-1',
209
607
  sessionCronJobs: [],
210
- sessionProjectRoot: "/Users/peiwang/Projects/nextbot",
211
- sessionWorkingDir: "/Users/peiwang/Projects/nextbot",
608
+ sessionProjectRoot: '/Users/peiwang/Projects/nextbot',
609
+ sessionWorkingDir: '/Users/peiwang/Projects/nextbot',
212
610
  };
213
- const { rerender } = render(
214
- <ChatSessionWorkspacePanelContent {...projectFilesProps} />,
215
- );
216
- const tree = screen.getByRole("tree", { name: "Project files" });
217
- const sourceDirectory = screen.getByRole("treeitem", {
218
- name: "Open directory: src",
611
+ const { rerender } = render(<ChatSessionWorkspacePanelContent {...projectFilesProps} />);
612
+ const tree = screen.getByRole('tree', { name: 'Project files' });
613
+ const sourceDirectory = screen.getByRole('treeitem', {
614
+ name: 'Open directory: src',
219
615
  });
220
- await user.click(screen.getByRole("button", { name: "src" }));
616
+ await user.click(screen.getByRole('button', { name: 'src' }));
221
617
  tree.scrollTop = 128;
222
- expect(sourceDirectory.getAttribute("aria-expanded")).toBe("true");
618
+ expect(sourceDirectory.getAttribute('aria-expanded')).toBe('true');
619
+
620
+ rerender(<ChatSessionWorkspacePanelContent {...projectFilesProps} activeSelection={{ kind: 'overview' }} />);
621
+ rerender(<ChatSessionWorkspacePanelContent {...projectFilesProps} />);
622
+
623
+ expect(screen.getByRole('tree', { name: 'Project files' })).toBe(tree);
624
+ expect(sourceDirectory.isConnected).toBe(true);
625
+ expect(sourceDirectory.getAttribute('aria-expanded')).toBe('true');
626
+ expect(tree.scrollTop).toBe(128);
627
+ });
628
+
629
+ it('reuses the Explorer beside a file and persists a dragged compact width', () => {
630
+ const projectFilesProps = {
631
+ activeSelection: { kind: 'project-files' as const },
632
+ childSessionTabs: [],
633
+ filePreviewRefreshVersion: 0,
634
+ sessionKey: 'parent-1',
635
+ sessionCronJobs: [],
636
+ sessionProjectRoot: '/Users/peiwang/Projects/nextbot',
637
+ sessionWorkingDir: '/Users/peiwang/Projects/nextbot',
638
+ };
639
+ const { rerender } = render(<ChatSessionWorkspacePanelContent {...projectFilesProps} />);
640
+ const tree = screen.getByRole('tree', { name: 'Project files' });
223
641
 
224
642
  rerender(
225
643
  <ChatSessionWorkspacePanelContent
226
644
  {...projectFilesProps}
227
- activeSelection={{ kind: "overview" }}
645
+ activeSelection={{
646
+ kind: 'file',
647
+ file: {
648
+ key: 'parent-1::preview::package.json',
649
+ parentSessionKey: 'parent-1',
650
+ path: '/Users/peiwang/Projects/nextbot/package.json',
651
+ label: 'package.json',
652
+ viewMode: 'preview',
653
+ previewViewer: 'source',
654
+ },
655
+ }}
228
656
  />,
229
657
  );
230
- rerender(<ChatSessionWorkspacePanelContent {...projectFilesProps} />);
231
658
 
232
- expect(screen.getByRole("tree", { name: "Project files" })).toBe(tree);
233
- expect(sourceDirectory.isConnected).toBe(true);
234
- expect(sourceDirectory.getAttribute("aria-expanded")).toBe("true");
235
- expect(tree.scrollTop).toBe(128);
659
+ const explorer = screen.getByTestId('workspace-shared-explorer');
660
+ const breadcrumbs = screen.getByTestId('workspace-file-breadcrumbs');
661
+ const toggle = screen.getByTestId('workspace-explorer-toggle');
662
+ expect(screen.getByRole('tree', { name: 'Project files' })).toBe(tree);
663
+ expect(explorer.getAttribute('data-mode')).toBe('side');
664
+ expect(explorer.style.width).toBe('224px');
665
+ expect(breadcrumbs.contains(toggle)).toBe(true);
666
+ expect(toggle.parentElement?.className).not.toContain('border-r');
667
+
668
+ const handle = screen.getByRole('separator', {
669
+ name: 'Resize project files',
670
+ });
671
+ firePointerEvent(handle, 'pointerdown', { clientX: 224, pointerId: 1 });
672
+ firePointerEvent(window, 'pointermove', { clientX: 300, pointerId: 1 });
673
+ firePointerEvent(window, 'pointerup', { clientX: 300, pointerId: 1 });
674
+ expect(mocks.setWorkspaceExplorerWidth).toHaveBeenCalledWith(300);
675
+ });
676
+
677
+ it('uses an overlay only below the compact threshold and closes it without unmounting', () => {
678
+ HTMLElement.prototype.getBoundingClientRect = () => ({
679
+ bottom: 600,
680
+ height: 600,
681
+ left: 0,
682
+ right: 540,
683
+ top: 0,
684
+ width: 540,
685
+ x: 0,
686
+ y: 0,
687
+ toJSON: () => ({}),
688
+ });
689
+ render(
690
+ <ChatSessionWorkspacePanelContent
691
+ activeSelection={{
692
+ kind: 'file',
693
+ file: {
694
+ key: 'parent-1::preview::package.json',
695
+ parentSessionKey: 'parent-1',
696
+ path: '/Users/peiwang/Projects/nextbot/package.json',
697
+ label: 'package.json',
698
+ viewMode: 'preview',
699
+ previewViewer: 'source',
700
+ },
701
+ }}
702
+ childSessionTabs={[]}
703
+ filePreviewRefreshVersion={0}
704
+ sessionKey="parent-1"
705
+ sessionCronJobs={[]}
706
+ sessionProjectRoot="/Users/peiwang/Projects/nextbot"
707
+ sessionWorkingDir="/Users/peiwang/Projects/nextbot"
708
+ />,
709
+ );
710
+
711
+ const explorer = screen.getByTestId('workspace-shared-explorer');
712
+ const tree = screen.getByRole('tree', { name: 'Project files' });
713
+ expect(explorer.getAttribute('data-mode')).toBe('overlay');
714
+ expect(screen.queryByTestId('workspace-explorer-resize-handle')).toBeNull();
715
+
716
+ fireEvent.click(screen.getByTestId('workspace-explorer-scrim'));
717
+ expect(explorer.getAttribute('aria-hidden')).toBe('true');
718
+ expect(tree.isConnected).toBe(true);
236
719
  });