@nextclaw/ui 0.15.5 → 0.15.6

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 (147) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/dist/assets/api-Dyb5rgh0.js +13 -0
  3. package/dist/assets/app-presenter-provider-7jV0nPiL.js +106 -0
  4. package/dist/assets/{appearance-settings-page-80fLa4-9.js → appearance-settings-page-DFm3BbEb.js} +1 -1
  5. package/dist/assets/{book-open-CN7Shpij.js → book-open-L6LCuqOr.js} +1 -1
  6. package/dist/assets/{channels-list-page-R7M6pzp6.js → channels-list-page-DOaPKNJA.js} +2 -2
  7. package/dist/assets/{chat-page-152YuwfU.js → chat-page-Cw9uDhD_.js} +1 -1
  8. package/dist/assets/{config-split-page-BlfvfmTm.js → config-split-page-CqlqjnCK.js} +1 -1
  9. package/dist/assets/{confirm-dialog-Dt8Kdbgp.js → confirm-dialog-bAp8NU93.js} +1 -1
  10. package/dist/assets/desktop-update-config-2SjMfCMl.js +1 -0
  11. package/dist/assets/{dist-D5ycbwCo.js → dist-CrtbGLlh.js} +1 -1
  12. package/dist/assets/doc-browser-A5IRyDXQ2.js +1 -0
  13. package/dist/assets/{doc-browser-C9u7N9vH.js → doc-browser-BmnQa30-.js} +1 -1
  14. package/dist/assets/{doc-browser-context-CCdW9TJf.js → doc-browser-context-BlpUUFrR.js} +1 -1
  15. package/dist/assets/{download-CyPgglKr.js → download-BhZZNXD8.js} +1 -1
  16. package/dist/assets/{external-link-BahW1sl3.js → external-link-B9Uoq5J7.js} +1 -1
  17. package/dist/assets/{eye-D8gGXMkD.js → eye-K5knauEM.js} +1 -1
  18. package/dist/assets/{folder-DGWqFDvE.js → folder-BIYZZWJv.js} +1 -1
  19. package/dist/assets/{form-actions-D2imZLnU.js → form-actions-CKprWFeM.js} +1 -1
  20. package/dist/assets/index-BVn702m4.css +1 -0
  21. package/dist/assets/{index-CT0iYlxV.js → index-CFxJ1Fkg.js} +2 -2
  22. package/dist/assets/loader-circle-Bj2RMrCR.js +1 -0
  23. package/dist/assets/{logo-badge-CuPetu-v.js → logo-badge-BQ-XFZod.js} +1 -1
  24. package/dist/assets/mcp-marketplace-page-BfA7A3n4.js +9 -0
  25. package/dist/assets/mcp-marketplace-page-CXHYO95j.js +1 -0
  26. package/dist/assets/{model-config-page-DiJcvBML.js → model-config-page-BbWbW_qM.js} +1 -1
  27. package/dist/assets/plus-TmFdwO3P.js +1 -0
  28. package/dist/assets/{popover-Btp7KVc3.js → popover-CX9zFsuc.js} +1 -1
  29. package/dist/assets/{provider-scoped-model-input-DZMtoAJG.js → provider-scoped-model-input-B-9_DtIP.js} +1 -1
  30. package/dist/assets/providers-config-page-xwIjGur2.js +1 -0
  31. package/dist/assets/{react-BY-U3h3t.js → react-DsgudwpT.js} +1 -1
  32. package/dist/assets/{refresh-cw-Cme5qFej.js → refresh-cw-kXdvbhFf.js} +1 -1
  33. package/dist/assets/remote-B9F2uCk9.js +1 -0
  34. package/dist/assets/rotate-cw-DEc6aihr.js +1 -0
  35. package/dist/assets/runtime-config-page-DTsi7czJ.js +1 -0
  36. package/dist/assets/{save-DNg2yJTS.js → save-C9qpz2_2.js} +1 -1
  37. package/dist/assets/{search-3ykfrAFt.js → search-_kEi29qo.js} +1 -1
  38. package/dist/assets/{search-config-page-DrVKa_Ls.js → search-config-page-H7ug8f4D.js} +1 -1
  39. package/dist/assets/{secrets-config-page-B39uQ5kF.js → secrets-config-page-C1_6rINu.js} +2 -2
  40. package/dist/assets/security-config-Cr2ERKLu.js +1 -0
  41. package/dist/assets/{select-B5EecXJt.js → select-MJKhKAWR.js} +1 -1
  42. package/dist/assets/{setting-row-Qv1Ps79v.js → setting-row-DA3tJuew.js} +1 -1
  43. package/dist/assets/{settings-2-D-3um9Az.js → settings-2-D_y7zXJz.js} +1 -1
  44. package/dist/assets/{skeleton-BgkzQd4U.js → skeleton-0uTLUhm9.js} +1 -1
  45. package/dist/assets/{tabs-BVD8gp3y.js → tabs-h44XPocd.js} +1 -1
  46. package/dist/assets/{tag-chip-0qn-f3Bo.js → tag-chip-bRZ04q3C.js} +1 -1
  47. package/dist/assets/{trash-2-cnsMppRC.js → trash-2-BRpSE-Wt.js} +1 -1
  48. package/dist/assets/use-config-CwDEAl6f.js +1 -0
  49. package/dist/assets/{use-confirm-dialog-C_o0eQeH.js → use-confirm-dialog-BbnWw-8c.js} +1 -1
  50. package/dist/assets/{use-viewport-layout-BWav4KCL.js → use-viewport-layout-CqgIMDQL.js} +1 -1
  51. package/dist/assets/x-DdEDL6ju.js +1 -0
  52. package/dist/index.html +29 -29
  53. package/package.json +8 -8
  54. package/src/features/chat/components/conversation/__tests__/chat-conversation-header-section.test.tsx +84 -1
  55. package/src/features/chat/components/conversation/chat-conversation-header-section.tsx +13 -2
  56. package/src/features/chat/components/layout/__tests__/chat-sidebar-test.utils.ts +22 -0
  57. package/src/features/chat/components/layout/__tests__/chat-sidebar.test.tsx +13 -24
  58. package/src/features/chat/components/layout/chat-sidebar-desktop-layout.tsx +16 -4
  59. package/src/features/chat/components/layout/chat-sidebar.tsx +38 -2
  60. package/src/features/chat/features/conversation/components/__tests__/session-conversation-area.test.tsx +1 -0
  61. package/src/features/chat/features/conversation/components/__tests__/session-conversation-input.streaming.test.tsx +52 -2
  62. package/src/features/chat/features/conversation/components/session-conversation-area.tsx +22 -0
  63. package/src/features/chat/features/conversation/components/session-conversation-input.tsx +8 -9
  64. package/src/features/chat/features/conversation/hooks/__tests__/use-session-conversation-input-state.test.tsx +55 -0
  65. package/src/features/chat/features/conversation/hooks/use-session-conversation-input-state.ts +12 -11
  66. package/src/features/chat/features/conversation/hooks/use-session-conversation-preference-actions.ts +94 -0
  67. package/src/features/chat/features/input/hooks/__tests__/use-chat-input-surface-state.test.tsx +37 -0
  68. package/src/features/chat/features/input/hooks/use-chat-input-surface-state.ts +140 -63
  69. package/src/features/chat/features/input/input-surface-plugins/__tests__/context-reference-plugin.test.ts +175 -0
  70. package/src/features/chat/features/input/input-surface-plugins/__tests__/slash-command-plugin.test.ts +23 -0
  71. package/src/features/chat/features/input/input-surface-plugins/chat-input-product-plugin-adapters.types.ts +8 -1
  72. package/src/features/chat/features/input/input-surface-plugins/context-reference-plugin.utils.ts +274 -0
  73. package/src/features/chat/features/input/input-surface-plugins/slash-command-plugin.utils.ts +4 -1
  74. package/src/features/chat/features/input/utils/__tests__/chat-composer-state.utils.test.ts +26 -0
  75. package/src/features/chat/features/input/utils/__tests__/chat-inline-token.utils.test.ts +50 -4
  76. package/src/features/chat/features/input/utils/chat-composer-state.utils.ts +4 -10
  77. package/src/features/chat/features/input/utils/chat-composer-token-protocol.utils.ts +29 -0
  78. package/src/features/chat/features/input/utils/chat-inline-token.utils.ts +66 -8
  79. package/src/features/chat/features/input/utils/chat-input-bar.utils.ts +1 -0
  80. package/src/features/chat/features/ncp/hooks/__tests__/use-hydrated-ncp-agent.test.tsx +41 -3
  81. package/src/features/chat/features/project/components/__tests__/chat-project-create-dialog.test.tsx +67 -0
  82. package/src/features/chat/features/project/components/chat-project-create-dialog.tsx +203 -0
  83. package/src/features/chat/features/session/components/session-header/__tests__/chat-session-header-actions.test.tsx +7 -7
  84. package/src/features/chat/features/session/components/session-header/__tests__/chat-session-project-dialog.test.tsx +47 -0
  85. package/src/features/chat/features/session/components/session-header/chat-session-header-actions.tsx +6 -3
  86. package/src/features/chat/features/session/components/session-header/chat-session-project-badge.tsx +3 -0
  87. package/src/features/chat/features/session/components/session-header/chat-session-project-dialog.tsx +3 -0
  88. package/src/features/chat/features/session/components/session-header/chat-session-switcher-groups.tsx +245 -0
  89. package/src/features/chat/features/session/components/session-header/chat-session-title-switcher.tsx +81 -127
  90. package/src/features/chat/features/session/utils/__tests__/chat-sidebar-session-groups.utils.test.ts +22 -0
  91. package/src/features/chat/features/session/utils/chat-run-metadata.utils.ts +6 -6
  92. package/src/features/chat/features/session/utils/chat-sidebar-session-groups.utils.ts +12 -1
  93. package/src/features/chat/features/welcome/components/chat-conversation-welcome.tsx +3 -0
  94. package/src/features/chat/features/welcome/components/chat-welcome.tsx +1 -0
  95. package/src/features/chat/features/welcome/utils/__tests__/chat-welcome-project-options.utils.test.ts +20 -0
  96. package/src/features/chat/features/welcome/utils/chat-welcome-project-options.utils.ts +15 -2
  97. package/src/features/chat/pages/__tests__/ncp-chat-page.test.ts +23 -0
  98. package/src/features/settings/components/config/provider-form.tsx +0 -4
  99. package/src/features/settings/utils/__tests__/provider-form-support.utils.test.ts +7 -9
  100. package/src/features/settings/utils/provider-form-model.utils.ts +0 -4
  101. package/src/features/system-status/hooks/use-current-version-release-notes-link.ts +33 -0
  102. package/src/features/system-status/index.ts +9 -0
  103. package/src/features/system-status/utils/update-release-notes.utils.ts +44 -3
  104. package/src/index.css +10 -16
  105. package/src/shared/components/common/__tests__/brand-header.test.tsx +110 -3
  106. package/src/shared/components/common/brand-header.tsx +121 -35
  107. package/src/shared/components/path-picker/__tests__/server-path-picker-dialog.test.tsx +288 -38
  108. package/src/shared/components/path-picker/server-path-picker-dialog.tsx +290 -199
  109. package/src/shared/components/path-picker/server-path-picker-directory-list.tsx +103 -0
  110. package/src/shared/components/path-picker/server-path-picker-footer.tsx +58 -0
  111. package/src/shared/components/path-picker/server-path-picker-locations.tsx +148 -0
  112. package/src/shared/components/path-picker/server-path-picker-navigation-bar.tsx +198 -0
  113. package/src/shared/components/path-picker/server-path-picker-new-folder.tsx +59 -0
  114. package/src/shared/components/path-picker/server-path-picker-toolbar.tsx +25 -0
  115. package/src/shared/hooks/use-projects.ts +28 -0
  116. package/src/shared/hooks/use-server-path-create-directory.ts +6 -0
  117. package/src/shared/hooks/use-server-path-search.ts +40 -0
  118. package/src/shared/lib/api/index.ts +1 -0
  119. package/src/shared/lib/api/projects/project.utils.ts +14 -0
  120. package/src/shared/lib/api/types.ts +27 -0
  121. package/src/shared/lib/api/utils/server-path.utils.ts +21 -1
  122. package/src/shared/lib/i18n/locales/en-US/chat.json +35 -2
  123. package/src/shared/lib/i18n/locales/en-US/core.json +2 -3
  124. package/src/shared/lib/i18n/locales/en-US/desktop-update.json +4 -0
  125. package/src/shared/lib/i18n/locales/en-US/path-picker.json +25 -0
  126. package/src/shared/lib/i18n/locales/zh-CN/chat.json +35 -2
  127. package/src/shared/lib/i18n/locales/zh-CN/core.json +2 -3
  128. package/src/shared/lib/i18n/locales/zh-CN/desktop-update.json +4 -0
  129. package/src/shared/lib/i18n/locales/zh-CN/path-picker.json +25 -0
  130. package/dist/assets/api-IlX_CbwU.js +0 -13
  131. package/dist/assets/app-presenter-provider-Bbl_MVbg.js +0 -106
  132. package/dist/assets/desktop-update-config-DreMJu9z.js +0 -1
  133. package/dist/assets/doc-browser-DJQ5XPsi2.js +0 -1
  134. package/dist/assets/index-0av6WO9-.css +0 -1
  135. package/dist/assets/loader-circle-DtKek-qG.js +0 -1
  136. package/dist/assets/mcp-marketplace-page-4qMwFDpz.js +0 -1
  137. package/dist/assets/mcp-marketplace-page-BbxN5aZF.js +0 -9
  138. package/dist/assets/plus-BwgE-Zwf.js +0 -1
  139. package/dist/assets/providers-config-page-DfiWveEF.js +0 -1
  140. package/dist/assets/remote-BaVECEZw.js +0 -1
  141. package/dist/assets/rotate-cw-CMRZxIkH.js +0 -1
  142. package/dist/assets/runtime-config-page-wygwvMB4.js +0 -1
  143. package/dist/assets/security-config-BPzet76z.js +0 -1
  144. package/dist/assets/use-config-D6yx0lb0.js +0 -1
  145. package/dist/assets/x-B3o1dWR5.js +0 -1
  146. package/src/features/chat/features/input/input-surface-plugins/__tests__/panel-app-reference-plugin.test.ts +0 -77
  147. package/src/features/chat/features/input/input-surface-plugins/panel-app-reference-plugin.utils.ts +0 -144
