@nextclaw/ui 0.14.1-beta.1 → 0.14.2

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 (179) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/dist/assets/{api-dQ6uQSZG.js → api-a14tR_z1.js} +7 -7
  3. package/dist/assets/channels-list-page-CFGkm4DT.js +8 -0
  4. package/dist/assets/chat-page-CEdavJXc.js +105 -0
  5. package/dist/assets/{config-split-page-CeFfbaqc.js → config-split-page-B5b9B0uE.js} +1 -1
  6. package/dist/assets/{confirm-dialog-C95Y4QPC.js → confirm-dialog-ClH_Ec7n.js} +1 -1
  7. package/dist/assets/{desktop-update-config-CcK6jiEb.js → desktop-update-config-DPNDnVpk.js} +1 -1
  8. package/dist/assets/{dist-DTGzeil6.js → dist-BtCS2Ucq.js} +1 -1
  9. package/dist/assets/{dist-fGogBmPA.js → dist-CLGGLm8b.js} +1 -1
  10. package/dist/assets/doc-browser-5nMWYKBD.js +1 -0
  11. package/dist/assets/doc-browser-BJRLhztv.js +1 -0
  12. package/dist/assets/doc-browser-context-D4JwMMxK.js +1 -0
  13. package/dist/assets/{ellipsis-D2F4KMiU.js → ellipsis-CtJXjIol.js} +1 -1
  14. package/dist/assets/{external-link-CcgtBK26.js → external-link-CdJiG13p.js} +1 -1
  15. package/dist/assets/index-BDN1zzMQ.js +105 -0
  16. package/dist/assets/index-g3uSZXoc.css +1 -0
  17. package/dist/assets/mcp-marketplace-page-BU0kIjhR.js +40 -0
  18. package/dist/assets/mcp-marketplace-page-sMNFSq_T.js +1 -0
  19. package/dist/assets/middleware-CkFT3R4v.js +1 -0
  20. package/dist/assets/model-config-page-D6hYXROd.js +1 -0
  21. package/dist/assets/plus-DPjb0piO.js +1 -0
  22. package/dist/assets/provider-scoped-model-input-DAX2HgmR.js +1 -0
  23. package/dist/assets/providers-config-page-BzAeaACA.js +1 -0
  24. package/dist/assets/{react-CTqdhFMk.js → react-C6cZgP44.js} +1 -1
  25. package/dist/assets/remote-B0G80uPr.js +1 -0
  26. package/dist/assets/rotate-cw-DnEB1Rju.js +1 -0
  27. package/dist/assets/runtime-config-page-C5xKaV3i.js +1 -0
  28. package/dist/assets/{save-C9fKWfVB.js → save-DKA0x4zk.js} +1 -1
  29. package/dist/assets/{search-D5FQ5q0X.js → search-BNlcoj5c.js} +1 -1
  30. package/dist/assets/{search-config-page-AMEJJNIS.js → search-config-page-Cq9uJ-N8.js} +1 -1
  31. package/dist/assets/{secrets-config-page-CcgJnvBO.js → secrets-config-page-CxSbZLBo.js} +2 -2
  32. package/dist/assets/{select-Cu5n0E-3.js → select-CvTlDZTQ.js} +1 -1
  33. package/dist/assets/{tag-chip-C5aOtjxu.js → tag-chip-8ffF2y2C.js} +1 -1
  34. package/dist/assets/use-config-DmT0o1gv.js +1 -0
  35. package/dist/assets/x-BiSSpgqB.js +1 -0
  36. package/dist/index.html +15 -14
  37. package/package.json +9 -9
  38. package/src/app/components/app-presenter-provider.tsx +11 -2
  39. package/src/app/components/layout/__tests__/app-layout.test.tsx +11 -2
  40. package/src/app/components/layout/__tests__/sidebar.layout.test.tsx +110 -79
  41. package/src/app/components/layout/app-layout.tsx +18 -4
  42. package/src/app/components/layout/sidebar-items.tsx +194 -71
  43. package/src/app/components/layout/sidebar-rail.styles.ts +15 -0
  44. package/src/app/components/layout/sidebar.tsx +340 -83
  45. package/src/app/managers/viewport-layout.manager.ts +10 -0
  46. package/src/app/stores/viewport-layout.store.ts +39 -3
  47. package/src/features/agents/components/__tests__/agents-page.test.tsx +62 -39
  48. package/src/features/agents/components/agents-page.tsx +10 -6
  49. package/src/features/chat/components/conversation/__tests__/chat-conversation-header-section.test.tsx +145 -0
  50. package/src/features/chat/components/conversation/__tests__/chat-conversation-panel.test.tsx +69 -762
  51. package/src/features/chat/components/conversation/__tests__/chat-conversation-workspace-section.test.tsx +132 -0
  52. package/src/features/chat/components/conversation/chat-conversation-content.tsx +22 -15
  53. package/src/features/chat/components/conversation/chat-conversation-header-section.tsx +14 -15
  54. package/src/features/chat/components/conversation/chat-conversation-panel.tsx +18 -18
  55. package/src/features/chat/components/conversation/chat-conversation-workspace-section.tsx +10 -4
  56. package/src/features/chat/components/layout/__tests__/chat-sidebar-read-state.test.tsx +52 -11
  57. package/src/features/chat/components/layout/__tests__/chat-sidebar.test.tsx +77 -12
  58. package/src/features/chat/components/layout/chat-sidebar-desktop-layout.tsx +247 -0
  59. package/src/features/chat/components/layout/chat-sidebar-toolbar.tsx +182 -21
  60. package/src/features/chat/components/layout/chat-sidebar-utility-menu.tsx +82 -25
  61. package/src/features/chat/components/layout/chat-sidebar.tsx +79 -93
  62. package/src/features/chat/components/providers/chat-presenter.provider.tsx +0 -6
  63. package/src/features/chat/features/conversation/components/session-conversation-area.tsx +289 -0
  64. package/src/features/chat/features/conversation/components/session-conversation-input.tsx +427 -0
  65. package/src/features/chat/features/conversation/hooks/use-session-conversation-controller.ts +204 -0
  66. package/src/features/chat/features/conversation/hooks/use-session-conversation-input-attachments.ts +66 -0
  67. package/src/features/chat/features/{input/hooks/use-chat-input-bar-query-state.ts → conversation/hooks/use-session-conversation-input-query.ts} +32 -17
  68. package/src/features/chat/features/conversation/hooks/use-session-conversation-input-state.ts +281 -0
  69. package/src/features/chat/features/conversation/utils/session-conversation-input-toolbar.utils.ts +158 -0
  70. package/src/features/chat/features/input/hooks/__tests__/use-chat-input-surface-state.test.tsx +56 -0
  71. package/src/features/chat/features/input/hooks/use-chat-input-surface-state.ts +117 -0
  72. package/src/features/chat/features/input/hooks/use-chat-model-favorites.ts +2 -1
  73. package/src/features/chat/features/input/input-surface-plugins/__tests__/panel-app-reference-plugin.test.ts +77 -0
  74. package/src/features/chat/features/input/input-surface-plugins/__tests__/slash-command-plugin.test.ts +108 -0
  75. package/src/features/chat/features/input/input-surface-plugins/chat-input-product-plugin-adapters.types.ts +10 -0
  76. package/src/features/chat/features/input/input-surface-plugins/input-surface-search.utils.ts +108 -0
  77. package/src/features/chat/features/input/input-surface-plugins/panel-app-reference-plugin.utils.ts +108 -0
  78. package/src/features/chat/features/input/input-surface-plugins/slash-command-plugin.utils.ts +161 -0
  79. package/src/features/chat/features/input/utils/__tests__/chat-composer-state.utils.test.ts +22 -1
  80. package/src/features/chat/features/input/utils/__tests__/chat-inline-token.utils.test.ts +26 -13
  81. package/src/features/chat/features/input/utils/__tests__/ncp-chat-input-availability.utils.test.ts +7 -28
  82. package/src/features/chat/features/input/utils/chat-composer-state.utils.ts +6 -0
  83. package/src/features/chat/features/input/utils/chat-inline-token.utils.ts +26 -51
  84. package/src/features/chat/features/input/utils/chat-input-bar.utils.ts +2 -0
  85. package/src/features/chat/features/input/utils/ncp-chat-input-availability.utils.ts +5 -6
  86. package/src/features/chat/features/message/components/__tests__/chat-message-list.container.test.tsx +47 -12
  87. package/src/features/chat/features/message/components/chat-message-list.container.tsx +93 -12
  88. package/src/features/chat/features/message/utils/__tests__/chat-message.utils.test.ts +31 -12
  89. package/src/features/chat/features/message/utils/chat-message-markdown-part.utils.ts +38 -0
  90. package/src/features/chat/features/message/utils/chat-message-part.utils.ts +1 -1
  91. package/src/features/chat/features/session/components/chat-sidebar-project-groups.tsx +5 -4
  92. package/src/features/chat/features/session/components/chat-sidebar-session-list.tsx +2 -2
  93. package/src/features/chat/features/session/hooks/__tests__/use-chat-session-project.test.tsx +3 -30
  94. package/src/features/chat/features/session/hooks/use-chat-session-project.ts +0 -5
  95. package/src/features/chat/features/session/utils/chat-run-metadata.utils.ts +8 -0
  96. package/src/features/chat/features/session-type/components/__tests__/{chat-sidebar-create-menu.test.tsx → chat-session-type-menu.test.tsx} +5 -5
  97. package/src/features/chat/features/session-type/components/__tests__/chat-session-type-option-item.test.tsx +29 -1
  98. package/src/features/chat/features/session-type/components/{chat-sidebar-create-menu.tsx → chat-session-type-menu.tsx} +13 -9
  99. package/src/features/chat/features/session-type/components/chat-session-type-option-item.tsx +17 -5
  100. package/src/features/chat/features/session-type/hooks/__tests__/use-chat-new-session-type-preference.test.tsx +152 -0
  101. package/src/features/chat/features/session-type/hooks/use-chat-new-session-type-preference.ts +159 -0
  102. package/src/features/chat/features/welcome/components/__tests__/chat-conversation-welcome.test.tsx +49 -31
  103. package/src/features/chat/features/welcome/components/__tests__/chat-welcome.test.tsx +2 -2
  104. package/src/features/chat/features/welcome/components/chat-conversation-welcome.tsx +21 -10
  105. package/src/features/chat/features/welcome/components/chat-welcome-session-type-picker.tsx +2 -2
  106. package/src/features/chat/features/welcome/components/chat-welcome.tsx +2 -2
  107. package/src/features/chat/features/workspace/components/__tests__/chat-session-workspace-panel-content.test.tsx +54 -0
  108. package/src/features/chat/features/workspace/components/chat-session-workspace-panel-content.tsx +44 -76
  109. package/src/features/chat/features/workspace/components/chat-session-workspace-panel-nav.tsx +5 -1
  110. package/src/features/chat/features/workspace/components/chat-session-workspace-panel.tsx +7 -1
  111. package/src/features/chat/features/workspace/hooks/use-chat-conversation-workspace-state.ts +40 -31
  112. package/src/features/chat/features/workspace/utils/__tests__/chat-workspace-panel-view-model.utils.test.ts +59 -1
  113. package/src/features/chat/features/workspace/utils/chat-thread-workspace-session.utils.ts +109 -0
  114. package/src/features/chat/features/workspace/utils/chat-workspace-panel-view-model.utils.ts +44 -4
  115. package/src/features/chat/index.ts +0 -1
  116. package/src/features/chat/managers/__tests__/chat-session-list.manager.test.ts +51 -99
  117. package/src/features/chat/managers/__tests__/chat-thread.manager.test.ts +135 -21
  118. package/src/features/chat/managers/chat-session-list.manager.ts +12 -43
  119. package/src/features/chat/managers/chat-thread.manager.ts +108 -34
  120. package/src/features/chat/pages/__tests__/ncp-chat-page.test.ts +48 -1
  121. package/src/features/chat/pages/__tests__/ncp-chat-page.test.tsx +72 -0
  122. package/src/features/chat/pages/ncp-chat-page.tsx +0 -122
  123. package/src/features/chat/presenters/chat.presenter.ts +2 -19
  124. package/src/features/chat/stores/__tests__/chat-thread.store.test.ts +31 -0
  125. package/src/features/chat/stores/chat-thread.store.ts +42 -26
  126. package/src/features/marketplace/components/__tests__/marketplace-page.test.tsx +24 -0
  127. package/src/features/marketplace/components/curated-shelves/marketplace-shelf-card.tsx +8 -3
  128. package/src/features/marketplace/components/marketplace-item-list-view.tsx +2 -2
  129. package/src/features/marketplace/components/marketplace-list-card.tsx +124 -98
  130. package/src/features/marketplace/components/marketplace-page-parts.tsx +11 -6
  131. package/src/features/panel-apps/hooks/use-panel-apps.ts +2 -1
  132. package/src/platforms/desktop/components/__tests__/desktop-app-shell.test.tsx +50 -10
  133. package/src/platforms/desktop/components/desktop-app-shell.tsx +34 -10
  134. package/src/platforms/desktop/components/desktop-window-chrome.tsx +41 -16
  135. package/src/shared/components/common/__tests__/agent-avatar.test.tsx +3 -1
  136. package/src/shared/components/common/agent-avatar.tsx +5 -2
  137. package/src/shared/components/ui/tab-strip/__tests__/compact-tab-strip.test.tsx +24 -1
  138. package/src/shared/components/ui/tab-strip/compact-tab-strip.tsx +8 -4
  139. package/src/shared/lib/api/index.ts +1 -0
  140. package/src/shared/lib/api/preferences/preference-keys.config.ts +9 -0
  141. package/src/shared/lib/i18n/locales/en-US/chat.json +20 -2
  142. package/src/shared/lib/i18n/locales/en-US/core.json +2 -0
  143. package/src/shared/lib/i18n/locales/en-US/marketplace.json +2 -0
  144. package/src/shared/lib/i18n/locales/zh-CN/chat.json +20 -2
  145. package/src/shared/lib/i18n/locales/zh-CN/core.json +2 -0
  146. package/src/shared/lib/i18n/locales/zh-CN/marketplace.json +2 -0
  147. package/src/shared/lib/ui-document-title/__tests__/ui-document-title.test.ts +49 -0
  148. package/src/shared/lib/ui-document-title/index.ts +50 -21
  149. package/dist/assets/channels-list-page-DfpjB8XB.js +0 -8
  150. package/dist/assets/chat-page-D3QaEqjs.js +0 -105
  151. package/dist/assets/doc-browser-CuBTcBEZ.js +0 -1
  152. package/dist/assets/doc-browser-DVHhEb96.js +0 -1
  153. package/dist/assets/doc-browser-context-BfMXwnrz.js +0 -1
  154. package/dist/assets/index-ByJCbQqr.css +0 -1
  155. package/dist/assets/index-DjXqiloY.js +0 -103
  156. package/dist/assets/mcp-marketplace-page-98-V__3u.js +0 -1
  157. package/dist/assets/mcp-marketplace-page-BNq_PNmO.js +0 -40
  158. package/dist/assets/model-config-page-aAUkZkSX.js +0 -1
  159. package/dist/assets/plus-1Rxl5FlV.js +0 -1
  160. package/dist/assets/provider-scoped-model-input-we4_Z4gP.js +0 -1
  161. package/dist/assets/providers-config-page-CtMkPSyu.js +0 -1
  162. package/dist/assets/remote-fQ7N_r8u.js +0 -1
  163. package/dist/assets/rotate-cw-CzfIeAJj.js +0 -1
  164. package/dist/assets/runtime-config-page-TD1UrTbG.js +0 -1
  165. package/dist/assets/use-config-D8TESeo6.js +0 -1
  166. package/dist/assets/x-1qETdQ70.js +0 -1
  167. package/src/features/chat/components/conversation/chat-conversation-alerts.tsx +0 -37
  168. package/src/features/chat/features/input/components/__tests__/chat-attachment-upload-limit.test.ts +0 -38
  169. package/src/features/chat/features/input/components/chat-input-bar.container.tsx +0 -496
  170. package/src/features/chat/features/message/utils/chat-message-inline-content.utils.ts +0 -95
  171. package/src/features/chat/managers/__tests__/chat-input.manager.test.ts +0 -448
  172. package/src/features/chat/managers/__tests__/chat-run-snapshot.manager.test.ts +0 -129
  173. package/src/features/chat/managers/__tests__/chat-run.manager.test.ts +0 -236
  174. package/src/features/chat/managers/__tests__/chat-session-preference-sync.manager.test.ts +0 -219
  175. package/src/features/chat/managers/chat-input.manager.ts +0 -430
  176. package/src/features/chat/managers/chat-run.manager.ts +0 -110
  177. package/src/features/chat/managers/chat-session-preference-sync.manager.ts +0 -142
  178. package/src/features/chat/stores/chat-input.store.ts +0 -104
  179. /package/dist/assets/{config-hints-CTqBnCDp.js → config-hints-BkYBc7z0.js} +0 -0
