@nextclaw/ui 0.14.1-beta.0 → 0.14.1

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 (144) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/dist/assets/{api-C6EDHBDR.js → api-DPcvCLfa.js} +3 -3
  3. package/dist/assets/{channels-list-page-DKj6m1Hc.js → channels-list-page-D6jtziu4.js} +2 -2
  4. package/dist/assets/chat-page-BBnNJoKG.js +105 -0
  5. package/dist/assets/{config-split-page-CjlmJy8X.js → config-split-page-BCJhqdK9.js} +1 -1
  6. package/dist/assets/{confirm-dialog-B39X0pYp.js → confirm-dialog-CWh5FfK2.js} +1 -1
  7. package/dist/assets/{desktop-update-config-DfUQ8nly.js → desktop-update-config-DsRhBizi.js} +1 -1
  8. package/dist/assets/{dist-CmMro3nV.js → dist-DPrgcEC0.js} +1 -1
  9. package/dist/assets/{dist-BIYV2-Ia.js → dist-DsE4OsSS.js} +1 -1
  10. package/dist/assets/doc-browser-C8xOF_9F.js +1 -0
  11. package/dist/assets/{doc-browser-CDtUtcim.js → doc-browser-DgQedeAn.js} +1 -1
  12. package/dist/assets/{doc-browser-context-DZbC9zVB.js → doc-browser-context-B46tQGmO.js} +1 -1
  13. package/dist/assets/{ellipsis-DDpgTzgx.js → ellipsis-B81FStLm.js} +1 -1
  14. package/dist/assets/{external-link-D2cBOCqn.js → external-link-C_dmG-WJ.js} +1 -1
  15. package/dist/assets/index-7o1WdUbU.js +103 -0
  16. package/dist/assets/index-DQWmEWzk.css +1 -0
  17. package/dist/assets/mcp-marketplace-page-CWZN7ohK.js +1 -0
  18. package/dist/assets/mcp-marketplace-page-Dzull_4-.js +40 -0
  19. package/dist/assets/model-config-page-Cv0O3BIj.js +1 -0
  20. package/dist/assets/plus-Bnt56oqG.js +1 -0
  21. package/dist/assets/provider-scoped-model-input-Duuh7i3h.js +1 -0
  22. package/dist/assets/providers-config-page-CPDz0Lxc.js +1 -0
  23. package/dist/assets/{react-DYQXXGbH.js → react-sZz5HSCm.js} +1 -1
  24. package/dist/assets/remote-B0LMs8u7.js +1 -0
  25. package/dist/assets/rotate-cw-BfyG_KEF.js +1 -0
  26. package/dist/assets/runtime-config-page-q0fvv8ME.js +1 -0
  27. package/dist/assets/{save-BNVTgBVn.js → save-MzjoiHri.js} +1 -1
  28. package/dist/assets/{search-CKCzwLId.js → search-B29dE4GN.js} +1 -1
  29. package/dist/assets/{search-config-page-DG5Q2hF1.js → search-config-page-Dl5sZzob.js} +1 -1
  30. package/dist/assets/secrets-config-page-C_l-IFCK.js +3 -0
  31. package/dist/assets/{select-CIRwTNP4.js → select-BKmh05hd.js} +2 -2
  32. package/dist/assets/{tag-chip-DKhVFtTE.js → tag-chip-BS_7Ueom.js} +1 -1
  33. package/dist/assets/use-config-DZkBWIdL.js +1 -0
  34. package/dist/assets/x-CW2KO-Hz.js +1 -0
  35. package/dist/index.html +14 -14
  36. package/package.json +9 -9
  37. package/src/features/chat/components/conversation/__tests__/chat-conversation-panel.test.tsx +12 -63
  38. package/src/features/chat/components/conversation/chat-conversation-content.tsx +11 -50
  39. package/src/features/chat/components/conversation/chat-conversation-panel.tsx +15 -2
  40. package/src/features/chat/components/layout/__tests__/chat-sidebar.test.tsx +70 -7
  41. package/src/features/chat/components/layout/chat-sidebar-toolbar.tsx +104 -16
  42. package/src/features/chat/components/layout/chat-sidebar.tsx +21 -2
  43. package/src/features/chat/features/input/components/chat-input-bar.container.tsx +84 -6
  44. package/src/features/chat/features/input/hooks/use-chat-input-bar-query-state.ts +2 -0
  45. package/src/features/chat/features/input/hooks/use-chat-model-favorites.ts +88 -0
  46. package/src/features/chat/features/input/utils/__tests__/chat-input-bar.utils.test.ts +64 -24
  47. package/src/features/chat/features/input/utils/__tests__/ncp-chat-input-availability.utils.test.ts +1 -0
  48. package/src/features/chat/features/input/utils/chat-input-bar.utils.ts +21 -12
  49. package/src/features/chat/features/input/utils/chat-input-toolbar.utils.ts +80 -25
  50. package/src/features/chat/features/message/utils/__tests__/chat-message-file-operation.utils.test.ts +310 -0
  51. package/src/features/chat/features/message/utils/__tests__/chat-message-test.utils.ts +43 -0
  52. package/src/features/chat/features/message/utils/__tests__/chat-message.utils.test.ts +1 -346
  53. package/src/features/chat/features/message/utils/chat-message-tool-card.utils.ts +10 -9
  54. package/src/features/chat/features/message/utils/file-operation/diff.utils.ts +39 -36
  55. package/src/features/chat/features/message/utils/file-operation/line-builder.utils.ts +46 -38
  56. package/src/features/chat/features/message/utils/file-operation/parsed-block.utils.ts +18 -15
  57. package/src/features/chat/features/session/components/chat-sidebar-project-groups.tsx +3 -2
  58. package/src/features/chat/features/session/components/session-header/__tests__/chat-session-project-badge.test.tsx +25 -25
  59. package/src/features/chat/features/session/components/session-header/chat-session-project-badge.tsx +15 -2
  60. package/src/features/chat/features/session-type/components/__tests__/chat-session-type-menu.test.tsx +24 -0
  61. package/src/features/chat/features/session-type/components/__tests__/chat-session-type-option-item.test.tsx +27 -1
  62. package/src/features/chat/features/session-type/components/{chat-sidebar-create-menu.tsx → chat-session-type-menu.tsx} +17 -4
  63. package/src/features/chat/features/session-type/components/chat-session-type-option-item.tsx +15 -3
  64. package/src/features/chat/features/session-type/hooks/__tests__/use-chat-new-session-type-preference.test.tsx +152 -0
  65. package/src/features/chat/features/session-type/hooks/use-chat-new-session-type-preference.ts +159 -0
  66. package/src/features/chat/features/welcome/components/__tests__/chat-conversation-welcome.test.tsx +193 -0
  67. package/src/features/chat/features/welcome/components/__tests__/chat-welcome.test.tsx +123 -0
  68. package/src/features/chat/features/welcome/components/chat-conversation-welcome.tsx +104 -0
  69. package/src/features/chat/features/welcome/components/chat-welcome-agent-picker.tsx +72 -0
  70. package/src/features/chat/features/welcome/components/chat-welcome-capability-grid.tsx +54 -0
  71. package/src/features/chat/features/welcome/components/chat-welcome-project-picker.tsx +132 -0
  72. package/src/features/chat/features/welcome/components/chat-welcome-session-type-picker.tsx +82 -0
  73. package/src/features/chat/features/welcome/components/chat-welcome.tsx +121 -0
  74. package/src/features/chat/features/welcome/utils/__tests__/chat-welcome-project-options.utils.test.ts +53 -0
  75. package/src/features/chat/features/welcome/utils/__tests__/chat-welcome-selection.utils.test.ts +63 -0
  76. package/src/features/chat/features/welcome/utils/__tests__/chat-welcome-visibility.utils.test.ts +34 -0
  77. package/src/features/chat/features/welcome/utils/chat-welcome-project-options.utils.ts +71 -0
  78. package/src/features/chat/features/welcome/utils/chat-welcome-selection.utils.ts +79 -0
  79. package/src/features/chat/features/welcome/utils/chat-welcome-visibility.utils.ts +17 -0
  80. package/src/features/chat/managers/__tests__/chat-input.manager.test.ts +65 -0
  81. package/src/features/chat/managers/chat-input.manager.ts +24 -2
  82. package/src/features/chat/stores/chat-input.store.ts +2 -0
  83. package/src/features/chat/types/chat-input-bar.types.ts +5 -0
  84. package/src/features/marketplace/components/__tests__/marketplace-page.test.tsx +24 -0
  85. package/src/features/marketplace/components/curated-shelves/marketplace-shelf-card.tsx +8 -3
  86. package/src/features/marketplace/components/marketplace-item-list-view.tsx +2 -2
  87. package/src/features/marketplace/components/marketplace-list-card.tsx +124 -98
  88. package/src/features/marketplace/components/marketplace-page-parts.tsx +11 -6
  89. package/src/features/marketplace/utils/marketplace-installed-cache.utils.ts +27 -23
  90. package/src/features/settings/components/config/provider-form.tsx +284 -387
  91. package/src/features/settings/components/config/provider-status-badge.tsx +4 -4
  92. package/src/features/settings/components/config/secrets-config-form.tsx +11 -135
  93. package/src/features/settings/hooks/use-provider-auth-flow.ts +140 -0
  94. package/src/features/settings/pages/__tests__/model-config-page.test.tsx +16 -0
  95. package/src/features/settings/pages/secrets-config-page.tsx +2 -1
  96. package/src/features/settings/utils/__tests__/provider-form-support.utils.test.ts +60 -1
  97. package/src/features/settings/utils/__tests__/secrets-config-form.utils.test.ts +66 -0
  98. package/src/features/settings/utils/provider-form-context.utils.ts +131 -0
  99. package/src/features/settings/utils/provider-form-model.utils.ts +101 -0
  100. package/src/features/settings/utils/provider-form-support.utils.ts +102 -1
  101. package/src/features/settings/utils/secrets-config-form.utils.ts +139 -0
  102. package/src/features/system-status/components/config/runtime-agent-list-card.tsx +17 -11
  103. package/src/features/system-status/components/config/runtime-binding-list-card.tsx +14 -9
  104. package/src/features/system-status/components/config/runtime-config-editor.tsx +8 -5
  105. package/src/features/system-status/components/config/runtime-entry-list-card.tsx +14 -9
  106. package/src/features/system-status/components/config/runtime-settings-card.tsx +16 -7
  107. package/src/features/system-status/components/runtime-security-card.tsx +79 -25
  108. package/src/shared/components/common/__tests__/agent-avatar.test.tsx +3 -1
  109. package/src/shared/components/common/__tests__/searchable-model-input.test.tsx +34 -0
  110. package/src/shared/components/common/agent-avatar.tsx +5 -2
  111. package/src/shared/components/common/provider-scoped-model-input.tsx +1 -0
  112. package/src/shared/components/common/searchable-model-input.tsx +102 -87
  113. package/src/shared/components/ui/README.md +7 -0
  114. package/src/shared/components/ui/popover.tsx +19 -3
  115. package/src/shared/components/ui/select.tsx +14 -4
  116. package/src/shared/hooks/use-infinite-scroll-loader.ts +14 -10
  117. package/src/shared/lib/api/index.ts +3 -0
  118. package/src/shared/lib/api/preferences/preference-keys.config.ts +9 -0
  119. package/src/shared/lib/api/preferences/preference.types.ts +22 -0
  120. package/src/shared/lib/api/preferences/preference.utils.ts +40 -0
  121. package/src/shared/lib/i18n/locales/en-US/chat.json +16 -1
  122. package/src/shared/lib/i18n/locales/en-US/core.json +1 -0
  123. package/src/shared/lib/i18n/locales/en-US/marketplace.json +2 -0
  124. package/src/shared/lib/i18n/locales/zh-CN/chat.json +16 -1
  125. package/src/shared/lib/i18n/locales/zh-CN/core.json +1 -0
  126. package/src/shared/lib/i18n/locales/zh-CN/marketplace.json +2 -0
  127. package/dist/assets/chat-page-BXngLJY6.js +0 -105
  128. package/dist/assets/doc-browser-B6Wc_TIY.js +0 -1
  129. package/dist/assets/index-DTb_XVIH.js +0 -103
  130. package/dist/assets/index-ayiPYhf1.css +0 -1
  131. package/dist/assets/mcp-marketplace-page-DZ9H0v05.js +0 -1
  132. package/dist/assets/mcp-marketplace-page-cnVWf7QQ.js +0 -40
  133. package/dist/assets/model-config-page-Bcb35AVv.js +0 -1
  134. package/dist/assets/plus-CYE1onU2.js +0 -1
  135. package/dist/assets/provider-scoped-model-input-kdoNp4HG.js +0 -1
  136. package/dist/assets/providers-config-page-CQSibSwO.js +0 -1
  137. package/dist/assets/remote-BlJDbXOI.js +0 -1
  138. package/dist/assets/rotate-cw-BWbvFDAX.js +0 -1
  139. package/dist/assets/runtime-config-page-Tn2yLQ0c.js +0 -1
  140. package/dist/assets/secrets-config-page-BXn5Uuwp.js +0 -3
  141. package/dist/assets/use-config-DgKM9VIU.js +0 -1
  142. package/dist/assets/x-Cm3JjYut.js +0 -1
  143. package/src/features/chat/components/__tests__/chat-welcome.test.tsx +0 -45
  144. package/src/features/chat/components/chat-welcome.tsx +0 -102