@@ -1,11 +1,19 @@
1
1
  import type { ChatComposerNode } from '@nextclaw/agent-chat-ui';
2
+ import {
3
+ CHAT_INLINE_TOKENS_METADATA_KEY,
4
+ CHAT_WORKSPACE_DIRECTORY_TOKEN_KIND,
5
+ CHAT_WORKSPACE_FILE_TOKEN_KIND,
6
+ type ChatInlineTokenMetadata,
7
+ } from '@nextclaw/shared';
8
+ import { serializeChatComposerTokenText } from './chat-composer-token-protocol.utils';
2
9
 
3
- export const CHAT_UI_INLINE_TOKENS_METADATA_KEY = 'ui_inline_tokens';
4
- const CHAT_SKILL_TOKEN_PREFIX = '$';
10
+ export { CHAT_INLINE_TOKENS_METADATA_KEY };
5
11
  const CHAT_PANEL_APP_TOKEN_PREFIX = '@panel-app:';
6
12
  const CHAT_PANEL_APP_TOKEN_PATTERN = /@panel-app:([A-Za-z0-9_-]+)/g;
13
+ const CHAT_WORKSPACE_FILE_TOKEN_PATTERN = /@file:([^\s]+)/g;
14
+ const CHAT_WORKSPACE_DIRECTORY_TOKEN_PATTERN = /@folder:([^\s]+)/g;
7
15
 
