@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,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
  });
@@ -1,27 +1,38 @@
1
1
  import { ChatSessionTypeOptionItem } from "@/features/chat/features/session-type/components/chat-session-type-option-item";
2
2
  import type { ChatInputSnapshot } from "@/features/chat/stores/chat-input.store";
3
+ import { createPopoverAvailableHeightLimit } from "@/shared/components/ui/popover";
3
4
  import { t } from "@/shared/lib/i18n";
4
5
  import { cn } from "@/shared/lib/utils";
5
6
 
6
7
  type SessionTypeOption = ChatInputSnapshot["sessionTypeOptions"][number];
8
+ const CHAT_SESSION_TYPE_MENU_MAX_HEIGHT =
9
+ createPopoverAvailableHeightLimit("18rem");
10
+ const CHAT_SESSION_TYPE_MENU_STYLE = {
11
+ maxHeight: CHAT_SESSION_TYPE_MENU_MAX_HEIGHT,
12
+ };
7
13
 
8
- type ChatSidebarCreateMenuProps = {
14
+ type ChatSessionTypeMenuProps = {
9
15
  options: readonly SessionTypeOption[];
10
16
  onSelect: (sessionType: string) => void;
17
+ selectedSessionType?: string;
11
18
  title?: string;
12
19
  className?: string;
13
20
  titleClassName?: string;
14
21
  };
15
22
 
16
- export function ChatSidebarCreateMenu({
23
+ export function ChatSessionTypeMenu({
17
24
  className,
18
25
  onSelect,
19
26
  options,
27
+ selectedSessionType,
20
28
  title = t("chatSessionTypeLabel"),
21
29
  titleClassName,
22
- }: ChatSidebarCreateMenuProps) {
30
+ }: ChatSessionTypeMenuProps) {
23
31
  return (
24
- <div className={cn("space-y-1", className)}>
32
+ <div
33
+ className={cn("space-y-1 overflow-y-auto overscroll-contain", className)}
34
+ style={CHAT_SESSION_TYPE_MENU_STYLE}
35
+ >
25
36
  <div
26
37
  className={cn(
27
38
  "px-3 pb-1 pt-2 text-[10px] font-semibold uppercase tracking-[0.18em] text-gray-400",
@@ -33,7 +44,9 @@ export function ChatSidebarCreateMenu({
33
44
  {options.map((option) => (
34
45
  <ChatSessionTypeOptionItem
35
46
  key={option.value}
47
+ disabled={option.ready === false}
36
48
  option={option}
49
+ selected={option.value === selectedSessionType}
37
50
  onSelect={() => onSelect(option.value)}
38
51
  />
39
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
+ });
@@ -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
+ }
@@ -0,0 +1,193 @@
1
+ import type { ReactNode } from "react";
2
+ import { render, screen } from "@testing-library/react";
3
+ import userEvent from "@testing-library/user-event";
4
+ import { beforeEach, describe, expect, it, vi } from "vitest";
5
+ import { ChatConversationWelcome } from "@/features/chat/features/welcome/components/chat-conversation-welcome";
6
+ import { useChatInputStore } from "@/features/chat/stores/chat-input.store";
7
+ import { useChatQueryStore } from "@/features/chat/stores/ncp-chat-query.store";
8
+ import { useChatSessionListStore } from "@/features/chat/stores/chat-session-list.store";
9
+ import { useChatThreadStore } from "@/features/chat/stores/chat-thread.store";
10
+ import type { ChatQuerySnapshot } from "@/features/chat/stores/ncp-chat-query.store";
11
+
12
+ const mocks = vi.hoisted(() => ({
13
+ setSelectedAgentId: vi.fn(),
14
+ applyPromptSuggestion: vi.fn(),
15
+ setPendingProjectRoot: vi.fn(),
16
+ setPendingSessionType: vi.fn(),
17
+ agents: [
18
+ { id: "main", displayName: "Main", runtime: "native" },
19
+ { id: "engineer", displayName: "Engineer", runtime: "codex" },
20
+ ],
21
+ }));
22
+
23
+ vi.mock("@/features/chat/features/welcome/components/chat-welcome", () => ({
24
+ ChatWelcome: ({
25
+ inputSlot,
26
+ onSelectAgent,
27
+ onSelectPrompt,
28
+ onSelectProjectRoot,
29
+ onSelectSessionType,
30
+ }: {
31
+ inputSlot: ReactNode;
32
+ onSelectAgent: (agentId: string) => void;
33
+ onSelectPrompt: (prompt: string) => void;
34
+ onSelectProjectRoot: (projectRoot: string) => void;
35
+ onSelectSessionType: (sessionType: string) => void;
36
+ }) => (
37
+ <div>
38
+ {inputSlot}
39
+ <button type="button" onClick={() => onSelectAgent("engineer")}>
40
+ switch draft agent
41
+ </button>
42
+ <button type="button" onClick={() => onSelectPrompt("example prompt")}>
43
+ pick prompt
44
+ </button>
45
+ <button type="button" onClick={() => onSelectSessionType("codex")}>
46
+ switch session type
47
+ </button>
48
+ <button
49
+ type="button"
50
+ onClick={() => onSelectProjectRoot("/tmp/project-alpha")}
51
+ >
52
+ select draft project
53
+ </button>
54
+ </div>
55
+ ),
56
+ }));
57
+
58
+ vi.mock("@/features/chat/components/providers/chat-presenter.provider", () => ({
59
+ usePresenter: () => ({
60
+ chatSessionListManager: {
61
+ setSelectedAgentId: mocks.setSelectedAgentId,
62
+ },
63
+ chatInputManager: {
64
+ applyPromptSuggestion: mocks.applyPromptSuggestion,
65
+ setPendingProjectRoot: mocks.setPendingProjectRoot,
66
+ setPendingSessionType: mocks.setPendingSessionType,
67
+ },
68
+ }),
69
+ }));
70
+
71
+ vi.mock("@/shared/hooks/use-agents", () => ({
72
+ useAgents: () => ({
73
+ data: { agents: mocks.agents },
74
+ }),
75
+ }));
76
+
77
+ function createFetchedQuery<TData>(data: TData) {
78
+ return {
79
+ data,
80
+ error: null,
81
+ fetchStatus: "idle",
82
+ isFetched: true,
83
+ isFetching: false,
84
+ isLoading: false,
85
+ isSuccess: true,
86
+ status: "success",
87
+ };
88
+ }
89
+
90
+ function resetWelcomeTestState() {
91
+ mocks.setSelectedAgentId.mockReset();
92
+ mocks.applyPromptSuggestion.mockReset();
93
+ mocks.setPendingProjectRoot.mockReset();
94
+ mocks.setPendingSessionType.mockReset();
95
+ useChatInputStore.setState({
96
+ snapshot: {
97
+ ...useChatInputStore.getState().snapshot,
98
+ defaultSessionType: "native",
99
+ pendingSessionType: "native",
100
+ selectedSessionType: undefined,
101
+ pendingProjectRoot: null,
102
+ },
103
+ });
104
+ useChatSessionListStore.setState({
105
+ snapshot: {
106
+ ...useChatSessionListStore.getState().snapshot,
107
+ selectedAgentId: "main",
108
+ },
109
+ });
110
+ useChatThreadStore.setState({
111
+ snapshot: {
112
+ ...useChatThreadStore.getState().snapshot,
113
+ agentId: null,
114
+ },
115
+ });
116
+ useChatQueryStore.setState({
117
+ snapshot: {
118
+ configQuery: createFetchedQuery({
119
+ agents: {
120
+ defaults: {
121
+ workspace: "/Users/demo/.nextclaw/workspace",
122
+ },
123
+ },
124
+ providers: {},
125
+ }) as unknown as ChatQuerySnapshot["configQuery"],
126
+ sessionTypesQuery: createFetchedQuery({
127
+ defaultType: "native",
128
+ options: [
129
+ { value: "native", label: "Native", ready: true },
130
+ { value: "codex", label: "Codex", ready: true },
131
+ ],
132
+ }) as unknown as ChatQuerySnapshot["sessionTypesQuery"],
133
+ sessionsQuery: createFetchedQuery({
134
+ sessions: [],
135
+ total: 0,
136
+ }) as unknown as ChatQuerySnapshot["sessionsQuery"],
137
+ },
138
+ });
139
+ }
140
+
141
+ describe("ChatConversationWelcome", () => {
142
+ beforeEach(resetWelcomeTestState);
143
+
144
+ it("stores the selected welcome project through the input manager", async () => {
145
+ const user = userEvent.setup();
146
+
147
+ render(<ChatConversationWelcome inputSlot={<div data-testid="input-slot" />} />);
148
+
149
+ await user.click(
150
+ screen.getByRole("button", { name: "select draft project" }),
151
+ );
152
+
153
+ expect(mocks.setPendingProjectRoot).toHaveBeenCalledWith("/tmp/project-alpha");
154
+ });
155
+
156
+ it("applies a selected prompt suggestion through the input manager", async () => {
157
+ const user = userEvent.setup();
158
+
159
+ render(<ChatConversationWelcome inputSlot={<div data-testid="input-slot" />} />);
160
+
161
+ await user.click(
162
+ screen.getByRole("button", { name: "pick prompt" }),
163
+ );
164
+
165
+ expect(mocks.applyPromptSuggestion).toHaveBeenCalledWith("example prompt");
166
+ });
167
+
168
+
169
+ it("syncs the pending session type when switching the draft agent", async () => {
170
+ const user = userEvent.setup();
171
+
172
+ render(<ChatConversationWelcome inputSlot={<div data-testid="input-slot" />} />);
173
+
174
+ await user.click(
175
+ screen.getByRole("button", { name: "switch draft agent" }),
176
+ );
177
+
178
+ expect(mocks.setSelectedAgentId).toHaveBeenCalledWith("engineer");
179
+ expect(mocks.setPendingSessionType).toHaveBeenCalledWith("codex");
180
+ });
181
+
182
+ it("stores an explicit welcome session type selection", async () => {
183
+ const user = userEvent.setup();
184
+
185
+ render(<ChatConversationWelcome inputSlot={<div data-testid="input-slot" />} />);
186
+
187
+ await user.click(
188
+ screen.getByRole("button", { name: "switch session type" }),
189
+ );
190
+
191
+ expect(mocks.setPendingSessionType).toHaveBeenCalledWith("codex");
192
+ });
193
+ });