@nextclaw/ui 0.12.3 → 0.12.5
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 +49 -0
- package/dist/assets/{ChannelsList-DZWam3Ob.js → ChannelsList-C6-lh55g.js} +2 -2
- package/dist/assets/ChatPage-DOW0gPc2.js +45 -0
- package/dist/assets/DocBrowser-CGyeswYP.js +1 -0
- package/dist/assets/{DocBrowser-C7-1sXqo.js → DocBrowser-QUZ3nfmH.js} +1 -1
- package/dist/assets/{DocBrowserContext-DN5tjUoS.js → DocBrowserContext-CpiIfhJO.js} +1 -1
- package/dist/assets/{LogoBadge-DDS1sU_U.js → LogoBadge-BUK13xK5.js} +1 -1
- package/dist/assets/MarketplacePage-BDVwhIYE.js +1 -0
- package/dist/assets/MarketplacePage-LnKKL3xK.js +49 -0
- package/dist/assets/McpMarketplacePage-BG4T_Pcx.js +40 -0
- package/dist/assets/ModelConfig-LtWuogIw.js +1 -0
- package/dist/assets/ProviderScopedModelInput-DGn6sFEN.js +1 -0
- package/dist/assets/ProvidersList-ma-_MlLo.js +1 -0
- package/dist/assets/{RemoteAccessPage-COnjm8_x.js → RemoteAccessPage-ff15qO-c.js} +1 -1
- package/dist/assets/RuntimeConfig-TgPandXF.js +1 -0
- package/dist/assets/SearchConfig-C9iBt7pl.js +1 -0
- package/dist/assets/{SecretsConfig-Cefg1LFJ.js → SecretsConfig-Bew4EF2A.js} +2 -2
- package/dist/assets/{SessionsConfig-BZnmVTIu.js → SessionsConfig-2r2yAGZg.js} +2 -2
- package/dist/assets/{book-open-DvWqOode.js → book-open-CJG8Yz3U.js} +1 -1
- package/dist/assets/{chat-session-display-D4bYa0b8.js → chat-session-display-DkAC5OMC.js} +1 -1
- package/dist/assets/{chunk-JZWAC4HX-CxfKRD7X.js → chunk-JZWAC4HX-D5b3Iyas.js} +1 -1
- package/dist/assets/{config-BeGwf2Ao.js → config-zvnxSXSP.js} +1 -1
- package/dist/assets/{createLucideIcon-C7MmdIX3.js → createLucideIcon-_FMJqZw2.js} +1 -1
- package/dist/assets/{dist-B6VMuIQN.js → dist-B1fpOuON.js} +1 -1
- package/dist/assets/{dist-RWNFhxvR.js → dist-BCXX7FD-.js} +2 -2
- package/dist/assets/{external-link-U86Acd1t.js → external-link-b7gAJWYY.js} +1 -1
- package/dist/assets/{hash-D-OVfV3Z.js → hash-Bhy4TwfZ.js} +1 -1
- package/dist/assets/i18n-DJg9BPYk.js +1 -0
- package/dist/assets/index-BoJbxdvZ.css +1 -0
- package/dist/assets/index-CtlT4E9Y.js +6 -0
- package/dist/assets/infiniteQueryBehavior-CTcVlD9s.js +1 -0
- package/dist/assets/loader-circle-B60I0hEk.js +1 -0
- package/dist/assets/{logos-U1_qDA3U.js → logos-GMeYU9vc.js} +1 -1
- package/dist/assets/{page-layout-Z1klaUFW.js → page-layout-C8UbWuMt.js} +1 -1
- package/dist/assets/plus-CR7RfK3H.js +1 -0
- package/dist/assets/{popover-xWbqMnIN.js → popover-8HSx9wQj.js} +1 -1
- package/dist/assets/react-BB4jko2M.js +1 -0
- package/dist/assets/{refresh-ccw-JQh1lwq-.js → refresh-ccw-CA4_C7Zg.js} +1 -1
- package/dist/assets/{save-4VRlzkii.js → save-BtvMy4lk.js} +1 -1
- package/dist/assets/search-C60UA27E.js +1 -0
- package/dist/assets/security-config-BkFDYZ6j.js +1 -0
- package/dist/assets/{select-DF-AUoie.js → select-xp_Ac8ip.js} +1 -1
- package/dist/assets/skeleton-uxz_5h3A.js +1 -0
- package/dist/assets/{status-dot-Bq_8Ojvv.js → status-dot-Cn4Pp7DZ.js} +1 -1
- package/dist/assets/{switch-D7JF_RZ-.js → switch-BTi6UOij.js} +1 -1
- package/dist/assets/{tabs-custom-CLksZ2bO.js → tabs-custom-BiiN8DME.js} +1 -1
- package/dist/assets/{trash-2-VV8jvziy.js → trash-2-BpsF0N-r.js} +1 -1
- package/dist/assets/use-infinite-scroll-loader-C8jBv11-.js +1 -0
- package/dist/assets/{useConfirmDialog-CuQqiPx7.js → useConfirmDialog-BJIwUZjH.js} +1 -1
- package/dist/assets/{useMutation-DBTWPbTg.js → useMutation-BjBOKHj_.js} +1 -1
- package/dist/assets/x-BfTu-g7D.js +1 -0
- package/dist/index.html +19 -18
- package/package.json +5 -5
- package/src/account/components/account-panel.tsx +46 -4
- package/src/account/managers/account.manager.ts +19 -4
- package/src/api/remote.ts +9 -0
- package/src/api/remote.types.ts +5 -0
- package/src/components/chat/ChatConversationPanel.test.tsx +183 -141
- package/src/components/chat/ChatSidebar.test.tsx +168 -28
- package/src/components/chat/ChatSidebar.tsx +103 -28
- package/src/components/chat/adapters/chat-message-tool-agent-id.test.ts +11 -11
- package/src/components/chat/adapters/chat-message.adapter.test.ts +43 -6
- package/src/components/chat/adapters/chat-message.session-request-tool-card.ts +182 -44
- package/src/components/chat/adapters/chat-message.session-spawn-tool-card.test.ts +104 -0
- package/src/components/chat/chat-child-session-panel.tsx +103 -45
- package/src/components/chat/chat-page-runtime.test.ts +16 -19
- package/src/components/chat/chat-session-preference-sync.test.ts +13 -0
- package/src/components/chat/chat-session-preference-sync.ts +9 -7
- package/src/components/chat/chat-sidebar-list-mode-switch.tsx +43 -0
- package/src/components/chat/chat-sidebar-project-groups.tsx +152 -0
- package/src/components/chat/hooks/use-chat-session-project.test.tsx +5 -5
- package/src/components/chat/hooks/use-chat-session-project.ts +0 -5
- package/src/components/chat/hooks/use-chat-session-update.test.tsx +75 -0
- package/src/components/chat/hooks/use-chat-session-update.ts +4 -2
- package/src/components/chat/managers/chat-session-list.manager.test.ts +46 -6
- package/src/components/chat/managers/chat-session-list.manager.ts +19 -6
- package/src/components/chat/ncp/NcpChatPage.tsx +33 -38
- package/src/components/chat/ncp/ncp-chat-input.manager.ts +3 -5
- package/src/components/chat/ncp/ncp-chat-page-data.ts +0 -1
- package/src/components/chat/ncp/ncp-chat.presenter.ts +2 -16
- package/src/components/chat/ncp/session-conversation/use-ncp-child-session-tabs-view.ts +20 -7
- package/src/components/chat/session-header/chat-session-project-badge.test.tsx +16 -0
- package/src/components/chat/session-header/chat-session-project-badge.tsx +2 -2
- package/src/components/chat/stores/chat-session-list.store.ts +3 -0
- package/src/components/chat/useChatSessionTypeState.test.tsx +0 -3
- package/src/components/chat/useChatSessionTypeState.ts +3 -5
- package/src/components/config/ChannelsList.test.tsx +68 -0
- package/src/components/config/ChannelsList.tsx +22 -4
- package/src/components/config/ProvidersList.tsx +17 -3
- package/src/components/config/providers-list.test.tsx +68 -0
- package/src/components/layout/Sidebar.tsx +13 -13
- package/src/components/layout/sidebar.layout.test.tsx +32 -1
- package/src/components/marketplace/MarketplacePage.tsx +30 -30
- package/src/components/marketplace/marketplace-page-parts.tsx +16 -24
- package/src/components/marketplace/mcp/McpMarketplacePage.tsx +28 -26
- package/src/hooks/marketplace-list-pages.ts +27 -0
- package/src/hooks/use-infinite-scroll-loader.ts +88 -0
- package/src/hooks/useMarketplace.ts +14 -3
- package/src/hooks/useMcpMarketplace.ts +14 -3
- package/src/lib/i18n.chat.ts +3 -0
- package/src/lib/i18n.remote.ts +15 -0
- package/dist/assets/ChatPage-YBL7iJ1X.js +0 -43
- package/dist/assets/DocBrowser-DQjtSsY3.js +0 -1
- package/dist/assets/MarketplacePage-2tWWgwAb.js +0 -49
- package/dist/assets/MarketplacePage-BorWJftJ.js +0 -1
- package/dist/assets/McpMarketplacePage-N-fB4HID.js +0 -40
- package/dist/assets/ModelConfig-DvsBTUiE.js +0 -1
- package/dist/assets/ProviderScopedModelInput-D9woCARc.js +0 -1
- package/dist/assets/ProvidersList-D-qPGgC4.js +0 -1
- package/dist/assets/RuntimeConfig-BHpqcaHm.js +0 -1
- package/dist/assets/SearchConfig-DIT6M65Q.js +0 -1
- package/dist/assets/i18n-hM3v-3YG.js +0 -1
- package/dist/assets/index-CpxuJa9o.css +0 -1
- package/dist/assets/index-DHmCjcxq.js +0 -6
- package/dist/assets/label-CHJ1ATds.js +0 -1
- package/dist/assets/loader-circle-C8cpaL0w.js +0 -1
- package/dist/assets/marketplace-localization-CxSTG9wr.js +0 -1
- package/dist/assets/plus-CrkO1kob.js +0 -1
- package/dist/assets/react-3YE87-lE.js +0 -1
- package/dist/assets/search-EX-Papzl.js +0 -1
- package/dist/assets/security-config-DEgOD4VX.js +0 -1
- package/dist/assets/skeleton-B0mmt1vo.js +0 -1
- package/dist/assets/x-B4sxJkGY.js +0 -1
|
@@ -3,6 +3,7 @@ import { updateNcpSession } from '@/api/ncp-session';
|
|
|
3
3
|
import { ChatSessionPreferenceSync } from '@/components/chat/chat-session-preference-sync';
|
|
4
4
|
import { useChatInputStore } from '@/components/chat/stores/chat-input.store';
|
|
5
5
|
import { useChatSessionListStore } from '@/components/chat/stores/chat-session-list.store';
|
|
6
|
+
import { useChatThreadStore } from '@/components/chat/stores/chat-thread.store';
|
|
6
7
|
|
|
7
8
|
vi.mock('@/api/ncp-session', () => ({
|
|
8
9
|
updateNcpSession: vi.fn(async () => ({
|
|
@@ -29,6 +30,12 @@ describe('ChatSessionPreferenceSync', () => {
|
|
|
29
30
|
selectedSessionKey: null
|
|
30
31
|
}
|
|
31
32
|
}));
|
|
33
|
+
useChatThreadStore.setState((state) => ({
|
|
34
|
+
snapshot: {
|
|
35
|
+
...state.snapshot,
|
|
36
|
+
canDeleteSession: false
|
|
37
|
+
}
|
|
38
|
+
}));
|
|
32
39
|
vi.clearAllMocks();
|
|
33
40
|
});
|
|
34
41
|
|
|
@@ -46,6 +53,12 @@ describe('ChatSessionPreferenceSync', () => {
|
|
|
46
53
|
selectedSessionKey: 'session-1'
|
|
47
54
|
}
|
|
48
55
|
}));
|
|
56
|
+
useChatThreadStore.setState((state) => ({
|
|
57
|
+
snapshot: {
|
|
58
|
+
...state.snapshot,
|
|
59
|
+
canDeleteSession: true
|
|
60
|
+
}
|
|
61
|
+
}));
|
|
49
62
|
|
|
50
63
|
const sync = new ChatSessionPreferenceSync(updateNcpSession);
|
|
51
64
|
sync.syncSelectedSessionPreferences();
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { SessionPatchUpdate, ThinkingLevel } from '@/api/types';
|
|
2
2
|
import { useChatInputStore } from '@/components/chat/stores/chat-input.store';
|
|
3
3
|
import { useChatSessionListStore } from '@/components/chat/stores/chat-session-list.store';
|
|
4
|
+
import { useChatThreadStore } from '@/components/chat/stores/chat-thread.store';
|
|
4
5
|
|
|
5
6
|
type QueuedSessionPreferenceSync = {
|
|
6
7
|
sessionKey: string;
|
|
@@ -30,8 +31,9 @@ export class ChatSessionPreferenceSync {
|
|
|
30
31
|
syncSelectedSessionPreferences = (): void => {
|
|
31
32
|
const inputSnapshot = useChatInputStore.getState().snapshot;
|
|
32
33
|
const sessionSnapshot = useChatSessionListStore.getState().snapshot;
|
|
34
|
+
const threadSnapshot = useChatThreadStore.getState().snapshot;
|
|
33
35
|
const sessionKey = sessionSnapshot.selectedSessionKey;
|
|
34
|
-
if (!sessionKey) {
|
|
36
|
+
if (!sessionKey || !threadSnapshot.canDeleteSession) {
|
|
35
37
|
return;
|
|
36
38
|
}
|
|
37
39
|
|
|
@@ -44,15 +46,15 @@ export class ChatSessionPreferenceSync {
|
|
|
44
46
|
});
|
|
45
47
|
};
|
|
46
48
|
|
|
47
|
-
private enqueue(next: QueuedSessionPreferenceSync): void {
|
|
49
|
+
private enqueue = (next: QueuedSessionPreferenceSync): void => {
|
|
48
50
|
this.queued = next;
|
|
49
51
|
if (this.inFlight) {
|
|
50
52
|
return;
|
|
51
53
|
}
|
|
52
54
|
this.startFlush();
|
|
53
|
-
}
|
|
55
|
+
};
|
|
54
56
|
|
|
55
|
-
private startFlush(): void {
|
|
57
|
+
private startFlush = (): void => {
|
|
56
58
|
this.inFlight = this.flush()
|
|
57
59
|
.catch((error) => {
|
|
58
60
|
console.error(`Failed to sync chat session preferences: ${String(error)}`);
|
|
@@ -63,13 +65,13 @@ export class ChatSessionPreferenceSync {
|
|
|
63
65
|
this.startFlush();
|
|
64
66
|
}
|
|
65
67
|
});
|
|
66
|
-
}
|
|
68
|
+
};
|
|
67
69
|
|
|
68
|
-
private async
|
|
70
|
+
private flush = async (): Promise<void> => {
|
|
69
71
|
while (this.queued) {
|
|
70
72
|
const current = this.queued;
|
|
71
73
|
this.queued = null;
|
|
72
74
|
await this.updateSession(current.sessionKey, current.patch);
|
|
73
75
|
}
|
|
74
|
-
}
|
|
76
|
+
};
|
|
75
77
|
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { t } from '@/lib/i18n';
|
|
2
|
+
import { cn } from '@/lib/utils';
|
|
3
|
+
|
|
4
|
+
type ChatSidebarListModeSwitchProps = {
|
|
5
|
+
isProjectFirstView: boolean;
|
|
6
|
+
onSelectMode: (mode: 'time-first' | 'project-first') => void;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export function ChatSidebarListModeSwitch(props: ChatSidebarListModeSwitchProps) {
|
|
10
|
+
const { isProjectFirstView, onSelectMode } = props;
|
|
11
|
+
|
|
12
|
+
return (
|
|
13
|
+
<div className="flex items-center gap-1.5 text-[11px]">
|
|
14
|
+
<button
|
|
15
|
+
type="button"
|
|
16
|
+
aria-pressed={!isProjectFirstView}
|
|
17
|
+
onClick={() => onSelectMode('time-first')}
|
|
18
|
+
className={cn(
|
|
19
|
+
'transition-colors',
|
|
20
|
+
isProjectFirstView
|
|
21
|
+
? 'text-gray-400 hover:text-gray-600'
|
|
22
|
+
: 'font-medium text-gray-600'
|
|
23
|
+
)}
|
|
24
|
+
>
|
|
25
|
+
{t('chatSidebarViewTime')}
|
|
26
|
+
</button>
|
|
27
|
+
<span className="text-gray-300">/</span>
|
|
28
|
+
<button
|
|
29
|
+
type="button"
|
|
30
|
+
aria-pressed={isProjectFirstView}
|
|
31
|
+
onClick={() => onSelectMode('project-first')}
|
|
32
|
+
className={cn(
|
|
33
|
+
'transition-colors',
|
|
34
|
+
isProjectFirstView
|
|
35
|
+
? 'font-medium text-gray-600'
|
|
36
|
+
: 'text-gray-400 hover:text-gray-600'
|
|
37
|
+
)}
|
|
38
|
+
>
|
|
39
|
+
{t('chatSidebarViewProject')}
|
|
40
|
+
</button>
|
|
41
|
+
</div>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { useMemo, useState, type ReactNode } from 'react';
|
|
2
|
+
import { Plus } from 'lucide-react';
|
|
3
|
+
import { Button } from '@/components/ui/button';
|
|
4
|
+
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
|
5
|
+
import type { ChatInputSnapshot } from '@/components/chat/stores/chat-input.store';
|
|
6
|
+
import type { NcpSessionListItemView } from '@/components/chat/ncp/use-ncp-session-list-view';
|
|
7
|
+
import { t } from '@/lib/i18n';
|
|
8
|
+
import { cn } from '@/lib/utils';
|
|
9
|
+
|
|
10
|
+
export type ChatSidebarProjectGroup = {
|
|
11
|
+
projectRoot: string;
|
|
12
|
+
projectName: string;
|
|
13
|
+
items: NcpSessionListItemView[];
|
|
14
|
+
latestUpdatedAt: number;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
type SessionTypeOption = ChatInputSnapshot['sessionTypeOptions'][number];
|
|
18
|
+
|
|
19
|
+
type ChatSidebarProjectGroupsProps = {
|
|
20
|
+
groups: ChatSidebarProjectGroup[];
|
|
21
|
+
defaultSessionType: string;
|
|
22
|
+
sessionTypeOptions: SessionTypeOption[];
|
|
23
|
+
renderSessionItem: (item: NcpSessionListItemView) => ReactNode;
|
|
24
|
+
onCreateSession: (sessionType: string, projectRoot: string) => void;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
function resolveProjectGroupDefaultSessionType(
|
|
28
|
+
defaultSessionType: string,
|
|
29
|
+
sessionTypeOptions: SessionTypeOption[]
|
|
30
|
+
): string {
|
|
31
|
+
if (sessionTypeOptions.some((option) => option.value === defaultSessionType)) {
|
|
32
|
+
return defaultSessionType;
|
|
33
|
+
}
|
|
34
|
+
return sessionTypeOptions[0]?.value ?? defaultSessionType;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function resolveSessionTypeStatusText(option: {
|
|
38
|
+
ready?: boolean;
|
|
39
|
+
reasonMessage?: string | null;
|
|
40
|
+
}): string {
|
|
41
|
+
if (option.ready === false) {
|
|
42
|
+
return option.reasonMessage?.trim() || t('statusSetup');
|
|
43
|
+
}
|
|
44
|
+
return t('statusReady');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function ChatSidebarProjectGroups(props: ChatSidebarProjectGroupsProps) {
|
|
48
|
+
const { groups, defaultSessionType, sessionTypeOptions, renderSessionItem, onCreateSession } = props;
|
|
49
|
+
const [openProjectRoot, setOpenProjectRoot] = useState<string | null>(null);
|
|
50
|
+
const preferredSessionType = useMemo(
|
|
51
|
+
() => resolveProjectGroupDefaultSessionType(defaultSessionType, sessionTypeOptions),
|
|
52
|
+
[defaultSessionType, sessionTypeOptions]
|
|
53
|
+
);
|
|
54
|
+
const supportsSessionTypeChoice = sessionTypeOptions.length > 1;
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<div className="space-y-3">
|
|
58
|
+
{groups.map((group) => {
|
|
59
|
+
const actionLabel = `${t('chatSidebarNewTask')} · ${group.projectName}`;
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<div key={group.projectRoot}>
|
|
63
|
+
<div className="flex items-center justify-between gap-2 px-2 py-0.5">
|
|
64
|
+
<div className="flex min-w-0 items-center gap-1.5">
|
|
65
|
+
<div
|
|
66
|
+
className="truncate text-[11px] font-medium uppercase tracking-wider text-gray-500"
|
|
67
|
+
title={group.projectRoot}
|
|
68
|
+
>
|
|
69
|
+
{group.projectName}
|
|
70
|
+
</div>
|
|
71
|
+
<span className="shrink-0 text-[10px] text-gray-400">
|
|
72
|
+
{group.items.length}
|
|
73
|
+
</span>
|
|
74
|
+
</div>
|
|
75
|
+
{supportsSessionTypeChoice ? (
|
|
76
|
+
<Popover
|
|
77
|
+
open={openProjectRoot === group.projectRoot}
|
|
78
|
+
onOpenChange={(nextOpen) => {
|
|
79
|
+
setOpenProjectRoot(nextOpen ? group.projectRoot : null);
|
|
80
|
+
}}
|
|
81
|
+
>
|
|
82
|
+
<PopoverTrigger asChild>
|
|
83
|
+
<Button
|
|
84
|
+
type="button"
|
|
85
|
+
variant="ghost"
|
|
86
|
+
size="icon"
|
|
87
|
+
className="h-7 w-7 shrink-0 rounded-lg text-gray-400 hover:bg-white hover:text-gray-900"
|
|
88
|
+
aria-label={actionLabel}
|
|
89
|
+
title={actionLabel}
|
|
90
|
+
>
|
|
91
|
+
<Plus className="h-3.5 w-3.5" />
|
|
92
|
+
</Button>
|
|
93
|
+
</PopoverTrigger>
|
|
94
|
+
<PopoverContent align="end" className="w-64 p-2">
|
|
95
|
+
<div className="px-2 py-1 text-[11px] font-medium uppercase tracking-wider text-gray-400">
|
|
96
|
+
{t('chatSessionTypeLabel')}
|
|
97
|
+
</div>
|
|
98
|
+
<div className="mt-1 space-y-1">
|
|
99
|
+
{sessionTypeOptions.map((option) => (
|
|
100
|
+
<button
|
|
101
|
+
key={`${group.projectRoot}:${option.value}`}
|
|
102
|
+
type="button"
|
|
103
|
+
onClick={() => {
|
|
104
|
+
onCreateSession(option.value, group.projectRoot);
|
|
105
|
+
setOpenProjectRoot(null);
|
|
106
|
+
}}
|
|
107
|
+
className="w-full rounded-xl px-3 py-2 text-left transition-colors hover:bg-gray-100"
|
|
108
|
+
>
|
|
109
|
+
<div className="flex items-center justify-between gap-3">
|
|
110
|
+
<div className="text-[13px] font-medium text-gray-900">{option.label}</div>
|
|
111
|
+
<span
|
|
112
|
+
className={cn(
|
|
113
|
+
'shrink-0 rounded-full px-2 py-0.5 text-[10px] font-semibold',
|
|
114
|
+
option.ready === false
|
|
115
|
+
? 'bg-amber-100 text-amber-800'
|
|
116
|
+
: 'bg-emerald-100 text-emerald-700'
|
|
117
|
+
)}
|
|
118
|
+
>
|
|
119
|
+
{option.ready === false ? t('statusSetup') : t('statusReady')}
|
|
120
|
+
</span>
|
|
121
|
+
</div>
|
|
122
|
+
<div className="mt-0.5 text-[11px] text-gray-500">
|
|
123
|
+
{resolveSessionTypeStatusText(option)}
|
|
124
|
+
</div>
|
|
125
|
+
</button>
|
|
126
|
+
))}
|
|
127
|
+
</div>
|
|
128
|
+
</PopoverContent>
|
|
129
|
+
</Popover>
|
|
130
|
+
) : (
|
|
131
|
+
<Button
|
|
132
|
+
type="button"
|
|
133
|
+
variant="ghost"
|
|
134
|
+
size="icon"
|
|
135
|
+
className="h-7 w-7 shrink-0 rounded-lg text-gray-400 hover:bg-white hover:text-gray-900"
|
|
136
|
+
onClick={() => onCreateSession(preferredSessionType, group.projectRoot)}
|
|
137
|
+
aria-label={actionLabel}
|
|
138
|
+
title={actionLabel}
|
|
139
|
+
>
|
|
140
|
+
<Plus className="h-3.5 w-3.5" />
|
|
141
|
+
</Button>
|
|
142
|
+
)}
|
|
143
|
+
</div>
|
|
144
|
+
<div className="space-y-0.5 pl-2">
|
|
145
|
+
{group.items.map(renderSessionItem)}
|
|
146
|
+
</div>
|
|
147
|
+
</div>
|
|
148
|
+
);
|
|
149
|
+
})}
|
|
150
|
+
</div>
|
|
151
|
+
);
|
|
152
|
+
}
|
|
@@ -71,7 +71,7 @@ describe('useChatSessionProject', () => {
|
|
|
71
71
|
expect(toast.success).toHaveBeenCalledTimes(1);
|
|
72
72
|
});
|
|
73
73
|
|
|
74
|
-
it('persists to the server
|
|
74
|
+
it('persists to the server without reusing the draft override state for an existing session', async () => {
|
|
75
75
|
const { result } = renderHook(() => useChatSessionProject());
|
|
76
76
|
|
|
77
77
|
await act(async () => {
|
|
@@ -88,12 +88,12 @@ describe('useChatSessionProject', () => {
|
|
|
88
88
|
successMessage: 'Project directory updated',
|
|
89
89
|
});
|
|
90
90
|
expect(useChatInputStore.getState().snapshot).toMatchObject({
|
|
91
|
-
pendingProjectRoot:
|
|
92
|
-
pendingProjectRootSessionKey:
|
|
91
|
+
pendingProjectRoot: null,
|
|
92
|
+
pendingProjectRootSessionKey: null,
|
|
93
93
|
});
|
|
94
94
|
});
|
|
95
95
|
|
|
96
|
-
it('persists clearing to the server
|
|
96
|
+
it('persists clearing to the server without keeping a session-scoped local override', async () => {
|
|
97
97
|
const { result } = renderHook(() => useChatSessionProject());
|
|
98
98
|
|
|
99
99
|
await act(async () => {
|
|
@@ -111,7 +111,7 @@ describe('useChatSessionProject', () => {
|
|
|
111
111
|
});
|
|
112
112
|
expect(useChatInputStore.getState().snapshot).toMatchObject({
|
|
113
113
|
pendingProjectRoot: null,
|
|
114
|
-
pendingProjectRootSessionKey:
|
|
114
|
+
pendingProjectRootSessionKey: null,
|
|
115
115
|
});
|
|
116
116
|
});
|
|
117
117
|
});
|
|
@@ -31,10 +31,5 @@ export function useChatSessionProject() {
|
|
|
31
31
|
patch: { projectRoot: params.projectRoot },
|
|
32
32
|
successMessage,
|
|
33
33
|
});
|
|
34
|
-
|
|
35
|
-
useChatInputStore.getState().setSnapshot({
|
|
36
|
-
pendingProjectRoot: params.projectRoot,
|
|
37
|
-
pendingProjectRootSessionKey: params.sessionKey,
|
|
38
|
-
});
|
|
39
34
|
};
|
|
40
35
|
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { act, renderHook } from '@testing-library/react';
|
|
2
|
+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
3
|
+
import type { ReactNode } from 'react';
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
5
|
+
import { toast } from 'sonner';
|
|
6
|
+
import { useChatSessionUpdate } from '@/components/chat/hooks/use-chat-session-update';
|
|
7
|
+
|
|
8
|
+
const mocks = vi.hoisted(() => ({
|
|
9
|
+
updateNcpSession: vi.fn(),
|
|
10
|
+
upsertNcpSessionSummaryInQueryClient: vi.fn(),
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
vi.mock('sonner', () => ({
|
|
14
|
+
toast: {
|
|
15
|
+
success: vi.fn(),
|
|
16
|
+
error: vi.fn(),
|
|
17
|
+
},
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
vi.mock('@/api/ncp-session', () => ({
|
|
21
|
+
updateNcpSession: (...args: unknown[]) => mocks.updateNcpSession(...args),
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
vi.mock('@/api/ncp-session-query-cache', () => ({
|
|
25
|
+
upsertNcpSessionSummaryInQueryClient: (...args: unknown[]) =>
|
|
26
|
+
mocks.upsertNcpSessionSummaryInQueryClient(...args),
|
|
27
|
+
}));
|
|
28
|
+
|
|
29
|
+
function createWrapper(queryClient: QueryClient) {
|
|
30
|
+
return function Wrapper({ children }: { children: ReactNode }) {
|
|
31
|
+
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe('useChatSessionUpdate', () => {
|
|
36
|
+
afterEach(() => {
|
|
37
|
+
vi.clearAllMocks();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('updates the session summary and invalidates the matching session skills queries', async () => {
|
|
41
|
+
const queryClient = new QueryClient();
|
|
42
|
+
const invalidateQueriesSpy = vi.spyOn(queryClient, 'invalidateQueries');
|
|
43
|
+
const updatedSession = {
|
|
44
|
+
sessionId: 'session-1',
|
|
45
|
+
updatedAt: '2026-04-09T00:00:00.000Z',
|
|
46
|
+
status: 'idle',
|
|
47
|
+
metadata: { project_root: '/tmp/project-alpha' },
|
|
48
|
+
};
|
|
49
|
+
mocks.updateNcpSession.mockResolvedValue(updatedSession);
|
|
50
|
+
|
|
51
|
+
const { result } = renderHook(() => useChatSessionUpdate(), {
|
|
52
|
+
wrapper: createWrapper(queryClient),
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
await act(async () => {
|
|
56
|
+
await result.current({
|
|
57
|
+
sessionKey: 'session-1',
|
|
58
|
+
patch: { projectRoot: '/tmp/project-alpha' },
|
|
59
|
+
successMessage: 'Project directory updated',
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
expect(mocks.updateNcpSession).toHaveBeenCalledWith('session-1', {
|
|
64
|
+
projectRoot: '/tmp/project-alpha',
|
|
65
|
+
});
|
|
66
|
+
expect(mocks.upsertNcpSessionSummaryInQueryClient).toHaveBeenCalledWith(
|
|
67
|
+
queryClient,
|
|
68
|
+
updatedSession,
|
|
69
|
+
);
|
|
70
|
+
expect(invalidateQueriesSpy).toHaveBeenCalledWith({
|
|
71
|
+
queryKey: ['ncp-session-skills', 'session-1'],
|
|
72
|
+
});
|
|
73
|
+
expect(toast.success).toHaveBeenCalledWith('Project directory updated');
|
|
74
|
+
});
|
|
75
|
+
});
|
|
@@ -15,10 +15,12 @@ export function useChatSessionUpdate() {
|
|
|
15
15
|
const queryClient = useQueryClient();
|
|
16
16
|
|
|
17
17
|
return async (params: UpdateChatSessionParams): Promise<void> => {
|
|
18
|
+
const { sessionKey, patch, successMessage } = params;
|
|
18
19
|
try {
|
|
19
|
-
const updated = await updateNcpSession(
|
|
20
|
+
const updated = await updateNcpSession(sessionKey, patch);
|
|
20
21
|
upsertNcpSessionSummaryInQueryClient(queryClient, updated);
|
|
21
|
-
|
|
22
|
+
await queryClient.invalidateQueries({ queryKey: ['ncp-session-skills', sessionKey] });
|
|
23
|
+
toast.success(successMessage ?? t('configSavedApplied'));
|
|
22
24
|
} catch (error) {
|
|
23
25
|
toast.error(
|
|
24
26
|
t('configSaveFailed') + ': ' + (error instanceof Error ? error.message : String(error)),
|
|
@@ -18,6 +18,7 @@ describe('ChatSessionListManager', () => {
|
|
|
18
18
|
snapshot: {
|
|
19
19
|
...useChatSessionListStore.getState().snapshot,
|
|
20
20
|
selectedSessionKey: 'session-1',
|
|
21
|
+
draftSessionKey: 'draft-root-1',
|
|
21
22
|
listMode: 'time-first'
|
|
22
23
|
}
|
|
23
24
|
});
|
|
@@ -25,18 +26,19 @@ describe('ChatSessionListManager', () => {
|
|
|
25
26
|
|
|
26
27
|
it('applies the requested session type when creating a session', () => {
|
|
27
28
|
const uiManager = {
|
|
28
|
-
|
|
29
|
+
goToSession: vi.fn()
|
|
29
30
|
} as unknown as ConstructorParameters<typeof ChatSessionListManager>[0];
|
|
30
31
|
const streamActionsManager = {
|
|
31
32
|
resetStreamState: vi.fn()
|
|
32
33
|
} as unknown as ConstructorParameters<typeof ChatSessionListManager>[1];
|
|
33
34
|
|
|
34
|
-
const manager = new ChatSessionListManager(uiManager, streamActionsManager
|
|
35
|
+
const manager = new ChatSessionListManager(uiManager, streamActionsManager);
|
|
35
36
|
manager.createSession('codex');
|
|
36
37
|
|
|
37
38
|
expect(streamActionsManager.resetStreamState).toHaveBeenCalledTimes(1);
|
|
38
|
-
expect(uiManager.
|
|
39
|
+
expect(uiManager.goToSession).toHaveBeenCalledWith('draft-root-1');
|
|
39
40
|
expect(useChatSessionListStore.getState().snapshot.selectedSessionKey).toBe('session-1');
|
|
41
|
+
expect(useChatSessionListStore.getState().snapshot.draftSessionKey).not.toBe('draft-root-1');
|
|
40
42
|
expect(useChatInputStore.getState().snapshot.pendingSessionType).toBe('codex');
|
|
41
43
|
expect(useChatInputStore.getState().snapshot.pendingProjectRoot).toBeNull();
|
|
42
44
|
expect(useChatInputStore.getState().snapshot.pendingProjectRootSessionKey).toBeNull();
|
|
@@ -44,17 +46,55 @@ describe('ChatSessionListManager', () => {
|
|
|
44
46
|
|
|
45
47
|
it('hydrates the draft project root when creating a session inside a project group', () => {
|
|
46
48
|
const uiManager = {
|
|
47
|
-
|
|
49
|
+
goToSession: vi.fn()
|
|
48
50
|
} as unknown as ConstructorParameters<typeof ChatSessionListManager>[0];
|
|
49
51
|
const streamActionsManager = {
|
|
50
52
|
resetStreamState: vi.fn()
|
|
51
53
|
} as unknown as ConstructorParameters<typeof ChatSessionListManager>[1];
|
|
52
54
|
|
|
53
|
-
const manager = new ChatSessionListManager(uiManager, streamActionsManager
|
|
55
|
+
const manager = new ChatSessionListManager(uiManager, streamActionsManager);
|
|
54
56
|
manager.createSession('native', '/tmp/project-alpha');
|
|
55
57
|
|
|
56
58
|
expect(useChatInputStore.getState().snapshot.pendingProjectRoot).toBe('/tmp/project-alpha');
|
|
57
|
-
expect(useChatInputStore.getState().snapshot.pendingProjectRootSessionKey).toBe('draft-
|
|
59
|
+
expect(useChatInputStore.getState().snapshot.pendingProjectRootSessionKey).toBe('draft-root-1');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('promotes the current root draft when send flow needs a concrete session key', () => {
|
|
63
|
+
useChatSessionListStore.setState({
|
|
64
|
+
snapshot: {
|
|
65
|
+
...useChatSessionListStore.getState().snapshot,
|
|
66
|
+
selectedSessionKey: null,
|
|
67
|
+
draftSessionKey: 'draft-root-2'
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
const uiManager = {
|
|
71
|
+
goToSession: vi.fn()
|
|
72
|
+
} as unknown as ConstructorParameters<typeof ChatSessionListManager>[0];
|
|
73
|
+
const streamActionsManager = {
|
|
74
|
+
resetStreamState: vi.fn()
|
|
75
|
+
} as unknown as ConstructorParameters<typeof ChatSessionListManager>[1];
|
|
76
|
+
|
|
77
|
+
const manager = new ChatSessionListManager(uiManager, streamActionsManager);
|
|
78
|
+
const sessionKey = manager.ensureDraftSession('native');
|
|
79
|
+
|
|
80
|
+
expect(sessionKey).toBe('draft-root-2');
|
|
81
|
+
expect(uiManager.goToSession).toHaveBeenCalledWith('draft-root-2');
|
|
82
|
+
expect(useChatSessionListStore.getState().snapshot.selectedSessionKey).toBeNull();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('does not eagerly replace the old selected session before the route finishes switching', () => {
|
|
86
|
+
const uiManager = {
|
|
87
|
+
goToSession: vi.fn()
|
|
88
|
+
} as unknown as ConstructorParameters<typeof ChatSessionListManager>[0];
|
|
89
|
+
const streamActionsManager = {
|
|
90
|
+
resetStreamState: vi.fn()
|
|
91
|
+
} as unknown as ConstructorParameters<typeof ChatSessionListManager>[1];
|
|
92
|
+
|
|
93
|
+
const manager = new ChatSessionListManager(uiManager, streamActionsManager);
|
|
94
|
+
manager.createSession('native', '/tmp/project-alpha');
|
|
95
|
+
|
|
96
|
+
expect(useChatSessionListStore.getState().snapshot.selectedSessionKey).toBe('session-1');
|
|
97
|
+
expect(useChatInputStore.getState().snapshot.pendingProjectRootSessionKey).toBe('draft-root-1');
|
|
58
98
|
});
|
|
59
99
|
|
|
60
100
|
it('delegates existing-session selection to routing without eagerly mutating the selected session state', () => {
|
|
@@ -4,12 +4,12 @@ import type { ChatUiManager } from '@/components/chat/managers/chat-ui.manager';
|
|
|
4
4
|
import type { SetStateAction } from 'react';
|
|
5
5
|
import type { ChatStreamActionsManager } from '@/components/chat/managers/chat-stream-actions.manager';
|
|
6
6
|
import { normalizeSessionProjectRootValue } from '@/lib/session-project/session-project.utils';
|
|
7
|
+
import { createNcpSessionId } from '@/components/chat/ncp/ncp-session-adapter';
|
|
7
8
|
|
|
8
9
|
export class ChatSessionListManager {
|
|
9
10
|
constructor(
|
|
10
11
|
private uiManager: ChatUiManager,
|
|
11
|
-
private streamActionsManager: ChatStreamActionsManager
|
|
12
|
-
private getDraftSessionId: () => string = () => ''
|
|
12
|
+
private streamActionsManager: ChatStreamActionsManager
|
|
13
13
|
) {}
|
|
14
14
|
|
|
15
15
|
private resolveUpdateValue = <T>(prev: T, next: SetStateAction<T>): T => {
|
|
@@ -46,8 +46,9 @@ export class ChatSessionListManager {
|
|
|
46
46
|
useChatSessionListStore.getState().setSnapshot({ listMode: value });
|
|
47
47
|
};
|
|
48
48
|
|
|
49
|
-
createSession = (sessionType?: string, projectRoot?: string | null) => {
|
|
49
|
+
createSession = (sessionType?: string, projectRoot?: string | null): string => {
|
|
50
50
|
const { snapshot } = useChatInputStore.getState();
|
|
51
|
+
const { snapshot: sessionListSnapshot } = useChatSessionListStore.getState();
|
|
51
52
|
const { defaultSessionType: configuredDefaultSessionType } = snapshot;
|
|
52
53
|
const defaultSessionType = configuredDefaultSessionType || 'native';
|
|
53
54
|
const nextSessionType =
|
|
@@ -55,14 +56,26 @@ export class ChatSessionListManager {
|
|
|
55
56
|
? sessionType.trim()
|
|
56
57
|
: defaultSessionType;
|
|
57
58
|
const normalizedProjectRoot = normalizeSessionProjectRootValue(projectRoot);
|
|
58
|
-
const
|
|
59
|
+
const nextSessionKey = sessionListSnapshot.draftSessionKey;
|
|
59
60
|
this.streamActionsManager.resetStreamState();
|
|
61
|
+
useChatSessionListStore.getState().setSnapshot({
|
|
62
|
+
draftSessionKey: createNcpSessionId()
|
|
63
|
+
});
|
|
60
64
|
useChatInputStore.getState().setSnapshot({
|
|
61
65
|
pendingSessionType: nextSessionType,
|
|
62
66
|
pendingProjectRoot: normalizedProjectRoot,
|
|
63
|
-
pendingProjectRootSessionKey:
|
|
67
|
+
pendingProjectRootSessionKey: normalizedProjectRoot ? nextSessionKey : null
|
|
64
68
|
});
|
|
65
|
-
this.uiManager.
|
|
69
|
+
this.uiManager.goToSession(nextSessionKey);
|
|
70
|
+
return nextSessionKey;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
ensureDraftSession = (sessionType?: string): string => {
|
|
74
|
+
const { snapshot } = useChatSessionListStore.getState();
|
|
75
|
+
if (snapshot.selectedSessionKey) {
|
|
76
|
+
return snapshot.selectedSessionKey;
|
|
77
|
+
}
|
|
78
|
+
return this.createSession(sessionType);
|
|
66
79
|
};
|
|
67
80
|
|
|
68
81
|
selectSession = (sessionKey: string) => {
|