@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,22 +1,31 @@
1
- import type { ReactNode } from "react";
1
+ import type { ReactNode } from 'react';
2
+ import type { ChatFileOpenActionViewModel } from '@nextclaw/agent-chat-ui';
2
3
  import {
3
4
  AlarmClock,
4
5
  ChevronRight,
5
6
  FolderTree,
6
7
  GitBranch,
7
8
  MessageSquarePlus,
8
- } from "lucide-react";
9
- import type { CronJobView } from "@/shared/lib/api";
10
- import { usePresenter } from "@/features/chat/components/providers/chat-presenter.provider";
11
- import { SessionConversationArea } from "@/features/chat/features/conversation/components/session-conversation-area";
12
- import { ChatSessionWorkspaceFilePreview } from "@/features/chat/features/workspace/components/chat-session-workspace-file-preview";
13
- import { ChatSessionWorkspaceDirectoryBrowser } from "@/features/chat/features/workspace/components/chat-session-workspace-directory-browser";
14
- import { SessionCronJobContent } from "@/features/chat/features/workspace/components/session-cron-job-content";
15
- import type { ResolvedChildSessionTab } from "@/features/chat/features/ncp/hooks/use-ncp-child-session-tabs-view";
16
- import type { WorkspaceSelection } from "@/features/chat/features/workspace/utils/chat-workspace-panel-view-model.utils";
17
- import { useServerPathBrowse } from "@/shared/hooks/use-server-path-browse";
18
- import { t } from "@/shared/lib/i18n";
19
- import { cn } from "@/shared/lib/utils";
9
+ PanelLeftClose,
10
+ PanelLeftOpen,
11
+ } from 'lucide-react';
12
+ import type { CronJobView } from '@/shared/lib/api';
13
+ import { usePresenter } from '@/features/chat/components/providers/chat-presenter.provider';
14
+ import { SessionConversationArea } from '@/features/chat/features/conversation/components/session-conversation-area';
15
+ import { ChatSessionWorkspaceFilePreview } from '@/features/chat/features/workspace/components/chat-session-workspace-file-preview';
16
+ import { ChatSessionWorkspaceDirectoryBrowser } from '@/features/chat/features/workspace/components/chat-session-workspace-directory-browser';
17
+ import { SessionCronJobContent } from '@/features/chat/features/workspace/components/session-cron-job-content';
18
+ import type { ResolvedChildSessionTab } from '@/features/chat/features/ncp/hooks/use-ncp-child-session-tabs-view';
19
+ import type { WorkspaceSelection } from '@/features/chat/features/workspace/utils/chat-workspace-panel-view-model.utils';
20
+ import { useServerPathBrowse } from '@/shared/hooks/use-server-path-browse';
21
+ import {
22
+ CHAT_WORKSPACE_EXPLORER_MAX_WIDTH,
23
+ CHAT_WORKSPACE_EXPLORER_MIN_WIDTH,
24
+ } from '@/features/chat/features/workspace/utils/chat-workspace-panel-layout.utils';
25
+ import { useWorkspaceExplorerLayout } from '@/features/chat/features/workspace/hooks/use-workspace-explorer-layout';
26
+ import { t } from '@/shared/lib/i18n';
27
+ import { cn } from '@/shared/lib/utils';
28
+ import { IconActionButton } from '@/shared/components/ui/actions/icon-action-button';
20
29
 
