@nextclaw/ui 0.15.12 → 0.15.14

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 (100) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/dist/assets/{api-Bw1oLhwf.js → api-DGG69ZcJ.js} +3 -3
  3. package/dist/assets/app-presenter-provider-Dp90n1Va.js +106 -0
  4. package/dist/assets/{appearance-settings-page-BDpels6a.js → appearance-settings-page-Byr55LGb.js} +1 -1
  5. package/dist/assets/{book-open-D-oLp0QJ.js → book-open-BhaS5BJv.js} +1 -1
  6. package/dist/assets/{card-CA3ozZwW.js → card-Bcr5YM8F.js} +1 -1
  7. package/dist/assets/{channels-list-page-B44EWctT.js → channels-list-page-C_x83ZCT.js} +2 -2
  8. package/dist/assets/{chat-page-CH8ypOKO.js → chat-page-CEavSS6B.js} +1 -1
  9. package/dist/assets/{config-split-page-DskB5yAV.js → config-split-page-RXAoLeNv.js} +1 -1
  10. package/dist/assets/{confirm-dialog-BRQpFnvD.js → confirm-dialog-o_VPKvVD.js} +1 -1
  11. package/dist/assets/{desktop-update-config-se0OEu06.js → desktop-update-config-DEOMOsDN.js} +1 -1
  12. package/dist/assets/{dist-f6aZEsu8.js → dist-C87P4QWA.js} +1 -1
  13. package/dist/assets/doc-browser-BuGh646I.js +1 -0
  14. package/dist/assets/{doc-browser-DW7afLXs.js → doc-browser-DQ75iUkA.js} +1 -1
  15. package/dist/assets/{doc-browser-context-DhTa-zP_.js → doc-browser-context-tE0--wSa.js} +1 -1
  16. package/dist/assets/{eye-DMGpISEo.js → eye-DL9QkrKg.js} +1 -1
  17. package/dist/assets/{form-actions-SgP78YnZ.js → form-actions-J0tIaUdK.js} +1 -1
  18. package/dist/assets/index-BFIZUZP3.js +2 -0
  19. package/dist/assets/loader-circle-CAnHHSrY.js +1 -0
  20. package/dist/assets/{logo-badge-BZnxuJYW.js → logo-badge-BYTbZbOF.js} +1 -1
  21. package/dist/assets/{mcp-marketplace-page-Dm08nf11.js → mcp-marketplace-page-COWlzeP1.js} +2 -2
  22. package/dist/assets/mcp-marketplace-page-CaUFv37E.js +1 -0
  23. package/dist/assets/{model-config-page-k_tCjGlK.js → model-config-page-oNqnMXNY.js} +1 -1
  24. package/dist/assets/{navigation-link-CJ3fpUBN.js → navigation-link-Dk6VdDkc.js} +1 -1
  25. package/dist/assets/plus-DjdTWPjQ.js +1 -0
  26. package/dist/assets/{popover-Cq_oB7HI.js → popover-Txwrum65.js} +1 -1
  27. package/dist/assets/{provider-scoped-model-input-Cwv5zw0Z.js → provider-scoped-model-input-B7rQM4b6.js} +1 -1
  28. package/dist/assets/{providers-config-page-D82jX13I.js → providers-config-page-CP-OnvLK.js} +1 -1
  29. package/dist/assets/{react-BjpVlGAu.js → react-B0LbRnAE.js} +1 -1
  30. package/dist/assets/{refresh-cw-CYyWovQ1.js → refresh-cw-C84mMku5.js} +1 -1
  31. package/dist/assets/remote-_L4pTEaW.js +1 -0
  32. package/dist/assets/rotate-cw-DQJhDnjg.js +1 -0
  33. package/dist/assets/{runtime-config-page-OS6ZT2b0.js → runtime-config-page-CIGtssWi.js} +1 -1
  34. package/dist/assets/{save-B0V_M-kz.js → save-R_qyrHpa.js} +1 -1
  35. package/dist/assets/{search-vFYD1SOf.js → search-UKxiouNi.js} +1 -1
  36. package/dist/assets/{search-config-page-IzK1hMQX.js → search-config-page-DzkpSGFH.js} +1 -1
  37. package/dist/assets/{secrets-config-page-Cm3IXYpK.js → secrets-config-page-DhoLYm7Z.js} +2 -2
  38. package/dist/assets/security-config-yraYoy9s.js +1 -0
  39. package/dist/assets/{select-BAYdPRVG.js → select-CYHT8P5S.js} +1 -1
  40. package/dist/assets/{settings-2-QX-FHs_i.js → settings-2-DGh4bGSi.js} +1 -1
  41. package/dist/assets/skeleton-BQuylYFO.js +1 -0
  42. package/dist/assets/{tabs-Ddprun22.js → tabs-BCihm1zK.js} +1 -1
  43. package/dist/assets/{tag-chip-C9SLhljq.js → tag-chip-De0iz4o2.js} +1 -1
  44. package/dist/assets/{trash-2-CODUwc31.js → trash-2-Bo3DWiju.js} +1 -1
  45. package/dist/assets/use-config-DCzwgChy.js +1 -0
  46. package/dist/assets/{use-confirm-dialog-B_ZW-WgC.js → use-confirm-dialog-WZ94CehU.js} +1 -1
  47. package/dist/assets/{use-viewport-layout-OtzTlg5U.js → use-viewport-layout-CZ-3pMne.js} +1 -1
  48. package/dist/assets/x-NZxXyqW1.js +1 -0
  49. package/dist/index.html +27 -27
  50. package/package.json +9 -9
  51. package/src/features/chat/components/layout/__tests__/chat-sidebar.test.tsx +1 -0
  52. package/src/features/chat/components/layout/chat-sidebar-desktop-layout.tsx +4 -4
  53. package/src/features/chat/components/layout/chat-sidebar.tsx +27 -12
  54. package/src/features/chat/features/conversation/components/session-conversation-input.tsx +4 -19
  55. package/src/features/chat/features/conversation/hooks/__tests__/use-session-conversation-slash-commands.test.tsx +79 -0
  56. package/src/features/chat/features/conversation/hooks/use-session-conversation-slash-commands.ts +55 -0
  57. package/src/features/chat/features/message/components/__tests__/chat-inline-panel-app-card.test.tsx +28 -0
  58. package/src/features/chat/features/message/components/chat-inline-panel-app-card.tsx +10 -7
  59. package/src/features/chat/features/message/components/chat-message-list.container.tsx +1 -0
  60. package/src/features/chat/features/message/utils/__tests__/chat-message-show-content-tool-card.utils.test.ts +3 -0
  61. package/src/features/chat/features/message/utils/chat-inline-panel-app-card.utils.ts +8 -6
  62. package/src/features/chat/features/message/utils/chat-message-show-content-tool-card.utils.ts +2 -0
  63. package/src/features/chat/features/project/components/__tests__/chat-project-add-dialog.test.tsx +116 -0
  64. package/src/features/chat/features/project/components/chat-project-add-dialog.tsx +305 -0
  65. package/src/features/chat/features/session/components/__tests__/chat-sidebar-project-groups.test.tsx +3 -2
  66. package/src/features/chat/features/session/components/chat-sidebar-project-groups.tsx +2 -2
  67. package/src/features/chat/features/workspace/components/__tests__/workspace-file-preview-error-text.test.ts +22 -0
  68. package/src/features/chat/features/workspace/components/chat-session-workspace-file-preview.tsx +9 -5
  69. package/src/features/chat/managers/__tests__/chat-ui.manager.test.ts +24 -0
  70. package/src/features/chat/managers/chat-ui.manager.ts +7 -1
  71. package/src/features/right-panel-resources/configs/right-panel-resource-routes.config.ts +18 -4
  72. package/src/features/right-panel-resources/index.ts +1 -0
  73. package/src/features/right-panel-resources/utils/__tests__/right-panel-resource-route-resolver.utils.test.ts +33 -0
  74. package/src/features/right-panel-resources/utils/__tests__/right-panel-resource-uri.utils.test.ts +14 -1
  75. package/src/features/right-panel-resources/utils/right-panel-resource-uri.utils.ts +10 -2
  76. package/src/shared/components/path-picker/__tests__/server-path-picker-dialog.test.tsx +9 -0
  77. package/src/shared/components/path-picker/server-path-picker-dialog.tsx +8 -2
  78. package/src/shared/components/path-picker/server-path-picker-toolbar.tsx +8 -4
  79. package/src/shared/hooks/use-projects.ts +15 -1
  80. package/src/shared/lib/api/projects/project.utils.ts +7 -0
  81. package/src/shared/lib/api/types.ts +1 -1
  82. package/src/shared/lib/api/utils/ncp-session.utils.ts +7 -0
  83. package/src/shared/lib/i18n/locales/en-US/chat.json +26 -8
  84. package/src/shared/lib/i18n/locales/zh-CN/chat.json +26 -8
  85. package/src/shared/lib/resource-uri/utils/__tests__/resource-uri-resolver.utils.test.ts +13 -1
  86. package/src/shared/lib/resource-uri/utils/resource-uri.utils.ts +6 -3
  87. package/dist/assets/app-presenter-provider-DSN90VJw.js +0 -106
  88. package/dist/assets/doc-browser-BRiAfy_N.js +0 -1
  89. package/dist/assets/index-CBD4zQ_A.js +0 -2
  90. package/dist/assets/loader-circle-DEQ2CflP.js +0 -1
  91. package/dist/assets/mcp-marketplace-page-B8eWiT9H.js +0 -1
  92. package/dist/assets/plus-9_9Q74qc.js +0 -1
  93. package/dist/assets/remote-C2MGMI2j.js +0 -1
  94. package/dist/assets/rotate-cw-BkGMTudQ.js +0 -1
  95. package/dist/assets/security-config-DfzQ8txL.js +0 -1
  96. package/dist/assets/skeleton-2eczb2co.js +0 -1
  97. package/dist/assets/use-config-C5OKT3lk.js +0 -1
  98. package/dist/assets/x-Cbfpb-Zm.js +0 -1
  99. package/src/features/chat/features/project/components/__tests__/chat-project-create-dialog.test.tsx +0 -67
  100. package/src/features/chat/features/project/components/chat-project-create-dialog.tsx +0 -203
