@nextclaw/ui 0.15.11 → 0.15.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/dist/assets/{api-pbx14YAL.js → api-DGG69ZcJ.js} +3 -3
  3. package/dist/assets/app-presenter-provider-B1RgR5ug.js +106 -0
  4. package/dist/assets/{appearance-settings-page-D9wJdf-0.js → appearance-settings-page-BZSd1SrH.js} +1 -1
  5. package/dist/assets/{book-open-DZyssdCc.js → book-open-BhaS5BJv.js} +1 -1
  6. package/dist/assets/{card-D7l-gWkY.js → card-Bcr5YM8F.js} +1 -1
  7. package/dist/assets/{channels-list-page-B3qW3T-B.js → channels-list-page-C_x83ZCT.js} +2 -2
  8. package/dist/assets/{chat-page-DUI_Jm6r.js → chat-page-D2xfR6TK.js} +1 -1
  9. package/dist/assets/{config-split-page-DFEKStD7.js → config-split-page-RXAoLeNv.js} +1 -1
  10. package/dist/assets/{confirm-dialog-7wpxfMCp.js → confirm-dialog-o_VPKvVD.js} +1 -1
  11. package/dist/assets/{desktop-update-config-DP6IbxBO.js → desktop-update-config-DEOMOsDN.js} +1 -1
  12. package/dist/assets/{dist-B9UN0HrS.js → dist-C87P4QWA.js} +1 -1
  13. package/dist/assets/doc-browser-BuGh646I.js +1 -0
  14. package/dist/assets/{doc-browser-D7klPMHs.js → doc-browser-DQ75iUkA.js} +1 -1
  15. package/dist/assets/{doc-browser-context-Bw9m83Jp.js → doc-browser-context-tE0--wSa.js} +1 -1
  16. package/dist/assets/{eye-IVMB0Rrz.js → eye-DL9QkrKg.js} +1 -1
  17. package/dist/assets/{form-actions-DrFAgO58.js → form-actions-J0tIaUdK.js} +1 -1
  18. package/dist/assets/index-B2YF-7Yw.css +1 -0
  19. package/dist/assets/index-B9QEjnoY.js +2 -0
  20. package/dist/assets/loader-circle-CAnHHSrY.js +1 -0
  21. package/dist/assets/{logo-badge-DoZOVuPr.js → logo-badge-BYTbZbOF.js} +1 -1
  22. package/dist/assets/{mcp-marketplace-page-C151EGP-.js → mcp-marketplace-page-COWlzeP1.js} +2 -2
  23. package/dist/assets/mcp-marketplace-page-CaUFv37E.js +1 -0
  24. package/dist/assets/{model-config-page-BmfQzc52.js → model-config-page-oNqnMXNY.js} +1 -1
  25. package/dist/assets/{navigation-link-CJw6FLOj.js → navigation-link-Dk6VdDkc.js} +1 -1
  26. package/dist/assets/plus-DjdTWPjQ.js +1 -0
  27. package/dist/assets/{popover-DitLJE6k.js → popover-Txwrum65.js} +1 -1
  28. package/dist/assets/{provider-scoped-model-input-CuAtduIc.js → provider-scoped-model-input-B7rQM4b6.js} +1 -1
  29. package/dist/assets/{providers-config-page-CrmuY4DC.js → providers-config-page-CP-OnvLK.js} +1 -1
  30. package/dist/assets/{react-CTDaaQ9V.js → react-B0LbRnAE.js} +1 -1
  31. package/dist/assets/{refresh-cw-E5qTlQY6.js → refresh-cw-C84mMku5.js} +1 -1
  32. package/dist/assets/remote-2gZkJSX4.js +1 -0
  33. package/dist/assets/rotate-cw-DQJhDnjg.js +1 -0
  34. package/dist/assets/{runtime-config-page-1fPJJvo5.js → runtime-config-page-u_fuHxc8.js} +1 -1
  35. package/dist/assets/{save-CTiZvAd2.js → save-R_qyrHpa.js} +1 -1
  36. package/dist/assets/{search-CztGmpnR.js → search-UKxiouNi.js} +1 -1
  37. package/dist/assets/{search-config-page-D3jVALZU.js → search-config-page-DzkpSGFH.js} +1 -1
  38. package/dist/assets/{secrets-config-page-CxWLQCjZ.js → secrets-config-page-DhoLYm7Z.js} +2 -2
  39. package/dist/assets/security-config-yraYoy9s.js +1 -0
  40. package/dist/assets/{select-BPVI2dQ_.js → select-CYHT8P5S.js} +1 -1
  41. package/dist/assets/{settings-2-Cd7QaBsq.js → settings-2-DGh4bGSi.js} +1 -1
  42. package/dist/assets/skeleton-BQuylYFO.js +1 -0
  43. package/dist/assets/{tabs-DrUmYwEQ.js → tabs-BCihm1zK.js} +1 -1
  44. package/dist/assets/{tag-chip-CmViAZKK.js → tag-chip-De0iz4o2.js} +1 -1
  45. package/dist/assets/{trash-2-DMRJ63AB.js → trash-2-Bo3DWiju.js} +1 -1
  46. package/dist/assets/use-config-DCzwgChy.js +1 -0
  47. package/dist/assets/{use-confirm-dialog-Du33bHPz.js → use-confirm-dialog-WZ94CehU.js} +1 -1
  48. package/dist/assets/{use-viewport-layout-D80O1d90.js → use-viewport-layout-CZ-3pMne.js} +1 -1
  49. package/dist/assets/x-NZxXyqW1.js +1 -0
  50. package/dist/index.html +28 -28
  51. package/package.json +9 -9
  52. package/src/features/chat/components/layout/__tests__/chat-sidebar.test.tsx +1 -0
  53. package/src/features/chat/components/layout/chat-sidebar-desktop-layout.tsx +4 -4
  54. package/src/features/chat/components/layout/chat-sidebar.tsx +27 -12
  55. package/src/features/chat/features/conversation/components/session-conversation-input.tsx +4 -19
  56. package/src/features/chat/features/conversation/hooks/__tests__/use-session-conversation-slash-commands.test.tsx +79 -0
  57. package/src/features/chat/features/conversation/hooks/use-session-conversation-slash-commands.ts +55 -0
  58. package/src/features/chat/features/message/components/__tests__/chat-inline-panel-app-card.test.tsx +28 -0
  59. package/src/features/chat/features/message/components/__tests__/chat-message-list.container.test.tsx +11 -12
  60. package/src/features/chat/features/message/components/chat-inline-panel-app-card.tsx +10 -7
  61. package/src/features/chat/features/message/components/chat-message-list.container.tsx +1 -0
  62. package/src/features/chat/features/message/utils/__tests__/chat-message-execution-summary.utils.test.ts +27 -15
  63. package/src/features/chat/features/message/utils/__tests__/chat-message-show-content-tool-card.utils.test.ts +3 -0
  64. package/src/features/chat/features/message/utils/chat-inline-panel-app-card.utils.ts +8 -6
  65. package/src/features/chat/features/message/utils/chat-message-execution-summary.utils.ts +1 -6
  66. package/src/features/chat/features/message/utils/chat-message-show-content-tool-card.utils.ts +2 -0
  67. package/src/features/chat/features/message/utils/chat-message-texts.utils.ts +0 -1
  68. package/src/features/chat/features/project/components/__tests__/chat-project-add-dialog.test.tsx +116 -0
  69. package/src/features/chat/features/project/components/chat-project-add-dialog.tsx +305 -0
  70. package/src/features/chat/features/session/components/__tests__/chat-sidebar-project-groups.test.tsx +3 -2
  71. package/src/features/chat/features/session/components/chat-sidebar-project-groups.tsx +2 -2
  72. package/src/features/chat/features/workspace/components/__tests__/workspace-file-preview-error-text.test.ts +22 -0
  73. package/src/features/chat/features/workspace/components/chat-session-workspace-file-preview.tsx +9 -5
  74. package/src/features/chat/managers/__tests__/chat-ui.manager.test.ts +24 -0
  75. package/src/features/chat/managers/chat-ui.manager.ts +7 -1
  76. package/src/features/cron/components/cron-task-discovery.tsx +1 -6
  77. package/src/features/right-panel-resources/configs/right-panel-resource-routes.config.ts +18 -4
  78. package/src/features/right-panel-resources/index.ts +1 -0
  79. package/src/features/right-panel-resources/utils/__tests__/right-panel-resource-route-resolver.utils.test.ts +33 -0
  80. package/src/features/right-panel-resources/utils/__tests__/right-panel-resource-uri.utils.test.ts +14 -1
  81. package/src/features/right-panel-resources/utils/right-panel-resource-uri.utils.ts +10 -2
  82. package/src/shared/components/path-picker/__tests__/server-path-picker-dialog.test.tsx +9 -0
  83. package/src/shared/components/path-picker/server-path-picker-dialog.tsx +8 -2
  84. package/src/shared/components/path-picker/server-path-picker-toolbar.tsx +8 -4
  85. package/src/shared/hooks/use-projects.ts +15 -1
  86. package/src/shared/lib/api/projects/project.utils.ts +7 -0
  87. package/src/shared/lib/api/types.ts +1 -1
  88. package/src/shared/lib/api/utils/ncp-session.utils.ts +7 -0
  89. package/src/shared/lib/i18n/locales/en-US/chat.json +26 -9
  90. package/src/shared/lib/i18n/locales/zh-CN/chat.json +26 -9
  91. package/src/shared/lib/resource-uri/utils/__tests__/resource-uri-resolver.utils.test.ts +13 -1
  92. package/src/shared/lib/resource-uri/utils/resource-uri.utils.ts +6 -3
  93. package/dist/assets/app-presenter-provider-Q6u2xBAn.js +0 -106
  94. package/dist/assets/doc-browser-wgEeLkn1.js +0 -1
  95. package/dist/assets/index-D297NGOb.js +0 -2
  96. package/dist/assets/index-DqZoodZL.css +0 -1
  97. package/dist/assets/loader-circle-DDgFo4Kv.js +0 -1
  98. package/dist/assets/mcp-marketplace-page-BtNExfMn.js +0 -1
  99. package/dist/assets/plus-CYxLSfLh.js +0 -1
  100. package/dist/assets/remote-YzPpOPmK.js +0 -1
  101. package/dist/assets/rotate-cw-BXeiDT6C.js +0 -1
  102. package/dist/assets/security-config-DEyj4WBh.js +0 -1
  103. package/dist/assets/skeleton-CUpAXGoe.js +0 -1
  104. package/dist/assets/use-config-gpXy2Iyc.js +0 -1
  105. package/dist/assets/x-CsLMuNFe.js +0 -1
  106. package/src/features/chat/features/project/components/__tests__/chat-project-create-dialog.test.tsx +0 -67
  107. package/src/features/chat/features/project/components/chat-project-create-dialog.tsx +0 -203
