@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.
- package/CHANGELOG.md +50 -0
- package/dist/assets/{api-C6EDHBDR.js → api-DPcvCLfa.js} +3 -3
- package/dist/assets/{channels-list-page-DKj6m1Hc.js → channels-list-page-D6jtziu4.js} +2 -2
- package/dist/assets/chat-page-BBnNJoKG.js +105 -0
- package/dist/assets/{config-split-page-CjlmJy8X.js → config-split-page-BCJhqdK9.js} +1 -1
- package/dist/assets/{confirm-dialog-B39X0pYp.js → confirm-dialog-CWh5FfK2.js} +1 -1
- package/dist/assets/{desktop-update-config-DfUQ8nly.js → desktop-update-config-DsRhBizi.js} +1 -1
- package/dist/assets/{dist-CmMro3nV.js → dist-DPrgcEC0.js} +1 -1
- package/dist/assets/{dist-BIYV2-Ia.js → dist-DsE4OsSS.js} +1 -1
- package/dist/assets/doc-browser-C8xOF_9F.js +1 -0
- package/dist/assets/{doc-browser-CDtUtcim.js → doc-browser-DgQedeAn.js} +1 -1
- package/dist/assets/{doc-browser-context-DZbC9zVB.js → doc-browser-context-B46tQGmO.js} +1 -1
- package/dist/assets/{ellipsis-DDpgTzgx.js → ellipsis-B81FStLm.js} +1 -1
- package/dist/assets/{external-link-D2cBOCqn.js → external-link-C_dmG-WJ.js} +1 -1
- package/dist/assets/index-7o1WdUbU.js +103 -0
- package/dist/assets/index-DQWmEWzk.css +1 -0
- package/dist/assets/mcp-marketplace-page-CWZN7ohK.js +1 -0
- package/dist/assets/mcp-marketplace-page-Dzull_4-.js +40 -0
- package/dist/assets/model-config-page-Cv0O3BIj.js +1 -0
- package/dist/assets/plus-Bnt56oqG.js +1 -0
- package/dist/assets/provider-scoped-model-input-Duuh7i3h.js +1 -0
- package/dist/assets/providers-config-page-CPDz0Lxc.js +1 -0
- package/dist/assets/{react-DYQXXGbH.js → react-sZz5HSCm.js} +1 -1
- package/dist/assets/remote-B0LMs8u7.js +1 -0
- package/dist/assets/rotate-cw-BfyG_KEF.js +1 -0
- package/dist/assets/runtime-config-page-q0fvv8ME.js +1 -0
- package/dist/assets/{save-BNVTgBVn.js → save-MzjoiHri.js} +1 -1
- package/dist/assets/{search-CKCzwLId.js → search-B29dE4GN.js} +1 -1
- package/dist/assets/{search-config-page-DG5Q2hF1.js → search-config-page-Dl5sZzob.js} +1 -1
- package/dist/assets/secrets-config-page-C_l-IFCK.js +3 -0
- package/dist/assets/{select-CIRwTNP4.js → select-BKmh05hd.js} +2 -2
- package/dist/assets/{tag-chip-DKhVFtTE.js → tag-chip-BS_7Ueom.js} +1 -1
- package/dist/assets/use-config-DZkBWIdL.js +1 -0
- package/dist/assets/x-CW2KO-Hz.js +1 -0
- package/dist/index.html +14 -14
- package/package.json +9 -9
- package/src/features/chat/components/conversation/__tests__/chat-conversation-panel.test.tsx +12 -63
- package/src/features/chat/components/conversation/chat-conversation-content.tsx +11 -50
- package/src/features/chat/components/conversation/chat-conversation-panel.tsx +15 -2
- package/src/features/chat/components/layout/__tests__/chat-sidebar.test.tsx +70 -7
- package/src/features/chat/components/layout/chat-sidebar-toolbar.tsx +104 -16
- package/src/features/chat/components/layout/chat-sidebar.tsx +21 -2
- package/src/features/chat/features/input/components/chat-input-bar.container.tsx +84 -6
- package/src/features/chat/features/input/hooks/use-chat-input-bar-query-state.ts +2 -0
- package/src/features/chat/features/input/hooks/use-chat-model-favorites.ts +88 -0
- package/src/features/chat/features/input/utils/__tests__/chat-input-bar.utils.test.ts +64 -24
- package/src/features/chat/features/input/utils/__tests__/ncp-chat-input-availability.utils.test.ts +1 -0
- package/src/features/chat/features/input/utils/chat-input-bar.utils.ts +21 -12
- package/src/features/chat/features/input/utils/chat-input-toolbar.utils.ts +80 -25
- package/src/features/chat/features/message/utils/__tests__/chat-message-file-operation.utils.test.ts +310 -0
- package/src/features/chat/features/message/utils/__tests__/chat-message-test.utils.ts +43 -0
- package/src/features/chat/features/message/utils/__tests__/chat-message.utils.test.ts +1 -346
- package/src/features/chat/features/message/utils/chat-message-tool-card.utils.ts +10 -9
- package/src/features/chat/features/message/utils/file-operation/diff.utils.ts +39 -36
- package/src/features/chat/features/message/utils/file-operation/line-builder.utils.ts +46 -38
- package/src/features/chat/features/message/utils/file-operation/parsed-block.utils.ts +18 -15
- package/src/features/chat/features/session/components/chat-sidebar-project-groups.tsx +3 -2
- package/src/features/chat/features/session/components/session-header/__tests__/chat-session-project-badge.test.tsx +25 -25
- package/src/features/chat/features/session/components/session-header/chat-session-project-badge.tsx +15 -2
- package/src/features/chat/features/session-type/components/__tests__/chat-session-type-menu.test.tsx +24 -0
- package/src/features/chat/features/session-type/components/__tests__/chat-session-type-option-item.test.tsx +27 -1
- package/src/features/chat/features/session-type/components/{chat-sidebar-create-menu.tsx → chat-session-type-menu.tsx} +17 -4
- package/src/features/chat/features/session-type/components/chat-session-type-option-item.tsx +15 -3
- package/src/features/chat/features/session-type/hooks/__tests__/use-chat-new-session-type-preference.test.tsx +152 -0
- package/src/features/chat/features/session-type/hooks/use-chat-new-session-type-preference.ts +159 -0
- package/src/features/chat/features/welcome/components/__tests__/chat-conversation-welcome.test.tsx +193 -0
- package/src/features/chat/features/welcome/components/__tests__/chat-welcome.test.tsx +123 -0
- package/src/features/chat/features/welcome/components/chat-conversation-welcome.tsx +104 -0
- package/src/features/chat/features/welcome/components/chat-welcome-agent-picker.tsx +72 -0
- package/src/features/chat/features/welcome/components/chat-welcome-capability-grid.tsx +54 -0
- package/src/features/chat/features/welcome/components/chat-welcome-project-picker.tsx +132 -0
- package/src/features/chat/features/welcome/components/chat-welcome-session-type-picker.tsx +82 -0
- package/src/features/chat/features/welcome/components/chat-welcome.tsx +121 -0
- package/src/features/chat/features/welcome/utils/__tests__/chat-welcome-project-options.utils.test.ts +53 -0
- package/src/features/chat/features/welcome/utils/__tests__/chat-welcome-selection.utils.test.ts +63 -0
- package/src/features/chat/features/welcome/utils/__tests__/chat-welcome-visibility.utils.test.ts +34 -0
- package/src/features/chat/features/welcome/utils/chat-welcome-project-options.utils.ts +71 -0
- package/src/features/chat/features/welcome/utils/chat-welcome-selection.utils.ts +79 -0
- package/src/features/chat/features/welcome/utils/chat-welcome-visibility.utils.ts +17 -0
- package/src/features/chat/managers/__tests__/chat-input.manager.test.ts +65 -0
- package/src/features/chat/managers/chat-input.manager.ts +24 -2
- package/src/features/chat/stores/chat-input.store.ts +2 -0
- package/src/features/chat/types/chat-input-bar.types.ts +5 -0
- package/src/features/marketplace/components/__tests__/marketplace-page.test.tsx +24 -0
- package/src/features/marketplace/components/curated-shelves/marketplace-shelf-card.tsx +8 -3
- package/src/features/marketplace/components/marketplace-item-list-view.tsx +2 -2
- package/src/features/marketplace/components/marketplace-list-card.tsx +124 -98
- package/src/features/marketplace/components/marketplace-page-parts.tsx +11 -6
- package/src/features/marketplace/utils/marketplace-installed-cache.utils.ts +27 -23
- package/src/features/settings/components/config/provider-form.tsx +284 -387
- package/src/features/settings/components/config/provider-status-badge.tsx +4 -4
- package/src/features/settings/components/config/secrets-config-form.tsx +11 -135
- package/src/features/settings/hooks/use-provider-auth-flow.ts +140 -0
- package/src/features/settings/pages/__tests__/model-config-page.test.tsx +16 -0
- package/src/features/settings/pages/secrets-config-page.tsx +2 -1
- package/src/features/settings/utils/__tests__/provider-form-support.utils.test.ts +60 -1
- package/src/features/settings/utils/__tests__/secrets-config-form.utils.test.ts +66 -0
- package/src/features/settings/utils/provider-form-context.utils.ts +131 -0
- package/src/features/settings/utils/provider-form-model.utils.ts +101 -0
- package/src/features/settings/utils/provider-form-support.utils.ts +102 -1
- package/src/features/settings/utils/secrets-config-form.utils.ts +139 -0
- package/src/features/system-status/components/config/runtime-agent-list-card.tsx +17 -11
- package/src/features/system-status/components/config/runtime-binding-list-card.tsx +14 -9
- package/src/features/system-status/components/config/runtime-config-editor.tsx +8 -5
- package/src/features/system-status/components/config/runtime-entry-list-card.tsx +14 -9
- package/src/features/system-status/components/config/runtime-settings-card.tsx +16 -7
- package/src/features/system-status/components/runtime-security-card.tsx +79 -25
- package/src/shared/components/common/__tests__/agent-avatar.test.tsx +3 -1
- package/src/shared/components/common/__tests__/searchable-model-input.test.tsx +34 -0
- package/src/shared/components/common/agent-avatar.tsx +5 -2
- package/src/shared/components/common/provider-scoped-model-input.tsx +1 -0
- package/src/shared/components/common/searchable-model-input.tsx +102 -87
- package/src/shared/components/ui/README.md +7 -0
- package/src/shared/components/ui/popover.tsx +19 -3
- package/src/shared/components/ui/select.tsx +14 -4
- package/src/shared/hooks/use-infinite-scroll-loader.ts +14 -10
- package/src/shared/lib/api/index.ts +3 -0
- package/src/shared/lib/api/preferences/preference-keys.config.ts +9 -0
- package/src/shared/lib/api/preferences/preference.types.ts +22 -0
- package/src/shared/lib/api/preferences/preference.utils.ts +40 -0
- package/src/shared/lib/i18n/locales/en-US/chat.json +16 -1
- package/src/shared/lib/i18n/locales/en-US/core.json +1 -0
- package/src/shared/lib/i18n/locales/en-US/marketplace.json +2 -0
- package/src/shared/lib/i18n/locales/zh-CN/chat.json +16 -1
- package/src/shared/lib/i18n/locales/zh-CN/core.json +1 -0
- package/src/shared/lib/i18n/locales/zh-CN/marketplace.json +2 -0
- package/dist/assets/chat-page-BXngLJY6.js +0 -105
- package/dist/assets/doc-browser-B6Wc_TIY.js +0 -1
- package/dist/assets/index-DTb_XVIH.js +0 -103
- package/dist/assets/index-ayiPYhf1.css +0 -1
- package/dist/assets/mcp-marketplace-page-DZ9H0v05.js +0 -1
- package/dist/assets/mcp-marketplace-page-cnVWf7QQ.js +0 -40
- package/dist/assets/model-config-page-Bcb35AVv.js +0 -1
- package/dist/assets/plus-CYE1onU2.js +0 -1
- package/dist/assets/provider-scoped-model-input-kdoNp4HG.js +0 -1
- package/dist/assets/providers-config-page-CQSibSwO.js +0 -1
- package/dist/assets/remote-BlJDbXOI.js +0 -1
- package/dist/assets/rotate-cw-BWbvFDAX.js +0 -1
- package/dist/assets/runtime-config-page-Tn2yLQ0c.js +0 -1
- package/dist/assets/secrets-config-page-BXn5Uuwp.js +0 -3
- package/dist/assets/use-config-DgKM9VIU.js +0 -1
- package/dist/assets/x-Cm3JjYut.js +0 -1
- package/src/features/chat/components/__tests__/chat-welcome.test.tsx +0 -45
- 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
|
|
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
|
|
23
|
+
export function ChatSessionTypeMenu({
|
|
17
24
|
className,
|
|
18
25
|
onSelect,
|
|
19
26
|
options,
|
|
27
|
+
selectedSessionType,
|
|
20
28
|
title = t("chatSessionTypeLabel"),
|
|
21
29
|
titleClassName,
|
|
22
|
-
}:
|
|
30
|
+
}: ChatSessionTypeMenuProps) {
|
|
23
31
|
return (
|
|
24
|
-
<div
|
|
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
|
))}
|
package/src/features/chat/features/session-type/components/chat-session-type-option-item.tsx
CHANGED
|
@@ -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 {
|
|
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=
|
|
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
|
+
}
|
package/src/features/chat/features/welcome/components/__tests__/chat-conversation-welcome.test.tsx
ADDED
|
@@ -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
|
+
});
|