@@ -0,0 +1,305 @@
1
+ import { useState } from 'react';
2
+ import { FolderOpen } from 'lucide-react';
3
+ import type {
4
+ ProjectAddExistingRequest,
5
+ ProjectCreateRequest,
6
+ ProjectTemplateView,
7
+ } from '@/shared/lib/api';
8
+ import { ServerPathPickerDialog } from '@/shared/components/path-picker/server-path-picker-dialog';
9
+ import { Button } from '@/shared/components/ui/button';
10
+ import {
11
+ Dialog,
12
+ DialogContent,
13
+ DialogDescription,
14
+ DialogFooter,
15
+ DialogHeader,
16
+ DialogTitle,
17
+ } from '@/shared/components/ui/dialog';
18
+ import { Input } from '@/shared/components/ui/input';
19
+ import { Label } from '@/shared/components/ui/label';
20
+ import {
21
+ Select,
22
+ SelectContent,
23
+ SelectItem,
24
+ SelectTrigger,
25
+ SelectValue,
26
+ } from '@/shared/components/ui/select';
27
+ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/shared/components/ui/tabs';
28
+ import { t } from '@/shared/lib/i18n';
29
+
30
+ type ProjectTemplateId = NonNullable<ProjectCreateRequest['template']>;
31
+ type ProjectAddMode = 'create' | 'existing';
32
+
33
+ type ChatProjectAddDialogProps = {
34
+ open: boolean;
35
+ defaultWorkspacePath?: string | null;
36
+ templates: readonly ProjectTemplateView[];
37
+ isCreating: boolean;
38
+ isAddingExisting: boolean;
39
+ createErrorMessage?: string | null;
40
+ addExistingErrorMessage?: string | null;
41
+ onOpenChange: (open: boolean) => void;
42
+ onCreate: (input: ProjectCreateRequest) => Promise<void>;
43
+ onAddExisting: (input: ProjectAddExistingRequest) => Promise<void>;
44
+ };
45
+
46
+ function projectTemplateLabel(templateId: ProjectTemplateId): string {
47
+ return t(
48
+ templateId === 'knowledge-base'
49
+ ? 'chatProjectTemplateKnowledgeBase'
50
+ : 'chatProjectTemplateEmpty',
51
+ );
52
+ }
53
+
54
+ export function ChatProjectAddDialog({
55
+ open,
56
+ defaultWorkspacePath,
57
+ templates,
58
+ isCreating,
59
+ isAddingExisting,
60
+ createErrorMessage,
61
+ addExistingErrorMessage,
62
+ onOpenChange,
63
+ onCreate,
64
+ onAddExisting,
65
+ }: ChatProjectAddDialogProps) {
66
+ const [mode, setMode] = useState<ProjectAddMode>('create');
67
+ const [name, setName] = useState('');
68
+ const [createRootPath, setCreateRootPath] = useState('');
69
+ const [existingRootPath, setExistingRootPath] = useState('');
70
+ const [pathPickerOpen, setPathPickerOpen] = useState(false);
71
+ const [template, setTemplate] = useState<ProjectTemplateId>('empty');
72
+ const availableTemplateIds = templates.length > 0
73
+ ? templates.map((entry) => entry.id)
74
+ : ['empty' as const];
75
+ const normalizedName = name.trim();
76
+ const normalizedCreateRootPath = createRootPath.trim();
77
+ const normalizedExistingRootPath = existingRootPath.trim();
78
+ const isSubmitting = isCreating || isAddingExisting;
79
+ const submitDisabled = isSubmitting || (
80
+ mode === 'create' ? !normalizedName : !normalizedExistingRootPath
81
+ );
82
+ const errorMessage = mode === 'create' ? createErrorMessage : addExistingErrorMessage;
83
+
84
+ const resetForm = () => {
85
+ setMode('create');
86
+ setName('');
87
+ setCreateRootPath('');
88
+ setExistingRootPath('');
89
+ setTemplate('empty');
90
+ setPathPickerOpen(false);
91
+ };
92
+
93
+ const closeDialog = () => {
94
+ if (isSubmitting) {
95
+ return;
96
+ }
97
+ resetForm();
98
+ onOpenChange(false);
99
+ };
100
+
101
+ return (
102
+ <>
103
+ <Dialog
104
+ open={open}
105
+ onOpenChange={(nextOpen) => {
106
+ if (nextOpen) {
107
+ onOpenChange(true);
108
+ return;
109
+ }
110
+ closeDialog();
111
+ }}
112
+ >
113
+ <DialogContent className="sm:max-w-lg">
114
+ <DialogHeader>
115
+ <DialogTitle>{t('chatProjectAddTitle')}</DialogTitle>
116
+ <DialogDescription>{t('chatProjectAddDescription')}</DialogDescription>
117
+ </DialogHeader>
118
+ <form
119
+ className="space-y-4"
120
+ onSubmit={(event) => {
121
+ event.preventDefault();
122
+ if (submitDisabled) {
123
+ return;
124
+ }
125
+ const submit = mode === 'create'
126
+ ? onCreate({
127
+ name: normalizedName,
128
+ template,
129
+ rootPath: normalizedCreateRootPath || undefined,
130
+ })
131
+ : onAddExisting({ rootPath: normalizedExistingRootPath });
132
+ void submit.then(resetForm).catch(() => undefined);
133
+ }}
134
+ >
135
+ <Tabs
136
+ value={mode}
137
+ onValueChange={(value) => {
138
+ if (!isSubmitting) {
139
+ setMode(value === 'existing' ? 'existing' : 'create');
140
+ }
141
+ }}
142
+ >
143
+ <TabsList className="grid w-full grid-cols-2">
144
+ <TabsTrigger value="create">{t('chatProjectCreateMode')}</TabsTrigger>
145
+ <TabsTrigger value="existing">{t('chatProjectExistingMode')}</TabsTrigger>
146
+ </TabsList>
147
+
148
+ <TabsContent value="create" className="mt-4 space-y-4">
149
+ <p className="text-sm leading-relaxed text-muted-foreground">
150
+ {t('chatProjectCreateModeDescription')}
151
+ </p>
152
+ <div className="space-y-2">
153
+ <Label htmlFor="chat-project-name">{t('chatProjectNameLabel')}</Label>
154
+ <Input
155
+ id="chat-project-name"
156
+ value={name}
157
+ onChange={(event) => setName(event.target.value)}
158
+ placeholder={t('chatProjectNamePlaceholder')}
159
+ autoFocus
160
+ disabled={isSubmitting}
161
+ />
162
+ </div>
163
+
164
+ <div className="space-y-2">
165
+ <Label htmlFor="chat-project-template">{t('chatProjectTemplateLabel')}</Label>
166
+ <Select
167
+ value={template}
168
+ onValueChange={(value) => setTemplate(value as ProjectTemplateId)}
169
+ disabled={isSubmitting}
170
+ >
171
+ <SelectTrigger id="chat-project-template">
172
+ <SelectValue />
173
+ </SelectTrigger>
174
+ <SelectContent>
175
+ {availableTemplateIds.map((templateId) => (
176
+ <SelectItem key={templateId} value={templateId}>
177
+ {projectTemplateLabel(templateId)}
178
+ </SelectItem>
179
+ ))}
180
+ </SelectContent>
181
+ </Select>
182
+ </div>
183
+
184
+ <div className="space-y-2">
185
+ <Label htmlFor="chat-project-create-root-path">
186
+ {t('chatProjectCreatePathLabel')}
187
+ </Label>
188
+ <div className="flex gap-2">
189
+ <Input
190
+ id="chat-project-create-root-path"
191
+ value={createRootPath}
192
+ onChange={(event) => setCreateRootPath(event.target.value)}
193
+ placeholder={t('chatProjectCreatePathPlaceholder')}
194
+ disabled={isSubmitting}
195
+ />
196
+ <Button
197
+ type="button"
198
+ variant="outline"
199
+ onClick={() => setPathPickerOpen(true)}
200
+ disabled={isSubmitting}
201
+ >
202
+ <FolderOpen className="mr-2 h-4 w-4" />
203
+ {t('browse')}
204
+ </Button>
205
+ </div>
206
+ <p className="text-xs leading-relaxed text-muted-foreground">
207
+ {t('chatProjectCreatePathHint')}
208
+ </p>
209
+ </div>
210
+ </TabsContent>
211
+
212
+ <TabsContent value="existing" className="mt-4 space-y-4">
213
+ <p className="text-sm leading-relaxed text-muted-foreground">
214
+ {t('chatProjectExistingModeDescription')}
215
+ </p>
216
+ <div className="space-y-2">
217
+ <Label htmlFor="chat-project-existing-root-path">
218
+ {t('chatProjectExistingPathLabel')}
219
+ </Label>
220
+ <div className="flex gap-2">
221
+ <Input
222
+ id="chat-project-existing-root-path"
223
+ value={existingRootPath}
224
+ onChange={(event) => setExistingRootPath(event.target.value)}
225
+ placeholder={t('chatProjectExistingPathPlaceholder')}
226
+ autoFocus
227
+ disabled={isSubmitting}
228
+ />
229
+ <Button
230
+ type="button"
231
+ variant="outline"
232
+ onClick={() => setPathPickerOpen(true)}
233
+ disabled={isSubmitting}
234
+ >
235
+ <FolderOpen className="mr-2 h-4 w-4" />
236
+ {t('browse')}
237
+ </Button>
238
+ </div>
239
+ <p className="text-xs leading-relaxed text-muted-foreground">
240
+ {t('chatProjectExistingPathHint')}
241
+ </p>
242
+ </div>
243
+ </TabsContent>
244
+ </Tabs>
245
+
246
+ {errorMessage ? (
247
+ <p role="alert" className="text-sm text-destructive">
248
+ {errorMessage}
249
+ </p>
250
+ ) : null}
251
+
252
+ <DialogFooter>
253
+ <Button type="button" variant="outline" onClick={closeDialog} disabled={isSubmitting}>
254
+ {t('cancel')}
255
+ </Button>
256
+ <Button type="submit" disabled={submitDisabled}>
257
+ {isSubmitting
258
+ ? t('saving')
259
+ : t(mode === 'create' ? 'chatProjectCreateAction' : 'chatProjectExistingAction')}
260
+ </Button>
261
+ </DialogFooter>
262
+ </form>
263
+ </DialogContent>
264
+ </Dialog>
265
+ <ServerPathPickerDialog
266
+ open={pathPickerOpen}
267
+ allowCreateDirectory={mode === 'create'}
268
+ currentPath={mode === 'create' ? createRootPath : existingRootPath}
269
+ defaultWorkspacePath={defaultWorkspacePath}
270
+ isSaving={false}
271
+ onOpenChange={setPathPickerOpen}
272
+ onConfirm={(path) => {
273
+ if (mode === 'create') {
274
+ setCreateRootPath(path);
275
+ } else {
276
+ setExistingRootPath(path);
277
+ }
278
+ setPathPickerOpen(false);
279
+ }}
280
+ title={t(
281
+ mode === 'create'
282
+ ? 'chatProjectCreatePathPickerTitle'
283
+ : 'chatProjectExistingPathPickerTitle',
284
+ )}
285
+ description={t(
286
+ mode === 'create'
287
+ ? 'chatProjectCreatePathPickerDescription'
288
+ : 'chatProjectExistingPathPickerDescription',
289
+ )}
290
+ pathLabel={t(
291
+ mode === 'create' ? 'chatProjectCreatePathLabel' : 'chatProjectExistingPathLabel',
292
+ )}
293
+ pathPlaceholder={t(
294
+ mode === 'create'
295
+ ? 'chatProjectCreatePathPlaceholder'
296
+ : 'chatProjectExistingPathPlaceholder',
297
+ )}
298
+ confirmLabel={t('selectCurrentDirectory')}
299
+ hint={t(
300
+ mode === 'create' ? 'chatProjectCreatePathHint' : 'chatProjectExistingPathHint',
301
+ )}
302
+ />
303
+ </>
304
+ );
305
+ }
@@ -67,10 +67,11 @@ describe("ChatSidebarProjectGroups", () => {
67
67
  });
