@nextclaw/ui 0.14.1-beta.1 → 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 (69) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/dist/assets/{api-dQ6uQSZG.js → api-DPcvCLfa.js} +3 -3
  3. package/dist/assets/{channels-list-page-DfpjB8XB.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-CeFfbaqc.js → config-split-page-BCJhqdK9.js} +1 -1
  6. package/dist/assets/{confirm-dialog-C95Y4QPC.js → confirm-dialog-CWh5FfK2.js} +1 -1
  7. package/dist/assets/{desktop-update-config-CcK6jiEb.js → desktop-update-config-DsRhBizi.js} +1 -1
  8. package/dist/assets/{dist-DTGzeil6.js → dist-DPrgcEC0.js} +1 -1
  9. package/dist/assets/{dist-fGogBmPA.js → dist-DsE4OsSS.js} +1 -1
  10. package/dist/assets/doc-browser-C8xOF_9F.js +1 -0
  11. package/dist/assets/{doc-browser-DVHhEb96.js → doc-browser-DgQedeAn.js} +1 -1
  12. package/dist/assets/{doc-browser-context-BfMXwnrz.js → doc-browser-context-B46tQGmO.js} +1 -1
  13. package/dist/assets/{ellipsis-D2F4KMiU.js → ellipsis-B81FStLm.js} +1 -1
  14. package/dist/assets/{external-link-CcgtBK26.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-aAUkZkSX.js → model-config-page-Cv0O3BIj.js} +1 -1
  20. package/dist/assets/plus-Bnt56oqG.js +1 -0
  21. package/dist/assets/{provider-scoped-model-input-we4_Z4gP.js → provider-scoped-model-input-Duuh7i3h.js} +1 -1
  22. package/dist/assets/{providers-config-page-CtMkPSyu.js → providers-config-page-CPDz0Lxc.js} +1 -1
  23. package/dist/assets/{react-CTqdhFMk.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-TD1UrTbG.js → runtime-config-page-q0fvv8ME.js} +1 -1
  27. package/dist/assets/{save-C9fKWfVB.js → save-MzjoiHri.js} +1 -1
  28. package/dist/assets/{search-D5FQ5q0X.js → search-B29dE4GN.js} +1 -1
  29. package/dist/assets/{search-config-page-AMEJJNIS.js → search-config-page-Dl5sZzob.js} +1 -1
  30. package/dist/assets/{secrets-config-page-CcgJnvBO.js → secrets-config-page-C_l-IFCK.js} +2 -2
  31. package/dist/assets/{select-Cu5n0E-3.js → select-BKmh05hd.js} +1 -1
  32. package/dist/assets/{tag-chip-C5aOtjxu.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/layout/__tests__/chat-sidebar.test.tsx +70 -7
  38. package/src/features/chat/components/layout/chat-sidebar-toolbar.tsx +104 -16
  39. package/src/features/chat/components/layout/chat-sidebar.tsx +21 -2
  40. package/src/features/chat/features/input/hooks/use-chat-model-favorites.ts +2 -1
  41. package/src/features/chat/features/session/components/chat-sidebar-project-groups.tsx +3 -2
  42. package/src/features/chat/features/session-type/components/__tests__/{chat-sidebar-create-menu.test.tsx → chat-session-type-menu.test.tsx} +3 -3
  43. package/src/features/chat/features/session-type/components/__tests__/chat-session-type-option-item.test.tsx +27 -1
  44. package/src/features/chat/features/session-type/components/{chat-sidebar-create-menu.tsx → chat-session-type-menu.tsx} +11 -7
  45. package/src/features/chat/features/session-type/components/chat-session-type-option-item.tsx +15 -3
  46. package/src/features/chat/features/session-type/hooks/__tests__/use-chat-new-session-type-preference.test.tsx +152 -0
  47. package/src/features/chat/features/session-type/hooks/use-chat-new-session-type-preference.ts +159 -0
  48. package/src/features/marketplace/components/__tests__/marketplace-page.test.tsx +24 -0
  49. package/src/features/marketplace/components/curated-shelves/marketplace-shelf-card.tsx +8 -3
  50. package/src/features/marketplace/components/marketplace-item-list-view.tsx +2 -2
  51. package/src/features/marketplace/components/marketplace-list-card.tsx +124 -98
  52. package/src/features/marketplace/components/marketplace-page-parts.tsx +11 -6
  53. package/src/shared/components/common/__tests__/agent-avatar.test.tsx +3 -1
  54. package/src/shared/components/common/agent-avatar.tsx +5 -2
  55. package/src/shared/lib/api/index.ts +1 -0
  56. package/src/shared/lib/api/preferences/preference-keys.config.ts +9 -0
  57. package/src/shared/lib/i18n/locales/en-US/marketplace.json +2 -0
  58. package/src/shared/lib/i18n/locales/zh-CN/marketplace.json +2 -0
  59. package/dist/assets/chat-page-D3QaEqjs.js +0 -105
  60. package/dist/assets/doc-browser-CuBTcBEZ.js +0 -1
  61. package/dist/assets/index-ByJCbQqr.css +0 -1
  62. package/dist/assets/index-DjXqiloY.js +0 -103
  63. package/dist/assets/mcp-marketplace-page-98-V__3u.js +0 -1
  64. package/dist/assets/mcp-marketplace-page-BNq_PNmO.js +0 -40
  65. package/dist/assets/plus-1Rxl5FlV.js +0 -1
  66. package/dist/assets/remote-fQ7N_r8u.js +0 -1
  67. package/dist/assets/rotate-cw-CzfIeAJj.js +0 -1
  68. package/dist/assets/use-config-D8TESeo6.js +0 -1
  69. package/dist/assets/x-1qETdQ70.js +0 -1
@@ -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}
@@ -2,12 +2,13 @@ import { useCallback, useMemo } from 'react';
2
2
  import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