8
- export type ChatInlineTokenSource = { kind: string; key: string; label: string; rawText: string };
16
+ export type ChatInlineTokenSource = ChatInlineTokenMetadata;
9
17
 
10
18
  function isRecord(value: unknown): value is Record<string, unknown> {
11
19
  return typeof value === 'object' && value !== null && !Array.isArray(value);
@@ -33,22 +41,60 @@ function dedupeInlineTokens(tokens: readonly ChatInlineTokenSource[]): ChatInlin
33
41
  return output;
34
42
  }
35
43
 
36
- export function buildInlineSkillTokensFromComposer(nodes: readonly ChatComposerNode[]): ChatInlineTokenSource[] {
44
+ export function buildInlineTokensFromComposer(nodes: readonly ChatComposerNode[]): ChatInlineTokenSource[] {
37
45
  const tokens: ChatInlineTokenSource[] = [];
38
46
  for (const node of nodes) {
39
- if (node.type !== 'token' || node.tokenKind !== 'skill') {
47
+ if (node.type !== 'token') {
48
+ continue;
49
+ }
50
+ if (
51
+ node.tokenKind !== 'skill' &&
52
+ node.tokenKind !== CHAT_WORKSPACE_FILE_TOKEN_KIND &&
53
+ node.tokenKind !== CHAT_WORKSPACE_DIRECTORY_TOKEN_KIND
54
+ ) {
55
+ continue;
56
+ }
57
+ const rawText = serializeChatComposerTokenText(node);
58
+ if (!rawText) {
40
59
  continue;
41
60
  }
42
61
  tokens.push({
43
- kind: 'skill',
62
+ kind: node.tokenKind,
44
63
  key: node.tokenKey,
45
64
  label: node.label,
46
- rawText: `${CHAT_SKILL_TOKEN_PREFIX}${node.tokenKey}`
65
+ rawText,
47
66
  });
48
67
  }
49
68
  return dedupeInlineTokens(tokens);
50
69
  }
51
70
 
71
+ function appendWorkspaceTokens(params: {
72
+ kind: typeof CHAT_WORKSPACE_FILE_TOKEN_KIND | typeof CHAT_WORKSPACE_DIRECTORY_TOKEN_KIND;
73
+ pattern: RegExp;
74
+ text: string;
75
+ tokens: ChatInlineTokenSource[];
76
+ }): void {
77
+ const { kind, pattern, text, tokens } = params;
78
+ for (const match of text.matchAll(pattern)) {
79
+ const encodedKey = match[1];
80
+ if (!encodedKey) {
81
+ continue;
82
+ }
83
+ let key: string;
84
+ try {
85
+ key = decodeURIComponent(encodedKey);
86
+ } catch {
87
+ continue;
88
+ }
89
+ tokens.push({
90
+ kind,
91
+ key,
92
+ label: key.split('/').filter(Boolean).at(-1) ?? key,
93
+ rawText: match[0],
94
+ });
95
+ }
96
+ }
97
+
52
98
  export function buildInlineTokensFromTextProtocol(text: string): ChatInlineTokenSource[] {
53
99
  const tokens: ChatInlineTokenSource[] = [];
54
100
  for (const match of text.matchAll(CHAT_PANEL_APP_TOKEN_PATTERN)) {
@@ -63,6 +109,18 @@ export function buildInlineTokensFromTextProtocol(text: string): ChatInlineToken
63
109
  rawText: `${CHAT_PANEL_APP_TOKEN_PREFIX}${key}`
64
110
  });
65
111
  }
112
+ appendWorkspaceTokens({
113
+ kind: CHAT_WORKSPACE_FILE_TOKEN_KIND,
114
+ pattern: CHAT_WORKSPACE_FILE_TOKEN_PATTERN,
115
+ text,
116
+ tokens,
117
+ });
118
+ appendWorkspaceTokens({
119
+ kind: CHAT_WORKSPACE_DIRECTORY_TOKEN_KIND,
120
+ pattern: CHAT_WORKSPACE_DIRECTORY_TOKEN_PATTERN,
121
+ text,
122
+ tokens,
123
+ });
66
124
  return dedupeInlineTokens(tokens);
67
125
  }
