@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.
- package/CHANGELOG.md +41 -0
- package/dist/assets/{api-pbx14YAL.js → api-DGG69ZcJ.js} +3 -3
- package/dist/assets/app-presenter-provider-B1RgR5ug.js +106 -0
- package/dist/assets/{appearance-settings-page-D9wJdf-0.js → appearance-settings-page-BZSd1SrH.js} +1 -1
- package/dist/assets/{book-open-DZyssdCc.js → book-open-BhaS5BJv.js} +1 -1
- package/dist/assets/{card-D7l-gWkY.js → card-Bcr5YM8F.js} +1 -1
- package/dist/assets/{channels-list-page-B3qW3T-B.js → channels-list-page-C_x83ZCT.js} +2 -2
- package/dist/assets/{chat-page-DUI_Jm6r.js → chat-page-D2xfR6TK.js} +1 -1
- package/dist/assets/{config-split-page-DFEKStD7.js → config-split-page-RXAoLeNv.js} +1 -1
- package/dist/assets/{confirm-dialog-7wpxfMCp.js → confirm-dialog-o_VPKvVD.js} +1 -1
- package/dist/assets/{desktop-update-config-DP6IbxBO.js → desktop-update-config-DEOMOsDN.js} +1 -1
- package/dist/assets/{dist-B9UN0HrS.js → dist-C87P4QWA.js} +1 -1
- package/dist/assets/doc-browser-BuGh646I.js +1 -0
- package/dist/assets/{doc-browser-D7klPMHs.js → doc-browser-DQ75iUkA.js} +1 -1
- package/dist/assets/{doc-browser-context-Bw9m83Jp.js → doc-browser-context-tE0--wSa.js} +1 -1
- package/dist/assets/{eye-IVMB0Rrz.js → eye-DL9QkrKg.js} +1 -1
- package/dist/assets/{form-actions-DrFAgO58.js → form-actions-J0tIaUdK.js} +1 -1
- package/dist/assets/index-B2YF-7Yw.css +1 -0
- package/dist/assets/index-B9QEjnoY.js +2 -0
- package/dist/assets/loader-circle-CAnHHSrY.js +1 -0
- package/dist/assets/{logo-badge-DoZOVuPr.js → logo-badge-BYTbZbOF.js} +1 -1
- package/dist/assets/{mcp-marketplace-page-C151EGP-.js → mcp-marketplace-page-COWlzeP1.js} +2 -2
- package/dist/assets/mcp-marketplace-page-CaUFv37E.js +1 -0
- package/dist/assets/{model-config-page-BmfQzc52.js → model-config-page-oNqnMXNY.js} +1 -1
- package/dist/assets/{navigation-link-CJw6FLOj.js → navigation-link-Dk6VdDkc.js} +1 -1
- package/dist/assets/plus-DjdTWPjQ.js +1 -0
- package/dist/assets/{popover-DitLJE6k.js → popover-Txwrum65.js} +1 -1
- package/dist/assets/{provider-scoped-model-input-CuAtduIc.js → provider-scoped-model-input-B7rQM4b6.js} +1 -1
- package/dist/assets/{providers-config-page-CrmuY4DC.js → providers-config-page-CP-OnvLK.js} +1 -1
- package/dist/assets/{react-CTDaaQ9V.js → react-B0LbRnAE.js} +1 -1
- package/dist/assets/{refresh-cw-E5qTlQY6.js → refresh-cw-C84mMku5.js} +1 -1
- package/dist/assets/remote-2gZkJSX4.js +1 -0
- package/dist/assets/rotate-cw-DQJhDnjg.js +1 -0
- package/dist/assets/{runtime-config-page-1fPJJvo5.js → runtime-config-page-u_fuHxc8.js} +1 -1
- package/dist/assets/{save-CTiZvAd2.js → save-R_qyrHpa.js} +1 -1
- package/dist/assets/{search-CztGmpnR.js → search-UKxiouNi.js} +1 -1
- package/dist/assets/{search-config-page-D3jVALZU.js → search-config-page-DzkpSGFH.js} +1 -1
- package/dist/assets/{secrets-config-page-CxWLQCjZ.js → secrets-config-page-DhoLYm7Z.js} +2 -2
- package/dist/assets/security-config-yraYoy9s.js +1 -0
- package/dist/assets/{select-BPVI2dQ_.js → select-CYHT8P5S.js} +1 -1
- package/dist/assets/{settings-2-Cd7QaBsq.js → settings-2-DGh4bGSi.js} +1 -1
- package/dist/assets/skeleton-BQuylYFO.js +1 -0
- package/dist/assets/{tabs-DrUmYwEQ.js → tabs-BCihm1zK.js} +1 -1
- package/dist/assets/{tag-chip-CmViAZKK.js → tag-chip-De0iz4o2.js} +1 -1
- package/dist/assets/{trash-2-DMRJ63AB.js → trash-2-Bo3DWiju.js} +1 -1
- package/dist/assets/use-config-DCzwgChy.js +1 -0
- package/dist/assets/{use-confirm-dialog-Du33bHPz.js → use-confirm-dialog-WZ94CehU.js} +1 -1
- package/dist/assets/{use-viewport-layout-D80O1d90.js → use-viewport-layout-CZ-3pMne.js} +1 -1
- package/dist/assets/x-NZxXyqW1.js +1 -0
- package/dist/index.html +28 -28
- package/package.json +9 -9
- package/src/features/chat/components/layout/__tests__/chat-sidebar.test.tsx +1 -0
- package/src/features/chat/components/layout/chat-sidebar-desktop-layout.tsx +4 -4
- package/src/features/chat/components/layout/chat-sidebar.tsx +27 -12
- package/src/features/chat/features/conversation/components/session-conversation-input.tsx +4 -19
- package/src/features/chat/features/conversation/hooks/__tests__/use-session-conversation-slash-commands.test.tsx +79 -0
- package/src/features/chat/features/conversation/hooks/use-session-conversation-slash-commands.ts +55 -0
- package/src/features/chat/features/message/components/__tests__/chat-inline-panel-app-card.test.tsx +28 -0
- package/src/features/chat/features/message/components/__tests__/chat-message-list.container.test.tsx +11 -12
- package/src/features/chat/features/message/components/chat-inline-panel-app-card.tsx +10 -7
- package/src/features/chat/features/message/components/chat-message-list.container.tsx +1 -0
- package/src/features/chat/features/message/utils/__tests__/chat-message-execution-summary.utils.test.ts +27 -15
- package/src/features/chat/features/message/utils/__tests__/chat-message-show-content-tool-card.utils.test.ts +3 -0
- package/src/features/chat/features/message/utils/chat-inline-panel-app-card.utils.ts +8 -6
- package/src/features/chat/features/message/utils/chat-message-execution-summary.utils.ts +1 -6
- package/src/features/chat/features/message/utils/chat-message-show-content-tool-card.utils.ts +2 -0
- package/src/features/chat/features/message/utils/chat-message-texts.utils.ts +0 -1
- package/src/features/chat/features/project/components/__tests__/chat-project-add-dialog.test.tsx +116 -0
- package/src/features/chat/features/project/components/chat-project-add-dialog.tsx +305 -0
- package/src/features/chat/features/session/components/__tests__/chat-sidebar-project-groups.test.tsx +3 -2
- package/src/features/chat/features/session/components/chat-sidebar-project-groups.tsx +2 -2
- package/src/features/chat/features/workspace/components/__tests__/workspace-file-preview-error-text.test.ts +22 -0
- package/src/features/chat/features/workspace/components/chat-session-workspace-file-preview.tsx +9 -5
- package/src/features/chat/managers/__tests__/chat-ui.manager.test.ts +24 -0
- package/src/features/chat/managers/chat-ui.manager.ts +7 -1
- package/src/features/cron/components/cron-task-discovery.tsx +1 -6
- package/src/features/right-panel-resources/configs/right-panel-resource-routes.config.ts +18 -4
- package/src/features/right-panel-resources/index.ts +1 -0
- package/src/features/right-panel-resources/utils/__tests__/right-panel-resource-route-resolver.utils.test.ts +33 -0
- package/src/features/right-panel-resources/utils/__tests__/right-panel-resource-uri.utils.test.ts +14 -1
- package/src/features/right-panel-resources/utils/right-panel-resource-uri.utils.ts +10 -2
- package/src/shared/components/path-picker/__tests__/server-path-picker-dialog.test.tsx +9 -0
- package/src/shared/components/path-picker/server-path-picker-dialog.tsx +8 -2
- package/src/shared/components/path-picker/server-path-picker-toolbar.tsx +8 -4
- package/src/shared/hooks/use-projects.ts +15 -1
- package/src/shared/lib/api/projects/project.utils.ts +7 -0
- package/src/shared/lib/api/types.ts +1 -1
- package/src/shared/lib/api/utils/ncp-session.utils.ts +7 -0
- package/src/shared/lib/i18n/locales/en-US/chat.json +26 -9
- package/src/shared/lib/i18n/locales/zh-CN/chat.json +26 -9
- package/src/shared/lib/resource-uri/utils/__tests__/resource-uri-resolver.utils.test.ts +13 -1
- package/src/shared/lib/resource-uri/utils/resource-uri.utils.ts +6 -3
- package/dist/assets/app-presenter-provider-Q6u2xBAn.js +0 -106
- package/dist/assets/doc-browser-wgEeLkn1.js +0 -1
- package/dist/assets/index-D297NGOb.js +0 -2
- package/dist/assets/index-DqZoodZL.css +0 -1
- package/dist/assets/loader-circle-DDgFo4Kv.js +0 -1
- package/dist/assets/mcp-marketplace-page-BtNExfMn.js +0 -1
- package/dist/assets/plus-CYxLSfLh.js +0 -1
- package/dist/assets/remote-YzPpOPmK.js +0 -1
- package/dist/assets/rotate-cw-BXeiDT6C.js +0 -1
- package/dist/assets/security-config-DEyj4WBh.js +0 -1
- package/dist/assets/skeleton-CUpAXGoe.js +0 -1
- package/dist/assets/use-config-gpXy2Iyc.js +0 -1
- package/dist/assets/x-CsLMuNFe.js +0 -1
- package/src/features/chat/features/project/components/__tests__/chat-project-create-dialog.test.tsx +0 -67
- package/src/features/chat/features/project/components/chat-project-create-dialog.tsx +0 -203
package/src/features/chat/features/project/components/__tests__/chat-project-add-dialog.test.tsx
ADDED
|
@@ -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
|
+
}
|
package/src/features/chat/features/session/components/__tests__/chat-sidebar-project-groups.test.tsx
CHANGED
|
@@ -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(
|
|
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-
|
|
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-
|
|
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
|
+
});
|
package/src/features/chat/features/workspace/components/chat-session-workspace-file-preview.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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),
|
|
@@ -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
|
});
|