@@ -1,7 +1,6 @@
1
1
  import { act, renderHook } from '@testing-library/react';
2
- import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { afterEach, describe, expect, it, vi } from 'vitest';
3
3
  import { toast } from 'sonner';
4
- import { useChatInputStore } from '@/features/chat/stores/chat-input.store';
5
4
  import { useChatSessionProject } from '@/features/chat/features/session/hooks/use-chat-session-project';
6
5
 
7
6
  const mocks = vi.hoisted(() => ({
@@ -19,21 +18,11 @@ vi.mock('@/features/chat/features/session/hooks/use-chat-session-update', () =>
19
18
  }));
20
19
 
21
20
  describe('useChatSessionProject', () => {
22
- beforeEach(() => {
23
- useChatInputStore.setState((state) => ({
24
- snapshot: {
25
- ...state.snapshot,
26
- pendingProjectRoot: null,
27
- pendingProjectRootSessionKey: null
28
- }
29
- }));
30
- });
31
-
32
21
  afterEach(() => {
33
22
  vi.clearAllMocks();
34
23
  });
35
24
 
36
- it('stores the draft project root locally when the session does not exist yet', async () => {
25
+ it('does not persist draft project root through the session update hook', async () => {
37
26
  const { result } = renderHook(() => useChatSessionProject());
38
27
 
39
28
  await act(async () => {
@@ -45,14 +34,10 @@ describe('useChatSessionProject', () => {
45
34
  });
46
35
 
47
36
  expect(mocks.updateSession).not.toHaveBeenCalled();
48
- expect(useChatInputStore.getState().snapshot).toMatchObject({
49
- pendingProjectRoot: '/tmp/project-alpha',
50
- pendingProjectRootSessionKey: 'draft-session-1'
51
- });
52
37
  expect(toast.success).toHaveBeenCalledTimes(1);
53
38
  });
54
39
 
55
- it('keeps an explicit draft override when clearing the project root locally', async () => {
40
+ it('does not persist draft project clearing through the session update hook', async () => {
56
41
  const { result } = renderHook(() => useChatSessionProject());
57
42
 
58
43
  await act(async () => {
@@ -64,10 +49,6 @@ describe('useChatSessionProject', () => {
64
49
  });
65
50
 
66
51
  expect(mocks.updateSession).not.toHaveBeenCalled();
67
- expect(useChatInputStore.getState().snapshot).toMatchObject({
68
- pendingProjectRoot: null,
69
- pendingProjectRootSessionKey: 'draft-session-1'
70
- });
71
52
  expect(toast.success).toHaveBeenCalledTimes(1);
72
53
  });
73
54
 
@@ -87,10 +68,6 @@ describe('useChatSessionProject', () => {
87
68
  patch: { projectRoot: '/tmp/project-beta' },
88
69
  successMessage: 'Project directory updated'
89
70
  });
90
- expect(useChatInputStore.getState().snapshot).toMatchObject({
91
- pendingProjectRoot: null,
92
- pendingProjectRootSessionKey: null
93
- });
94
71
  });
95
72
 
96
73
  it('persists clearing to the server without keeping a session-scoped local override', async () => {
@@ -109,9 +86,5 @@ describe('useChatSessionProject', () => {
109
86
  patch: { projectRoot: null },
110
87
  successMessage: 'Project directory cleared'
111
88
  });
112
- expect(useChatInputStore.getState().snapshot).toMatchObject({
113
- pendingProjectRoot: null,
114
- pendingProjectRootSessionKey: null
115
- });
116
89
  });
117
90
  });
@@ -1,6 +1,5 @@
1
1
  import { toast } from 'sonner';
2
2
  import { t } from '@/shared/lib/i18n';
3
- import { useChatInputStore } from '@/features/chat/stores/chat-input.store';
4
3
  import { useChatSessionUpdate } from '@/features/chat/features/session/hooks/use-chat-session-update';
5
4
 
6
5
  type UpdateChatSessionProjectParams = {
@@ -17,10 +16,6 @@ export function useChatSessionProject() {
17
16
  const successMessage = projectRoot ? t('chatSessionProjectUpdated') : t('chatSessionProjectCleared');
18
17
 
19
18
  if (!persistToServer) {
20
- useChatInputStore.getState().setSnapshot({
21
- pendingProjectRoot: projectRoot,
22
- pendingProjectRootSessionKey: sessionKey
23
- });
24
19
  toast.success(successMessage);
25
20
  return;
26
21
  }
@@ -1,3 +1,7 @@
1
+ import {
2
+ CHAT_SESSION_MATERIALIZATION_METADATA_KEY,
3
+ type AgentRunSessionMaterializationMetadata,
4
+ } from "@nextclaw/shared";
1
5
  import { buildInlineSkillTokensFromComposer, CHAT_UI_INLINE_TOKENS_METADATA_KEY } from "@/features/chat/features/input/utils/chat-inline-token.utils";
2
6
  import { normalizeRequestedSkills } from "@/features/chat/features/runtime/utils/chat-runtime.utils";
3
7
  import { normalizeSessionProjectRootValue } from "@/shared/lib/session-project";
@@ -17,6 +21,7 @@ export function buildChatRunMetadata(payload: {
17
21
  projectRoot?: string | null;
18
22
  requestedSkills?: string[];
19
23
  composerNodes?: Parameters<typeof buildInlineSkillTokensFromComposer>[0];
24
+ sessionMaterialization?: AgentRunSessionMaterializationMetadata | null;
20
25
  }): Record<string, unknown> {
21
26
  const projectRoot = normalizeSessionProjectRootValue(payload.projectRoot);
22
27
  const metadata: Record<string, unknown> = {
@@ -36,6 +41,9 @@ export function buildChatRunMetadata(payload: {
36
41
  if (inlineSkillTokens.length > 0) {
37
42
  metadata[CHAT_UI_INLINE_TOKENS_METADATA_KEY] = inlineSkillTokens;
38
43
  }
44
+ if (payload.sessionMaterialization) {
45
+ metadata[CHAT_SESSION_MATERIALIZATION_METADATA_KEY] = payload.sessionMaterialization;
46
+ }
39
47
  return metadata;
40
48
  }
41
49
 
@@ -1,16 +1,16 @@
1
1
  import { render, screen } from "@testing-library/react";
2
2
  import { describe, expect, it, vi } from "vitest";
3
- import { ChatSidebarCreateMenu } from "@/features/chat/features/session-type/components/chat-sidebar-create-menu";
3
+ import { ChatSessionTypeMenu } from "@/features/chat/features/session-type/components/chat-session-type-menu";
4
4
  import { createPopoverAvailableHeightLimit } from "@/shared/components/ui/popover";
5
5
 
6
6
  const options = [
7
- { value: "native", label: "Native", ready: true },
8
- { value: "codex", label: "Codex", ready: true },
7
+ { value: "native", label: "Native", icon: null, ready: true },
8
+ { value: "codex", label: "Codex", icon: null, ready: true },
9
9
  ];
10
10
 
11
- describe("ChatSidebarCreateMenu", () => {
11
+ describe("ChatSessionTypeMenu", () => {
12
12
  it("keeps the session type menu height bounded", () => {
13
- render(<ChatSidebarCreateMenu options={options} onSelect={vi.fn()} />);
13
+ render(<ChatSessionTypeMenu options={options} onSelect={vi.fn()} />);
14
14
 
15
15
  const createMenu = screen.getByText("Session Type").parentElement;
16
16
  expect(createMenu?.style.maxHeight).toBe(
@@ -1,4 +1,4 @@
1
- import { render, screen } from "@testing-library/react";
1
+ import { fireEvent, render, screen } from "@testing-library/react";
2
2
  import { describe, expect, it, vi } from "vitest";
3
3
  import { ChatSessionTypeOptionItem } from "@/features/chat/features/session-type/components/chat-session-type-option-item";
4
4
 
@@ -50,6 +50,7 @@ describe("ChatSessionTypeOptionItem", () => {
50
50
  option={{
51
51
  value: "native",
52
52
  label: "Native",
53
+ icon: null,
53
54
  ready: true,
54
55
  }}
55
56
  onSelect={vi.fn()}
@@ -59,4 +60,31 @@ describe("ChatSessionTypeOptionItem", () => {
59
60
  expect(screen.getByRole("button", { name: /native/i })).toBeTruthy();
60
61
  expect(screen.getByText("Native")).toBeTruthy();
61
62
  });
63
+
64
+ it("marks selected options and does not call select when disabled", () => {
65
+ const onSelect = vi.fn();
66
+
67
+ render(
68
+ <ChatSessionTypeOptionItem
69
+ disabled
70
+ selected
71
+ option={{
72
+ value: "claude",
73
+ label: "Claude",
74
+ icon: null,
75
+ ready: false,
76
+ reasonMessage: "Configure a provider API key first.",
77
+ }}
78
+ onSelect={onSelect}
79
+ />,
80
+ );
81
+
82
+ const optionButton = screen.getByRole("button", { name: /claude/i });
83
+ expect(optionButton.getAttribute("aria-pressed")).toBe("true");
84
+ expect((optionButton as HTMLButtonElement).disabled).toBe(true);
85
+
86
+ fireEvent.click(optionButton);
87
+
88
+ expect(onSelect).not.toHaveBeenCalled();
89
+ });
62
90
  });
@@ -1,35 +1,37 @@
1
1
  import { ChatSessionTypeOptionItem } from "@/features/chat/features/session-type/components/chat-session-type-option-item";
2
- import type { ChatInputSnapshot } from "@/features/chat/stores/chat-input.store";
2
+ import type { ChatSessionTypeOption } from "@/features/chat/features/session-type/utils/chat-session-type.utils";
3
3
  import { createPopoverAvailableHeightLimit } from "@/shared/components/ui/popover";
4
4
  import { t } from "@/shared/lib/i18n";
5
5
  import { cn } from "@/shared/lib/utils";
6
6
 
7
- type SessionTypeOption = ChatInputSnapshot["sessionTypeOptions"][number];
8
- const CHAT_SIDEBAR_CREATE_MENU_MAX_HEIGHT =
7
+ type SessionTypeOption = ChatSessionTypeOption;
8
+ const CHAT_SESSION_TYPE_MENU_MAX_HEIGHT =
9
9
  createPopoverAvailableHeightLimit("18rem");
10
- const CHAT_SIDEBAR_CREATE_MENU_STYLE = {
11
- maxHeight: CHAT_SIDEBAR_CREATE_MENU_MAX_HEIGHT,
10
+ const CHAT_SESSION_TYPE_MENU_STYLE = {
11
+ maxHeight: CHAT_SESSION_TYPE_MENU_MAX_HEIGHT,
12
12
  };
13
13
 
14
- type ChatSidebarCreateMenuProps = {
14
+ type ChatSessionTypeMenuProps = {
15
15
  options: readonly SessionTypeOption[];
16
16
  onSelect: (sessionType: string) => void;
17
+ selectedSessionType?: string;
17
18
  title?: string;
18
19
  className?: string;
19
20
  titleClassName?: string;
20
21
  };
21
22
 
22
- export function ChatSidebarCreateMenu({
23
+ export function ChatSessionTypeMenu({
23
24
  className,
24
25
  onSelect,
25
26
  options,
27
+ selectedSessionType,
26
28
  title = t("chatSessionTypeLabel"),
27
29
  titleClassName,
28
- }: ChatSidebarCreateMenuProps) {
30
+ }: ChatSessionTypeMenuProps) {
29
31
  return (
30
32
  <div
31
33
  className={cn("space-y-1 overflow-y-auto overscroll-contain", className)}
32
- style={CHAT_SIDEBAR_CREATE_MENU_STYLE}
34
+ style={CHAT_SESSION_TYPE_MENU_STYLE}
33
35
  >
34
36
  <div
35
37
  className={cn(
@@ -42,7 +44,9 @@ export function ChatSidebarCreateMenu({
42
44
  {options.map((option) => (
43
45
  <ChatSessionTypeOptionItem
44
46
  key={option.value}
47
+ disabled={option.ready === false}
45
48
  option={option}
49
+ selected={option.value === selectedSessionType}
46
50
  onSelect={() => onSelect(option.value)}
47
51
  />
48
52
  ))}
@@ -1,16 +1,19 @@
1
- import type { ChatInputSnapshot } from "@/features/chat/stores/chat-input.store";
2
1
  import { SessionContextIconNode } from "@/features/chat/features/session/components/session-context-icon";
2
+ import type { ChatSessionTypeOption } from "@/features/chat/features/session-type/utils/chat-session-type.utils";
3
3
  import { StatusDot } from "@/shared/components/status/status-dot";
4
4
  import { t } from "@/shared/lib/i18n";
5
- import { Bot } from "lucide-react";
5
+ import { cn } from "@/shared/lib/utils";
6
+ import { Bot, Check } from "lucide-react";
6
7
 
7
- type SessionTypeOption = ChatInputSnapshot["sessionTypeOptions"][number];
8
+ type SessionTypeOption = ChatSessionTypeOption;
8
9
 
9
10
  export function ChatSessionTypeOptionItem(props: {
10
11
  option: SessionTypeOption;
12
+ selected?: boolean;
13
+ disabled?: boolean;
11
14
  onSelect: () => void;
12
15
  }) {
13
- const { option, onSelect } = props;
16
+ const { disabled = false, option, onSelect, selected = false } = props;
14
17
  const helperText =
15
18
  option.ready === false
16
19
  ? option.reasonMessage?.trim() || t("statusSetup")
@@ -19,8 +22,14 @@ export function ChatSessionTypeOptionItem(props: {
19
22
  return (
20
23
  <button
21
24
  type="button"
25
+ aria-pressed={selected}
26
+ disabled={disabled}
22
27
  onClick={onSelect}
23
- className="w-full rounded-2xl px-3 py-2.5 text-left transition-colors hover:bg-gray-50"
28
+ className={cn(
29
+ "w-full rounded-2xl px-3 py-2.5 text-left transition-colors",
30
+ selected ? "bg-gray-50" : "hover:bg-gray-50",
31
+ disabled ? "cursor-not-allowed opacity-70" : null,
32
+ )}
24
33
  >
25
34
  <div className="flex items-start gap-3">
26
35
  <div className="flex min-w-0 flex-1 items-start gap-2.5">
@@ -56,6 +65,9 @@ export function ChatSessionTypeOptionItem(props: {
56
65
  ) : null}
57
66
  </div>
58
67
  </div>
68
+ {selected ? (
69
+ <Check className="mt-1 h-3.5 w-3.5 shrink-0 text-primary" />
70
+ ) : null}
59
71
  </div>
60
72
  </button>
61
73
  );
@@ -0,0 +1,152 @@
1
+ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
2
+ import { act, renderHook, waitFor } from "@testing-library/react";
3
+ import type { ReactNode } from "react";
4
+ import { beforeEach, describe, expect, it, vi } from "vitest";
5
+ import { PREFERENCE_KEYS } from "@/shared/lib/api";
6
+ import { useChatNewSessionTypePreference } from "@/features/chat/features/session-type/hooks/use-chat-new-session-type-preference";
7
+ import type { ChatSessionTypeOption } from "@/features/chat/features/session-type/utils/chat-session-type.utils";
8
+
9
+ const mocks = vi.hoisted(() => ({
10
+ fetchPreference: vi.fn(),
11
+ updatePreference: vi.fn(),
12
+ }));
13
+
14
+ vi.mock("@/shared/lib/api", async (importOriginal) => {
15
+ const actual = await importOriginal();
16
+ return {
17
+ ...(actual as Record<string, unknown>),
18
+ fetchPreference: mocks.fetchPreference,
19
+ updatePreference: mocks.updatePreference,
20
+ };
21
+ });
22
+
23
+ const sessionTypeOptions: ChatSessionTypeOption[] = [
24
+ {
25
+ value: "native",
26
+ label: "Native",
27
+ icon: null,
28
+ ready: true,
29
+ reason: null,
30
+ reasonMessage: null,
31
+ supportedModels: undefined,
32
+ recommendedModel: null,
33
+ modelSelectionMode: "nextclaw",
34
+ runtimeDefaultThinking: null,
35
+ cta: null,
36
+ },
37
+ {
38
+ value: "codex",
39
+ label: "Codex",
40
+ icon: null,
41
+ ready: true,
42
+ reason: null,
43
+ reasonMessage: null,
44
+ supportedModels: undefined,
45
+ recommendedModel: null,
46
+ modelSelectionMode: "runtime-default",
47
+ runtimeDefaultThinking: null,
48
+ cta: null,
49
+ },
50
+ ];
51
+
52
+ function createWrapper() {
53
+ const queryClient = new QueryClient({
54
+ defaultOptions: {
55
+ queries: { retry: false },
56
+ mutations: { retry: false },
57
+ },
58
+ });
59
+ return function Wrapper({ children }: { children: ReactNode }) {
60
+ return (
61
+ <QueryClientProvider client={queryClient}>
62
+ {children}
63
+ </QueryClientProvider>
64
+ );
65
+ };
66
+ }
67
+
68
+ describe("useChatNewSessionTypePreference", () => {
69
+ beforeEach(() => {
70
+ mocks.fetchPreference.mockReset();
71
+ mocks.updatePreference.mockReset();
72
+ mocks.fetchPreference.mockResolvedValue({
73
+ key: PREFERENCE_KEYS.chat.newSessionType,
74
+ value: null,
75
+ });
76
+ mocks.updatePreference.mockImplementation(
77
+ async (key: string, value: string) => ({
78
+ key,
79
+ value,
80
+ updatedAt: "2026-06-17T00:00:00.000Z",
81
+ }),
82
+ );
83
+ });
84
+
85
+ it("uses the stored new-session type when it is selectable", async () => {
86
+ mocks.fetchPreference.mockResolvedValue({
87
+ key: PREFERENCE_KEYS.chat.newSessionType,
88
+ value: "codex",
89
+ });
90
+
91
+ const { result } = renderHook(
92
+ () =>
93
+ useChatNewSessionTypePreference({
94
+ defaultSessionType: "native",
95
+ sessionTypeOptions,
96
+ }),
97
+ { wrapper: createWrapper() },
98
+ );
99
+
100
+ await waitFor(() => {
101
+ expect(result.current.selectedSessionType).toBe("codex");
102
+ });
103
+ });
104
+
105
+ it("falls back to the default type when the stored type is not selectable", async () => {
106
+ mocks.fetchPreference.mockResolvedValue({
107
+ key: PREFERENCE_KEYS.chat.newSessionType,
108
+ value: "claude",
109
+ });
110
+
111
+ const { result } = renderHook(
112
+ () =>
113
+ useChatNewSessionTypePreference({
114
+ defaultSessionType: "native",
115
+ sessionTypeOptions,
116
+ }),
117
+ { wrapper: createWrapper() },
118
+ );
119
+
120
+ await waitFor(() => {
121
+ expect(result.current.selectedSessionType).toBe("native");
122
+ });
123
+ });
124
+
125
+ it("persists only selectable session types", async () => {
126
+ const { result } = renderHook(
127
+ () =>
128
+ useChatNewSessionTypePreference({
129
+ defaultSessionType: "native",
130
+ sessionTypeOptions,
131
+ }),
132
+ { wrapper: createWrapper() },
133
+ );
134
+
135
+ act(() => {
136
+ result.current.setSelectedSessionType("codex");
137
+ });
138
+
139
+ await waitFor(() => {
140
+ expect(mocks.updatePreference).toHaveBeenCalledWith(
141
+ PREFERENCE_KEYS.chat.newSessionType,
142
+ "codex",
143
+ );
144
+ });
145
+
146
+ act(() => {
147
+ result.current.setSelectedSessionType("claude");
148
+ });
149
+
150
+ expect(mocks.updatePreference).toHaveBeenCalledTimes(1);
151
+ });
152
+ });
@@ -0,0 +1,159 @@
1
+ import { useCallback, useMemo } from "react";
2
+ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
3
+ import {
4
+ fetchPreference,
5
+ PREFERENCE_KEYS,
6
+ updatePreference,
7
+ type PreferenceEntryView,
8
+ } from "@/shared/lib/api";
9
+ import {
10
+ normalizeSessionType,
11
+ type ChatSessionTypeOption,
12
+ } from "@/features/chat/features/session-type/utils/chat-session-type.utils";
13
+
14
+ const CHAT_NEW_SESSION_TYPE_PREFERENCE_KEY =
15
+ PREFERENCE_KEYS.chat.newSessionType;
16
+ const chatNewSessionTypePreferenceQueryKey = [
17
+ "preference",
18
+ CHAT_NEW_SESSION_TYPE_PREFERENCE_KEY,
19
+ ] as const;
20
+
21
+ type UseChatNewSessionTypePreferenceParams = {
22
+ defaultSessionType: string;
23
+ sessionTypeOptions: readonly ChatSessionTypeOption[];
24
+ };
25
+
26
+ function findSelectableSessionTypeOption(
27
+ sessionType: string,
28
+ sessionTypeOptions: readonly ChatSessionTypeOption[],
29
+ ): ChatSessionTypeOption | null {
30
+ const normalizedSessionType = normalizeSessionType(sessionType);
31
+ return (
32
+ sessionTypeOptions.find(
33
+ (option) =>
34
+ option.value === normalizedSessionType && option.ready !== false,
35
+ ) ?? null
36
+ );
37
+ }
38
+
39
+ function resolveNewSessionTypeOption(params: {
40
+ preferredSessionType: string | null;
41
+ defaultSessionType: string;
42
+ sessionTypeOptions: readonly ChatSessionTypeOption[];
43
+ }): ChatSessionTypeOption {
44
+ const { defaultSessionType, preferredSessionType, sessionTypeOptions } =
45
+ params;
46
+ const preferredOption = preferredSessionType
47
+ ? findSelectableSessionTypeOption(preferredSessionType, sessionTypeOptions)
48
+ : null;
49
+ const defaultOption = findSelectableSessionTypeOption(
50
+ defaultSessionType,
51
+ sessionTypeOptions,
52
+ );
53
+ return (
54
+ preferredOption ??
55
+ defaultOption ??
56
+ sessionTypeOptions.find((option) => option.ready !== false) ??
57
+ sessionTypeOptions[0] ?? {
58
+ value: normalizeSessionType(defaultSessionType),
59
+ label: normalizeSessionType(defaultSessionType),
60
+ icon: null,
61
+ ready: true,
62
+ reason: null,
63
+ reasonMessage: null,
64
+ supportedModels: undefined,
65
+ recommendedModel: null,
66
+ modelSelectionMode: "nextclaw",
67
+ runtimeDefaultThinking: null,
68
+ cta: null,
69
+ }
70
+ );
71
+ }
72
+
73
+ function readPreferredSessionType(entry: PreferenceEntryView | undefined) {
74
+ return typeof entry?.value === "string"
75
+ ? normalizeSessionType(entry.value)
76
+ : null;
77
+ }
78
+
79
+ export function useChatNewSessionTypePreference({
80
+ defaultSessionType,
81
+ sessionTypeOptions,
82
+ }: UseChatNewSessionTypePreferenceParams) {
83
+ const queryClient = useQueryClient();
84
+ const preferenceQuery = useQuery({
85
+ queryKey: chatNewSessionTypePreferenceQueryKey,
86
+ queryFn: () => fetchPreference(CHAT_NEW_SESSION_TYPE_PREFERENCE_KEY),
87
+ staleTime: 30_000,
88
+ retry: false,
89
+ });
90
+ const preferredSessionType = useMemo(
91
+ () => readPreferredSessionType(preferenceQuery.data),
92
+ [preferenceQuery.data],
93
+ );
94
+ const selectedSessionTypeOption = useMemo(
95
+ () =>
96
+ resolveNewSessionTypeOption({
97
+ preferredSessionType,
98
+ defaultSessionType,
99
+ sessionTypeOptions,
100
+ }),
101
+ [defaultSessionType, preferredSessionType, sessionTypeOptions],
102
+ );
103
+ const updateNewSessionType = useMutation({
104
+ mutationFn: async (sessionType: string) =>
105
+ await updatePreference(
106
+ CHAT_NEW_SESSION_TYPE_PREFERENCE_KEY,
107
+ normalizeSessionType(sessionType),
108
+ ),
109
+ onMutate: async (sessionType) => {
110
+ const normalizedSessionType = normalizeSessionType(sessionType);
111
+ await queryClient.cancelQueries({
112
+ queryKey: chatNewSessionTypePreferenceQueryKey,
113
+ });
114
+ const previous = queryClient.getQueryData<PreferenceEntryView>(
115
+ chatNewSessionTypePreferenceQueryKey,
116
+ );
117
+ queryClient.setQueryData<PreferenceEntryView>(
118
+ chatNewSessionTypePreferenceQueryKey,
119
+ {
120
+ key: CHAT_NEW_SESSION_TYPE_PREFERENCE_KEY,
121
+ value: normalizedSessionType,
122
+ updatedAt: new Date().toISOString(),
123
+ },
124
+ );
125
+ return { previous };
126
+ },
127
+ onError: (_error, _sessionType, context) => {
128
+ if (context?.previous) {
129
+ queryClient.setQueryData(
130
+ chatNewSessionTypePreferenceQueryKey,
131
+ context.previous,
132
+ );
133
+ }
134
+ },
135
+ onSuccess: (entry) => {
136
+ queryClient.setQueryData(chatNewSessionTypePreferenceQueryKey, entry);
137
+ },
138
+ });
139
+ const setSelectedSessionType = useCallback(
140
+ (sessionType: string) => {
141
+ const nextOption = findSelectableSessionTypeOption(
142
+ sessionType,
143
+ sessionTypeOptions,
144
+ );
145
+ if (!nextOption) {
146
+ return;
147
+ }
148
+ updateNewSessionType.mutate(nextOption.value);
149
+ },
150
+ [sessionTypeOptions, updateNewSessionType],
151
+ );
152
+
153
+ return {
154
+ selectedSessionType: selectedSessionTypeOption.value,
155
+ selectedSessionTypeOption,
156
+ isLoading: preferenceQuery.isLoading,
157
+ setSelectedSessionType,
158
+ };
159
+ }