68
126
 
@@ -76,7 +134,7 @@ export function resolveInlineTokensForText(
76
134
  export function readInlineTokensFromMetadata(
77
135
  metadata: Record<string, unknown> | undefined
78
136
  ): ChatInlineTokenSource[] {
79
- const raw = metadata?.[CHAT_UI_INLINE_TOKENS_METADATA_KEY];
137
+ const raw = metadata?.[CHAT_INLINE_TOKENS_METADATA_KEY];
80
138
  if (!Array.isArray(raw)) {
81
139
  return [];
82
140
  }
@@ -278,6 +278,7 @@ export function buildSkillPickerModel(params: {
278
278
  const recentKeySet = new Set(groupedRecentSkillValues ?? []);
279
279
  return {
280
280
  title: texts.title,
281
+ allGroupsLabel: texts.allSkillsLabel,
281
282
  searchPlaceholder: texts.searchPlaceholder,
282
283
  emptyLabel: texts.emptyLabel,
283
284
  loadingLabel: texts.loadingLabel,
@@ -1,19 +1,57 @@
1
- import { renderHook, waitFor } from "@testing-library/react";
2
- import type { NcpAgentClientEndpoint } from "@nextclaw/ncp";
3
- import { useHydratedNcpAgent } from "@nextclaw/ncp-react";
1
+ import { act, renderHook, waitFor } from "@testing-library/react";
2
+ import { NcpEventType, type NcpAgentClientEndpoint } from "@nextclaw/ncp";
3
+ import { useHydratedNcpAgent, useNcpAgentRuntime } from "@nextclaw/ncp-react";
4
+ import { DefaultNcpAgentConversationStateManager } from "@nextclaw/ncp-toolkit";
4
5
  import { beforeEach, describe, expect, it, vi } from "vitest";
5
6
 
6
7
  const mocks = vi.hoisted(() => ({
8
+ send: vi.fn(),
7
9
  stream: vi.fn(),
8
10
  stop: vi.fn(),
9
11
  }));
10
12
 
11
13
  describe("useHydratedNcpAgent", () => {
12
14
  beforeEach(() => {
15
+ mocks.send.mockReset();
13
16
  mocks.stream.mockReset();
14
17
  mocks.stop.mockReset();
15
18
  });
16
19
 
20
+ it("clears a draft conversation error when a valid retry starts", async () => {
21
+ const client = {
22
+ send: mocks.send.mockResolvedValue(null),
23
+ stop: mocks.stop.mockResolvedValue(undefined),
24
+ stream: mocks.stream.mockResolvedValue(undefined),
25
+ subscribe: vi.fn(() => () => {}),
26
+ } as unknown as NcpAgentClientEndpoint;
27
+ const manager = new DefaultNcpAgentConversationStateManager();
28
+ const { result } = renderHook(() =>
29
+ useNcpAgentRuntime({
30
+ client,
31
+ manager,
32
+ }),
33
+ );
34
+
35
+ await act(async () => {
36
+ await manager.dispatch({
37
+ occurredAt: new Date().toISOString(),
38
+ type: NcpEventType.EndpointError,
39
+ payload: {
40
+ code: "runtime-error",
41
+ message: "network error",
42
+ },
43
+ });
44
+ });
45
+ expect(result.current.snapshot.error?.message).toBe("network error");
46
+
47
+ await act(async () => {
48
+ await result.current.send("retry");
49
+ });
50
+
51
+ expect(result.current.snapshot.error).toBeNull();
52
+ expect(mocks.send).toHaveBeenCalledOnce();
53
+ });
54
+
17
55
  it("treats a newly selected session as hydrating immediately on rerender", async () => {
18
56
  const client = {
19
57
  stop: mocks.stop.mockResolvedValue(undefined),
@@ -0,0 +1,67 @@
1
+ import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
2
+ import userEvent from '@testing-library/user-event';
3
+ import { describe, expect, it, vi } from 'vitest';
4
+ import { ChatProjectCreateDialog } from '@/features/chat/features/project/components/chat-project-create-dialog';
5
+ import { setLanguage } from '@/shared/lib/i18n';
6
+
7
+ const pathPickerSpy = vi.fn();
8
+
9
+ vi.mock('@/shared/components/path-picker/server-path-picker-dialog', () => ({
10
+ ServerPathPickerDialog: (props: unknown) => pathPickerSpy(props),
11
+ }));
12
+
13
+ describe('ChatProjectCreateDialog', () => {
14
+ it('selects the optional target directory with the shared path picker', async () => {
15
+ setLanguage('zh');
16
+ const user = userEvent.setup();
17
+ const onCreate = vi.fn(async () => undefined);
18
+
19
+ render(
20
+ <ChatProjectCreateDialog
21
+ open
22
+ defaultWorkspacePath="/workspace"
23
+ templates={[
24
+ { id: 'empty', name: 'Empty', description: 'Empty project' },
25
+ {
26
+ id: 'knowledge-base',
27
+ name: 'Knowledge base',
28
+ description: 'Knowledge base project',
29
+ },
30
+ ]}
31
+ isCreating={false}
32
+ onOpenChange={() => {}}
33
+ onCreate={onCreate}
34
+ />,
35
+ );
36
+
37
+ await user.type(screen.getByLabelText('项目名称'), '个人知识库');
38
+ fireEvent.change(document.querySelector('select')!, {
39
+ target: { value: 'knowledge-base' },
40
+ });
41
+ await user.click(screen.getByRole('button', { name: '浏览' }));
42
+ expect(pathPickerSpy).toHaveBeenLastCalledWith(
43
+ expect.objectContaining({
44
+ open: true,
45
+ currentPath: '',
46
+ defaultWorkspacePath: '/workspace',
47
+ title: '选择项目目标目录',
48
+ }),
49
+ );
50
+ const pickerProps = pathPickerSpy.mock.lastCall?.[0] as {
51
+ onConfirm: (path: string) => void;
52
+ };
53
+ act(() => pickerProps.onConfirm('/srv/knowledge'));
54
+ expect((screen.getByLabelText('目标目录(可选)') as HTMLInputElement).value).toBe(
55
+ '/srv/knowledge',
56
+ );
57
+ await user.click(screen.getByRole('button', { name: '创建项目' }));
58
+
59
+ await waitFor(() => {
60
+ expect(onCreate).toHaveBeenCalledWith({
61
+ name: '个人知识库',
62
+ rootPath: '/srv/knowledge',
63
+ template: 'knowledge-base',
64
+ });
65
+ });
66
+ });
67
+ });
@@ -0,0 +1,203 @@
1
+ import { useState } from 'react';
2
+ import { FolderOpen } from 'lucide-react';
3
+ import type { ProjectCreateRequest, ProjectTemplateView } from '@/shared/lib/api';
4
+ import { ServerPathPickerDialog } from '@/shared/components/path-picker/server-path-picker-dialog';
5
+ import { Button } from '@/shared/components/ui/button';
6
+ import {
7
+ Dialog,
8
+ DialogContent,
9
+ DialogDescription,
10
+ DialogFooter,
11
+ DialogHeader,
12
+ DialogTitle,
13
+ } from '@/shared/components/ui/dialog';
14
+ import { Input } from '@/shared/components/ui/input';
15
+ import { Label } from '@/shared/components/ui/label';
16
+ import {
17
+ Select,
18
+ SelectContent,
19
+ SelectItem,
20
+ SelectTrigger,
21
+ SelectValue,
22
+ } from '@/shared/components/ui/select';
23
+ import { t } from '@/shared/lib/i18n';
24
+
25
+ type ProjectTemplateId = NonNullable<ProjectCreateRequest['template']>;
26
+
27
+ type ChatProjectCreateDialogProps = {
28
+ open: boolean;
29
+ defaultWorkspacePath?: string | null;
30
+ templates: readonly ProjectTemplateView[];
31
+ isCreating: boolean;
32
+ errorMessage?: string | null;
33
+ onOpenChange: (open: boolean) => void;
34
+ onCreate: (input: ProjectCreateRequest) => Promise<void>;
35
+ };
36
+
37
+ function projectTemplateLabel(templateId: ProjectTemplateId): string {
38
+ return t(
39
+ templateId === 'knowledge-base'
40
+ ? 'chatProjectTemplateKnowledgeBase'
41
+ : 'chatProjectTemplateEmpty',
42
+ );
43
+ }
44
+
45
+ export function ChatProjectCreateDialog({
46
+ open,
47
+ defaultWorkspacePath,
48
+ templates,
49
+ isCreating,
50
+ errorMessage,
51
+ onOpenChange,
52
+ onCreate,
53
+ }: ChatProjectCreateDialogProps) {
54
+ const [name, setName] = useState('');
55
+ const [rootPath, setRootPath] = useState('');
56
+ const [pathPickerOpen, setPathPickerOpen] = useState(false);
57
+ const [template, setTemplate] = useState<ProjectTemplateId>('empty');
58
+ const availableTemplateIds = templates.length > 0
59
+ ? templates.map((entry) => entry.id)
60
+ : ['empty' as const];
61
+ const normalizedName = name.trim();
62
+
63
+ const resetForm = () => {
64
+ setName('');
65
+ setRootPath('');
66
+ setTemplate('empty');
67
+ setPathPickerOpen(false);
68
+ };
69
+
70
+ const closeDialog = () => {
71
+ if (isCreating) {
72
+ return;
73
+ }
74
+ resetForm();
75
+ onOpenChange(false);
76
+ };
77
+
78
+ return (
79
+ <>
80
+ <Dialog
81
+ open={open}
82
+ onOpenChange={(nextOpen) => {
83
+ if (nextOpen) {
84
+ onOpenChange(true);
85
+ return;
86
+ }
87
+ closeDialog();
88
+ }}
89
+ >
90
+ <DialogContent className="sm:max-w-lg">
91
+ <DialogHeader>
92
+ <DialogTitle>{t('chatProjectCreateTitle')}</DialogTitle>
93
+ <DialogDescription>{t('chatProjectCreateDescription')}</DialogDescription>
94
+ </DialogHeader>
95
+ <form
96
+ className="space-y-4"
97
+ onSubmit={(event) => {
98
+ event.preventDefault();
99
+ if (!normalizedName || isCreating) {
100
+ return;
101
+ }
102
+ const normalizedRootPath = rootPath.trim();
103
+ void onCreate({
104
+ name: normalizedName,
105
+ template,
106
+ ...(normalizedRootPath ? { rootPath: normalizedRootPath } : {}),
107
+ }).then(resetForm).catch(() => undefined);
108
+ }}
109
+ >
110
+ <div className="space-y-2">
111
+ <Label htmlFor="chat-project-name">{t('chatProjectNameLabel')}</Label>
112
+ <Input
113
+ id="chat-project-name"
114
+ value={name}
115
+ onChange={(event) => setName(event.target.value)}
116
+ placeholder={t('chatProjectNamePlaceholder')}
117
+ autoFocus
118
+ disabled={isCreating}
119
+ />
120
+ </div>
121
+
122
+ <div className="space-y-2">
123
+ <Label htmlFor="chat-project-template">{t('chatProjectTemplateLabel')}</Label>
124
+ <Select
125
+ value={template}
126
+ onValueChange={(value) => setTemplate(value as ProjectTemplateId)}
127
+ disabled={isCreating}
128
+ >
129
+ <SelectTrigger id="chat-project-template">
130
+ <SelectValue />
131
+ </SelectTrigger>
132
+ <SelectContent>
133
+ {availableTemplateIds.map((templateId) => (
134
+ <SelectItem key={templateId} value={templateId}>
135
+ {projectTemplateLabel(templateId)}
136
+ </SelectItem>
137
+ ))}
138
+ </SelectContent>
139
+ </Select>
140
+ </div>
141
+
142
+ <div className="space-y-2">
143
+ <Label htmlFor="chat-project-root-path">{t('chatProjectPathLabel')}</Label>
144
+ <div className="flex gap-2">
145
+ <Input
146
+ id="chat-project-root-path"
147
+ value={rootPath}
148
+ onChange={(event) => setRootPath(event.target.value)}
149
+ placeholder={t('chatProjectPathPlaceholder')}
150
+ disabled={isCreating}
151
+ />
152
+ <Button
153
+ type="button"
154
+ variant="outline"
155
+ onClick={() => setPathPickerOpen(true)}
156
+ disabled={isCreating}
157
+ >
158
+ <FolderOpen className="mr-2 h-4 w-4" />
159
+ {t('browse')}
160
+ </Button>
161
+ </div>
162
+ <p className="text-xs leading-relaxed text-muted-foreground">
163
+ {t('chatProjectPathHint')}
164
+ </p>
165
+ </div>
166
+
167
+ {errorMessage ? (
168
+ <p role="alert" className="text-sm text-destructive">
169
+ {errorMessage}
170
+ </p>
171
+ ) : null}
172
+
173
+ <DialogFooter>
174
+ <Button type="button" variant="outline" onClick={closeDialog} disabled={isCreating}>
175
+ {t('cancel')}
176
+ </Button>
177
+ <Button type="submit" disabled={!normalizedName || isCreating}>
178
+ {isCreating ? t('saving') : t('chatProjectCreateAction')}
179
+ </Button>
180
+ </DialogFooter>
181
+ </form>
182
+ </DialogContent>
183
+ </Dialog>
184
+ <ServerPathPickerDialog
185
+ open={pathPickerOpen}
186
+ currentPath={rootPath}
187
+ defaultWorkspacePath={defaultWorkspacePath}
188
+ isSaving={false}
189
+ onOpenChange={setPathPickerOpen}
190
+ onConfirm={(path) => {
191
+ setRootPath(path);
192
+ setPathPickerOpen(false);
193
+ }}
194
+ title={t('chatProjectPathPickerTitle')}
195
+ description={t('chatProjectPathPickerDescription')}
196
+ pathLabel={t('chatProjectPathLabel')}
197
+ pathPlaceholder={t('chatProjectPathPlaceholder')}
198
+ confirmLabel={t('selectCurrentDirectory')}
199
+ hint={t('chatProjectPathHint')}
200
+ />
201
+ </>
202
+ );
203
+ }
@@ -32,7 +32,7 @@ describe('ChatSessionHeaderActions', () => {
32
32
  sessionKey="session-1"
33
33
  canDeleteSession
34
34
  isDeletePending={false}
35
- projectRoot="/tmp/project-alpha"
35
+ currentPath="/tmp/project-alpha"
36
36
  isWorkspaceOpen={false}
37
37
  onToggleWorkspace={mocks.onToggleWorkspace}
38
38
  onDeleteSession={mocks.onDeleteSession}
@@ -55,7 +55,7 @@ describe('ChatSessionHeaderActions', () => {
55
55
  sessionKey="draft-session"
56
56
  canDeleteSession={false}
57
57
  isDeletePending={false}
58
- projectRoot={null}
58
+ currentPath={null}
59
59
  isWorkspaceOpen={false}
60
60
  onToggleWorkspace={mocks.onToggleWorkspace}
61
61
  onDeleteSession={mocks.onDeleteSession}
@@ -77,7 +77,7 @@ describe('ChatSessionHeaderActions', () => {
77
77
  sessionKey="session-codex"
78
78
  canDeleteSession
79
79
  isDeletePending={false}
80
- projectRoot={null}
80
+ currentPath={null}
81
81
  metadata={{
82
82
  codex_thread_id: 'thread-123',
83
83
  runtime: 'codex',
@@ -106,7 +106,7 @@ describe('ChatSessionHeaderActions', () => {
106
106
  sessionKey="session-children"
107
107
  canDeleteSession
108
108
  isDeletePending={false}
109
- projectRoot={null}
109
+ currentPath={null}
110
110
  isWorkspaceOpen={false}
111
111
  onToggleWorkspace={mocks.onToggleWorkspace}
112
112
  onDeleteSession={mocks.onDeleteSession}
@@ -126,7 +126,7 @@ describe('ChatSessionHeaderActions', () => {
126
126
  sessionKey="session-actions"
127
127
  canDeleteSession
128
128
  isDeletePending={false}
129
- projectRoot={null}
129
+ currentPath={null}
130
130
  isWorkspaceOpen={false}
131
131
  onToggleWorkspace={mocks.onToggleWorkspace}
132
132
  onDeleteSession={mocks.onDeleteSession}
@@ -146,7 +146,7 @@ describe('ChatSessionHeaderActions', () => {
146
146
  sessionKey="session-actions"
147
147
  canDeleteSession
148
148
  isDeletePending={false}
149
- projectRoot={null}
149
+ currentPath={null}
150
150
  isWorkspaceOpen={false}
151
151
  onToggleWorkspace={mocks.onToggleWorkspace}
152
152
  onDeleteSession={mocks.onDeleteSession}
@@ -169,7 +169,7 @@ describe('ChatSessionHeaderActions', () => {
169
169
  sessionKey="session-actions"
170
170
  canDeleteSession
171
171
  isDeletePending={false}
172
- projectRoot={null}
172
+ currentPath={null}
173
173
  isWorkspaceOpen
174
174
  onToggleWorkspace={mocks.onToggleWorkspace}
175
175
  onDeleteSession={mocks.onDeleteSession}
@@ -0,0 +1,47 @@
1
+ import { render } from "@testing-library/react";
2
+ import { describe, expect, it, vi } from "vitest";
3
+ import { ChatSessionProjectDialog } from "@/features/chat/features/session/components/session-header/chat-session-project-dialog";
4
+
5
+ const pathPickerSpy = vi.fn((_props: unknown) => null);
6
+
7
+ vi.mock("@/shared/components/path-picker/server-path-picker-dialog", () => ({
8
+ ServerPathPickerDialog: (props: unknown) => pathPickerSpy(props),
9
+ }));
10
+
11
+ describe("ChatSessionProjectDialog", () => {
12
+ it("uses the default workspace as the browse start and quick-access location", () => {
13
+ render(
14
+ <ChatSessionProjectDialog
15
+ open
16
+ currentProjectRoot={null}
17
+ defaultWorkspacePath="/tmp/default-workspace"
18
+ isSaving={false}
19
+ onOpenChange={() => undefined}
20
+ onSave={() => undefined}
21
+ />,
22
+ );
23
+
24
+ expect(pathPickerSpy).toHaveBeenCalledWith(expect.objectContaining({
25
+ currentPath: null,
26
+ defaultWorkspacePath: "/tmp/default-workspace",
27
+ }));
28
+ });
29
+
30
+ it("prefers an explicit project over the default workdir", () => {
31
+ render(
32
+ <ChatSessionProjectDialog
33
+ open
34
+ currentProjectRoot="/tmp/project"
35
+ defaultWorkspacePath="/tmp/default-workspace"
36
+ isSaving={false}
37
+ onOpenChange={() => undefined}
38
+ onSave={() => undefined}
39
+ />,
40
+ );
41
+
42
+ expect(pathPickerSpy).toHaveBeenLastCalledWith(expect.objectContaining({
43
+ currentPath: "/tmp/project",
44
+ defaultWorkspacePath: "/tmp/default-workspace",
45
+ }));
46
+ });
47
+ });
@@ -15,7 +15,8 @@ type ChatSessionHeaderActionsProps = {
15
15
  sessionKey: string;
16
16
  canDeleteSession: boolean;
17
17
  isDeletePending: boolean;
18
- projectRoot?: string | null;
18
+ currentPath?: string | null;
19
+ defaultWorkspacePath?: string | null;
19
20
  metadata?: Record<string, unknown> | null;
20
21
  isWorkspaceOpen: boolean;
21
22
  onToggleWorkspace: () => void;
@@ -26,7 +27,8 @@ export function ChatSessionHeaderActions({
26
27
  sessionKey,
27
28
  canDeleteSession,
28
29
  isDeletePending,
29
- projectRoot,
30
+ currentPath,
31
+ defaultWorkspacePath,
30
32
  metadata,
31
33
  isWorkspaceOpen,
32
34
  onToggleWorkspace,
@@ -119,7 +121,8 @@ export function ChatSessionHeaderActions({
119
121
 
120
122
  <ChatSessionProjectDialog
121
123
  open={isDialogOpen}
122
- currentProjectRoot={projectRoot}
124
+ currentProjectRoot={currentPath}
125
+ defaultWorkspacePath={defaultWorkspacePath}
123
126
  isSaving={isProjectPending}
124
127
  onOpenChange={setIsDialogOpen}
125
128
  onSave={runProjectUpdate}
@@ -15,6 +15,7 @@ type ChatSessionProjectBadgeProps = {
15
15
  sessionKey: string;
16
16
  projectName: string;
17
17
  projectRoot?: string | null;
18
+ defaultWorkspacePath?: string | null;
18
19
  persistToServer: boolean;
19
20
  };
20
21
 
@@ -26,6 +27,7 @@ export function ChatSessionProjectBadge({
26
27
  sessionKey,
27
28
  projectName,
28
29
  projectRoot,
30
+ defaultWorkspacePath,
29
31
  persistToServer,
30
32
  }: ChatSessionProjectBadgeProps) {
31
33
  const updateSessionProject = useChatSessionProject();
@@ -106,6 +108,7 @@ export function ChatSessionProjectBadge({
106
108
  <ChatSessionProjectDialog
107
109
  open={isDialogOpen}
108
110
  currentProjectRoot={projectRoot}
111
+ defaultWorkspacePath={defaultWorkspacePath}
109
112
  isSaving={isProjectPending}
110
113
  onOpenChange={setIsDialogOpen}
111
114
  onSave={runProjectUpdate}
@@ -4,6 +4,7 @@ import { t } from '@/shared/lib/i18n';
4
4
  type ChatSessionProjectDialogProps = {
5
5
  open: boolean;
6
6
  currentProjectRoot?: string | null;
7
+ defaultWorkspacePath?: string | null;
7
8
  isSaving: boolean;
8
9
  onOpenChange: (open: boolean) => void;
9
10
  onSave: (projectRoot: string) => Promise<void> | void;
@@ -12,6 +13,7 @@ type ChatSessionProjectDialogProps = {
12
13
  export function ChatSessionProjectDialog({
13
14
  open,
14
15
  currentProjectRoot,
16
+ defaultWorkspacePath,
15
17
  isSaving,
16
18
  onOpenChange,
17
19
  onSave,
@@ -20,6 +22,7 @@ export function ChatSessionProjectDialog({
20
22
  <ServerPathPickerDialog
21
23
  open={open}
22
24
  currentPath={currentProjectRoot}
25
+ defaultWorkspacePath={defaultWorkspacePath}
23
26
  isSaving={isSaving}
24
27
  onOpenChange={onOpenChange}
25
28
  onConfirm={onSave}