@@ -0,0 +1,116 @@
1
+ import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
2
+ import type { ComponentProps } from 'react';
3
+ import userEvent from '@testing-library/user-event';
4
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
5
+ import { ChatProjectAddDialog } from '@/features/chat/features/project/components/chat-project-add-dialog';
6
+ import { setLanguage } from '@/shared/lib/i18n';
7
+
8
+ const pathPickerSpy = vi.fn();
9
+
10
+ vi.mock('@/shared/components/path-picker/server-path-picker-dialog', () => ({
11
+ ServerPathPickerDialog: (props: unknown) => pathPickerSpy(props),
12
+ }));
13
+
14
+ function renderDialog(overrides: Partial<ComponentProps<typeof ChatProjectAddDialog>> = {}) {
15
+ const onCreate = vi.fn(async () => undefined);
16
+ const onAddExisting = vi.fn(async () => undefined);
17
+ render(
18
+ <ChatProjectAddDialog
19
+ open
20
+ defaultWorkspacePath="/workspace"
21
+ templates={[
22
+ { id: 'empty', name: 'Empty', description: 'Empty project' },
23
+ {
24
+ id: 'knowledge-base',
25
+ name: 'Knowledge base',
26
+ description: 'Knowledge base project',
27
+ },
28
+ ]}
29
+ isCreating={false}
30
+ isAddingExisting={false}
31
+ onOpenChange={() => {}}
32
+ onCreate={onCreate}
33
+ onAddExisting={onAddExisting}
34
+ {...overrides}
35
+ />,
36
+ );
37
+ return { onCreate, onAddExisting };
38
+ }
39
+
40
+ describe('ChatProjectAddDialog', () => {
41
+ beforeEach(() => {
42
+ setLanguage('zh');
43
+ pathPickerSpy.mockClear();
44
+ });
45
+
46
+ it('creates a new project with an optional project directory and template', async () => {
47
+ const user = userEvent.setup();
48
+ const { onCreate, onAddExisting } = renderDialog();
49
+
50
+ expect(screen.getByRole('heading', { name: '添加项目' })).toBeTruthy();
51
+ expect(screen.getByRole('button', { name: '新建项目' }).getAttribute('aria-pressed')).toBe(
52
+ 'true',
53
+ );
54
+ await user.type(screen.getByLabelText('项目名称'), '个人知识库');
55
+ fireEvent.change(document.querySelector('select')!, {
56
+ target: { value: 'knowledge-base' },
57
+ });
58
+ await user.click(screen.getByRole('button', { name: '浏览' }));
59
+ expect(pathPickerSpy).toHaveBeenLastCalledWith(
60
+ expect.objectContaining({
61
+ open: true,
62
+ allowCreateDirectory: true,
63
+ currentPath: '',
64
+ defaultWorkspacePath: '/workspace',
65
+ title: '选择或新建项目目录',
66
+ }),
67
+ );
68
+ const pickerProps = pathPickerSpy.mock.lastCall?.[0] as {
69
+ onConfirm: (path: string) => void;
70
+ };
71
+ act(() => pickerProps.onConfirm('/srv/knowledge'));
72
+ expect((screen.getByLabelText('项目目录(可选)') as HTMLInputElement).value).toBe(
73
+ '/srv/knowledge',
74
+ );
75
+ await user.click(screen.getByRole('button', { name: '创建项目' }));
76
+
77
+ await waitFor(() => {
78
+ expect(onCreate).toHaveBeenCalledWith({
79
+ name: '个人知识库',
80
+ rootPath: '/srv/knowledge',
81
+ template: 'knowledge-base',
82
+ });
83
+ });
84
+ expect(onAddExisting).not.toHaveBeenCalled();
85
+ });
86
+
87
+ it('adds an existing directory without exposing project initialization', async () => {
88
+ const user = userEvent.setup();
89
+ const { onCreate, onAddExisting } = renderDialog();
90
+
91
+ await user.click(screen.getByRole('button', { name: '添加已有目录' }));
92
+ expect(screen.queryByLabelText('项目名称')).toBeNull();
93
+ expect(screen.queryByLabelText('项目模板')).toBeNull();
94
+ expect(screen.getByText(
95
+ '选择服务端机器上的已有目录。项目名称取目录名,不会修改或初始化目录内容。',
96
+ )).toBeTruthy();
97
+ await user.click(screen.getByRole('button', { name: '浏览' }));
98
+ expect(pathPickerSpy).toHaveBeenLastCalledWith(
99
+ expect.objectContaining({
100
+ open: true,
101
+ allowCreateDirectory: false,
102
+ title: '选择已有项目目录',
103
+ }),
104
+ );
105
+ const pickerProps = pathPickerSpy.mock.lastCall?.[0] as {
106
+ onConfirm: (path: string) => void;
107
+ };
108
+ act(() => pickerProps.onConfirm('/srv/existing-project'));
109
+ await user.click(screen.getByRole('button', { name: '添加到项目列表' }));
110
+
111
+ await waitFor(() => {
112
+ expect(onAddExisting).toHaveBeenCalledWith({ rootPath: '/srv/existing-project' });
113
+ });
114
+ expect(onCreate).not.toHaveBeenCalled();
115
+ });
116
+ });
@@ -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), {
@@ -123,12 +123,7 @@ export function CronTemplateGallery({
123
123
  const templates = compact ? CRON_TEMPLATES.slice(0, 4) : CRON_TEMPLATES;
124
124
 
125
125
  return (
126
- <div
127
- className={cn(
128
- "grid gap-3 sm:grid-cols-2",
129
- compact ? "xl:grid-cols-4" : "lg:grid-cols-3",
130
- )}
131
- >
126
+ <div className="grid grid-cols-[repeat(auto-fit,minmax(min(100%,17.5rem),1fr))] gap-3">
132
127
  {templates.map((template) => {
133
128
  const Icon = template.icon;
134
129
  return (
@@ -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
  });