21
30
  type ChatSessionWorkspacePanelContentProps = {
22
31
  activeSelection: WorkspaceSelection;
@@ -53,15 +62,13 @@ function WorkspaceOverviewEntry({
53
62
  <span className="min-w-0 flex-1">
54
63
  <span className="flex items-center gap-2 text-sm font-medium text-gray-900">
55
64
  <span className="truncate">{title}</span>
56
- {typeof count === "number" ? (
65
+ {typeof count === 'number' ? (
57
66
  <span className="rounded-full bg-gray-100 px-1.5 py-0.5 text-[10px] font-semibold tabular-nums text-gray-500">
58
67
  {count}
59
68
  </span>
60
69
  ) : null}
61
70
  </span>
62
- <span className="mt-0.5 block text-xs leading-5 text-gray-500">
63
- {description}
64
- </span>
71
+ <span className="mt-0.5 block text-xs leading-5 text-gray-500">{description}</span>
65
72
  </span>
66
73
  <ChevronRight className="h-4 w-4 shrink-0 text-gray-300 transition-transform group-hover:translate-x-0.5 group-hover:text-gray-500" />
67
74
  </button>
@@ -82,18 +89,14 @@ function WorkspaceOverview({
82
89
  return (
83
90
  <div className="h-full overflow-auto bg-gray-50/45 px-4 py-5 custom-scrollbar">
84
91
  <div className="mx-auto max-w-xl">
85
- <h2 className="text-base font-semibold text-gray-900">
86
- {t("chatWorkspaceOverview")}
87
- </h2>
88
- <p className="mt-1 text-xs leading-5 text-gray-500">
89
- {t("chatWorkspaceOverviewDescription")}
90
- </p>
92
+ <h2 className="text-base font-semibold text-gray-900">{t('chatWorkspaceOverview')}</h2>
93
+ <p className="mt-1 text-xs leading-5 text-gray-500">{t('chatWorkspaceOverviewDescription')}</p>
91
94
  <div className="mt-4 space-y-2">
92
95
  <WorkspaceOverviewEntry
93
96
  count={childSessionTabs.length}
94
- description={t("chatWorkspaceChildSessionsDescription")}
97
+ description={t('chatWorkspaceChildSessionsDescription')}
95
98
  icon={<GitBranch className="h-4 w-4" />}
96
- title={t("chatWorkspaceChildSessions")}
99
+ title={t('chatWorkspaceChildSessions')}
97
100
  onClick={() => {
98
101
  if (sessionKey) {
99
102
  presenter.chatThreadManager.openChildSessions(sessionKey);
@@ -102,9 +105,9 @@ function WorkspaceOverview({
102
105
  />
103
106
  <WorkspaceOverviewEntry
104
107
  count={sessionCronJobs.length}
105
- description={t("chatWorkspaceSessionCronJobsDescription")}
108
+ description={t('chatWorkspaceSessionCronJobsDescription')}
106
109
  icon={<AlarmClock className="h-4 w-4" />}
107
- title={t("chatWorkspaceSessionCronJobs")}
110
+ title={t('chatWorkspaceSessionCronJobs')}
108
111
  onClick={() => {
109
112
  if (sessionKey) {
110
113
  presenter.chatThreadManager.openSessionCronPanel(sessionKey);
@@ -112,9 +115,9 @@ function WorkspaceOverview({
112
115
  }}
113
116
  />
114
117
  <WorkspaceOverviewEntry
115
- description={t("chatWorkspaceProjectFilesDescription")}
118
+ description={t('chatWorkspaceProjectFilesDescription')}
116
119
  icon={<FolderTree className="h-4 w-4" />}
117
- title={t("chatWorkspaceProjectFiles")}
120
+ title={t('chatWorkspaceProjectFiles')}
118
121
  onClick={() => {
119
122
  if (sessionKey) {
120
123
  presenter.chatThreadManager.openProjectFiles(sessionKey);
@@ -127,28 +130,20 @@ function WorkspaceOverview({
127
130
  );
128
131
  }
129
132
 
130
- function WorkspaceChildSessions({
131
- childSessionTabs,
132
- }: {
133
- childSessionTabs: readonly ResolvedChildSessionTab[];
134
- }) {
133
+ function WorkspaceChildSessions({ childSessionTabs }: { childSessionTabs: readonly ResolvedChildSessionTab[] }) {
135
134
  const presenter = usePresenter();
136
135
 
137
136
  return (
138
137
  <div className="h-full overflow-auto bg-gray-50/45 px-4 py-5 custom-scrollbar">
139
138
  <div className="mx-auto max-w-xl">
140
139
  <div className="flex items-center gap-2">
141
- <h2 className="text-base font-semibold text-gray-900">
142
- {t("chatWorkspaceChildSessions")}
143
- </h2>
140
+ <h2 className="text-base font-semibold text-gray-900">{t('chatWorkspaceChildSessions')}</h2>
144
141
  <span className="rounded-full bg-gray-100 px-1.5 py-0.5 text-[10px] font-semibold tabular-nums text-gray-500">
145
142
  {childSessionTabs.length}
146
143
  </span>
147
144
  </div>
148
145
  {childSessionTabs.length === 0 ? (
149
- <div className="py-10 text-center text-sm text-gray-500">
150
- {t("chatWorkspaceChildSessionsEmpty")}
151
- </div>
146
+ <div className="py-10 text-center text-sm text-gray-500">{t('chatWorkspaceChildSessionsEmpty')}</div>
152
147
  ) : (
153
148
  <div className="mt-4 space-y-2">
154
149
  {childSessionTabs.map((tab) => (
@@ -156,24 +151,16 @@ function WorkspaceChildSessions({
156
151
  key={tab.sessionKey}
157
152
  type="button"
158
153
  className="group flex w-full items-center gap-3 rounded-lg border border-gray-200/80 bg-white px-3 py-3 text-left transition-colors hover:border-gray-300 hover:bg-gray-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/25"
159
- onClick={() =>
160
- presenter.chatThreadManager.selectChildSessionDetail(
161
- tab.sessionKey,
162
- )
163
- }
154
+ onClick={() => presenter.chatThreadManager.selectChildSessionDetail(tab.sessionKey)}
164
155
  >
165
156
  <span className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-gray-100 text-gray-600">
166
157
  <GitBranch className="h-4 w-4" />
167
158
  </span>
168
159
  <span className="min-w-0 flex-1">
169
- <span className="block truncate text-sm font-medium text-gray-900">
170
- {tab.title}
171
- </span>
160
+ <span className="block truncate text-sm font-medium text-gray-900">{tab.title}</span>
172
161
  {tab.projectName || tab.sessionTypeLabel ? (
173
162
  <span className="mt-0.5 block truncate text-xs text-gray-500">
174
- {[tab.sessionTypeLabel, tab.projectName]
175
- .filter(Boolean)
176
- .join(" · ")}
163
+ {[tab.sessionTypeLabel, tab.projectName].filter(Boolean).join(' · ')}
177
164
  </span>
178
165
  ) : null}
179
166
  </span>
@@ -188,10 +175,12 @@ function WorkspaceChildSessions({
188
175
  }
189
176
 
190
177
  function WorkspaceProjectFiles({
178
+ onFileOpen,
191
179
  projectRoot,
192
180
  sessionKey,
193
181
  workingDir,
194
182
  }: {
183
+ onFileOpen?: (action: ChatFileOpenActionViewModel) => void;
195
184
  projectRoot: string | null;
196
185
  sessionKey: string | null;
197
186
  workingDir: string | null;
@@ -207,7 +196,7 @@ function WorkspaceProjectFiles({
207
196
  if (!rootPath) {
208
197
  return (
209
198
  <div className="flex h-full items-center justify-center px-6 text-center text-sm text-gray-500">
210
- {t("chatWorkspaceProjectFilesUnavailable")}
199
+ {t('chatWorkspaceProjectFilesUnavailable')}
211
200
  </div>
212
201
  );
213
202
  }
@@ -216,13 +205,13 @@ function WorkspaceProjectFiles({
216
205
  <ChatSessionWorkspaceDirectoryBrowser
217
206
  key={rootPath}
218
207
  browseQuery={browseQuery}
219
- onFileOpen={presenter.chatThreadManager.openFilePreview}
208
+ onFileOpen={onFileOpen ?? presenter.chatThreadManager.openFilePreview}
220
209
  sessionKey={sessionKey}
221
- renderStatus={({ text, tone = "muted" }) => (
210
+ renderStatus={({ text, tone = 'muted' }) => (
222
211
  <div
223
212
  className={cn(
224
- "flex h-full items-center justify-center px-6 text-center text-sm",
225
- tone === "error" ? "text-rose-600" : "text-gray-500",
213
+ 'flex h-full items-center justify-center px-6 text-center text-sm',
214
+ tone === 'error' ? 'text-rose-600' : 'text-gray-500',
226
215
  )}
227
216
  >
228
217
  {text}
@@ -242,11 +231,9 @@ function ChildSessionMetaChip({ value }: { value: string }) {
242
231
  }
243
232
 
244
233
  function ChildSessionMetaStrip({ tab }: { tab: ResolvedChildSessionTab }) {
245
- const metaItems = [
246
- tab.sessionTypeLabel,
247
- tab.preferredModel,
248
- tab.projectName,
249
- ].filter((value): value is string => Boolean(value?.trim()));
234
+ const metaItems = [tab.sessionTypeLabel, tab.preferredModel, tab.projectName].filter((value): value is string =>
235
+ Boolean(value?.trim()),
236
+ );
250
237
 
251
238
  if (metaItems.length === 0 && !tab.projectRoot) {
252
239
  return null;
@@ -274,11 +261,9 @@ function WorkspaceSideChatDraftHeader() {
274
261
  <div className="border-b border-gray-200/70 px-4 py-3">
275
262
  <div className="flex min-w-0 items-center gap-2 text-sm font-semibold text-gray-900">
276
263
  <MessageSquarePlus className="h-4 w-4 shrink-0 text-primary" />
277
- <span className="truncate">{t("chatWorkspaceSideChatDraftTitle")}</span>
264
+ <span className="truncate">{t('chatWorkspaceSideChatDraftTitle')}</span>
278
265
  </div>
279
- <p className="mt-1 text-xs leading-5 text-gray-500">
280
- {t("chatWorkspaceSideChatDraftSubtitle")}
281
- </p>
266
+ <p className="mt-1 text-xs leading-5 text-gray-500">{t('chatWorkspaceSideChatDraftSubtitle')}</p>
282
267
  </div>
283
268
  );
284
269
  }
@@ -291,10 +276,13 @@ function WorkspaceSelectedContent({
291
276
  sessionCronJobs,
292
277
  sessionProjectRoot,
293
278
  sessionWorkingDir,
294
- }: ChatSessionWorkspacePanelContentProps) {
279
+ explorerControl,
280
+ }: ChatSessionWorkspacePanelContentProps & {
281
+ explorerControl?: { open: boolean; onToggle: () => void };
282
+ }) {
295
283
  const presenter = usePresenter();
296
284
 
297
- if (activeSelection.kind === "overview") {
285
+ if (activeSelection.kind === 'overview') {
298
286
  return (
299
287
  <WorkspaceOverview
300
288
  childSessionTabs={childSessionTabs}
@@ -304,18 +292,18 @@ function WorkspaceSelectedContent({
304
292
  );
305
293
  }
306
294
 
307
- if (activeSelection.kind === "child-sessions") {
295
+ if (activeSelection.kind === 'child-sessions') {
308
296
  return <WorkspaceChildSessions childSessionTabs={childSessionTabs} />;
309
297
  }
310
298
 
311
- if (activeSelection.kind === "side-chat-draft") {
299
+ if (activeSelection.kind === 'side-chat-draft') {
312
300
  return (
313
301
  <>
314
302
  <WorkspaceSideChatDraftHeader />
315
303
  <div className="flex min-h-0 flex-1 flex-col">
316
304
  <SessionConversationArea
317
305
  materializationContext={{
318
- kind: "child",
306
+ kind: 'child',
319
307
  parentSessionKey: activeSelection.draft.parentSessionKey,
320
308
  inheritContext: true,
321
309
  }}
@@ -333,7 +321,7 @@ function WorkspaceSelectedContent({
333
321
  );
334
322
  }
335
323
 
336
- if (activeSelection.kind === "child-session") {
324
+ if (activeSelection.kind === 'child-session') {
337
325
  return (
338
326
  <>
339
327
  <ChildSessionMetaStrip tab={activeSelection.tab} />
@@ -344,9 +332,21 @@ function WorkspaceSelectedContent({
344
332
  );
345
333
  }
346
334
 
347
- if (activeSelection.kind === "file") {
335
+ if (activeSelection.kind === 'file') {
348
336
  return (
349
337
  <ChatSessionWorkspaceFilePreview
338
+ breadcrumbLeading={
339
+ <IconActionButton
340
+ data-testid="workspace-explorer-toggle"
341
+ icon={
342
+ explorerControl?.open ? <PanelLeftClose className="h-4 w-4" /> : <PanelLeftOpen className="h-4 w-4" />
343
+ }
344
+ label={t(explorerControl?.open ? 'chatWorkspaceHideProjectFiles' : 'chatWorkspaceShowProjectFiles')}
345
+ size="sm"
346
+ tooltipSide="bottom"
347
+ onClick={explorerControl?.onToggle}
348
+ />
349
+ }
350
350
  file={activeSelection.file}
351
351
  refreshVersion={filePreviewRefreshVersion}
352
352
  sessionProjectRoot={sessionProjectRoot}
@@ -365,41 +365,96 @@ function WorkspaceSelectedContent({
365
365
  return <SessionCronJobContent jobs={sessionCronJobs} />;
366
366
  }
367
367
 
368
- export function ChatSessionWorkspacePanelContent(
369
- {
370
- activeSelection,
371
- sessionKey,
372
- sessionProjectRoot,
373
- sessionWorkingDir,
374
- ...selectedContentProps
375
- }: ChatSessionWorkspacePanelContentProps,
376
- ) {
377
- const projectFilesActive = activeSelection.kind === "project-files";
368
+ export function ChatSessionWorkspacePanelContent({
369
+ activeSelection,
370
+ sessionKey,
371
+ sessionProjectRoot,
372
+ sessionWorkingDir,
373
+ ...selectedContentProps
374
+ }: ChatSessionWorkspacePanelContentProps) {
375
+ const projectFilesActive = activeSelection.kind === 'project-files';
376
+ const fileActive = activeSelection.kind === 'file';
377
+ const presenter = usePresenter();
378
+ const {
379
+ compact,
380
+ containerRef,
381
+ explorerOpen,
382
+ explorerOverlay,
383
+ explorerWidth,
384
+ onResizeStart,
385
+ setExplorerOpen,
386
+ showExplorer,
387
+ } = useWorkspaceExplorerLayout({ fileActive, projectFilesActive, sessionProjectRoot, sessionWorkingDir });
378
388
 
379
389
  return (
380
- <>
390
+ <div
391
+ ref={containerRef}
392
+ data-testid="workspace-shared-explorer-layout"
393
+ className="relative flex h-full min-h-0 flex-1 overflow-hidden"
394
+ >
395
+ {explorerOverlay ? (
396
+ <button
397
+ data-testid="workspace-explorer-scrim"
398
+ type="button"
399
+ aria-label={t('chatWorkspaceHideProjectFiles')}
400
+ className="absolute inset-0 z-10 bg-black/15"
401
+ onClick={() => setExplorerOpen(false)}
402
+ />
403
+ ) : null}
381
404
  <div
405
+ data-testid="workspace-shared-explorer"
406
+ data-mode={projectFilesActive ? 'full' : explorerOverlay ? 'overlay' : 'side'}
382
407
  className={cn(
383
- "min-h-0 flex-1 flex-col",
384
- projectFilesActive ? "flex" : "hidden",
408
+ 'relative min-h-0 shrink-0 flex-col border-r border-gray-200/80 bg-white',
409
+ showExplorer ? 'flex' : 'hidden',
410
+ projectFilesActive ? 'flex-1 border-r-0' : null,
411
+ explorerOverlay ? 'absolute inset-y-0 left-0 z-20 w-[min(320px,86%)] shadow-xl' : null,
385
412
  )}
386
- aria-hidden={!projectFilesActive}
413
+ style={!projectFilesActive && !explorerOverlay ? { width: explorerWidth } : undefined}
414
+ aria-hidden={!showExplorer}
387
415
  >
388
416
  <WorkspaceProjectFiles
417
+ onFileOpen={(action) => {
418
+ if (compact) setExplorerOpen(false);
419
+ presenter.chatThreadManager.openFilePreview(action);
420
+ }}
389
421
  projectRoot={sessionProjectRoot}
390
422
  sessionKey={sessionKey}
391
423
  workingDir={sessionWorkingDir}
392
424
  />
425
+ {fileActive && !compact ? (
426
+ <div
427
+ data-testid="workspace-explorer-resize-handle"
428
+ aria-label={t('chatWorkspaceResizeProjectFiles')}
429
+ aria-orientation="vertical"
430
+ aria-valuemax={CHAT_WORKSPACE_EXPLORER_MAX_WIDTH}
431
+ aria-valuemin={CHAT_WORKSPACE_EXPLORER_MIN_WIDTH}
432
+ aria-valuenow={explorerWidth}
433
+ role="separator"
434
+ className="absolute inset-y-0 right-[-4px] z-20 w-2 cursor-ew-resize hover:bg-primary/10"
435
+ onPointerDown={onResizeStart}
436
+ />
437
+ ) : null}
393
438
  </div>
394
439
  {projectFilesActive ? null : (
395
- <WorkspaceSelectedContent
396
- {...selectedContentProps}
397
- activeSelection={activeSelection}
398
- sessionKey={sessionKey}
399
- sessionProjectRoot={sessionProjectRoot}
400
- sessionWorkingDir={sessionWorkingDir}
401
- />
440
+ <div className="min-w-0 min-h-0 flex-1">
441
+ <WorkspaceSelectedContent
442
+ {...selectedContentProps}
443
+ activeSelection={activeSelection}
444
+ explorerControl={
445
+ fileActive
446
+ ? {
447
+ open: explorerOpen,
448
+ onToggle: () => setExplorerOpen((value) => !value),
449
+ }
450
+ : undefined
451
+ }
452
+ sessionKey={sessionKey}
453
+ sessionProjectRoot={sessionProjectRoot}
454
+ sessionWorkingDir={sessionWorkingDir}
455
+ />
456
+ </div>
402
457
  )}
403
- </>
458
+ </div>
404
459
  );
405
460
  }
@@ -0,0 +1,50 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { buildWorkspaceEntryContextMenuGroups } from "@/features/chat/features/workspace/components/project-files/workspace-directory-entry-menu";
3
+ import type { ServerPathEntryView } from "@/shared/lib/api";
4
+
5
+ function buildMenu(entry: ServerPathEntryView) {
6
+ return buildWorkspaceEntryContextMenuGroups({
7
+ busy: false,
8
+ downloadUrl: entry.kind === "file" ? "/download" : null,
9
+ entry,
10
+ onAddToChat: vi.fn(),
11
+ onCreateFile: vi.fn(),
12
+ onCreateFolder: vi.fn(),
13
+ onDelete: vi.fn(),
14
+ onOpen: vi.fn(),
15
+ onRenameRequest: vi.fn(),
16
+ onRevealPath: vi.fn(),
17
+ onUpload: vi.fn(),
18
+ ownCreateTarget: { label: entry.name, path: entry.path, refresh: vi.fn() },
19
+ parentRefresh: vi.fn(),
20
+ relativePath: entry.name,
21
+ });
22
+ }
23
+
24
+ function visibleGroupKeys(entry: ServerPathEntryView) {
25
+ return buildMenu(entry)
26
+ .filter((group) => group.items.length > 0)
27
+ .map((group) => group.key);
28
+ }
29
+
30
+ describe("workspace directory entry context menus", () => {
31
+ it("keeps Open exclusive to files", () => {
32
+ expect(
33
+ visibleGroupKeys({
34
+ name: "src",
35
+ path: "/workspace/src",
36
+ kind: "directory",
37
+ hidden: false,
38
+ }),
39
+ ).toEqual(["nextclaw", "manage", "path", "rename", "danger"]);
40
+
41
+ expect(
42
+ visibleGroupKeys({
43
+ name: "app.ts",
44
+ path: "/workspace/app.ts",
45
+ kind: "file",
46
+ hidden: false,
47
+ }),
48
+ ).toEqual(["open", "nextclaw", "path", "rename", "danger"]);
49
+ });
50
+ });