@@ -1,58 +1,24 @@
1
- import { useRef } from "react";
1
+ import { useRef, type ReactNode } from "react";
2
2
  import { useStickyBottomScroll } from "@nextclaw/agent-chat-ui";
3
3
  import { ChatMessageListContainer } from "@/features/chat/features/message/components/chat-message-list.container";
4
- import { ChatWelcome } from "@/features/chat/components/chat-welcome";
5
- import { usePresenter } from "@/features/chat/components/providers/chat-presenter.provider";
6
- import { resolveAgentRuntimeSessionType } from "@/features/chat/features/session-type/utils/chat-session-type.utils";
7
- import { useChatInputStore } from "@/features/chat/stores/chat-input.store";
8
- import { useChatSessionListStore } from "@/features/chat/stores/chat-session-list.store";
9
4
  import { useChatThreadStore } from "@/features/chat/stores/chat-thread.store";
10
- import { useAgents } from "@/shared/hooks/use-agents";
11
5
 
12
- export function ChatConversationContent() {
13
- const presenter = usePresenter();
14
- const defaultSessionType = useChatInputStore(
15
- (state) => state.snapshot.defaultSessionType,
16
- );
17
- const selectedAgentId = useChatSessionListStore(
18
- (state) => state.snapshot.selectedAgentId,
19
- );
6
+ type ChatConversationContentProps = {
7
+ showWelcome: boolean;
8
+ welcomeSlot?: ReactNode;
9
+ };
10
+
11
+ export function ChatConversationContent({
12
+ showWelcome,
13
+ welcomeSlot,
14
+ }: ChatConversationContentProps) {
20
15
  const snapshot = useChatThreadStore((state) => state.snapshot);
21
16
  const threadRef = useRef<HTMLDivElement | null>(null);
22
- const agentsQuery = useAgents();
23
- const draftAgentId = snapshot.agentId ?? selectedAgentId;
24
- const availableAgents =
25
- (agentsQuery.data?.agents?.length ?? 0) > 0
26
- ? (agentsQuery.data?.agents ?? [])
27
- : [{ id: draftAgentId }];
28
- const showWelcome =
29
- !snapshot.canDeleteSession &&
30
- !snapshot.hasSubmittedDraftMessage &&
31
- snapshot.messages.length === 0 &&
32
- !snapshot.isSending;
33
17
  const hideEmptyHint =
34
18
  snapshot.isHistoryLoading &&
35
19
  snapshot.messages.length === 0 &&
36
20
  !snapshot.isSending &&
37
21
  !snapshot.isAwaitingAssistantOutput;
38
- const resolveDraftAgent = (agentId: string) =>
39
- availableAgents.find((agent) => agent.id === agentId) ?? null;
40
- const createDraftSessionForAgent = () => {
41
- const sessionType = resolveAgentRuntimeSessionType(
42
- resolveDraftAgent(draftAgentId),
43
- defaultSessionType,
44
- );
45
- presenter.chatSessionListManager.createSession(sessionType);
46
- };
47
- const selectDraftAgent = (agentId: string) => {
48
- presenter.chatSessionListManager.setSelectedAgentId(agentId);
49
- presenter.chatInputManager.setPendingSessionType(
50
- resolveAgentRuntimeSessionType(
51
- resolveDraftAgent(agentId),
52
- defaultSessionType,
53
- ),
54
- );
55
- };
56
22
  const { onScroll } = useStickyBottomScroll({
57
23
  scrollRef: threadRef,
58
24
  resetKey: snapshot.sessionKey,
@@ -72,12 +38,7 @@ export function ChatConversationContent() {
72
38
  className="flex-1 min-h-0 overflow-y-auto custom-scrollbar"
73
39
  >
74
40
  {showWelcome ? (
75
- <ChatWelcome
76
- onCreateSession={createDraftSessionForAgent}
77
- agents={availableAgents}
78
- selectedAgentId={draftAgentId}
79
- onSelectAgent={selectDraftAgent}
80
- />
41
+ welcomeSlot ?? null
81
42
  ) : hideEmptyHint || !hasMessages ? null : (
82
43
  <div className="mx-auto w-full max-w-[min(1120px,100%)] px-4 py-4 sm:px-6 sm:py-5">
83
44
  <ChatMessageListContainer
@@ -6,6 +6,9 @@ import { ChatConversationParentBanner } from "@/features/chat/components/convers
6
6
  import { ChatConversationSkeleton } from "@/features/chat/components/conversation/chat-conversation-skeleton";
7
7
  import { ChatConversationWorkspaceSection } from "@/features/chat/components/conversation/chat-conversation-workspace-section";
8
8
  import { useNcpChatProviderStateResolved } from "@/features/chat/features/ncp/hooks/use-ncp-chat-derived-state";
9
+ import { ChatConversationWelcome } from "@/features/chat/features/welcome/components/chat-conversation-welcome";
10
+ import { shouldShowChatWelcome } from "@/features/chat/features/welcome/utils/chat-welcome-visibility.utils";
11
+ import { useChatThreadStore } from "@/features/chat/stores/chat-thread.store";
9
12
 
10
13
  type ChatConversationLayoutMode = "desktop" | "mobile";
11
14
 
@@ -17,6 +20,9 @@ export function ChatConversationPanel({
17
20
  onBackToList?: () => void;
18
21
  }) {
19
22
  const isProviderStateResolved = useNcpChatProviderStateResolved();
23
+ const showWelcome = useChatThreadStore((state) =>
24
+ shouldShowChatWelcome(state.snapshot),
25
+ );
20
26
 
21
27
  if (!isProviderStateResolved) {
22
28
  return <ChatConversationSkeleton />;
@@ -31,8 +37,15 @@ export function ChatConversationPanel({
31
37
  onBackToList={onBackToList}
32
38
  />
33
39
  <ChatConversationAlerts />
34
- <ChatConversationContent />
35
- <ChatInputBarContainer />
40
+ <ChatConversationContent
41
+ showWelcome={showWelcome}
42
+ welcomeSlot={
43
+ <ChatConversationWelcome
44
+ inputSlot={<ChatInputBarContainer surface="embedded" />}
45
+ />
46
+ }
47
+ />
48
+ {showWelcome ? null : <ChatInputBarContainer />}
36
49
  </div>
37
50
 
38
51
  <ChatConversationWorkspaceSection layoutMode={layoutMode} />
@@ -1,3 +1,4 @@
1
+ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
1
2
  import { fireEvent, render, screen, waitFor } from "@testing-library/react";
2
3
  import { MemoryRouter } from "react-router-dom";
3
4
  import { beforeEach, describe, expect, it, vi } from "vitest";
@@ -5,7 +6,7 @@ import { ChatSidebar } from "@/features/chat/components/layout/chat-sidebar";
5
6
  import type { NcpSessionListItemView } from "@/features/chat/features/ncp/hooks/use-ncp-session-list-view";
6
7
  import { useChatQueryStore } from "@/features/chat/stores/ncp-chat-query.store";
7
8
  import { useChatSessionListStore } from "@/features/chat/stores/chat-session-list.store";
8
- import type { ChatSessionTypeOptionView } from "@/shared/lib/api";
9
+ import { PREFERENCE_KEYS, type ChatSessionTypeOptionView } from "@/shared/lib/api";
9
10
 
10
11
  const mocks = vi.hoisted(() => ({
11
12
  createSession: vi.fn(() => "draft-session-key"),
@@ -18,6 +19,8 @@ const mocks = vi.hoisted(() => ({
18
19
  docOpen: vi.fn(),
19
20
  setLanguage: vi.fn(),
20
21
  setTheme: vi.fn(),
22
+ fetchPreference: vi.fn(),
23
+ updatePreference: vi.fn(),
21
24
  updateNcpSession: vi.fn(),
22
25
  agents: [] as Array<{
23
26
  id: string;
@@ -28,6 +31,15 @@ const mocks = vi.hoisted(() => ({
28
31
  isLoading: false,
29
32
  }));
30
33
 
34
+ vi.mock("@/shared/lib/api", async (importOriginal) => {
35
+ const actual = await importOriginal();
36
+ return {
37
+ ...(actual as Record<string, unknown>),
38
+ fetchPreference: mocks.fetchPreference,
39
+ updatePreference: mocks.updatePreference,
40
+ };
41
+ });
42
+
31
43
  function createSessionItem(
32
44
  session: NcpSessionListItemView["session"],
33
45
  runStatus?: NcpSessionListItemView["runStatus"],
@@ -53,10 +65,18 @@ function setSessionTypes(
53
65
  }
54
66
 
55
67
  function sidebarElement(variant?: "desktop" | "mobile") {
68
+ const queryClient = new QueryClient({
69
+ defaultOptions: {
70
+ queries: { retry: false },
71
+ mutations: { retry: false },
72
+ },
73
+ });
56
74
  return (
57
- <MemoryRouter>
58
- <ChatSidebar variant={variant} />
59
- </MemoryRouter>
75
+ <QueryClientProvider client={queryClient}>
76
+ <MemoryRouter>
77
+ <ChatSidebar variant={variant} />
78
+ </MemoryRouter>
79
+ </QueryClientProvider>
60
80
  );
61
81
  }
62
82
 
@@ -64,6 +84,11 @@ function renderSidebar(variant?: "desktop" | "mobile") {
64
84
  return render(sidebarElement(variant));
65
85
  }
66
86
 
87
+ function expectCodexSelectedInSessionTypeMenu() {
88
+ fireEvent.click(screen.getByLabelText("Session Type"));
89
+ expect(screen.getByRole("button", { name: /Codex/i }).getAttribute("aria-pressed")).toBe("true");
90
+ }
91
+
67
92
  vi.mock("@/features/chat/components/providers/chat-presenter.provider", () => ({
68
93
  usePresenter: () => ({
69
94
  chatUiManager: {
@@ -177,6 +202,16 @@ function resetSidebarTestState() {
177
202
  mocks.docOpen.mockReset();
178
203
  mocks.setLanguage.mockReset();
179
204
  mocks.setTheme.mockReset();
205
+ mocks.fetchPreference.mockReset();
206
+ mocks.fetchPreference.mockResolvedValue({
207
+ key: PREFERENCE_KEYS.chat.newSessionType,
208
+ value: null,
209
+ });
210
+ mocks.updatePreference.mockReset();
211
+ mocks.updatePreference.mockImplementation(async (key: string, value: string) => ({
212
+ key, value,
213
+ updatedAt: "2026-06-17T00:00:00.000Z",
214
+ }));
180
215
  mocks.updateNcpSession.mockReset();
181
216
  mocks.updateNcpSession.mockResolvedValue({});
182
217
  mocks.agents = [];
@@ -201,16 +236,44 @@ function resetSidebarTestState() {
201
236
  describe("ChatSidebar create and list basics", () => {
202
237
  beforeEach(resetSidebarTestState);
203
238
 
204
- it("closes the create-session menu after choosing a non-default session type", async () => {
239
+ it("switches the desktop new-session type without creating until the left button is clicked", async () => {
205
240
  renderSidebar();
206
241
 
207
242
  fireEvent.click(screen.getByLabelText("Session Type"));
208
- fireEvent.click(screen.getByText("Codex"));
243
+ fireEvent.click(screen.getByRole("button", { name: /Codex/i }));
244
+
245
+ expect(mocks.createSession).not.toHaveBeenCalled();
246
+ await waitFor(() => {
247
+ expect(mocks.updatePreference).toHaveBeenCalledWith(
248
+ PREFERENCE_KEYS.chat.newSessionType,
249
+ "codex",
250
+ );
251
+ expect(screen.getByLabelText("Session Type").textContent).toBe("");
252
+ });
253
+ expectCodexSelectedInSessionTypeMenu();
254
+
255
+ fireEvent.click(screen.getByRole("button", { name: "New Task" }));
209
256
 
210
257
  expect(mocks.createSession).toHaveBeenCalledWith("codex", undefined);
258
+ });
259
+
260
+ it("hydrates the desktop new-session type from stored preferences", async () => {
261
+ mocks.fetchPreference.mockResolvedValue({
262
+ key: PREFERENCE_KEYS.chat.newSessionType,
263
+ value: "codex",
264
+ });
265
+
266
+ renderSidebar();
267
+
211
268
  await waitFor(() => {
212
- expect(screen.queryByText("Codex")).toBeNull();
269
+ expect(screen.getByLabelText("Session Type").textContent).toBe("");
213
270
  });
271
+ expectCodexSelectedInSessionTypeMenu();
272
+ fireEvent.click(screen.getByLabelText("Session Type"));
273
+
274
+ fireEvent.click(screen.getByRole("button", { name: "New Task" }));
275
+
276
+ expect(mocks.createSession).toHaveBeenCalledWith("codex", undefined);
214
277
  });
215
278
 
216
279
  it("does not animate the desktop create task button width during runtime option hydration", () => {
@@ -1,4 +1,4 @@
1
- import { Button } from "@/shared/components/ui/button";
1
+ import { Button, type ButtonProps } from "@/shared/components/ui/button";
2
2
  import { IconActionButton } from "@/shared/components/ui/actions/icon-action-button";
3
3
  import { Input } from "@/shared/components/ui/input";
4
4
  import {
@@ -6,22 +6,71 @@ import {
6
6
  PopoverContent,
7
7
  PopoverTrigger,
8
8
  } from "@/shared/components/ui/popover";
9
- import { ChatSidebarCreateMenu } from "@/features/chat/features/session-type/components/chat-sidebar-create-menu";
9
+ import { SessionContextIconNode } from "@/features/chat/features/session/components/session-context-icon";
10
+ import { ChatSessionTypeMenu } from "@/features/chat/features/session-type/components/chat-session-type-menu";
10
11
  import type { ChatInputSnapshot } from "@/features/chat/stores/chat-input.store";
11
12
  import { cn } from "@/shared/lib/utils";
12
13
  import { t } from "@/shared/lib/i18n";
13
- import { ChevronDown, Plus, Search } from "lucide-react";
14
+ import { Bot, ChevronDown, Plus, Search } from "lucide-react";
14
15
 
15
16
  type SessionTypeOption = ChatInputSnapshot["sessionTypeOptions"][number];
17
+ type NewSessionActionStyleVariant =
18
+ | "neutralSurface"
19
+ | "brandSoft"
20
+ | "brandTextSurface"
21
+ | "brandSolid";
22
+
23
+ const NEW_SESSION_ACTION_STYLE_VARIANT: NewSessionActionStyleVariant =
24
+ "brandSoft";
25
+
26
+ const NEW_SESSION_ACTION_STYLE_CLASSES: Record<
27
+ NewSessionActionStyleVariant,
28
+ {
29
+ leftVariant: ButtonProps["variant"];
30
+ leftClassName: string;
31
+ rightClassName: string;
32
+ }
33
+ > = {
34
+ neutralSurface: {
35
+ leftVariant: "ghost",
36
+ leftClassName:
37
+ "bg-white/90 text-gray-700 shadow-none transition-[background-color,color,box-shadow] hover:bg-white hover:text-gray-950 hover:shadow-sm active:bg-white/95",
38
+ rightClassName:
39
+ "bg-white/90 text-gray-600 shadow-none transition-[background-color,color,box-shadow] hover:bg-white hover:text-gray-900 hover:shadow-sm active:bg-white/95",
40
+ },
41
+ brandSoft: {
42
+ leftVariant: "ghost",
43
+ leftClassName:
44
+ "bg-primary/10 text-primary shadow-none transition-[background-color,color,box-shadow] hover:bg-primary/15 hover:text-primary-700 hover:shadow-sm active:bg-primary/20",
45
+ rightClassName:
46
+ "bg-primary/10 text-primary shadow-none transition-[background-color,color,box-shadow] hover:bg-primary/15 hover:text-primary-700 hover:shadow-sm active:bg-primary/20",
47
+ },
48
+ brandTextSurface: {
49
+ leftVariant: "ghost",
50
+ leftClassName:
51
+ "bg-white/90 text-primary shadow-none transition-[background-color,color,box-shadow] hover:bg-primary/10 hover:text-primary-700 hover:shadow-sm active:bg-primary/15",
52
+ rightClassName:
53
+ "bg-white/90 text-primary shadow-none transition-[background-color,color,box-shadow] hover:bg-primary/10 hover:text-primary-700 hover:shadow-sm active:bg-primary/15",
54
+ },
55
+ brandSolid: {
56
+ leftVariant: "primary",
57
+ leftClassName: "",
58
+ rightClassName:
59
+ "bg-primary text-primary-foreground shadow-sm hover:bg-primary-600 hover:text-primary-foreground active:bg-primary-700",
60
+ },
61
+ };
16
62
 
17
63
  type ChatSidebarToolbarProps = {
18
64
  query: string;
19
65
  defaultSessionType: string;
20
66
  sessionTypeOptions: SessionTypeOption[];
21
67
  nonDefaultSessionTypeOptions: SessionTypeOption[];
68
+ selectedNewSessionType: string;
69
+ selectedNewSessionTypeOption: SessionTypeOption | null;
22
70
  isCreateMenuOpen: boolean;
23
71
  onCreateMenuOpenChange: (open: boolean) => void;
24
72
  onCreateSession: (sessionType: string) => void;
73
+ onSelectNewSessionType: (sessionType: string) => void;
25
74
  onQueryChange: (query: string) => void;
26
75
  };
27
76
 
@@ -39,58 +88,96 @@ function getMobileCreateOptions(params: {
39
88
  ];
40
89
  }
41
90
 
91
+ function SessionTypeTriggerIcon({
92
+ option,
93
+ }: {
94
+ option: SessionTypeOption | null;
95
+ }) {
96
+ if (option?.icon?.src) {
97
+ return (
98
+ <span className="inline-flex h-4 w-4 shrink-0 items-center justify-center">
99
+ <SessionContextIconNode
100
+ icon={{
101
+ kind: "runtime-image",
102
+ src: option.icon.src,
103
+ alt: option.icon.alt ?? null,
104
+ name: option.label,
105
+ }}
106
+ className="h-4 w-4"
107
+ />
108
+ </span>
109
+ );
110
+ }
111
+ return <Bot className="h-4 w-4 shrink-0" />;
112
+ }
113
+
42
114
  export function ChatSidebarDesktopToolbar(props: ChatSidebarToolbarProps) {
43
115
  const {
44
116
  query,
45
- defaultSessionType,
46
- nonDefaultSessionTypeOptions,
117
+ selectedNewSessionType,
118
+ selectedNewSessionTypeOption,
119
+ sessionTypeOptions,
47
120
  isCreateMenuOpen,
48
121
  onCreateMenuOpenChange,
49
122
  onCreateSession,
123
+ onSelectNewSessionType,
50
124
  onQueryChange,
51
125
  } = props;
126
+ const supportsSessionTypeSwitch = sessionTypeOptions.length > 1;
127
+ const actionStyle =
128
+ NEW_SESSION_ACTION_STYLE_CLASSES[NEW_SESSION_ACTION_STYLE_VARIANT];
52
129
 
53
130
  return (
54
131
  <>
55
132
  <div className="px-4 pb-3">
56
133
  <div className="flex items-center gap-2">
57
134
  <Button
58
- variant="primary"
135
+ variant={actionStyle.leftVariant}
59
136
  className={cn(
60
137
  "min-w-0 rounded-xl",
61
- nonDefaultSessionTypeOptions.length > 0
138
+ actionStyle.leftClassName,
139
+ supportsSessionTypeSwitch
62
140
  ? "flex-1 rounded-r-md"
63
141
  : "w-full",
64
142
  )}
65
143
  onClick={() => {
66
144
  onCreateMenuOpenChange(false);
67
- onCreateSession(defaultSessionType);
145
+ onCreateSession(selectedNewSessionType);
68
146
  }}
69
147
  >
70
148
  <Plus className="mr-2 h-4 w-4" />
71
149
  {t("chatSidebarNewTask")}
72
150
  </Button>
73
- {nonDefaultSessionTypeOptions.length > 0 ? (
151
+ {supportsSessionTypeSwitch ? (
74
152
  <Popover
75
153
  open={isCreateMenuOpen}
76
154
  onOpenChange={onCreateMenuOpenChange}
77
155
  >
78
156
  <PopoverTrigger asChild>
79
157
  <IconActionButton
80
- icon={<ChevronDown className="h-4 w-4" />}
158
+ icon={
159
+ <span className="inline-flex items-center gap-0.5">
160
+ <SessionTypeTriggerIcon option={selectedNewSessionTypeOption} />
161
+ <ChevronDown className="h-3 w-3 opacity-60" />
162
+ </span>
163
+ }
81
164
  label={t("chatSessionTypeLabel")}
82
- tooltip={false}
83
- className="h-9 w-10 shrink-0 rounded-xl rounded-l-md bg-primary text-primary-foreground shadow-sm hover:bg-primary-600 hover:text-primary-foreground active:bg-primary-700"
165
+ tooltip={t("chatSessionTypeLabel")}
166
+ className={cn(
167
+ "h-9 w-12 shrink-0 rounded-xl rounded-l-md",
168
+ actionStyle.rightClassName,
169
+ )}
84
170
  />
85
171
  </PopoverTrigger>
86
172
  <PopoverContent
87
173
  align="end"
88
174
  className="w-56 rounded-2xl border border-gray-200/80 bg-white p-1.5 shadow-[0_24px_60px_-28px_rgba(15,23,42,0.38)]"
89
175
  >
90
- <ChatSidebarCreateMenu
91
- options={nonDefaultSessionTypeOptions}
176
+ <ChatSessionTypeMenu
177
+ options={sessionTypeOptions}
178
+ selectedSessionType={selectedNewSessionType}
92
179
  onSelect={(sessionType) => {
93
- onCreateSession(sessionType);
180
+ onSelectNewSessionType(sessionType);
94
181
  onCreateMenuOpenChange(false);
95
182
  }}
96
183
  />
@@ -160,8 +247,9 @@ export function ChatSidebarMobileToolbar(props: ChatSidebarToolbarProps) {
160
247
  align="end"
161
248
  className="w-60 rounded-3xl border border-gray-200/80 bg-white p-2 shadow-[0_24px_70px_-30px_rgba(15,23,42,0.45)]"
162
249
  >
163
- <ChatSidebarCreateMenu
250
+ <ChatSessionTypeMenu
164
251
  options={createOptions}
252
+ selectedSessionType={defaultSessionType}
165
253
  title={t("chatSidebarNewTask")}
166
254
  titleClassName="pb-1.5 text-[11px] font-medium normal-case tracking-normal"
167
255
  onSelect={(sessionType) => {
@@ -41,6 +41,7 @@ import {
41
41
  DEFAULT_SESSION_TYPE,
42
42
  normalizeSessionType,
43
43
  } from "@/features/chat/features/session-type/utils/chat-session-type.utils";
44
+ import { useChatNewSessionTypePreference } from "@/features/chat/features/session-type/hooks/use-chat-new-session-type-preference";
44
45
 
45
46
  const navItems = [
46
47
  {
@@ -173,6 +174,10 @@ export function ChatSidebar({
173
174
  ),
174
175
  [defaultSessionType, sessionTypeOptions],
175
176
  );
177
+ const newSessionTypePreference = useChatNewSessionTypePreference({
178
+ defaultSessionType,
179
+ sessionTypeOptions,
180
+ });
176
181
  const isProjectFirstView = listSnapshot.listMode === "project-first";
177
182
  const optimisticReadAtBySessionKey = useChatSessionUnreadState(
178
183
  items,
@@ -250,12 +255,19 @@ export function ChatSidebar({
250
255
  {isMobileVariant ? (
251
256
  <ChatSidebarMobileToolbar
252
257
  query={listSnapshot.query}
253
- defaultSessionType={defaultSessionType}
258
+ defaultSessionType={newSessionTypePreference.selectedSessionType}
254
259
  sessionTypeOptions={sessionTypeOptions}
255
260
  nonDefaultSessionTypeOptions={nonDefaultSessionTypeOptions}
261
+ selectedNewSessionType={newSessionTypePreference.selectedSessionType}
262
+ selectedNewSessionTypeOption={
263
+ newSessionTypePreference.selectedSessionTypeOption
264
+ }
256
265
  isCreateMenuOpen={isCreateMenuOpen}
257
266
  onCreateMenuOpenChange={setIsCreateMenuOpen}
258
267
  onCreateSession={createSessionAndOpenIfNeeded}
268
+ onSelectNewSessionType={
269
+ newSessionTypePreference.setSelectedSessionType
270
+ }
259
271
  onQueryChange={presenter.chatSessionListManager.setQuery}
260
272
  />
261
273
  ) : (
@@ -264,9 +276,16 @@ export function ChatSidebar({
264
276
  defaultSessionType={defaultSessionType}
265
277
  sessionTypeOptions={sessionTypeOptions}
266
278
  nonDefaultSessionTypeOptions={nonDefaultSessionTypeOptions}
279
+ selectedNewSessionType={newSessionTypePreference.selectedSessionType}
280
+ selectedNewSessionTypeOption={
281
+ newSessionTypePreference.selectedSessionTypeOption
282
+ }
267
283
  isCreateMenuOpen={isCreateMenuOpen}
268
284
  onCreateMenuOpenChange={setIsCreateMenuOpen}
269
285
  onCreateSession={createSessionAndOpenIfNeeded}
286
+ onSelectNewSessionType={
287
+ newSessionTypePreference.setSelectedSessionType
288
+ }
270
289
  onQueryChange={presenter.chatSessionListManager.setQuery}
271
290
  />
272
291
  )}
@@ -308,7 +327,7 @@ export function ChatSidebar({
308
327
  isProjectFirstView={isProjectFirstView}
309
328
  groups={groups}
310
329
  projectGroups={projectGroups}
311
- defaultSessionType={defaultSessionType}
330
+ defaultSessionType={newSessionTypePreference.selectedSessionType}
312
331
  sessionTypeOptions={sessionTypeOptions}
313
332
  renderSessionItem={renderSessionItem}
314
333
  onCreateSession={createSessionAndOpenIfNeeded}