68
68
 
69
69
  it("keeps the project header as the full-width folder row and collapses its sessions", () => {
70
- renderProjectGroups();
70
+ const { container } = renderProjectGroups();
71
71
 
72
72
  const header = screen.getByLabelText("Collapse project");
73
- expect(header.parentElement?.className).toContain("h-10");
73
+ expect(container.firstElementChild?.className).toBe("space-y-0.5");
74
+ expect(header.parentElement?.className).toContain("h-8");
74
75
  expect(header.parentElement?.className).toContain("hover:bg-gray-200/60");
75
76
  expect(header.parentElement?.className).not.toContain("focus-within:bg");
76
77
  expect(screen.getByText("analysis-project").nextElementSibling?.tagName).toBe("svg");
@@ -46,7 +46,7 @@ export function ChatSidebarProjectGroups(props: ChatSidebarProjectGroupsProps) {
46
46
  const supportsSessionTypeChoice = sessionTypeOptions.length > 1;
47
47
 
48
48
  return (
49
- <div className="space-y-3">
49
+ <div className="space-y-0.5">
50
50
  {groups.map((group) => {
51
51
  const actionLabel = `${t('chatSidebarNewTask')} · ${group.projectName}`;
52
52
  const isCollapsed = collapsedProjectRoots.includes(group.projectRoot);
@@ -56,7 +56,7 @@ export function ChatSidebarProjectGroups(props: ChatSidebarProjectGroupsProps) {
56
56
 
57
57
  return (
58
58
  <div key={group.projectRoot}>
59
- <div className="group/project relative h-10 rounded-lg px-2 text-muted-foreground transition-colors hover:bg-gray-200/60 hover:text-gray-900">
59
+ <div className="group/project relative h-8 rounded-lg px-2 text-muted-foreground transition-colors hover:bg-gray-200/60 hover:text-gray-900">
60
60
  <button
61
61
  type="button"
62
62
  aria-expanded={!isCollapsed}
@@ -0,0 +1,22 @@
1
+ import { NextClawClientError } from "@nextclaw/client-sdk";
2
+ import { describe, expect, it } from "vitest";
3
+ import { resolveWorkspacePreviewErrorText } from "@/features/chat/features/workspace/components/chat-session-workspace-file-preview";
4
+ import { t } from "@/shared/lib/i18n";
5
+
6
+ describe("resolveWorkspacePreviewErrorText", () => {
7
+ it.each([
8
+ [
9
+ new NextClawClientError({
10
+ code: "SERVER_PATH_NOT_FOUND",
11
+ message: "server path does not exist",
12
+ status: 404,
13
+ }),
14
+ "chatWorkspacePreviewNotFound",
15
+ ],
16
+ [new Error("private upstream diagnostic"), "chatWorkspacePreviewFailed"],
17
+ ] as const)("maps preview errors to %s", (error, translationKey) => {
18
+ const message = resolveWorkspacePreviewErrorText(error);
19
+ expect(message).toBe(t(translationKey));
20
+ expect(message).not.toContain(error.message);
21
+ });
22
+ });
@@ -3,6 +3,7 @@ import type {
3
3
  ChatFileOpenActionViewModel,
4
4
  ChatFileOperationBlockViewModel,
5
5
  } from "@nextclaw/agent-chat-ui";
6
+ import { NextClawClientError } from "@nextclaw/client-sdk";
6
7
  import {
7
8
  ChatMessageMarkdown,
8
9
  FileOperationCodeSurface,
@@ -141,6 +142,13 @@ function WorkspaceFilePreviewStatus({
141
142
  );
142
143
  }
143
144
 
145
+ export function resolveWorkspacePreviewErrorText(error: unknown): string {
146
+ return error instanceof NextClawClientError &&
147
+ error.code === "SERVER_PATH_NOT_FOUND"
148
+ ? t("chatWorkspacePreviewNotFound")
149
+ : t("chatWorkspacePreviewFailed");
150
+ }
151
+
144
152
  function WorkspaceDiffBody({
145
153
  diffBlock,
146
154
  }: {
@@ -247,11 +255,7 @@ function WorkspacePreviewBody({
247
255
  return (
248
256
  <WorkspaceFilePreviewStatus
249
257
  tone="error"
250
- text={
251
- previewQuery.error instanceof Error
252
- ? previewQuery.error.message
253
- : String(previewQuery.error)
254
- }
258
+ text={resolveWorkspacePreviewErrorText(previewQuery.error)}
255
259
  />
256
260
  );
257
261
  }
@@ -125,4 +125,28 @@ describe('ChatUiManager content display', () => {
125
125
  title: 'Reader',
126
126
  });
127
127
  });
128
+
129
+ it('opens an explicit external panel app path without resolving the standard catalog', async () => {
130
+ const docBrowserManager = createDocBrowserManager();
131
+ const manager = new ChatUiManager(
132
+ docBrowserManager as unknown as ConstructorParameters<typeof ChatUiManager>[0],
133
+ );
134
+
135
+ await manager.showContent({
136
+ target: {
137
+ type: 'panel_app',
138
+ payload: {
139
+ appId: 'external-reader',
140
+ path: '/tmp/external-reader.panel',
141
+ },
142
+ },
143
+ title: 'External Reader',
144
+ });
145
+
146
+ expect(listPanelAppsMock).not.toHaveBeenCalled();
147
+ expect(docBrowserManager.open).toHaveBeenCalledWith(
148
+ 'nextclaw://panel-app/external-reader?path=%2Ftmp%2Fexternal-reader.panel',
149
+ { title: 'External Reader' },
150
+ );
151
+ });
128
152
  });
@@ -96,7 +96,13 @@ export class ChatUiManager {
96
96
  });
97
97
  return;
98
98
  }
99
- const { appId } = target.payload;
99
+ const { appId, path } = target.payload;
100
+ if (path) {
101
+ this.docBrowserManager.open(createPanelAppResourceUri(appId, path), {
102
+ title,
103
+ });
104
+ return;
105
+ }
100
106
  const entry = await this.resolvePanelAppEntry(appId);
101
107
  if (entry) {
102
108
  this.docBrowserManager.openTarget(createPanelAppRightPanelResourceTarget(entry), {
@@ -13,6 +13,7 @@ import type {
13
13
  RightPanelResourceTarget,
14
14
  } from '@/features/right-panel-resources/types/right-panel-resource.types';
15
15
  import {
16
+ createPanelAppContentPath,
16
17
  RIGHT_PANEL_APPS_URL,
17
18
  RIGHT_PANEL_APPS_TAB_KIND,
18
19
  RIGHT_PANEL_HOME_TAB_KIND,
@@ -77,12 +78,21 @@ function isPanelAppContentUri(uri: ParsedResourceUri): boolean {
77
78
 
78
79
  function resolvePanelAppPlaceholderUrl(uri: ParsedResourceUri): string {
79
80
  const appId = getPanelAppId(uri);
80
- return appId ? `/api/panel-apps/${encodeURIComponent(decodeURIComponent(appId))}/content` : 'nextclaw://panel-app';
81
+ if (!appId) {
82
+ return 'nextclaw://panel-app';
83
+ }
84
+ const path = uri.searchParams.get('path')?.trim();
85
+ return createPanelAppContentPath(decodeURIComponent(appId), path);
81
86
  }
82
87
 
83
88
  function createPanelAppResourceUri(uri: ParsedResourceUri): string {
84
89
  const appId = getPanelAppId(uri);
85
- return appId ? `nextclaw://panel-app/${encodeURIComponent(decodeURIComponent(appId))}` : 'nextclaw://panel-app';
90
+ if (!appId) {
91
+ return 'nextclaw://panel-app';
92
+ }
93
+ const resourceUri = `nextclaw://panel-app/${encodeURIComponent(decodeURIComponent(appId))}`;
94
+ const path = uri.searchParams.get('path')?.trim();
95
+ return path ? `${resourceUri}?${new URLSearchParams({ path }).toString()}` : resourceUri;
86
96
  }
87
97
 
88
98
  function arePanelAppUrlsEquivalent(left: string, right: string): boolean {
@@ -90,8 +100,10 @@ function arePanelAppUrlsEquivalent(left: string, right: string): boolean {
90
100
  const rightUri = parseResourceUri(right);
91
101
  const leftAppId = getPanelAppId(leftUri);
92
102
  const rightAppId = getPanelAppId(rightUri);
103
+ const leftPath = leftUri.searchParams.get('path')?.trim() ?? '';
104
+ const rightPath = rightUri.searchParams.get('path')?.trim() ?? '';
93
105
  return leftAppId.length > 0 && rightAppId.length > 0
94
- ? decodeURIComponent(leftAppId) === decodeURIComponent(rightAppId)
106
+ ? decodeURIComponent(leftAppId) === decodeURIComponent(rightAppId) && leftPath === rightPath
95
107
  : left === right;
96
108
  }
97
109
 
@@ -171,7 +183,9 @@ export const RIGHT_PANEL_RESOURCE_ROUTE_DEFINITIONS: RightPanelResourceRouteDefi
171
183
  const url = resolvePanelAppPlaceholderUrl(uri);
172
184
  const appId = getPanelAppId(uri);
173
185
  return {
174
- dedupeKey: appId ? `panel-app:${decodeURIComponent(appId)}` : undefined,
186
+ dedupeKey: appId
187
+ ? `panel-app:${uri.searchParams.get('path')?.trim() || decodeURIComponent(appId)}`
188
+ : undefined,
175
189
  historyPolicy: 'managed',
176
190
  kind: RIGHT_PANEL_PANEL_APP_TAB_KIND,
177
191
  resourceUri: createPanelAppResourceUri(uri),
@@ -7,6 +7,7 @@ export type {
7
7
  RightPanelResourceTarget,
8
8
  } from '@/features/right-panel-resources/types/right-panel-resource.types';
9
9
  export {
10
+ createPanelAppContentPath,
10
11
  createPanelAppResourceUri,
11
12
  createPanelAppRightPanelResourceTarget,
12
13
  RIGHT_PANEL_APPS_TAB_KIND,
@@ -59,4 +59,37 @@ describe('RightPanelResourceRouteResolver', () => {
59
59
  'panel-app',
60
60
  )).toBe(true);
61
61
  });
62
+
63
+ it('preserves an explicit panel app source path across resource and content URLs', () => {
64
+ const resolver = new RightPanelResourceRouteResolver();
65
+ const resourceUri = 'nextclaw://panel-app/timer?path=%2Ftmp%2Ftimer.panel';
66
+ const contentUrl = '/api/panel-apps/timer/content?path=%2Ftmp%2Ftimer.panel';
67
+
68
+ expect(resolver.resolve(resourceUri)).toMatchObject({
69
+ dedupeKey: 'panel-app:/tmp/timer.panel',
70
+ kind: 'panel-app',
71
+ resourceUri,
72
+ url: contentUrl,
73
+ });
74
+ expect(resolver.resolveOpenTarget({
75
+ kind: 'panel-app',
76
+ url: contentUrl,
77
+ })).toMatchObject({
78
+ dedupeKey: 'panel-app:/tmp/timer.panel',
79
+ resourceUri,
80
+ url: contentUrl,
81
+ });
82
+ expect(resolver.areUrlsEquivalent(
83
+ resourceUri,
84
+ contentUrl,
85
+ 'panel-app',
86
+ 'panel-app',
87
+ )).toBe(true);
88
+ expect(resolver.areUrlsEquivalent(
89
+ resourceUri,
90
+ 'nextclaw://panel-app/timer?path=%2Ftmp%2Fother.panel',
91
+ 'panel-app',
92
+ 'panel-app',
93
+ )).toBe(false);
94
+ });
62
95
  });
@@ -1,6 +1,10 @@
1
1
  import { describe, expect, it } from 'vitest';
2
2
  import type { PanelAppEntryView } from '@/shared/lib/api';
3
- import { createPanelAppRightPanelResourceTarget } from '@/features/right-panel-resources/utils/right-panel-resource-uri.utils';
3
+ import {
4
+ createPanelAppContentPath,
5
+ createPanelAppResourceUri,
6
+ createPanelAppRightPanelResourceTarget,
7
+ } from '@/features/right-panel-resources/utils/right-panel-resource-uri.utils';
4
8
 
5
9
  function createPanelAppEntry(overrides: Partial<PanelAppEntryView> = {}): PanelAppEntryView {
6
10
  return {
@@ -22,6 +26,15 @@ function createPanelAppEntry(overrides: Partial<PanelAppEntryView> = {}): PanelA
22
26
  }
23
27
 
24
28
  describe('right-panel-resource uri utils', () => {
29
+ it('builds matching external panel app resource and content URLs', () => {
30
+ expect(createPanelAppResourceUri('demo', '/tmp/demo.panel')).toBe(
31
+ 'nextclaw://panel-app/demo?path=%2Ftmp%2Fdemo.panel',
32
+ );
33
+ expect(createPanelAppContentPath('demo', '/tmp/demo.panel')).toBe(
34
+ '/api/panel-apps/demo/content?path=%2Ftmp%2Fdemo.panel',
35
+ );
36
+ });
37
+
25
38
  it('keeps panel app image icons as dock image icons', () => {
26
39
  const target = createPanelAppRightPanelResourceTarget(createPanelAppEntry({
27
40
  icon: '/api/panel-apps/demo/assets/icon.png',
@@ -34,8 +34,16 @@ export function normalizeRightPanelAppsUrl(url: string): string {
34
34
  return createRightPanelAppsUrl(getRightPanelAppsTabFromUrl(url));
35
35
  }
36
36
 
37
- export function createPanelAppResourceUri(appId: string): string {
38
- return `nextclaw://panel-app/${encodeURIComponent(appId)}`;
37
+ export function createPanelAppResourceUri(appId: string, sourcePath?: string): string {
38
+ const uri = `nextclaw://panel-app/${encodeURIComponent(appId)}`;
39
+ const path = sourcePath?.trim();
40
+ return path ? `${uri}?${new URLSearchParams({ path }).toString()}` : uri;
41
+ }
42
+
43
+ export function createPanelAppContentPath(appId: string, sourcePath?: string): string {
44
+ const url = `/api/panel-apps/${encodeURIComponent(appId)}/content`;
45
+ const path = sourcePath?.trim();
46
+ return path ? `${url}?${new URLSearchParams({ path }).toString()}` : url;
39
47
  }
40
48
 
41
49
  function isPanelAppImageIcon(icon: string): boolean {
@@ -20,6 +20,7 @@ vi.mock('@/shared/hooks/use-server-path-create-directory', () => ({
20
20
  }));
21
21
 
22
22
  type PickerOverrides = {
23
+ allowCreateDirectory?: boolean;
23
24
  currentPath?: string;
24
25
  defaultWorkspacePath?: string;
25
26
  description?: string;
@@ -29,6 +30,7 @@ type PickerOverrides = {
29
30
  const createPickerElement = (overrides?: PickerOverrides) => (
30
31
  <ServerPathPickerDialog
31
32
  open={overrides?.open ?? true}
33
+ allowCreateDirectory={overrides?.allowCreateDirectory}
32
34
  currentPath={overrides?.currentPath ?? '/workspace'}
33
35
  defaultWorkspacePath={overrides?.defaultWorkspacePath}
34
36
  isSaving={false}
@@ -236,6 +238,13 @@ describe('ServerPathPickerDialog', () => {
236
238
  );
237
239
  });
238
240
 
241
+ it('hides directory creation when the caller only allows existing directories', () => {
242
+ renderPicker({ allowCreateDirectory: false });
243
+
244
+ expect(screen.queryByRole('button', { name: '新建文件夹' })).toBeNull();
245
+ expect(screen.getByText('单击选择,双击进入')).toBeTruthy();
246
+ });
247
+
239
248
  it('uses single click to select and double click to enter a folder', async () => {
240
249
  const user = userEvent.setup();
241
250
  renderPicker();