@nextclaw/ui 0.15.23 → 0.15.25
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +38 -0
- package/dist/assets/{api-Glw2OGXK.js → api-FK7tikI1.js} +4 -4
- package/dist/assets/appearance-settings-page-BYbvl2wj.js +1 -0
- package/dist/assets/{channels-list-page-C7E4Sxwd.js → channels-list-page-CocdcIko.js} +2 -2
- package/dist/assets/{chat-page-BT6dNI-4.js → chat-page-C5E-LLVd.js} +1 -1
- package/dist/assets/config-split-page-CA_a0AJ5.js +1 -0
- package/dist/assets/confirm-dialog-CxZfenjK.js +5 -0
- package/dist/assets/desktop-update-config-BEKC8Ly9.js +1 -0
- package/dist/assets/{dist-dv_SUR54.js → dist-BH7W5yCi.js} +1 -1
- package/dist/assets/{dist-CtLyEJeO.js → dist-CaYdVVeO.js} +1 -1
- package/dist/assets/doc-browser-Cl2ldE3f.js +1 -0
- package/dist/assets/doc-browser-DXCSd63E.js +1 -0
- package/dist/assets/{doc-browser-context-BIBjkwfV.js → doc-browser-context-FDPs3gJf.js} +1 -1
- package/dist/assets/form-actions-0c0aw_bl.js +1 -0
- package/dist/assets/i18n-provider-DkITJNz8.js +1 -0
- package/dist/assets/index-CQ2LcI2p.css +1 -0
- package/dist/assets/index-CrdfvBVg.js +107 -0
- package/dist/assets/loader-circle-DHtUxCIA.js +1 -0
- package/dist/assets/mcp-marketplace-page-Bc_jI5iH.js +9 -0
- package/dist/assets/mcp-marketplace-page-CzCieD9r.js +1 -0
- package/dist/assets/model-config-page-C4jw-pcj.js +1 -0
- package/dist/assets/{navigation-link-CB0OQbnY.js → navigation-link-CVy0_kVl.js} +2 -2
- package/dist/assets/plus-CG1RwZQq.js +1 -0
- package/dist/assets/provider-scoped-model-input-CmH4BFvc.js +1 -0
- package/dist/assets/providers-config-page-CJQjwzaw.js +1 -0
- package/dist/assets/react-DjFBVA0n.js +8 -0
- package/dist/assets/{refresh-cw-Ba0LZou4.js → refresh-cw-j2bl79Ea.js} +1 -1
- package/dist/assets/remote-BIlpD3yL.js +1 -0
- package/dist/assets/rotate-cw-bKG8zH8M.js +1 -0
- package/dist/assets/runtime-config-page-B_x2Glz5.js +1 -0
- package/dist/assets/{save-DTOAvqzt.js → save-BKNrtK81.js} +1 -1
- package/dist/assets/{search-D4X5DNMe.js → search-CkOm65W_.js} +1 -1
- package/dist/assets/{search-config-page-Cr6254K5.js → search-config-page-BaIijtmO.js} +1 -1
- package/dist/assets/{secrets-config-page-W2Hx332d.js → secrets-config-page-CJjtdFAl.js} +2 -2
- package/dist/assets/security-config-DB8apj09.js +1 -0
- package/dist/assets/{select-CDv0g2Pj.js → select-Ea7ffKCE.js} +2 -2
- package/dist/assets/{settings-2-CoxzNABp.js → settings-2-CtyN3m_V.js} +1 -1
- package/dist/assets/skeleton-Bt6zxcAB.js +1 -0
- package/dist/assets/{tag-chip-CjkZEoS2.js → tag-chip-C9l4xsKp.js} +1 -1
- package/dist/assets/x-CowtCu9Z.js +1 -0
- package/dist/index.html +20 -20
- package/package.json +8 -8
- package/src/features/chat/features/conversation/components/__tests__/session-conversation-area.test.tsx +30 -1
- package/src/features/chat/features/conversation/components/session-conversation-area.tsx +1 -1
- package/src/features/chat/features/conversation/hooks/__tests__/use-session-provider-model-catalog.test.tsx +54 -2
- package/src/features/chat/features/conversation/hooks/use-chat-composer-reference-intent.ts +10 -1
- package/src/features/chat/features/conversation/hooks/use-session-provider-model-catalog.ts +76 -1
- package/src/features/chat/features/conversation/utils/session-conversation-input-toolbar.utils.ts +6 -1
- package/src/features/chat/features/input/utils/__tests__/chat-inline-token.utils.test.ts +29 -0
- package/src/features/chat/features/input/utils/__tests__/chat-input-bar.utils.test.ts +12 -3
- package/src/features/chat/features/input/utils/chat-composer-token-protocol.utils.ts +5 -0
- package/src/features/chat/features/input/utils/chat-conversation-excerpt-token.utils.ts +59 -0
- package/src/features/chat/features/input/utils/chat-inline-token.utils.ts +42 -16
- package/src/features/chat/features/input/utils/chat-input-toolbar.utils.ts +50 -14
- package/src/features/chat/features/message/components/chat-message-list.container.tsx +40 -0
- package/src/features/chat/features/message/utils/chat-message-texts.utils.ts +5 -0
- package/src/features/chat/features/ncp/hooks/__tests__/use-hydrated-ncp-agent.test.tsx +40 -3
- package/src/features/chat/features/workspace/components/__tests__/chat-session-workspace-file-preview.selection.test.tsx +66 -0
- package/src/features/chat/features/workspace/components/__tests__/chat-session-workspace-file-preview.test.tsx +13 -1
- package/src/features/chat/features/workspace/components/__tests__/chat-session-workspace-panel-content.test.tsx +573 -90
- package/src/features/chat/features/workspace/components/__tests__/workspace-text-selection-menu.test.tsx +11 -4
- package/src/features/chat/features/workspace/components/chat-session-workspace-directory-browser.tsx +389 -288
- package/src/features/chat/features/workspace/components/chat-session-workspace-file-breadcrumbs.tsx +54 -35
- package/src/features/chat/features/workspace/components/chat-session-workspace-file-preview.tsx +79 -148
- package/src/features/chat/features/workspace/components/chat-session-workspace-panel-content.tsx +149 -94
- package/src/features/chat/features/workspace/components/project-files/__tests__/workspace-directory-entry-menu.test.tsx +50 -0
- package/src/features/chat/features/workspace/components/project-files/workspace-directory-entry-menu.tsx +209 -0
- package/src/features/chat/features/workspace/components/project-files/workspace-directory-tree.tsx +411 -0
- package/src/features/chat/features/workspace/components/project-files/workspace-new-folder-tree-item.tsx +112 -0
- package/src/features/chat/features/workspace/components/project-files/workspace-project-files-toolbar.tsx +64 -0
- package/src/features/chat/features/workspace/components/workspace-text-selection-menu.tsx +17 -195
- package/src/features/chat/features/workspace/hooks/use-workspace-explorer-layout.ts +88 -0
- package/src/features/chat/features/workspace/hooks/use-workspace-file-actions.ts +335 -0
- package/src/features/chat/features/workspace/utils/chat-workspace-file-path-mutation.utils.ts +58 -0
- package/src/features/chat/features/workspace/utils/chat-workspace-file-tab.utils.ts +23 -22
- package/src/features/chat/features/workspace/utils/chat-workspace-panel-layout.utils.ts +13 -4
- package/src/features/chat/managers/__tests__/chat-composer-intent.manager.test.ts +94 -45
- package/src/features/chat/managers/__tests__/chat-thread.manager.test.ts +103 -99
- package/src/features/chat/managers/chat-composer-intent.manager.ts +83 -34
- package/src/features/chat/managers/chat-thread.manager.ts +132 -128
- package/src/features/chat/stores/__tests__/chat-thread.store.test.ts +20 -40
- package/src/features/chat/stores/chat-thread.store.ts +47 -74
- package/src/features/chat/types/chat-input-bar.types.ts +3 -0
- package/src/features/side-dock/components/__tests__/side-dock.test.tsx +1 -0
- package/src/features/side-dock/configs/side-dock-built-in-items.config.ts +0 -9
- package/src/shared/components/resizable-right-panel/__tests__/resizable-right-panel.test.tsx +35 -38
- package/src/shared/components/resizable-right-panel/resizable-right-panel.tsx +25 -19
- package/src/shared/components/ui/context-menu/__tests__/context-menu.test.tsx +60 -34
- package/src/shared/components/ui/context-menu/context-menu.tsx +95 -68
- package/src/shared/lib/api/managers/client.manager.ts +31 -17
- package/src/shared/lib/api/server-path/server-path.types.ts +66 -0
- package/src/shared/lib/api/types.ts +145 -116
- package/src/shared/lib/api/utils/server-path.utils.ts +35 -11
- package/src/shared/lib/i18n/locales/en-US/chat.json +49 -1
- package/src/shared/lib/i18n/locales/zh-CN/chat.json +49 -1
- package/dist/assets/appearance-settings-page-Bl_W_9m7.js +0 -1
- package/dist/assets/config-split-page-D5BgtKrm.js +0 -1
- package/dist/assets/confirm-dialog-Bf907Azu.js +0 -5
- package/dist/assets/desktop-update-config-BwuyBPJb.js +0 -1
- package/dist/assets/doc-browser-C23NDGh5.js +0 -1
- package/dist/assets/doc-browser-DlOOhBxI.js +0 -1
- package/dist/assets/form-actions-DAzi68Cn.js +0 -1
- package/dist/assets/index-BLNg2yg0.css +0 -1
- package/dist/assets/index-D3EKjEyK.js +0 -107
- package/dist/assets/loader-circle-BSg5u0r4.js +0 -1
- package/dist/assets/mcp-marketplace-page-ByhvQQj5.js +0 -9
- package/dist/assets/mcp-marketplace-page-CytXwnUs.js +0 -1
- package/dist/assets/model-config-page-bgEx3MjQ.js +0 -1
- package/dist/assets/plus-CNjWdLhZ.js +0 -1
- package/dist/assets/provider-scoped-model-input-8F6JMd2n.js +0 -1
- package/dist/assets/providers-config-page-CxpTjR81.js +0 -1
- package/dist/assets/react-Bk2ydNTe.js +0 -8
- package/dist/assets/remote-Dv38PuMV.js +0 -1
- package/dist/assets/rotate-cw-IkYVk9JX.js +0 -1
- package/dist/assets/runtime-config-page-C0ByyUVr.js +0 -1
- package/dist/assets/security-config-1E8OF4kR.js +0 -1
- package/dist/assets/skeleton-DW018fJv.js +0 -1
- package/dist/assets/use-confirm-dialog-CavtY1vJ.js +0 -1
- package/dist/assets/x-PTXYTEwz.js +0 -1
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { NextClawClientError } from '@nextclaw/client-sdk';
|
|
3
|
+
import { toast } from 'sonner';
|
|
4
|
+
import type { useConfirmDialog } from '@/shared/hooks/use-confirm-dialog';
|
|
5
|
+
import {
|
|
6
|
+
createServerPathDirectory,
|
|
7
|
+
createServerPathFile,
|
|
8
|
+
deleteServerPathEntry,
|
|
9
|
+
renameServerPathEntry,
|
|
10
|
+
uploadServerPathFiles,
|
|
11
|
+
type ServerPathEntryView,
|
|
12
|
+
} from '@/shared/lib/api';
|
|
13
|
+
import { t } from '@/shared/lib/i18n';
|
|
14
|
+
|
|
15
|
+
type WorkspaceFilePendingAction = 'create-directory' | 'create-file' | 'delete' | 'rename' | 'upload';
|
|
16
|
+
type Confirm = ReturnType<typeof useConfirmDialog>['confirm'];
|
|
17
|
+
|
|
18
|
+
export type RefreshDirectory = () => Promise<unknown>;
|
|
19
|
+
|
|
20
|
+
export type WorkspaceDirectoryActionTarget = {
|
|
21
|
+
label: string;
|
|
22
|
+
path: string;
|
|
23
|
+
refresh: RefreshDirectory;
|
|
24
|
+
expand?: () => void;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export type WorkspaceTreeSelection = {
|
|
28
|
+
path: string;
|
|
29
|
+
createTarget: WorkspaceDirectoryActionTarget;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
function readErrorMessage(error: unknown): string {
|
|
33
|
+
return error instanceof Error ? error.message : String(error);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function replaceCount(text: string, count: number): string {
|
|
37
|
+
return text.replace('{count}', String(count));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function useWorkspaceFileActions() {
|
|
41
|
+
const [pendingAction, setPendingAction] = useState<WorkspaceFilePendingAction | null>(null);
|
|
42
|
+
const pendingActionRef = useRef<WorkspaceFilePendingAction | null>(null);
|
|
43
|
+
|
|
44
|
+
const run = async <T>(action: WorkspaceFilePendingAction, operation: () => Promise<T>): Promise<T> => {
|
|
45
|
+
if (pendingActionRef.current) {
|
|
46
|
+
throw new Error('another workspace file operation is already running');
|
|
47
|
+
}
|
|
48
|
+
pendingActionRef.current = action;
|
|
49
|
+
setPendingAction(action);
|
|
50
|
+
try {
|
|
51
|
+
return await operation();
|
|
52
|
+
} finally {
|
|
53
|
+
pendingActionRef.current = null;
|
|
54
|
+
setPendingAction(null);
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
pendingAction,
|
|
60
|
+
createDirectory: (params: { basePath: string; parentPath: string; name: string }) =>
|
|
61
|
+
run('create-directory', () => createServerPathDirectory(params)),
|
|
62
|
+
createFile: (params: { basePath: string; parentPath: string; name: string }) =>
|
|
63
|
+
run('create-file', () => createServerPathFile(params)),
|
|
64
|
+
deleteEntry: (params: { basePath: string; path: string }) => run('delete', () => deleteServerPathEntry(params)),
|
|
65
|
+
renameEntry: (params: { basePath: string; path: string; name: string }) =>
|
|
66
|
+
run('rename', () => renameServerPathEntry(params)),
|
|
67
|
+
uploadFiles: (params: { basePath: string; targetPath: string; files: readonly File[]; overwrite?: boolean }) =>
|
|
68
|
+
run('upload', () => uploadServerPathFiles(params)),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
type WorkspaceFileActions = ReturnType<typeof useWorkspaceFileActions>;
|
|
73
|
+
|
|
74
|
+
async function uploadWorkspaceFiles({
|
|
75
|
+
actions,
|
|
76
|
+
confirm,
|
|
77
|
+
files,
|
|
78
|
+
rootPath,
|
|
79
|
+
target,
|
|
80
|
+
}: {
|
|
81
|
+
actions: WorkspaceFileActions;
|
|
82
|
+
confirm: Confirm;
|
|
83
|
+
files: readonly File[];
|
|
84
|
+
rootPath: string;
|
|
85
|
+
target: WorkspaceDirectoryActionTarget;
|
|
86
|
+
}) {
|
|
87
|
+
try {
|
|
88
|
+
let result;
|
|
89
|
+
try {
|
|
90
|
+
result = await actions.uploadFiles({
|
|
91
|
+
basePath: rootPath,
|
|
92
|
+
targetPath: target.path,
|
|
93
|
+
files,
|
|
94
|
+
});
|
|
95
|
+
} catch (error) {
|
|
96
|
+
if (!(error instanceof NextClawClientError) || error.code !== 'SERVER_PATH_FILE_EXISTS') {
|
|
97
|
+
throw error;
|
|
98
|
+
}
|
|
99
|
+
const conflicts = Array.isArray(error.details?.conflicts) ? error.details.conflicts.length : files.length;
|
|
100
|
+
const shouldReplace = await confirm({
|
|
101
|
+
title: t('chatWorkspaceUploadConflictTitle'),
|
|
102
|
+
description: replaceCount(t('chatWorkspaceUploadConflictDescription'), conflicts),
|
|
103
|
+
confirmLabel: t('chatWorkspaceReplaceFiles'),
|
|
104
|
+
variant: 'destructive',
|
|
105
|
+
});
|
|
106
|
+
if (!shouldReplace) return;
|
|
107
|
+
result = await actions.uploadFiles({
|
|
108
|
+
basePath: rootPath,
|
|
109
|
+
targetPath: target.path,
|
|
110
|
+
files,
|
|
111
|
+
overwrite: true,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
await target.refresh();
|
|
115
|
+
toast.success(replaceCount(t('chatWorkspaceUploadSucceeded'), result.files.length));
|
|
116
|
+
} catch (error) {
|
|
117
|
+
toast.error(`${t('chatWorkspaceUploadFailed')}: ${readErrorMessage(error)}`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function deleteWorkspaceEntry({
|
|
122
|
+
actions,
|
|
123
|
+
confirm,
|
|
124
|
+
entry,
|
|
125
|
+
refresh,
|
|
126
|
+
rootPath,
|
|
127
|
+
}: {
|
|
128
|
+
actions: WorkspaceFileActions;
|
|
129
|
+
confirm: Confirm;
|
|
130
|
+
entry: ServerPathEntryView;
|
|
131
|
+
refresh: RefreshDirectory;
|
|
132
|
+
rootPath: string;
|
|
133
|
+
}) {
|
|
134
|
+
const isDirectory = entry.kind === 'directory';
|
|
135
|
+
const confirmed = await confirm({
|
|
136
|
+
title: t(isDirectory ? 'chatWorkspaceDeleteFolderTitle' : 'chatWorkspaceDeleteFileTitle').replace(
|
|
137
|
+
'{name}',
|
|
138
|
+
entry.name,
|
|
139
|
+
),
|
|
140
|
+
description: t(isDirectory ? 'chatWorkspaceDeleteFolderDescription' : 'chatWorkspaceDeleteFileDescription'),
|
|
141
|
+
confirmLabel: t('chatWorkspaceDelete'),
|
|
142
|
+
variant: 'destructive',
|
|
143
|
+
});
|
|
144
|
+
if (!confirmed) return;
|
|
145
|
+
const toastId = toast.loading(t('chatWorkspaceDeleting').replace('{name}', entry.name));
|
|
146
|
+
try {
|
|
147
|
+
const deleted = await actions.deleteEntry({
|
|
148
|
+
basePath: rootPath,
|
|
149
|
+
path: entry.path,
|
|
150
|
+
});
|
|
151
|
+
await refresh();
|
|
152
|
+
toast.dismiss(toastId);
|
|
153
|
+
toast.success(t('chatWorkspaceDeleteSucceeded').replace('{name}', entry.name));
|
|
154
|
+
return deleted;
|
|
155
|
+
} catch (error) {
|
|
156
|
+
toast.dismiss(toastId);
|
|
157
|
+
toast.error(`${t('chatWorkspaceDeleteFailed').replace('{name}', entry.name)}: ${readErrorMessage(error)}`);
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function createWorkspaceEntry({
|
|
163
|
+
actions,
|
|
164
|
+
kind,
|
|
165
|
+
name,
|
|
166
|
+
rootPath,
|
|
167
|
+
setError,
|
|
168
|
+
target,
|
|
169
|
+
}: {
|
|
170
|
+
actions: WorkspaceFileActions;
|
|
171
|
+
kind: 'directory' | 'file';
|
|
172
|
+
name: string;
|
|
173
|
+
rootPath: string;
|
|
174
|
+
setError: (error: string | null) => void;
|
|
175
|
+
target: WorkspaceDirectoryActionTarget;
|
|
176
|
+
}) {
|
|
177
|
+
setError(null);
|
|
178
|
+
try {
|
|
179
|
+
const created = await (kind === 'file' ? actions.createFile : actions.createDirectory)({
|
|
180
|
+
basePath: rootPath,
|
|
181
|
+
parentPath: target.path,
|
|
182
|
+
name,
|
|
183
|
+
});
|
|
184
|
+
await target.refresh();
|
|
185
|
+
toast.success(t(kind === 'file' ? 'chatWorkspaceFileCreated' : 'chatWorkspaceFolderCreated'));
|
|
186
|
+
return created;
|
|
187
|
+
} catch (error) {
|
|
188
|
+
setError(
|
|
189
|
+
`${t(kind === 'file' ? 'chatWorkspaceFileCreateFailed' : 'chatWorkspaceFolderCreateFailed')}: ${readErrorMessage(error)}`,
|
|
190
|
+
);
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
type WorkspaceProjectFilesControllerOptions = {
|
|
196
|
+
confirm: Confirm;
|
|
197
|
+
onEntryDeleted?: (path: string) => void;
|
|
198
|
+
onEntryRenamed?: (params: { previousPath: string; path: string; label: string }) => void;
|
|
199
|
+
rootPath: string | null;
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
export function useWorkspaceProjectFilesController({
|
|
203
|
+
confirm,
|
|
204
|
+
onEntryDeleted,
|
|
205
|
+
onEntryRenamed,
|
|
206
|
+
rootPath,
|
|
207
|
+
}: WorkspaceProjectFilesControllerOptions) {
|
|
208
|
+
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
|
209
|
+
const actions = useWorkspaceFileActions();
|
|
210
|
+
const [createTarget, setCreateTarget] = useState<WorkspaceDirectoryActionTarget | null>(null);
|
|
211
|
+
const [createName, setCreateName] = useState('');
|
|
212
|
+
const [createKind, setCreateKind] = useState<'directory' | 'file'>('directory');
|
|
213
|
+
const [createError, setCreateError] = useState<string | null>(null);
|
|
214
|
+
const [selectedPath, setSelectedPath] = useState<string | null>(null);
|
|
215
|
+
const [selectedCreateTarget, setSelectedCreateTarget] = useState<WorkspaceDirectoryActionTarget | null>(null);
|
|
216
|
+
const [revealPath, setRevealPath] = useState<string | null>(null);
|
|
217
|
+
const [uploadTarget, setUploadTarget] = useState<WorkspaceDirectoryActionTarget | null>(null);
|
|
218
|
+
|
|
219
|
+
useEffect(() => {
|
|
220
|
+
setCreateTarget(null);
|
|
221
|
+
setSelectedPath(null);
|
|
222
|
+
setSelectedCreateTarget(null);
|
|
223
|
+
setRevealPath(null);
|
|
224
|
+
setUploadTarget(null);
|
|
225
|
+
}, [rootPath]);
|
|
226
|
+
|
|
227
|
+
const requestUpload = (target: WorkspaceDirectoryActionTarget) => {
|
|
228
|
+
setUploadTarget(target);
|
|
229
|
+
if (!fileInputRef.current) return;
|
|
230
|
+
fileInputRef.current.value = '';
|
|
231
|
+
fileInputRef.current.click();
|
|
232
|
+
};
|
|
233
|
+
const selectEntry = (selection: WorkspaceTreeSelection) => {
|
|
234
|
+
setSelectedPath(selection.path);
|
|
235
|
+
setSelectedCreateTarget(selection.createTarget);
|
|
236
|
+
};
|
|
237
|
+
const openCreate = (kind: 'directory' | 'file', target: WorkspaceDirectoryActionTarget) => {
|
|
238
|
+
target.expand?.();
|
|
239
|
+
setCreateKind(kind);
|
|
240
|
+
setCreateName('');
|
|
241
|
+
setCreateError(null);
|
|
242
|
+
setCreateTarget(target);
|
|
243
|
+
};
|
|
244
|
+
const uploadFiles = async (files: readonly File[]) => {
|
|
245
|
+
if (!rootPath || !uploadTarget || files.length === 0) return;
|
|
246
|
+
await uploadWorkspaceFiles({
|
|
247
|
+
actions,
|
|
248
|
+
confirm,
|
|
249
|
+
files,
|
|
250
|
+
rootPath,
|
|
251
|
+
target: uploadTarget,
|
|
252
|
+
});
|
|
253
|
+
};
|
|
254
|
+
const deleteEntry = async (entry: ServerPathEntryView, refresh: RefreshDirectory) => {
|
|
255
|
+
if (!rootPath) return;
|
|
256
|
+
const deleted = await deleteWorkspaceEntry({
|
|
257
|
+
actions,
|
|
258
|
+
confirm,
|
|
259
|
+
entry,
|
|
260
|
+
refresh,
|
|
261
|
+
rootPath,
|
|
262
|
+
});
|
|
263
|
+
if (deleted) onEntryDeleted?.(deleted.path);
|
|
264
|
+
};
|
|
265
|
+
const renameEntry = async (entry: ServerPathEntryView, name: string, refresh: RefreshDirectory) => {
|
|
266
|
+
if (!rootPath) return null;
|
|
267
|
+
const renamed = await actions.renameEntry({
|
|
268
|
+
basePath: rootPath,
|
|
269
|
+
path: entry.path,
|
|
270
|
+
name,
|
|
271
|
+
});
|
|
272
|
+
onEntryRenamed?.({
|
|
273
|
+
previousPath: renamed.oldPath,
|
|
274
|
+
path: renamed.path,
|
|
275
|
+
label: renamed.name,
|
|
276
|
+
});
|
|
277
|
+
await refresh();
|
|
278
|
+
setSelectedPath(renamed.path);
|
|
279
|
+
setRevealPath(renamed.path);
|
|
280
|
+
toast.success(t('chatWorkspaceRenameSucceeded').replace('{name}', renamed.name));
|
|
281
|
+
return renamed;
|
|
282
|
+
};
|
|
283
|
+
const submitCreate = async () => {
|
|
284
|
+
if (!rootPath || !createTarget) return;
|
|
285
|
+
const created = await createWorkspaceEntry({
|
|
286
|
+
actions,
|
|
287
|
+
kind: createKind,
|
|
288
|
+
name: createName,
|
|
289
|
+
rootPath,
|
|
290
|
+
setError: setCreateError,
|
|
291
|
+
target: createTarget,
|
|
292
|
+
});
|
|
293
|
+
if (!created) return;
|
|
294
|
+
setCreateTarget(null);
|
|
295
|
+
setSelectedPath(created.path);
|
|
296
|
+
setSelectedCreateTarget({
|
|
297
|
+
label: createName.trim(),
|
|
298
|
+
path: created.path,
|
|
299
|
+
refresh: createTarget.refresh,
|
|
300
|
+
});
|
|
301
|
+
setRevealPath(created.path);
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
return {
|
|
305
|
+
actions,
|
|
306
|
+
cancelCreateFolder: () => {
|
|
307
|
+
setCreateTarget(null);
|
|
308
|
+
setCreateName('');
|
|
309
|
+
setCreateError(null);
|
|
310
|
+
},
|
|
311
|
+
clearSelection: () => {
|
|
312
|
+
setSelectedPath(null);
|
|
313
|
+
setSelectedCreateTarget(null);
|
|
314
|
+
},
|
|
315
|
+
completeReveal: () => setRevealPath(null),
|
|
316
|
+
submitCreate,
|
|
317
|
+
createError,
|
|
318
|
+
createName,
|
|
319
|
+
createKind,
|
|
320
|
+
createTarget,
|
|
321
|
+
deleteEntry,
|
|
322
|
+
fileInputRef,
|
|
323
|
+
openCreateFile: (target: WorkspaceDirectoryActionTarget) => openCreate('file', target),
|
|
324
|
+
openCreateFolder: (target: WorkspaceDirectoryActionTarget) => openCreate('directory', target),
|
|
325
|
+
requestUpload,
|
|
326
|
+
renameEntry,
|
|
327
|
+
revealPath,
|
|
328
|
+
selectEntry,
|
|
329
|
+
selectedCreateTarget,
|
|
330
|
+
selectedPath,
|
|
331
|
+
setCreateError,
|
|
332
|
+
setCreateName,
|
|
333
|
+
uploadFiles,
|
|
334
|
+
};
|
|
335
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { ChatThreadSnapshot, ChatWorkspaceNavigationEntry } from '@/features/chat/stores/chat-thread.store';
|
|
2
|
+
import { closeWorkspaceTabSnapshot } from '@/features/chat/features/workspace/utils/chat-thread-workspace-session.utils';
|
|
3
|
+
import { replaceWorkspaceFileTabPath } from '@/features/chat/features/workspace/utils/chat-workspace-file-tab.utils';
|
|
4
|
+
|
|
5
|
+
function replaceWorkspacePathPrefix(candidate: string, previousPath: string, nextPath: string): string | null {
|
|
6
|
+
if (candidate === previousPath) return nextPath;
|
|
7
|
+
if (candidate.startsWith(`${previousPath}/`) || candidate.startsWith(`${previousPath}\\`)) {
|
|
8
|
+
return `${nextPath}${candidate.slice(previousPath.length)}`;
|
|
9
|
+
}
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function createRenamedWorkspacePathPatch(
|
|
14
|
+
snapshot: ChatThreadSnapshot,
|
|
15
|
+
params: { previousPath: string; path: string; label: string },
|
|
16
|
+
): Partial<ChatThreadSnapshot> | null {
|
|
17
|
+
const previousPath = params.previousPath.trim();
|
|
18
|
+
const path = params.path.trim();
|
|
19
|
+
if (!previousPath || !path || previousPath === path) return null;
|
|
20
|
+
|
|
21
|
+
const keyMap = new Map<string, string>();
|
|
22
|
+
const workspaceFileTabs = snapshot.workspaceFileTabs.map((tab) => {
|
|
23
|
+
const nextPath = replaceWorkspacePathPrefix(tab.path, previousPath, path);
|
|
24
|
+
if (!nextPath) return tab;
|
|
25
|
+
const nextTab = replaceWorkspaceFileTabPath(tab, nextPath, tab.path === previousPath ? params.label : tab.label);
|
|
26
|
+
keyMap.set(tab.key, nextTab.key);
|
|
27
|
+
return nextTab;
|
|
28
|
+
});
|
|
29
|
+
if (keyMap.size === 0) return null;
|
|
30
|
+
|
|
31
|
+
const remapEntry = (entry: ChatWorkspaceNavigationEntry): ChatWorkspaceNavigationEntry =>
|
|
32
|
+
entry.kind === 'file' && keyMap.has(entry.key) ? { kind: 'file', key: keyMap.get(entry.key)! } : entry;
|
|
33
|
+
return {
|
|
34
|
+
workspaceFileTabs,
|
|
35
|
+
activeWorkspaceFileKey: snapshot.activeWorkspaceFileKey
|
|
36
|
+
? (keyMap.get(snapshot.activeWorkspaceFileKey) ?? snapshot.activeWorkspaceFileKey)
|
|
37
|
+
: null,
|
|
38
|
+
closedWorkspaceTabEntries: snapshot.closedWorkspaceTabEntries.map(remapEntry),
|
|
39
|
+
workspaceNavigationHistory: snapshot.workspaceNavigationHistory.map(remapEntry),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function createRemovedWorkspacePathSnapshot(
|
|
44
|
+
snapshot: ChatThreadSnapshot,
|
|
45
|
+
path: string,
|
|
46
|
+
): ChatThreadSnapshot | null {
|
|
47
|
+
const normalizedPath = path.trim();
|
|
48
|
+
if (!normalizedPath) return null;
|
|
49
|
+
let nextSnapshot = snapshot;
|
|
50
|
+
const matchingKeys = snapshot.workspaceFileTabs
|
|
51
|
+
.filter((tab) => replaceWorkspacePathPrefix(tab.path, normalizedPath, normalizedPath) !== null)
|
|
52
|
+
.map((tab) => tab.key);
|
|
53
|
+
for (const key of matchingKeys) {
|
|
54
|
+
const patch = closeWorkspaceTabSnapshot(nextSnapshot, { kind: 'file', key });
|
|
55
|
+
if (patch) nextSnapshot = { ...nextSnapshot, ...patch };
|
|
56
|
+
}
|
|
57
|
+
return matchingKeys.length > 0 ? nextSnapshot : null;
|
|
58
|
+
}
|
|
@@ -11,15 +11,12 @@ export function createWorkspaceFileTab(
|
|
|
11
11
|
return null;
|
|
12
12
|
}
|
|
13
13
|
const normalizedParentSessionKey = parentSessionKey?.trim() || null;
|
|
14
|
-
const previewViewer =
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
const viewIdentity = action.viewMode === 'preview' && previewViewer === 'rendered'
|
|
21
|
-
? 'preview:rendered'
|
|
22
|
-
: action.viewMode;
|
|
14
|
+
const previewViewer =
|
|
15
|
+
action.viewMode === 'preview'
|
|
16
|
+
? normalizeWorkspaceFilePreviewViewer(path, action.previewViewer ?? (action.line ? 'source' : undefined))
|
|
17
|
+
: (action.previewViewer ?? null);
|
|
18
|
+
const viewIdentity =
|
|
19
|
+
action.viewMode === 'preview' && previewViewer === 'rendered' ? 'preview:rendered' : action.viewMode;
|
|
23
20
|
return {
|
|
24
21
|
key: `${normalizedParentSessionKey ?? 'draft'}::${viewIdentity}::${path}`,
|
|
25
22
|
parentSessionKey: normalizedParentSessionKey,
|
|
@@ -53,28 +50,32 @@ export function upsertWorkspaceFileTab(
|
|
|
53
50
|
nextTabs[existingIndex] = { ...tabs[existingIndex], ...nextTab };
|
|
54
51
|
return nextTabs;
|
|
55
52
|
}
|
|
56
|
-
const adjacentIndex = adjacentToKey
|
|
57
|
-
? tabs.findIndex((tab) => tab.key === adjacentToKey)
|
|
58
|
-
: -1;
|
|
53
|
+
const adjacentIndex = adjacentToKey ? tabs.findIndex((tab) => tab.key === adjacentToKey) : -1;
|
|
59
54
|
if (adjacentIndex === -1) {
|
|
60
55
|
return [nextTab, ...tabs];
|
|
61
56
|
}
|
|
62
|
-
return [
|
|
63
|
-
...tabs.slice(0, adjacentIndex + 1),
|
|
64
|
-
nextTab,
|
|
65
|
-
...tabs.slice(adjacentIndex + 1),
|
|
66
|
-
];
|
|
57
|
+
return [...tabs.slice(0, adjacentIndex + 1), nextTab, ...tabs.slice(adjacentIndex + 1)];
|
|
67
58
|
}
|
|
68
59
|
|
|
69
|
-
export function
|
|
60
|
+
export function replaceWorkspaceFileTabPath(
|
|
70
61
|
tab: ChatWorkspaceFileTab,
|
|
71
|
-
|
|
62
|
+
path: string,
|
|
63
|
+
label?: string | null,
|
|
72
64
|
): ChatWorkspaceFileTab {
|
|
65
|
+
const viewIdentity =
|
|
66
|
+
tab.viewMode === 'preview' && tab.previewViewer === 'rendered' ? 'preview:rendered' : tab.viewMode;
|
|
67
|
+
return {
|
|
68
|
+
...tab,
|
|
69
|
+
key: `${tab.parentSessionKey ?? 'draft'}::${viewIdentity}::${path}`,
|
|
70
|
+
path,
|
|
71
|
+
label: label?.trim() || tab.label,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function reparentWorkspaceFileTab(tab: ChatWorkspaceFileTab, parentSessionKey: string): ChatWorkspaceFileTab {
|
|
73
76
|
const normalizedParentSessionKey = parentSessionKey.trim();
|
|
74
77
|
const identitySeparatorIndex = tab.key.indexOf('::');
|
|
75
|
-
const tabIdentity = identitySeparatorIndex >= 0
|
|
76
|
-
? tab.key.slice(identitySeparatorIndex)
|
|
77
|
-
: `::${tab.key}`;
|
|
78
|
+
const tabIdentity = identitySeparatorIndex >= 0 ? tab.key.slice(identitySeparatorIndex) : `::${tab.key}`;
|
|
78
79
|
return {
|
|
79
80
|
...tab,
|
|
80
81
|
key: `${normalizedParentSessionKey}${tabIdentity}`,
|
|
@@ -2,12 +2,21 @@ export const CHAT_WORKSPACE_PANEL_DEFAULT_WIDTH = 480;
|
|
|
2
2
|
export const CHAT_WORKSPACE_PANEL_MIN_WIDTH = 360;
|
|
3
3
|
export const CHAT_WORKSPACE_PANEL_MAX_WIDTH = 860;
|
|
4
4
|
|
|
5
|
+
export const CHAT_WORKSPACE_EXPLORER_DEFAULT_WIDTH = 224;
|
|
6
|
+
export const CHAT_WORKSPACE_EXPLORER_MIN_WIDTH = 192;
|
|
7
|
+
export const CHAT_WORKSPACE_EXPLORER_MAX_WIDTH = 400;
|
|
8
|
+
export const CHAT_WORKSPACE_EXPLORER_COMPACT_THRESHOLD = 560;
|
|
9
|
+
|
|
5
10
|
export function normalizeChatWorkspacePanelWidth(value: unknown): number {
|
|
6
11
|
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
7
12
|
return CHAT_WORKSPACE_PANEL_DEFAULT_WIDTH;
|
|
8
13
|
}
|
|
9
|
-
return Math.max(
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
14
|
+
return Math.max(CHAT_WORKSPACE_PANEL_MIN_WIDTH, Math.min(CHAT_WORKSPACE_PANEL_MAX_WIDTH, value));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function normalizeChatWorkspaceExplorerWidth(value: unknown): number {
|
|
18
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
19
|
+
return CHAT_WORKSPACE_EXPLORER_DEFAULT_WIDTH;
|
|
20
|
+
}
|
|
21
|
+
return Math.max(CHAT_WORKSPACE_EXPLORER_MIN_WIDTH, Math.min(CHAT_WORKSPACE_EXPLORER_MAX_WIDTH, value));
|
|
13
22
|
}
|
|
@@ -1,106 +1,155 @@
|
|
|
1
|
-
import { describe, expect, it, vi } from
|
|
2
|
-
import { ChatComposerIntentManager } from
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { ChatComposerIntentManager } from '@/features/chat/managers/chat-composer-intent.manager';
|
|
3
3
|
|
|
4
|
-
describe(
|
|
5
|
-
it(
|
|
4
|
+
describe('ChatComposerIntentManager', () => {
|
|
5
|
+
it('delivers a file reference only to the targeted composer', () => {
|
|
6
6
|
const manager = new ChatComposerIntentManager();
|
|
7
7
|
const draftListener = vi.fn();
|
|
8
8
|
const sessionListener = vi.fn();
|
|
9
9
|
manager.subscribe(null, draftListener);
|
|
10
|
-
manager.subscribe(
|
|
10
|
+
manager.subscribe('session-1', sessionListener);
|
|
11
11
|
|
|
12
12
|
manager.requestFileReference({
|
|
13
13
|
targetSessionKey: null,
|
|
14
|
-
tokenKey:
|
|
15
|
-
label:
|
|
14
|
+
tokenKey: 'docs/guide.md',
|
|
15
|
+
label: 'guide.md',
|
|
16
16
|
});
|
|
17
17
|
|
|
18
18
|
expect(draftListener).toHaveBeenCalledWith(
|
|
19
19
|
expect.objectContaining({
|
|
20
20
|
targetSessionKey: null,
|
|
21
|
-
tokenKey:
|
|
22
|
-
label:
|
|
21
|
+
tokenKey: 'docs/guide.md',
|
|
22
|
+
label: 'guide.md',
|
|
23
23
|
}),
|
|
24
24
|
);
|
|
25
25
|
expect(sessionListener).not.toHaveBeenCalled();
|
|
26
26
|
});
|
|
27
27
|
|
|
28
|
-
it(
|
|
28
|
+
it('keeps an intent pending until its matching composer mounts', () => {
|
|
29
29
|
const manager = new ChatComposerIntentManager();
|
|
30
30
|
manager.requestFileReference({
|
|
31
|
-
targetSessionKey:
|
|
32
|
-
tokenKey:
|
|
33
|
-
label:
|
|
31
|
+
targetSessionKey: ' session-1 ',
|
|
32
|
+
tokenKey: ' src/index.ts ',
|
|
33
|
+
label: ' index.ts ',
|
|
34
34
|
});
|
|
35
35
|
|
|
36
36
|
expect(manager.consumePending(null)).toBeNull();
|
|
37
|
-
expect(manager.consumePending(
|
|
38
|
-
targetSessionKey:
|
|
39
|
-
tokenKey:
|
|
40
|
-
label:
|
|
37
|
+
expect(manager.consumePending('session-1')).toMatchObject({
|
|
38
|
+
targetSessionKey: 'session-1',
|
|
39
|
+
tokenKey: 'src/index.ts',
|
|
40
|
+
label: 'index.ts',
|
|
41
41
|
});
|
|
42
|
-
expect(manager.consumePending(
|
|
42
|
+
expect(manager.consumePending('session-1')).toBeNull();
|
|
43
43
|
});
|
|
44
44
|
|
|
45
|
-
it(
|
|
45
|
+
it('publishes a directory reference with the workspace directory kind', () => {
|
|
46
|
+
const manager = new ChatComposerIntentManager();
|
|
47
|
+
const listener = vi.fn();
|
|
48
|
+
manager.subscribe('session-1', listener);
|
|
49
|
+
|
|
50
|
+
manager.requestDirectoryReference({
|
|
51
|
+
targetSessionKey: 'session-1',
|
|
52
|
+
tokenKey: 'docs/designs',
|
|
53
|
+
label: 'designs',
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
expect(listener).toHaveBeenCalledWith(
|
|
57
|
+
expect.objectContaining({
|
|
58
|
+
kind: 'workspace_directory',
|
|
59
|
+
tokenKey: 'docs/designs',
|
|
60
|
+
label: 'designs',
|
|
61
|
+
}),
|
|
62
|
+
);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('ignores incomplete file references', () => {
|
|
46
66
|
const manager = new ChatComposerIntentManager();
|
|
47
67
|
const listener = vi.fn();
|
|
48
68
|
manager.subscribe(null, listener);
|
|
49
69
|
|
|
50
70
|
manager.requestFileReference({
|
|
51
71
|
targetSessionKey: null,
|
|
52
|
-
tokenKey:
|
|
53
|
-
label:
|
|
72
|
+
tokenKey: ' ',
|
|
73
|
+
label: 'README.md',
|
|
54
74
|
});
|
|
55
75
|
|
|
56
76
|
expect(listener).not.toHaveBeenCalled();
|
|
57
77
|
expect(manager.consumePending(null)).toBeNull();
|
|
58
78
|
});
|
|
59
79
|
|
|
60
|
-
it(
|
|
80
|
+
it('publishes a source-aware excerpt with a stable content identity', () => {
|
|
61
81
|
const manager = new ChatComposerIntentManager();
|
|
62
82
|
const listener = vi.fn();
|
|
63
|
-
manager.subscribe(
|
|
83
|
+
manager.subscribe('session-1', listener);
|
|
64
84
|
|
|
65
85
|
manager.requestExcerptReference({
|
|
66
|
-
targetSessionKey:
|
|
67
|
-
path:
|
|
68
|
-
label:
|
|
69
|
-
excerpt:
|
|
86
|
+
targetSessionKey: 'session-1',
|
|
87
|
+
path: 'docs/guide.md',
|
|
88
|
+
label: 'guide.md',
|
|
89
|
+
excerpt: 'selected text',
|
|
70
90
|
startLine: 12,
|
|
71
91
|
endLine: 13,
|
|
72
92
|
});
|
|
73
93
|
|
|
74
|
-
expect(listener).toHaveBeenCalledWith(
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
94
|
+
expect(listener).toHaveBeenCalledWith(
|
|
95
|
+
expect.objectContaining({
|
|
96
|
+
kind: 'workspace_excerpt',
|
|
97
|
+
path: 'docs/guide.md',
|
|
98
|
+
label: 'guide.md',
|
|
99
|
+
excerpt: 'selected text',
|
|
100
|
+
startLine: 12,
|
|
101
|
+
endLine: 13,
|
|
102
|
+
tokenKey: expect.stringMatching(/^docs\/guide\.md#excerpt-/),
|
|
103
|
+
}),
|
|
104
|
+
);
|
|
83
105
|
});
|
|
84
106
|
|
|
85
|
-
it(
|
|
107
|
+
it('delivers an excerpt to the unmaterialized new-chat composer', () => {
|
|
86
108
|
const manager = new ChatComposerIntentManager();
|
|
87
109
|
const draftListener = vi.fn();
|
|
88
110
|
manager.subscribe(null, draftListener);
|
|
89
111
|
|
|
90
112
|
manager.requestExcerptReference({
|
|
91
113
|
targetSessionKey: null,
|
|
92
|
-
path:
|
|
93
|
-
label:
|
|
114
|
+
path: 'package.json',
|
|
115
|
+
label: 'package.json',
|
|
94
116
|
excerpt: '"name": "nextclaw"',
|
|
95
117
|
startLine: 2,
|
|
96
118
|
endLine: 2,
|
|
97
119
|
});
|
|
98
120
|
|
|
99
|
-
expect(draftListener).toHaveBeenCalledWith(
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
121
|
+
expect(draftListener).toHaveBeenCalledWith(
|
|
122
|
+
expect.objectContaining({
|
|
123
|
+
targetSessionKey: null,
|
|
124
|
+
kind: 'workspace_excerpt',
|
|
125
|
+
path: 'package.json',
|
|
126
|
+
excerpt: '"name": "nextclaw"',
|
|
127
|
+
}),
|
|
128
|
+
);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('publishes a conversation excerpt with its source message identity', () => {
|
|
132
|
+
const manager = new ChatComposerIntentManager();
|
|
133
|
+
const listener = vi.fn();
|
|
134
|
+
manager.subscribe('session-1', listener);
|
|
135
|
+
|
|
136
|
+
manager.requestConversationExcerptReference({
|
|
137
|
+
targetSessionKey: 'session-1',
|
|
138
|
+
messageId: 'assistant-message-1',
|
|
139
|
+
role: 'assistant',
|
|
140
|
+
label: 'AI reply',
|
|
141
|
+
excerpt: 'Keep the visible tag concise.',
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
expect(listener).toHaveBeenCalledWith(
|
|
145
|
+
expect.objectContaining({
|
|
146
|
+
kind: 'conversation_excerpt',
|
|
147
|
+
messageId: 'assistant-message-1',
|
|
148
|
+
role: 'assistant',
|
|
149
|
+
label: 'AI reply',
|
|
150
|
+
excerpt: 'Keep the visible tag concise.',
|
|
151
|
+
tokenKey: expect.stringMatching(/^assistant-message-1#excerpt-/),
|
|
152
|
+
}),
|
|
153
|
+
);
|
|
105
154
|
});
|
|
106
155
|
});
|