3
3
  import {
4
4
  fetchPreference,
5
+ PREFERENCE_KEYS,
5
6
  updatePreference,
6
7
  type PreferenceEntryView,
7
8
  type PreferenceJsonValue,
8
9
  } from '@/shared/lib/api';
9
10
 
10
- const CHAT_MODEL_FAVORITES_PREFERENCE_KEY = 'chat.modelFavorites';
11
+ const CHAT_MODEL_FAVORITES_PREFERENCE_KEY = PREFERENCE_KEYS.chat.modelFavorites;
11
12
  const chatModelFavoritesQueryKey = ['preference', CHAT_MODEL_FAVORITES_PREFERENCE_KEY] as const;
12
13
 
13
14
  function normalizeFavoriteModelValues(value: PreferenceJsonValue | null | undefined): string[] {
@@ -1,6 +1,6 @@
1
1
  import { useMemo, useState, type ReactNode } from 'react';
2
2
  import { Plus } from 'lucide-react';
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 { Popover, PopoverContent, PopoverTrigger } from '@/shared/components/ui/popover';
5
5
  import { IconActionButton } from '@/shared/components/ui/actions/icon-action-button';
6
6
  import type { ChatInputSnapshot } from '@/features/chat/stores/chat-input.store';
@@ -77,8 +77,9 @@ export function ChatSidebarProjectGroups(props: ChatSidebarProjectGroupsProps) {
77
77
  align="end"
78
78
  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)]"
79
79
  >
80
- <ChatSidebarCreateMenu
80
+ <ChatSessionTypeMenu
81
81
  options={sessionTypeOptions}
82
+ selectedSessionType={preferredSessionType}
82
83
  onSelect={(sessionType) => {
83
84
  onCreateSession(sessionType, group.projectRoot);
84
85
  setOpenProjectRoot(null);
@@ -1,6 +1,6 @@
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 = [
@@ -8,9 +8,9 @@ const options = [
8
8
  { value: "codex", label: "Codex", 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
 
@@ -59,4 +59,30 @@ describe("ChatSessionTypeOptionItem", () => {
59
59
  expect(screen.getByRole("button", { name: /native/i })).toBeTruthy();
60
60
  expect(screen.getByText("Native")).toBeTruthy();
61
61
  });
62
+
63
+ it("marks selected options and does not call select when disabled", () => {
64
+ const onSelect = vi.fn();
65
+
66
+ render(
67
+ <ChatSessionTypeOptionItem
68
+ disabled
69
+ selected
70
+ option={{
71
+ value: "claude",
72
+ label: "Claude",
73
+ ready: false,
74
+ reasonMessage: "Configure a provider API key first.",
75
+ }}
76
+ onSelect={onSelect}
77
+ />,
78
+ );
79
+
80
+ const optionButton = screen.getByRole("button", { name: /claude/i });
81
+ expect(optionButton.getAttribute("aria-pressed")).toBe("true");
82
+ expect((optionButton as HTMLButtonElement).disabled).toBe(true);
83
+
84
+ fireEvent.click(optionButton);
85
+
86
+ expect(onSelect).not.toHaveBeenCalled();
87
+ });
62
88
  });
@@ -5,31 +5,33 @@ import { t } from "@/shared/lib/i18n";
5
5
  import { cn } from "@/shared/lib/utils";
6
6
 
7
7
  type SessionTypeOption = ChatInputSnapshot["sessionTypeOptions"][number];
8
- const CHAT_SIDEBAR_CREATE_MENU_MAX_HEIGHT =
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
  ))}
@@ -2,15 +2,18 @@ import type { ChatInputSnapshot } from "@/features/chat/stores/chat-input.store"
2
2
  import { SessionContextIconNode } from "@/features/chat/features/session/components/session-context-icon";
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
8
  type SessionTypeOption = ChatInputSnapshot["sessionTypeOptions"][number];
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
+ });