@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,5 +1,5 @@
1
1
  import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
2
- import { render, screen } from "@testing-library/react";
2
+ import { render, screen, within } from "@testing-library/react";
3
3
  import userEvent from "@testing-library/user-event";
4
4
  import type { PersistStorage, StorageValue } from "zustand/middleware";
5
5
  import { beforeEach, describe, expect, it, vi } from "vitest";
@@ -15,6 +15,8 @@ const mocks = vi.hoisted(() => ({
15
15
  deleteSession: vi.fn(),
16
16
  toggleWorkspacePanel: vi.fn(),
17
17
  selectSession: vi.fn(),
18
+ setListMode: vi.fn(),
19
+ toggleProjectCollapsed: vi.fn(),
18
20
  sessionItems: [] as NcpSessionListItemView[],
19
21
  isSessionListLoading: false,
20
22
  }));
@@ -27,6 +29,8 @@ vi.mock("@/features/chat/components/providers/chat-presenter.provider", () => ({
27
29
  },
28
30
  chatSessionListManager: {
29
31
  selectSession: mocks.selectSession,
32
+ setListMode: mocks.setListMode,
33
+ toggleProjectCollapsed: mocks.toggleProjectCollapsed,
30
34
  },
31
35
  }),
32
36
  }));
@@ -120,6 +124,8 @@ describe("ChatConversationHeaderSection", () => {
120
124
  mocks.deleteSession.mockReset();
121
125
  mocks.toggleWorkspacePanel.mockReset();
122
126
  mocks.selectSession.mockReset();
127
+ mocks.setListMode.mockReset();
128
+ mocks.toggleProjectCollapsed.mockReset();
123
129
  mocks.isSessionListLoading = false;
124
130
  viewportLayoutManager.resetForTests();
125
131
  useChatSessionListStore.persist.setOptions({
@@ -131,10 +137,25 @@ describe("ChatConversationHeaderSection", () => {
131
137
  useChatSessionListStore.setState({
132
138
  snapshot: {
133
139
  ...useChatSessionListStore.getState().snapshot,
140
+ collapsedProjectRoots: [],
141
+ listMode: "time-first",
142
+ pinnedProjectRoots: [],
143
+ pinnedSessionKeys: [],
134
144
  selectedAgentId: "main",
135
145
  selectedSessionKey: "parent-session-1",
136
146
  },
137
147
  });
148
+ mocks.setListMode.mockImplementation((listMode) => {
149
+ useChatSessionListStore.getState().setSnapshot({ listMode });
150
+ });
151
+ mocks.toggleProjectCollapsed.mockImplementation((projectRoot) => {
152
+ const { collapsedProjectRoots } = useChatSessionListStore.getState().snapshot;
153
+ useChatSessionListStore.getState().setSnapshot({
154
+ collapsedProjectRoots: collapsedProjectRoots.includes(projectRoot)
155
+ ? collapsedProjectRoots.filter((root) => root !== projectRoot)
156
+ : [...collapsedProjectRoots, projectRoot],
157
+ });
158
+ });
138
159
  useChatThreadStore.setState({
139
160
  snapshot: {
140
161
  ...useChatThreadStore.getState().snapshot,
@@ -229,6 +250,68 @@ describe("ChatConversationHeaderSection", () => {
229
250
  expect(mocks.selectSession).toHaveBeenCalledWith("session:ncp-2");
230
251
  });
231
252
 
253
+ it("uses lightweight static groups for pinned and activity dates", async () => {
254
+ const user = userEvent.setup();
255
+ viewportLayoutManager.setSidebarCollapsed(true);
256
+ useChatSessionListStore.setState({
257
+ snapshot: {
258
+ ...useChatSessionListStore.getState().snapshot,
259
+ pinnedSessionKeys: ["session:ncp-2"],
260
+ },
261
+ });
262
+
263
+ renderHeaderSection();
264
+ await user.click(screen.getByRole("button", { name: /Switch session/ }));
265
+
266
+ const pinnedGroup = screen.getByRole("region", { name: "Pinned" });
267
+ const pinnedTitle = within(pinnedGroup).getByText("Pinned");
268
+ expect(pinnedTitle.className).toContain("uppercase");
269
+ expect(pinnedTitle.className).not.toContain("bg-muted");
270
+ expect(within(pinnedGroup).queryByRole("button", { name: "Pinned" })).toBeNull();
271
+ expect(within(pinnedGroup).getByText("Background Task")).toBeTruthy();
272
+ expect(
273
+ within(screen.getByRole("region", { name: "Older" })).getByText("Parent Task"),
274
+ ).toBeTruthy();
275
+ });
276
+
277
+ it("switches to collapsible project groups inside the header popover", async () => {
278
+ const user = userEvent.setup();
279
+ viewportLayoutManager.setSidebarCollapsed(true);
280
+ mocks.sessionItems = [
281
+ createSessionListItem({
282
+ key: "parent-session-1",
283
+ label: "Parent Task",
284
+ projectName: "Alpha",
285
+ projectRoot: "/workspace/alpha",
286
+ }),
287
+ createSessionListItem({
288
+ key: "session:ncp-2",
289
+ label: "Background Task",
290
+ projectName: "Beta",
291
+ projectRoot: "/workspace/beta",
292
+ }),
293
+ ];
294
+
295
+ renderHeaderSection();
296
+ await user.click(screen.getByRole("button", { name: /Switch session/ }));
297
+ await user.click(screen.getByRole("button", { name: "Project" }));
298
+
299
+ expect(mocks.setListMode).toHaveBeenCalledWith("project-first");
300
+ const alphaGroup = screen.getByRole("region", { name: "Alpha" });
301
+ const alphaToggle = within(alphaGroup).getByRole("button", {
302
+ name: "Collapse project · Alpha",
303
+ });
304
+ expect(alphaToggle.getAttribute("aria-expanded")).toBe("true");
305
+ expect(alphaToggle.className).not.toContain("bg-muted");
306
+ expect(within(alphaGroup).getByText("Parent Task")).toBeTruthy();
307
+
308
+ await user.click(alphaToggle);
309
+ expect(mocks.toggleProjectCollapsed).toHaveBeenCalledWith("/workspace/alpha");
310
+ expect(
311
+ within(screen.getByRole("region", { name: "Alpha" })).queryByText("Parent Task"),
312
+ ).toBeNull();
313
+ });
314
+
232
315
  it("keeps the desktop title plain while the sidebar is expanded", () => {
233
316
  renderHeaderSection();
234
317
 
@@ -17,7 +17,10 @@ import { useChatSessionListStore } from "@/features/chat/stores/chat-session-lis
17
17
  import { useChatThreadStore } from "@/features/chat/stores/chat-thread.store";
18
18
  import { useChatQueryStore } from "@/features/chat/stores/ncp-chat-query.store";
19
19
  import { AgentIdentityAvatar } from "@/shared/components/common/agent-identity";
20
- import { getSessionProjectName } from "@/shared/lib/session-project";
20
+ import {
21
+ getSessionProjectName,
22
+ normalizeSessionProjectRootValue,
23
+ } from "@/shared/lib/session-project";
21
24
  import { t } from "@/shared/lib/i18n";
22
25
 
23
26
  type ChatConversationHeaderSectionProps = {
@@ -40,6 +43,9 @@ export function ChatConversationHeaderSection({
40
43
  const sessionTypesData = useChatQueryStore(
41
44
  (state) => state.snapshot.sessionTypesQuery?.data ?? null,
42
45
  );
46
+ const config = useChatQueryStore(
47
+ (state) => state.snapshot.configQuery?.data ?? null,
48
+ );
43
49
  const selectedSession = useNcpChatSelectedSession(sessionKey);
44
50
  const sessionTypeOptions = useMemo(
45
51
  () => buildSessionTypeOptions(sessionTypesData?.options ?? []),
@@ -55,6 +61,9 @@ export function ChatConversationHeaderSection({
55
61
  const shouldShowSessionTypeBadge =
56
62
  sessionType !== DEFAULT_SESSION_TYPE && Boolean(sessionTypeLabel);
57
63
  const sessionProjectRoot = selectedSession?.projectRoot ?? null;
64
+ const defaultWorkspacePath = normalizeSessionProjectRootValue(
65
+ config?.agents.defaults.workspace,
66
+ );
58
67
  const sessionProjectName =
59
68
  selectedSession?.projectName ?? getSessionProjectName(sessionProjectRoot);
60
69
  const canDeleteSession = Boolean(selectedSession);
@@ -129,6 +138,7 @@ export function ChatConversationHeaderSection({
129
138
  sessionKey={sessionKey ?? "draft"}
130
139
  projectName={sessionProjectName}
131
140
  projectRoot={sessionProjectRoot}
141
+ defaultWorkspacePath={defaultWorkspacePath}
132
142
  persistToServer={canDeleteSession}
133
143
  />
134
144
  ) : null
@@ -139,7 +149,8 @@ export function ChatConversationHeaderSection({
139
149
  sessionKey={sessionKey}
140
150
  canDeleteSession={canDeleteSession}
141
151
  isDeletePending={snapshot.isDeletePending}
142
- projectRoot={sessionProjectRoot}
152
+ currentPath={sessionProjectRoot ?? selectedSession?.workingDir ?? null}
153
+ defaultWorkspacePath={defaultWorkspacePath}
143
154
  metadata={selectedSession?.metadata ?? null}
144
155
  isWorkspaceOpen={isWorkspaceOpen}
145
156
  onToggleWorkspace={toggleWorkspace}
@@ -0,0 +1,22 @@
1
+ import type { NcpSessionListItemView } from '@/features/chat/features/ncp/hooks/use-ncp-session-list-view';
2
+ import { useChatQueryStore } from '@/features/chat/stores/ncp-chat-query.store';
3
+ import type { ChatSessionTypeOptionView } from '@/shared/lib/api';
4
+
5
+ export function createSidebarSessionItem(
6
+ session: NcpSessionListItemView['session'],
7
+ runStatus?: NcpSessionListItemView['runStatus'],
8
+ ): NcpSessionListItemView {
9
+ return { session, runStatus };
10
+ }
11
+
12
+ export function setSidebarSessionTypes(
13
+ options: ChatSessionTypeOptionView[],
14
+ defaultType = 'native',
15
+ ) {
16
+ useChatQueryStore.setState({
17
+ snapshot: {
18
+ ...useChatQueryStore.getState().snapshot,
19
+ sessionTypesQuery: { data: { defaultType, options } } as never,
20
+ },
21
+ });
22
+ }
@@ -3,11 +3,13 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react";
3
3
  import { MemoryRouter } from "react-router-dom";
4
4
  import { beforeEach, describe, expect, it, vi } from "vitest";
5
5
  import { ChatSidebar } from "@/features/chat/components/layout/chat-sidebar";
6
+ import {
7
+ createSidebarSessionItem as createSessionItem,
8
+ setSidebarSessionTypes as setSessionTypes,
9
+ } from "@/features/chat/components/layout/__tests__/chat-sidebar-test.utils";
6
10
  import type { NcpSessionListItemView } from "@/features/chat/features/ncp/hooks/use-ncp-session-list-view";
7
- import { useChatQueryStore } from "@/features/chat/stores/ncp-chat-query.store";
8
11
  import { useChatSessionListStore } from "@/features/chat/stores/chat-session-list.store";
9
12
  import { PREFERENCE_KEYS } from "@/shared/lib/api";
10
- import type { ChatSessionTypeOptionView } from "@/shared/lib/api";
11
13
  import { viewportLayoutManager } from "@/app/managers/viewport-layout.manager";
12
14
  const mocks = vi.hoisted(() => ({
13
15
  createSession: vi.fn(() => "draft-session-key"),
@@ -39,28 +41,15 @@ vi.mock("@/shared/lib/api", async (importOriginal) => {
39
41
  updatePreference: mocks.updatePreference,
40
42
  };
41
43
  });
42
- function createSessionItem(
43
- session: NcpSessionListItemView["session"],
44
- runStatus?: NcpSessionListItemView["runStatus"],
45
- ): NcpSessionListItemView {
46
- return { session, runStatus };
47
- }
48
- function setSessionTypes(
49
- options: ChatSessionTypeOptionView[],
50
- defaultType = "native",
51
- ) {
52
- useChatQueryStore.setState({
53
- snapshot: {
54
- ...useChatQueryStore.getState().snapshot,
55
- sessionTypesQuery: {
56
- data: {
57
- defaultType,
58
- options,
59
- },
60
- } as never,
61
- },
62
- });
63
- }
44
+ vi.mock("@/shared/hooks/use-projects", () => ({
45
+ useProjects: () => ({ data: { projects: [], templates: [] } }),
46
+ useCreateProject: () => ({
47
+ mutateAsync: vi.fn(),
48
+ reset: vi.fn(),
49
+ isPending: false,
50
+ error: null,
51
+ }),
52
+ }));
64
53
  function sidebarElement(variant?: "desktop" | "mobile") {
65
54
  const queryClient = new QueryClient({
66
55
  defaultOptions: {
@@ -18,6 +18,7 @@ import {
18
18
  BrainCircuit,
19
19
  PanelLeftClose,
20
20
  PanelLeftOpen,
21
+ Plus,
21
22
  } from "lucide-react";
22
23
  import { ChatSidebarSessionList } from "@/features/chat/features/session/components/chat-sidebar-session-list";
23
24
  import { ChatSidebarUtilityMenu } from "@/features/chat/components/layout/chat-sidebar-utility-menu";
@@ -134,6 +135,7 @@ export function ChatSidebarSessionArea({
134
135
  isCollapsed,
135
136
  isLoading,
136
137
  isProjectFirstView,
138
+ onCreateProject,
137
139
  onSelectMode,
138
140
  projectGroups,
139
141
  renderSessionItem,
@@ -144,6 +146,7 @@ export function ChatSidebarSessionArea({
144
146
  isCollapsed: boolean;
145
147
  isLoading: boolean;
146
148
  isProjectFirstView: boolean;
149
+ onCreateProject: () => void;
147
150
  onSelectMode: (mode: "time-first" | "project-first") => void;
148
151
  projectGroups: ReturnType<typeof groupSessionsByProject>;
149
152
  renderSessionItem: (item: NcpSessionListItemView) => JSX.Element;
@@ -161,10 +164,19 @@ export function ChatSidebarSessionArea({
161
164
  <div className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground/75">
162
165
  {t("chatSidebarTaskRecords")}
163
166
  </div>
164
- <ChatSidebarListModeSwitch
165
- isProjectFirstView={isProjectFirstView}
166
- onSelectMode={onSelectMode}
167
- />
167
+ <div className="flex items-center gap-1">
168
+ {isProjectFirstView ? (
169
+ <IconActionButton
170
+ icon={<Plus className="h-3.5 w-3.5" />}
171
+ label={t("chatProjectCreate")}
172
+ onClick={onCreateProject}
173
+ />
174
+ ) : null}
175
+ <ChatSidebarListModeSwitch
176
+ isProjectFirstView={isProjectFirstView}
177
+ onSelectMode={onSelectMode}
178
+ />
179
+ </div>
168
180
  </div>
169
181
 
170
182
  <div
@@ -16,6 +16,9 @@ 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";
21
+ import { normalizeSessionProjectRootValue } from "@/shared/lib/session-project";
19
22
  import { cn } from "@/shared/lib/utils";
20
23
  import { LANGUAGE_OPTIONS, t, type I18nLanguage } from "@/shared/lib/i18n";
21
24
  import { THEME_OPTIONS } from "@/shared/lib/theme";
@@ -42,6 +45,7 @@ import {
42
45
  import { useChatNewSessionTypePreference } from "@/features/chat/features/session-type/hooks/use-chat-new-session-type-preference";
43
46
  import { useViewportLayoutStore } from "@/app/stores/viewport-layout.store";
44
47
  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";
45
49
 
46
50
  type ChatSidebarVariant = "desktop" | "mobile";
47
51
 
@@ -94,13 +98,19 @@ export function ChatSidebar({
94
98
  (state) => state.isSidebarCollapsed,
95
99
  );
96
100
  const [isCreateMenuOpen, setIsCreateMenuOpen] = useState(false);
101
+ const [isProjectCreateOpen, setIsProjectCreateOpen] = useState(false);
97
102
  const [isUtilityMenuOpen, setIsUtilityMenuOpen] = useState(false);
98
103
  const listSnapshot = useChatSessionListStore((state) => state.snapshot);
99
104
  const sessionTypesData = useChatQueryStore(
100
105
  (state) => state.snapshot.sessionTypesQuery?.data ?? null,
101
106
  );
107
+ const config = useChatQueryStore(
108
+ (state) => state.snapshot.configQuery?.data ?? null,
109
+ );
102
110
  const systemStatus = useSystemStatus();
103
111
  const agentsQuery = useAgents();
112
+ const projectsQuery = useProjects();
113
+ const projectCreateMutation = useCreateProject();
104
114
  const { isLoading, items } = useNcpSessionListView();
105
115
  const { language, setLanguage } = useI18n();
106
116
  const { theme, setTheme } = useTheme();
@@ -153,8 +163,13 @@ export function ChatSidebar({
153
163
  [pinnedSessionKeys, sortedItems],
154
164
  );
155
165
  const projectGroups = useMemo(
156
- () => groupSessionsByProject(sortedItems, pinnedSessionKeys, pinnedProjectRoots),
157
- [pinnedProjectRoots, pinnedSessionKeys, sortedItems],
166
+ () => groupSessionsByProject(
167
+ sortedItems,
168
+ pinnedSessionKeys,
169
+ pinnedProjectRoots,
170
+ projectsQuery.data?.projects ?? [],
171
+ ),
172
+ [pinnedProjectRoots, pinnedSessionKeys, projectsQuery.data?.projects, sortedItems],
158
173
  );
159
174
  const sessionTypeOptions = useMemo(
160
175
  () => buildSessionTypeOptions(sessionTypesData?.options ?? []),
@@ -238,6 +253,14 @@ export function ChatSidebar({
238
253
  typeof projectRoot === "string" ? projectRoot : undefined,
239
254
  );
240
255
  };
256
+ const openProjectCreate = () => {
257
+ projectCreateMutation.reset();
258
+ setIsProjectCreateOpen(true);
259
+ };
260
+ const createProjectFromSidebar = async (input: ProjectCreateRequest): Promise<void> => {
261
+ await projectCreateMutation.mutateAsync(input);
262
+ setIsProjectCreateOpen(false);
263
+ };
241
264
 
242
265
  return (
243
266
  <aside
@@ -310,6 +333,7 @@ export function ChatSidebar({
310
333
  isCollapsed={shouldCollapse}
311
334
  isLoading={isLoading}
312
335
  isProjectFirstView={isProjectFirstView}
336
+ onCreateProject={openProjectCreate}
313
337
  onSelectMode={presenter.chatSessionListManager.setListMode}
314
338
  projectGroups={projectGroups}
315
339
  renderSessionItem={renderSessionItem}
@@ -338,6 +362,18 @@ export function ChatSidebar({
338
362
  themeOptions={utilityThemeOptions}
339
363
  />
340
364
  ) : null}
365
+
366
+ <ChatProjectCreateDialog
367
+ open={isProjectCreateOpen}
368
+ defaultWorkspacePath={normalizeSessionProjectRootValue(
369
+ config?.agents.defaults.workspace,
370
+ )}
371
+ templates={projectsQuery.data?.templates ?? []}
372
+ isCreating={projectCreateMutation.isPending}
373
+ errorMessage={projectCreateMutation.error?.message}
374
+ onOpenChange={setIsProjectCreateOpen}
375
+ onCreate={createProjectFromSidebar}
376
+ />
341
377
  </aside>
342
378
  );
343
379
  }
@@ -21,6 +21,7 @@ const mocks = vi.hoisted(() => {
21
21
  removeAttachment: vi.fn(),
22
22
  setSelectedModel: vi.fn(),
23
23
  setSelectedThinkingLevel: vi.fn(),
24
+ syncSessionPreferences: vi.fn(),
24
25
  setPendingSessionType: vi.fn(),
25
26
  setPendingProjectRoot: vi.fn(),
26
27
  setSelectedSkills: vi.fn(),
@@ -110,6 +110,42 @@ function createStreamingInputSnapshot(
110
110
  };
111
111
  }
112
112
 
113
+ function setImeDomText(textbox: HTMLElement, text: string): HTMLParagraphElement {
114
+ const paragraph = textbox.querySelector('p');
115
+ if (!paragraph) {
116
+ throw new Error('Expected the Lexical composer paragraph to exist.');
117
+ }
118
+ const range = document.createRange();
119
+ const lexicalText = paragraph.querySelector('[data-lexical-text="true"]')?.firstChild;
120
+ if (lexicalText?.nodeType === Node.TEXT_NODE) {
121
+ lexicalText.nodeValue = text;
122
+ range.setStart(lexicalText, text.length);
123
+ range.collapse(true);
124
+ } else {
125
+ paragraph.textContent = text;
126
+ range.selectNodeContents(paragraph);
127
+ range.collapse(false);
128
+ }
129
+ const selection = window.getSelection();
130
+ selection?.removeAllRanges();
131
+ selection?.addRange(range);
132
+ return paragraph;
133
+ }
134
+
135
+ async function insertText(textbox: HTMLElement, text: string): Promise<void> {
136
+ await act(async () => {
137
+ for (const character of text) {
138
+ textbox.dispatchEvent(new InputEvent('beforeinput', {
139
+ bubbles: true,
140
+ cancelable: true,
141
+ data: character,
142
+ inputType: 'insertText',
143
+ }));
144
+ await Promise.resolve();
145
+ }
146
+ });
147
+ }
148
+
113
149
  function StreamingSessionConversationInputHarness({
114
150
  controllerOverride = controller,
115
151
  controlRef,
@@ -145,6 +181,7 @@ function StreamingSessionConversationInputHarness({
145
181
  setSelectedModel: vi.fn(),
146
182
  setSelectedSkills: vi.fn(),
147
183
  setSelectedThinkingLevel: vi.fn(),
184
+ syncSessionPreferences: vi.fn(),
148
185
  setSendError: vi.fn(),
149
186
  syncComposer: vi.fn(),
150
187
  update: (patch: SessionConversationInputPatch) => {
@@ -209,23 +246,36 @@ describe('SessionConversationInput streaming stability', () => {
209
246
  expect(track?.firstElementChild?.className).toContain('px-0');
210
247
  });
211
248
 
212
- it('keeps IME composition stable while simulated streamed output rerenders the owner', async () => {
249
+ it('keeps a numbered IME candidate commit stable while streamed output rerenders the owner', async () => {
213
250
  const controlRef: MutableRefObject<StreamingInputControl | null> = { current: null };
214
251
  render(<StreamingSessionConversationInputHarness controlRef={controlRef} />);
215
252
 
216
253
  const textbox = screen.getByRole('textbox');
217
254
  fireEvent.focus(textbox);
255
+ await insertText(textbox, 'n');
218
256
  fireEvent.compositionStart(textbox);
257
+ const paragraph = setImeDomText(textbox, 'ni');
219
258
 
220
259
  act(() => {
221
260
  controlRef.current?.bumpStream();
222
261
  controlRef.current?.bumpStream();
223
262
  });
224
263
  expect(screen.getByTestId('stream-chunk').textContent).toBe('2');
225
-
264
+ expect(textbox.querySelector('p')).toBe(paragraph);
265
+ expect(textbox.textContent).toBe('ni');
266
+
267
+ fireEvent.keyDown(textbox, { key: '1', isComposing: true });
268
+ setImeDomText(textbox, '你');
269
+ fireEvent.input(textbox, {
270
+ data: '你',
271
+ inputType: 'insertCompositionText',
272
+ isComposing: true,
273
+ });
226
274
  fireEvent.compositionEnd(textbox, { data: '你' });
227
275
 
228
276
  await waitFor(() => expect(textbox.textContent).toBe('你'));
277
+ expect(textbox.querySelector('p')).toBe(paragraph);
278
+ await waitFor(() => expect(window.getSelection()?.anchorOffset).toBe(1));
229
279
  });
230
280
 
231
281
  it('renders queued inputs above the composer with edit and delete actions', () => {
@@ -188,6 +188,28 @@ export function SessionConversationArea(props: SessionConversationAreaProps) {
188
188
  inputSnapshot,
189
189
  setPendingSessionType: inputActions.setPendingSessionType,
190
190
  });
191
+ useEffect(() => {
192
+ inputActions.syncSessionPreferences({
193
+ defaultModel: inputQuery.defaultModel,
194
+ fallbackPreferredModel: inputQuery.fallbackPreferredModel,
195
+ fallbackPreferredThinking: inputQuery.fallbackPreferredThinking,
196
+ modelOptions: inputQuery.modelOptions,
197
+ selectedSessionExists: Boolean(inputQuery.selectedSession),
198
+ selectedSessionKey: inputQuery.selectedSessionKey,
199
+ selectedSessionPreferredModel:
200
+ inputQuery.selectedSession?.preferredModel ?? undefined,
201
+ selectedSessionPreferredThinking:
202
+ inputQuery.selectedSession?.preferredThinking ?? null,
203
+ });
204
+ }, [
205
+ inputActions,
206
+ inputQuery.defaultModel,
207
+ inputQuery.fallbackPreferredModel,
208
+ inputQuery.fallbackPreferredThinking,
209
+ inputQuery.modelOptions,
210
+ inputQuery.selectedSession,
211
+ inputQuery.selectedSessionKey,
212
+ ]);
191
213
  useSessionConversationDraftRouteState({
192
214
  sessionKey,
193
215
  setPendingProjectRoot: inputActions.setPendingProjectRoot,
@@ -249,6 +249,9 @@ export const SessionConversationInput = memo(function SessionConversationInput(p
249
249
  }),
250
250
  [presenter.chatUiManager],
251
251
  );
252
+ const contextReferenceProjectRoot = inputQuery.selectedSessionKey
253
+ ? inputQuery.selectedSession?.projectRoot ?? inputQuery.defaultProjectRoot ?? ''
254
+ : inputSnapshot.pendingProjectRoot ?? inputQuery.defaultProjectRoot ?? '';
252
255
  const { inputSurfaceState, setInputSurfaceTrigger } = useChatInputSurfaceState({
253
256
  commands: slashCommands,
254
257
  isSkillsLoading: inputQuery.isSkillsLoading,
@@ -258,6 +261,7 @@ export const SessionConversationInput = memo(function SessionConversationInput(p
258
261
  language,
259
262
  onSelectPanelApp: handleSlashPanelAppSelect,
260
263
  onSelectSkill: chatRecentSkillsManager.remember,
264
+ projectRoot: contextReferenceProjectRoot,
261
265
  recentSkillValues,
262
266
  skillRecords,
263
267
  });
@@ -269,15 +273,9 @@ export const SessionConversationInput = memo(function SessionConversationInput(p
269
273
  favoriteModelValues,
270
274
  setModelFavorite,
271
275
  } = useChatModelFavorites(modelRecordValues);
272
- const selectedModel =
273
- inputSnapshot.selectedModel ??
274
- inputQuery.fallbackPreferredModel ??
275
- inputQuery.defaultModel ??
276
- '';
276
+ const selectedModel = inputSnapshot.selectedModel ?? '';
277
277
  const selectedThinkingLevel = (
278
- inputSnapshot.selectedThinkingLevel ??
279
- inputQuery.fallbackPreferredThinking ??
280
- null
278
+ inputSnapshot.selectedThinkingLevel
281
279
  ) as ChatThinkingLevel | null;
282
280
  const availabilitySnapshot = {
283
281
  isProviderStateResolved: inputQuery.isProviderStateResolved,
@@ -408,6 +406,7 @@ export const SessionConversationInput = memo(function SessionConversationInput(p
408
406
  skillRecords,
409
407
  selectedSkills: inputSnapshot.selectedSkills,
410
408
  });
409
+ const composerNodes = useMemo(() => [...inputSnapshot.nodes], [inputSnapshot.nodes]);
411
410
 
412
411
  const useReadingTrack = surface === 'default' && messageLayout === 'flat';
413
412
  const inputBar = (
@@ -420,7 +419,7 @@ export const SessionConversationInput = memo(function SessionConversationInput(p
420
419
  ? <SessionQueuedInputRows controller={controller} />
421
420
  : null}
422
421
  composer={{
423
- nodes: [...inputSnapshot.nodes],
422
+ nodes: composerNodes,
424
423
  placeholder: textareaPlaceholder,
425
424
  disabled: inputDisabled,
426
425
  onNodesChange: handleNodesChange,
@@ -0,0 +1,55 @@
1
+ import { act, renderHook } from '@testing-library/react';
2
+ import { describe, expect, it } from 'vitest';
3
+
4
+ import { useSessionConversationInputState } from '@/features/chat/features/conversation/hooks/use-session-conversation-input-state';
5
+ import type { ChatModelOption } from '@/features/chat/types/chat-input.types';
6
+
7
+ const MODEL_OPTIONS: ChatModelOption[] = [
8
+ {
9
+ value: 'deepseek/deepseek-v4-flash',
10
+ modelLabel: 'DeepSeek V4 Flash',
11
+ providerLabel: 'DeepSeek',
12
+ thinkingCapability: null,
13
+ },
14
+ {
15
+ value: 'minimax/MiniMax-M3',
16
+ modelLabel: 'MiniMax M3',
17
+ providerLabel: 'MiniMax',
18
+ thinkingCapability: null,
19
+ },
20
+ ];
21
+
22
+ describe('useSessionConversationInputState session preferences', () => {
23
+ it('restores the switched session model after its metadata becomes available', () => {
24
+ const { result } = renderHook(() => useSessionConversationInputState());
25
+
26
+ act(() => {
27
+ result.current.inputActions.syncSessionPreferences({
28
+ modelOptions: MODEL_OPTIONS,
29
+ selectedSessionExists: true,
30
+ selectedSessionKey: 'session-a',
31
+ selectedSessionPreferredModel: 'deepseek/deepseek-v4-flash',
32
+ });
33
+ });
34
+ expect(result.current.inputSnapshot.selectedModel).toBe('deepseek/deepseek-v4-flash');
35
+
36
+ act(() => {
37
+ result.current.inputActions.syncSessionPreferences({
38
+ modelOptions: MODEL_OPTIONS,
39
+ selectedSessionExists: false,
40
+ selectedSessionKey: 'session-b',
41
+ });
42
+ });
43
+ expect(result.current.inputSnapshot.selectedModel).toBe('deepseek/deepseek-v4-flash');
44
+
45
+ act(() => {
46
+ result.current.inputActions.syncSessionPreferences({
47
+ modelOptions: MODEL_OPTIONS,
48
+ selectedSessionExists: true,
49
+ selectedSessionKey: 'session-b',
50
+ selectedSessionPreferredModel: 'minimax/MiniMax-M3',
51
+ });
52
+ });
53
+ expect(result.current.inputSnapshot.selectedModel).toBe('minimax/MiniMax-M3');
54
+ });
55
+ });