@nextclaw/ui 0.15.12 → 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 (100) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/dist/assets/{api-Bw1oLhwf.js → api-DGG69ZcJ.js} +3 -3
  3. package/dist/assets/app-presenter-provider-B1RgR5ug.js +106 -0
  4. package/dist/assets/{appearance-settings-page-BDpels6a.js → appearance-settings-page-BZSd1SrH.js} +1 -1
  5. package/dist/assets/{book-open-D-oLp0QJ.js → book-open-BhaS5BJv.js} +1 -1
  6. package/dist/assets/{card-CA3ozZwW.js → card-Bcr5YM8F.js} +1 -1
  7. package/dist/assets/{channels-list-page-B44EWctT.js → channels-list-page-C_x83ZCT.js} +2 -2
  8. package/dist/assets/{chat-page-CH8ypOKO.js → chat-page-D2xfR6TK.js} +1 -1
  9. package/dist/assets/{config-split-page-DskB5yAV.js → config-split-page-RXAoLeNv.js} +1 -1
  10. package/dist/assets/{confirm-dialog-BRQpFnvD.js → confirm-dialog-o_VPKvVD.js} +1 -1
  11. package/dist/assets/{desktop-update-config-se0OEu06.js → desktop-update-config-DEOMOsDN.js} +1 -1
  12. package/dist/assets/{dist-f6aZEsu8.js → dist-C87P4QWA.js} +1 -1
  13. package/dist/assets/doc-browser-BuGh646I.js +1 -0
  14. package/dist/assets/{doc-browser-DW7afLXs.js → doc-browser-DQ75iUkA.js} +1 -1
  15. package/dist/assets/{doc-browser-context-DhTa-zP_.js → doc-browser-context-tE0--wSa.js} +1 -1
  16. package/dist/assets/{eye-DMGpISEo.js → eye-DL9QkrKg.js} +1 -1
  17. package/dist/assets/{form-actions-SgP78YnZ.js → form-actions-J0tIaUdK.js} +1 -1
  18. package/dist/assets/index-B9QEjnoY.js +2 -0
  19. package/dist/assets/loader-circle-CAnHHSrY.js +1 -0
  20. package/dist/assets/{logo-badge-BZnxuJYW.js → logo-badge-BYTbZbOF.js} +1 -1
  21. package/dist/assets/{mcp-marketplace-page-Dm08nf11.js → mcp-marketplace-page-COWlzeP1.js} +2 -2
  22. package/dist/assets/mcp-marketplace-page-CaUFv37E.js +1 -0
  23. package/dist/assets/{model-config-page-k_tCjGlK.js → model-config-page-oNqnMXNY.js} +1 -1
  24. package/dist/assets/{navigation-link-CJ3fpUBN.js → navigation-link-Dk6VdDkc.js} +1 -1
  25. package/dist/assets/plus-DjdTWPjQ.js +1 -0
  26. package/dist/assets/{popover-Cq_oB7HI.js → popover-Txwrum65.js} +1 -1
  27. package/dist/assets/{provider-scoped-model-input-Cwv5zw0Z.js → provider-scoped-model-input-B7rQM4b6.js} +1 -1
  28. package/dist/assets/{providers-config-page-D82jX13I.js → providers-config-page-CP-OnvLK.js} +1 -1
  29. package/dist/assets/{react-BjpVlGAu.js → react-B0LbRnAE.js} +1 -1
  30. package/dist/assets/{refresh-cw-CYyWovQ1.js → refresh-cw-C84mMku5.js} +1 -1
  31. package/dist/assets/remote-2gZkJSX4.js +1 -0
  32. package/dist/assets/rotate-cw-DQJhDnjg.js +1 -0
  33. package/dist/assets/{runtime-config-page-OS6ZT2b0.js → runtime-config-page-u_fuHxc8.js} +1 -1
  34. package/dist/assets/{save-B0V_M-kz.js → save-R_qyrHpa.js} +1 -1
  35. package/dist/assets/{search-vFYD1SOf.js → search-UKxiouNi.js} +1 -1
  36. package/dist/assets/{search-config-page-IzK1hMQX.js → search-config-page-DzkpSGFH.js} +1 -1
  37. package/dist/assets/{secrets-config-page-Cm3IXYpK.js → secrets-config-page-DhoLYm7Z.js} +2 -2
  38. package/dist/assets/security-config-yraYoy9s.js +1 -0
  39. package/dist/assets/{select-BAYdPRVG.js → select-CYHT8P5S.js} +1 -1
  40. package/dist/assets/{settings-2-QX-FHs_i.js → settings-2-DGh4bGSi.js} +1 -1
  41. package/dist/assets/skeleton-BQuylYFO.js +1 -0
  42. package/dist/assets/{tabs-Ddprun22.js → tabs-BCihm1zK.js} +1 -1
  43. package/dist/assets/{tag-chip-C9SLhljq.js → tag-chip-De0iz4o2.js} +1 -1
  44. package/dist/assets/{trash-2-CODUwc31.js → trash-2-Bo3DWiju.js} +1 -1
  45. package/dist/assets/use-config-DCzwgChy.js +1 -0
  46. package/dist/assets/{use-confirm-dialog-B_ZW-WgC.js → use-confirm-dialog-WZ94CehU.js} +1 -1
  47. package/dist/assets/{use-viewport-layout-OtzTlg5U.js → use-viewport-layout-CZ-3pMne.js} +1 -1
  48. package/dist/assets/x-NZxXyqW1.js +1 -0
  49. package/dist/index.html +27 -27
  50. package/package.json +9 -9
  51. package/src/features/chat/components/layout/__tests__/chat-sidebar.test.tsx +1 -0
  52. package/src/features/chat/components/layout/chat-sidebar-desktop-layout.tsx +4 -4
  53. package/src/features/chat/components/layout/chat-sidebar.tsx +27 -12
  54. package/src/features/chat/features/conversation/components/session-conversation-input.tsx +4 -19
  55. package/src/features/chat/features/conversation/hooks/__tests__/use-session-conversation-slash-commands.test.tsx +79 -0
  56. package/src/features/chat/features/conversation/hooks/use-session-conversation-slash-commands.ts +55 -0
  57. package/src/features/chat/features/message/components/__tests__/chat-inline-panel-app-card.test.tsx +28 -0
  58. package/src/features/chat/features/message/components/chat-inline-panel-app-card.tsx +10 -7
  59. package/src/features/chat/features/message/components/chat-message-list.container.tsx +1 -0
  60. package/src/features/chat/features/message/utils/__tests__/chat-message-show-content-tool-card.utils.test.ts +3 -0
  61. package/src/features/chat/features/message/utils/chat-inline-panel-app-card.utils.ts +8 -6
  62. package/src/features/chat/features/message/utils/chat-message-show-content-tool-card.utils.ts +2 -0
  63. package/src/features/chat/features/project/components/__tests__/chat-project-add-dialog.test.tsx +116 -0
  64. package/src/features/chat/features/project/components/chat-project-add-dialog.tsx +305 -0
  65. package/src/features/chat/features/session/components/__tests__/chat-sidebar-project-groups.test.tsx +3 -2
  66. package/src/features/chat/features/session/components/chat-sidebar-project-groups.tsx +2 -2
  67. package/src/features/chat/features/workspace/components/__tests__/workspace-file-preview-error-text.test.ts +22 -0
  68. package/src/features/chat/features/workspace/components/chat-session-workspace-file-preview.tsx +9 -5
  69. package/src/features/chat/managers/__tests__/chat-ui.manager.test.ts +24 -0
  70. package/src/features/chat/managers/chat-ui.manager.ts +7 -1
  71. package/src/features/right-panel-resources/configs/right-panel-resource-routes.config.ts +18 -4
  72. package/src/features/right-panel-resources/index.ts +1 -0
  73. package/src/features/right-panel-resources/utils/__tests__/right-panel-resource-route-resolver.utils.test.ts +33 -0
  74. package/src/features/right-panel-resources/utils/__tests__/right-panel-resource-uri.utils.test.ts +14 -1
  75. package/src/features/right-panel-resources/utils/right-panel-resource-uri.utils.ts +10 -2
  76. package/src/shared/components/path-picker/__tests__/server-path-picker-dialog.test.tsx +9 -0
  77. package/src/shared/components/path-picker/server-path-picker-dialog.tsx +8 -2
  78. package/src/shared/components/path-picker/server-path-picker-toolbar.tsx +8 -4
  79. package/src/shared/hooks/use-projects.ts +15 -1
  80. package/src/shared/lib/api/projects/project.utils.ts +7 -0
  81. package/src/shared/lib/api/types.ts +1 -1
  82. package/src/shared/lib/api/utils/ncp-session.utils.ts +7 -0
  83. package/src/shared/lib/i18n/locales/en-US/chat.json +26 -8
  84. package/src/shared/lib/i18n/locales/zh-CN/chat.json +26 -8
  85. package/src/shared/lib/resource-uri/utils/__tests__/resource-uri-resolver.utils.test.ts +13 -1
  86. package/src/shared/lib/resource-uri/utils/resource-uri.utils.ts +6 -3
  87. package/dist/assets/app-presenter-provider-DSN90VJw.js +0 -106
  88. package/dist/assets/doc-browser-BRiAfy_N.js +0 -1
  89. package/dist/assets/index-CBD4zQ_A.js +0 -2
  90. package/dist/assets/loader-circle-DEQ2CflP.js +0 -1
  91. package/dist/assets/mcp-marketplace-page-B8eWiT9H.js +0 -1
  92. package/dist/assets/plus-9_9Q74qc.js +0 -1
  93. package/dist/assets/remote-C2MGMI2j.js +0 -1
  94. package/dist/assets/rotate-cw-BkGMTudQ.js +0 -1
  95. package/dist/assets/security-config-DfzQ8txL.js +0 -1
  96. package/dist/assets/skeleton-2eczb2co.js +0 -1
  97. package/dist/assets/use-config-C5OKT3lk.js +0 -1
  98. package/dist/assets/x-Cbfpb-Zm.js +0 -1
  99. package/src/features/chat/features/project/components/__tests__/chat-project-create-dialog.test.tsx +0 -67
  100. package/src/features/chat/features/project/components/chat-project-create-dialog.tsx +0 -203
@@ -16,8 +16,12 @@ import { useChatSessionListStore } from "@/features/chat/stores/chat-session-lis
16
16
  import { useChatQueryStore } from "@/features/chat/stores/ncp-chat-query.store";
17
17
  import { useSystemStatus } from "@/features/system-status";
18
18
  import { useAgents } from "@/shared/hooks/use-agents";
19
- import { useCreateProject, useProjects } from "@/shared/hooks/use-projects";
20
- import type { ProjectCreateRequest } from "@/shared/lib/api";
19
+ import {
20
+ useAddExistingProject,
21
+ useCreateProject,
22
+ useProjects,
23
+ } from "@/shared/hooks/use-projects";
24
+ import type { ProjectAddExistingRequest, ProjectCreateRequest } from "@/shared/lib/api";
21
25
  import { normalizeSessionProjectRootValue } from "@/shared/lib/session-project";
22
26
  import { cn } from "@/shared/lib/utils";
23
27
  import { LANGUAGE_OPTIONS, t, type I18nLanguage } from "@/shared/lib/i18n";
@@ -45,7 +49,7 @@ import {
45
49
  import { useChatNewSessionTypePreference } from "@/features/chat/features/session-type/hooks/use-chat-new-session-type-preference";
46
50
  import { useViewportLayoutStore } from "@/app/stores/viewport-layout.store";
47
51
  import { SIDEBAR_RAIL_WIDTH_CLASS } from "@/app/components/layout/sidebar-rail.styles";
48
- import { ChatProjectCreateDialog } from "@/features/chat/features/project/components/chat-project-create-dialog";
52
+ import { ChatProjectAddDialog } from "@/features/chat/features/project/components/chat-project-add-dialog";
49
53
 
50
54
  type ChatSidebarVariant = "desktop" | "mobile";
51
55
 
@@ -98,7 +102,7 @@ export function ChatSidebar({
98
102
  (state) => state.isSidebarCollapsed,
99
103
  );
100
104
  const [isCreateMenuOpen, setIsCreateMenuOpen] = useState(false);
101
- const [isProjectCreateOpen, setIsProjectCreateOpen] = useState(false);
105
+ const [isProjectAddOpen, setIsProjectAddOpen] = useState(false);
102
106
  const [isUtilityMenuOpen, setIsUtilityMenuOpen] = useState(false);
103
107
  const listSnapshot = useChatSessionListStore((state) => state.snapshot);
104
108
  const sessionTypesData = useChatQueryStore(
@@ -111,6 +115,7 @@ export function ChatSidebar({
111
115
  const agentsQuery = useAgents();
112
116
  const projectsQuery = useProjects();
113
117
  const projectCreateMutation = useCreateProject();
118
+ const projectAddExistingMutation = useAddExistingProject();
114
119
  const { isLoading, items } = useNcpSessionListView();
115
120
  const { language, setLanguage } = useI18n();
116
121
  const { theme, setTheme } = useTheme();
@@ -246,13 +251,20 @@ export function ChatSidebar({
246
251
  sessionType,
247
252
  });
248
253
  };
249
- const openProjectCreate = () => {
254
+ const openProjectAdd = () => {
250
255
  projectCreateMutation.reset();
251
- setIsProjectCreateOpen(true);
256
+ projectAddExistingMutation.reset();
257
+ setIsProjectAddOpen(true);
252
258
  };
253
259
  const createProjectFromSidebar = async (input: ProjectCreateRequest): Promise<void> => {
254
260
  await projectCreateMutation.mutateAsync(input);
255
- setIsProjectCreateOpen(false);
261
+ setIsProjectAddOpen(false);
262
+ };
263
+ const addExistingProjectFromSidebar = async (
264
+ input: ProjectAddExistingRequest,
265
+ ): Promise<void> => {
266
+ await projectAddExistingMutation.mutateAsync(input);
267
+ setIsProjectAddOpen(false);
256
268
  };
257
269
 
258
270
  return (
@@ -324,7 +336,7 @@ export function ChatSidebar({
324
336
  isCollapsed={shouldCollapse}
325
337
  isLoading={isLoading}
326
338
  isProjectFirstView={isProjectFirstView}
327
- onCreateProject={openProjectCreate}
339
+ onAddProject={openProjectAdd}
328
340
  onSelectMode={presenter.chatSessionListManager.setListMode}
329
341
  projectGroups={projectGroups}
330
342
  renderSessionItem={renderSessionItem}
@@ -354,16 +366,19 @@ export function ChatSidebar({
354
366
  />
355
367
  ) : null}
356
368
 
357
- <ChatProjectCreateDialog
358
- open={isProjectCreateOpen}
369
+ <ChatProjectAddDialog
370
+ open={isProjectAddOpen}
359
371
  defaultWorkspacePath={normalizeSessionProjectRootValue(
360
372
  config?.agents.defaults.workspace,
361
373
  )}
362
374
  templates={projectsQuery.data?.templates ?? []}
363
375
  isCreating={projectCreateMutation.isPending}
364
- errorMessage={projectCreateMutation.error?.message}
365
- onOpenChange={setIsProjectCreateOpen}
376
+ isAddingExisting={projectAddExistingMutation.isPending}
377
+ createErrorMessage={projectCreateMutation.error?.message}
378
+ addExistingErrorMessage={projectAddExistingMutation.error?.message}
379
+ onOpenChange={setIsProjectAddOpen}
366
380
  onCreate={createProjectFromSidebar}
381
+ onAddExisting={addExistingProjectFromSidebar}
367
382
  />
368
383
  </aside>
369
384
  );
@@ -49,6 +49,7 @@ import {
49
49
  } from '@/features/chat/managers/chat-recent-skills.manager';
50
50
 
51
51
  import { useSessionConversationInputAttachments } from '@/features/chat/features/conversation/hooks/use-session-conversation-input-attachments';
52
+ import { useSessionConversationSlashCommands } from '@/features/chat/features/conversation/hooks/use-session-conversation-slash-commands';
52
53
  import { ChatConversationTrack } from '@/features/chat/components/conversation/chat-conversation-track';
53
54
  import { useChatMessageLayoutStore } from '@/features/chat/stores/chat-message-layout.store';
54
55
  import type { useSessionConversationInputQuery } from '@/features/chat/features/conversation/hooks/use-session-conversation-input-query';
@@ -222,26 +223,10 @@ export const SessionConversationInput = memo(function SessionConversationInput(p
222
223
  skillGroupLabels: labels.skillGroupLabels,
223
224
  skillScopeLabels: labels.skillScopeLabels,
224
225
  });
225
- const slashCommands = useMemo(() => {
226
- const parentSessionKey = inputQuery.selectedSessionKey?.trim();
227
- if (!parentSessionKey) {
228
- return [];
229
- }
230
- return [
231
- {
232
- key: 'side-chat',
233
- title: t('chatSlashCommandSideChatTitle', language),
234
- description: t('chatSlashCommandSideChatDescription', language),
235
- detailLines: [t('chatSlashCommandSideChatDetail', language)],
236
- keywords: ['side', 'chat', 'child', 'branch', 'new'],
237
- onSelect: () => presenter.chatThreadManager.openSideChatDraft(parentSessionKey),
238
- },
239
- ];
240
- }, [
241
- inputQuery.selectedSessionKey,
226
+ const slashCommands = useSessionConversationSlashCommands({
242
227
  language,
243
- presenter.chatThreadManager,
244
- ]);
228
+ selectedSessionKey: inputQuery.selectedSessionKey,
229
+ });
245
230
  const handleSlashPanelAppSelect = useCallback(
246
231
  (appId: string) =>
247
232
  void presenter.chatUiManager.showContent({
@@ -0,0 +1,79 @@
1
+ import { act, renderHook, waitFor } from '@testing-library/react';
2
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
3
+ import { toast } from 'sonner';
4
+ import { compactNcpSessionContext } from '@/shared/lib/api';
5
+ import { useSessionConversationSlashCommands } from '@/features/chat/features/conversation/hooks/use-session-conversation-slash-commands';
6
+
7
+ const mocks = vi.hoisted(() => ({
8
+ compactNcpSessionContext: vi.fn(),
9
+ openSideChatDraft: vi.fn(),
10
+ }));
11
+
12
+ vi.mock('sonner', () => ({
13
+ toast: { error: vi.fn(), success: vi.fn() },
14
+ }));
15
+
16
+ vi.mock('@/shared/lib/api', () => ({
17
+ compactNcpSessionContext: mocks.compactNcpSessionContext,
18
+ }));
19
+
20
+ vi.mock('@/features/chat/components/providers/chat-presenter.provider', () => ({
21
+ usePresenter: () => ({
22
+ chatThreadManager: { openSideChatDraft: mocks.openSideChatDraft },
23
+ }),
24
+ }));
25
+
26
+ describe('useSessionConversationSlashCommands', () => {
27
+ beforeEach(() => {
28
+ vi.clearAllMocks();
29
+ mocks.compactNcpSessionContext.mockResolvedValue({
30
+ compacted: true,
31
+ sessionId: 'session-1',
32
+ });
33
+ });
34
+
35
+ it('exposes the runtime compaction command for the selected session', async () => {
36
+ const { result } = renderHook(() => useSessionConversationSlashCommands({
37
+ language: 'en',
38
+ selectedSessionKey: ' session-1 ',
39
+ }));
40
+ const command = result.current.find((entry) => entry.key === 'compact-context');
41
+
42
+ act(() => command?.onSelect());
43
+
44
+ await waitFor(() => expect(compactNcpSessionContext).toHaveBeenCalledWith('session-1'));
45
+ expect(toast.success).toHaveBeenCalledOnce();
46
+ });
47
+
48
+ it('reports runtime errors and suppresses duplicate in-flight requests', async () => {
49
+ let rejectRequest!: (error: Error) => void;
50
+ mocks.compactNcpSessionContext.mockReturnValue(new Promise((_resolve, reject) => {
51
+ rejectRequest = reject;
52
+ }));
53
+ const { result } = renderHook(() => useSessionConversationSlashCommands({
54
+ language: 'en',
55
+ selectedSessionKey: 'session-1',
56
+ }));
57
+ const command = result.current.find((entry) => entry.key === 'compact-context');
58
+
59
+ act(() => {
60
+ command?.onSelect();
61
+ command?.onSelect();
62
+ });
63
+ expect(compactNcpSessionContext).toHaveBeenCalledOnce();
64
+ rejectRequest(new Error('session is busy'));
65
+
66
+ await waitFor(() => expect(toast.error).toHaveBeenCalledWith(
67
+ expect.stringContaining('session is busy'),
68
+ ));
69
+ });
70
+
71
+ it('does not expose session commands before a session exists', () => {
72
+ const { result } = renderHook(() => useSessionConversationSlashCommands({
73
+ language: 'en',
74
+ selectedSessionKey: null,
75
+ }));
76
+
77
+ expect(result.current).toEqual([]);
78
+ });
79
+ });
@@ -0,0 +1,55 @@
1
+ import { useCallback, useMemo, useRef } from 'react';
2
+ import { toast } from 'sonner';
3
+ import { compactNcpSessionContext } from '@/shared/lib/api';
4
+ import { t, type I18nLanguage } from '@/shared/lib/i18n';
5
+ import { usePresenter } from '@/features/chat/components/providers/chat-presenter.provider';
6
+ import type { ChatSlashCommandDescriptor } from '@/features/chat/features/input/input-surface-plugins/slash-command-plugin.utils';
7
+
8
+ export function useSessionConversationSlashCommands(params: {
9
+ language: I18nLanguage;
10
+ selectedSessionKey?: string | null;
11
+ }): readonly ChatSlashCommandDescriptor[] {
12
+ const { language, selectedSessionKey } = params;
13
+ const presenter = usePresenter();
14
+ const compactingSessionIdsRef = useRef(new Set<string>());
15
+ const compactContext = useCallback(async (sessionId: string) => {
16
+ if (compactingSessionIdsRef.current.has(sessionId)) {
17
+ return;
18
+ }
19
+ compactingSessionIdsRef.current.add(sessionId);
20
+ try {
21
+ await compactNcpSessionContext(sessionId);
22
+ toast.success(t('chatSlashCommandCompactContextSuccess', language));
23
+ } catch (error) {
24
+ const message = error instanceof Error ? error.message : String(error);
25
+ toast.error(`${t('chatSlashCommandCompactContextFailed', language)}: ${message}`);
26
+ } finally {
27
+ compactingSessionIdsRef.current.delete(sessionId);
28
+ }
29
+ }, [language]);
30
+
31
+ return useMemo(() => {
32
+ const sessionId = selectedSessionKey?.trim();
33
+ if (!sessionId) {
34
+ return [];
35
+ }
36
+ return [
37
+ {
38
+ key: 'side-chat',
39
+ title: t('chatSlashCommandSideChatTitle', language),
40
+ description: t('chatSlashCommandSideChatDescription', language),
41
+ detailLines: [t('chatSlashCommandSideChatDetail', language)],
42
+ keywords: ['side', 'chat', 'child', 'branch', 'new'],
43
+ onSelect: () => presenter.chatThreadManager.openSideChatDraft(sessionId),
44
+ },
45
+ {
46
+ key: 'compact-context',
47
+ title: t('chatSlashCommandCompactContextTitle', language),
48
+ description: t('chatSlashCommandCompactContextDescription', language),
49
+ detailLines: [t('chatSlashCommandCompactContextDetail', language)],
50
+ keywords: ['compact', 'compress', 'context', 'summary', '压缩', '上下文'],
51
+ onSelect: () => void compactContext(sessionId),
52
+ },
53
+ ];
54
+ }, [compactContext, language, presenter.chatThreadManager, selectedSessionKey]);
55
+ }
@@ -126,6 +126,34 @@ it("renders panel apps through the shared adaptive inline surface", () => {
126
126
  );
127
127
  });
128
128
 
129
+ it("renders and expands a panel app from an explicit external path", () => {
130
+ render(
131
+ <ChatInlinePanelAppCard
132
+ panelApp={{
133
+ appId: "weather-card",
134
+ path: "/tmp/weather-card.panel",
135
+ title: "External Weather",
136
+ }}
137
+ />,
138
+ );
139
+
140
+ expect(screen.getByTitle("External Weather").getAttribute("src")).toBe(
141
+ "/api/panel-apps/weather-card/content?path=%2Ftmp%2Fweather-card.panel&nextclawDisplayMode=card&nextclawPlacement=inline",
142
+ );
143
+
144
+ fireEvent.click(screen.getByLabelText("chatPanelCardExpand"));
145
+
146
+ expect(mocks.openTarget).not.toHaveBeenCalled();
147
+ expect(mocks.open).toHaveBeenCalledWith(
148
+ "/api/panel-apps/weather-card/content?path=%2Ftmp%2Fweather-card.panel",
149
+ {
150
+ dedupeKey: "panel-app:/tmp/weather-card.panel",
151
+ kind: "panel-app",
152
+ title: "External Weather",
153
+ },
154
+ );
155
+ });
156
+
129
157
  it("ignores inline height messages from a different window", () => {
130
158
  const { container } = render(
131
159
  <ChatInlinePanelAppCard
@@ -22,7 +22,7 @@ import { t } from "@/shared/lib/i18n";
22
22
 
23
23
  type ChatInlinePanelAppDescriptor = Pick<
24
24
  ChatPanelAppCardViewModel,
25
- "appId" | "title"
25
+ "appId" | "path" | "title"
26
26
  >;
27
27
 
28
28
  export function ChatInlinePanelAppCard({
@@ -39,17 +39,19 @@ export function ChatInlinePanelAppCard({
39
39
  const panelApps = usePanelApps();
40
40
  const entry = useMemo(
41
41
  () =>
42
- findPanelAppEntryByDisplayId(
42
+ panelApp.path ? undefined : findPanelAppEntryByDisplayId(
43
43
  panelApps.data?.entries ?? [],
44
44
  panelApp.appId,
45
45
  ),
46
- [panelApp.appId, panelApps.data?.entries],
46
+ [panelApp.appId, panelApp.path, panelApps.data?.entries],
47
47
  );
48
48
  const target = entry ? createPanelAppRightPanelResourceTarget(entry) : null;
49
49
  const title = panelApp.title ?? target?.title ?? panelApp.appId;
50
- const url = panelApps.isLoading
51
- ? ""
52
- : (target?.url ?? createFallbackPanelAppContentPath(panelApp.appId));
50
+ const url = panelApp.path
51
+ ? createFallbackPanelAppContentPath(panelApp.appId, panelApp.path)
52
+ : panelApps.isLoading
53
+ ? ""
54
+ : (target?.url ?? createFallbackPanelAppContentPath(panelApp.appId));
53
55
  const cardUrl = useMemo(
54
56
  () => (url ? createInlinePanelAppCardUrl(url) : ""),
55
57
  [url],
@@ -58,10 +60,11 @@ export function ChatInlinePanelAppCard({
58
60
  () =>
59
61
  createInlinePanelAppTab({
60
62
  appId: entry?.id ?? panelApp.appId,
63
+ path: panelApp.path,
61
64
  title,
62
65
  url,
63
66
  }),
64
- [entry?.id, panelApp.appId, title, url],
67
+ [entry?.id, panelApp.appId, panelApp.path, title, url],
65
68
  );
66
69
  const iframeInstanceId = `${tab.id}:${tab.navVersion}:${iframeId}`;
67
70
  const openExpanded = () => {
@@ -144,6 +144,7 @@ function renderChatInlineDisplay(display: ChatInlineDisplayViewModel) {
144
144
  <ChatInlinePanelAppCard
145
145
  panelApp={{
146
146
  appId: display.target.payload.appId,
147
+ path: display.target.payload.path,
147
148
  title: display.title,
148
149
  }}
149
150
  />
@@ -149,6 +149,7 @@ describe("buildShowContentToolCard", () => {
149
149
  type: "panel_app",
150
150
  payload: {
151
151
  appId: "reader",
152
+ path: "/tmp/reader.panel",
152
153
  },
153
154
  },
154
155
  title: "Reader",
@@ -164,6 +165,7 @@ describe("buildShowContentToolCard", () => {
164
165
  expect(card).toMatchObject({
165
166
  panelApp: {
166
167
  appId: "reader",
168
+ path: "/tmp/reader.panel",
167
169
  title: "Reader",
168
170
  action: {
169
171
  kind: "show-content",
@@ -173,6 +175,7 @@ describe("buildShowContentToolCard", () => {
173
175
  type: "panel_app",
174
176
  payload: {
175
177
  appId: "reader",
178
+ path: "/tmp/reader.panel",
176
179
  },
177
180
  },
178
181
  title: "Reader",
@@ -1,12 +1,13 @@
1
1
  import {
2
+ createPanelAppContentPath,
2
3
  createPanelAppResourceUri,
3
4
  RIGHT_PANEL_PANEL_APP_TAB_KIND,
4
5
  } from "@/features/right-panel-resources";
5
6
  import { PANEL_APP_INLINE_HOST_CONTRACT } from "@nextclaw/shared";
6
7
  import type { DocBrowserTab } from "@/shared/components/doc-browser/doc-browser-context";
7
8
 
8
- export function createFallbackPanelAppContentPath(appId: string): string {
9
- return `/api/panel-apps/${encodeURIComponent(appId)}/content`;
9
+ export function createFallbackPanelAppContentPath(appId: string, sourcePath?: string): string {
10
+ return createPanelAppContentPath(appId, sourcePath);
10
11
  }
11
12
 
12
13
  export function createInlinePanelAppCardUrl(url: string): string {
@@ -35,19 +36,20 @@ export function readInlinePanelAppContentHeight(value: unknown): number | null {
35
36
 
36
37
  export function createInlinePanelAppTab(params: {
37
38
  appId: string;
39
+ path?: string;
38
40
  title: string;
39
41
  url: string;
40
42
  }): DocBrowserTab {
41
- const { appId, title, url } = params;
43
+ const { appId, path, title, url } = params;
42
44
  return {
43
45
  currentUrl: url,
44
- dedupeKey: `panel-app:${appId}`,
46
+ dedupeKey: `panel-app:${path ?? appId}`,
45
47
  history: [url],
46
48
  historyIndex: 0,
47
- id: `inline-panel-app:${appId}`,
49
+ id: `inline-panel-app:${path ?? appId}`,
48
50
  kind: RIGHT_PANEL_PANEL_APP_TAB_KIND,
49
51
  navVersion: 0,
50
- resourceUri: createPanelAppResourceUri(appId),
52
+ resourceUri: createPanelAppResourceUri(appId, path),
51
53
  title,
52
54
  };
53
55
  }
@@ -121,6 +121,7 @@ function readShowContentRequest(value: unknown): ChatUiShowContentRequest | null
121
121
  type: "panel_app",
122
122
  payload: {
123
123
  appId,
124
+ path: readOptionalString(payload.path),
124
125
  },
125
126
  },
126
127
  title,
@@ -178,6 +179,7 @@ export function buildShowContentToolCard(params: {
178
179
  const panelApp = request.target.type === "panel_app" && request.placement === "inline"
179
180
  ? {
180
181
  appId: request.target.payload.appId,
182
+ path: request.target.payload.path,
181
183
  title: request.title,
182
184
  action: showContentAction,
183
185
  }
@@ -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
+ });