@nextclaw/ui 0.12.4 → 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 +41 -0
- package/dist/assets/{ChannelsList-CobWeI2V.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-NSzgVKka.js → DocBrowser-QUZ3nfmH.js} +1 -1
- package/dist/assets/{DocBrowserContext-DpgVdRgk.js → DocBrowserContext-CpiIfhJO.js} +1 -1
- package/dist/assets/{LogoBadge-CHS4YNLw.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-yfbrveNQ.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-CLFSSoTl.js → SecretsConfig-Bew4EF2A.js} +2 -2
- package/dist/assets/{SessionsConfig-vYrvc2Fk.js → SessionsConfig-2r2yAGZg.js} +2 -2
- package/dist/assets/{book-open-C7TAghTk.js → book-open-CJG8Yz3U.js} +1 -1
- package/dist/assets/{chat-session-display-5dVFkJyw.js → chat-session-display-DkAC5OMC.js} +1 -1
- package/dist/assets/{chunk-JZWAC4HX-DbL4EmiT.js → chunk-JZWAC4HX-D5b3Iyas.js} +1 -1
- package/dist/assets/{config-CMiW0yaK.js → config-zvnxSXSP.js} +1 -1
- package/dist/assets/{createLucideIcon-BRLFtf-8.js → createLucideIcon-_FMJqZw2.js} +1 -1
- package/dist/assets/{dist-DP-JKR4G.js → dist-B1fpOuON.js} +1 -1
- package/dist/assets/{dist-BFc_H-lY.js → dist-BCXX7FD-.js} +2 -2
- package/dist/assets/{external-link-BkJkiWbH.js → external-link-b7gAJWYY.js} +1 -1
- package/dist/assets/{hash-CbP6-6R9.js → hash-Bhy4TwfZ.js} +1 -1
- package/dist/assets/{i18n-C_2dKw6w.js → i18n-DJg9BPYk.js} +1 -1
- 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-N3dbS6-I.js → logos-GMeYU9vc.js} +1 -1
- package/dist/assets/{page-layout-DyuvlNrg.js → page-layout-C8UbWuMt.js} +1 -1
- package/dist/assets/plus-CR7RfK3H.js +1 -0
- package/dist/assets/{popover-BKKWGUaG.js → popover-8HSx9wQj.js} +1 -1
- package/dist/assets/react-BB4jko2M.js +1 -0
- package/dist/assets/{refresh-ccw-BGMdiNGq.js → refresh-ccw-CA4_C7Zg.js} +1 -1
- package/dist/assets/{save-Dh4GQzzX.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-BtIi5fnh.js → select-xp_Ac8ip.js} +1 -1
- package/dist/assets/skeleton-uxz_5h3A.js +1 -0
- package/dist/assets/{status-dot-C4O-2jZP.js → status-dot-Cn4Pp7DZ.js} +1 -1
- package/dist/assets/{switch-DPegGIa_.js → switch-BTi6UOij.js} +1 -1
- package/dist/assets/{tabs-custom-x5GZexrF.js → tabs-custom-BiiN8DME.js} +1 -1
- package/dist/assets/{trash-2-CU3LYIpQ.js → trash-2-BpsF0N-r.js} +1 -1
- package/dist/assets/use-infinite-scroll-loader-C8jBv11-.js +1 -0
- package/dist/assets/{useConfirmDialog-S5WsGOGf.js → useConfirmDialog-BJIwUZjH.js} +1 -1
- package/dist/assets/{useMutation-DSinpgEq.js → useMutation-BjBOKHj_.js} +1 -1
- package/dist/assets/x-BfTu-g7D.js +1 -0
- package/dist/index.html +19 -18
- package/package.json +4 -4
- 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/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/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 +45 -5
- package/src/components/chat/managers/chat-session-list.manager.ts +18 -4
- package/src/components/chat/ncp/NcpChatPage.tsx +32 -51
- 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 +1 -11
- package/src/components/chat/ncp/session-conversation/use-ncp-child-session-tabs-view.ts +20 -7
- 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.remote.ts +15 -0
- package/dist/assets/ChatPage-ZIdFFVAv.js +0 -43
- package/dist/assets/DocBrowser-D55C0iyl.js +0 -1
- package/dist/assets/MarketplacePage-BFYsRss_.js +0 -49
- package/dist/assets/MarketplacePage-DII-q-Y1.js +0 -1
- package/dist/assets/McpMarketplacePage-CPqsGJzz.js +0 -40
- package/dist/assets/ModelConfig-Bvuo_IpS.js +0 -1
- package/dist/assets/ProviderScopedModelInput-BfY8rGsf.js +0 -1
- package/dist/assets/ProvidersList-3tlaqwSS.js +0 -1
- package/dist/assets/RuntimeConfig-CAd5Kta3.js +0 -1
- package/dist/assets/SearchConfig-DFwgaAa7.js +0 -1
- package/dist/assets/index-ChUXhq0G.css +0 -1
- package/dist/assets/index-DAE8Srx-.js +0 -6
- package/dist/assets/label-D8yyejJS.js +0 -1
- package/dist/assets/loader-circle-B0sKKO29.js +0 -1
- package/dist/assets/marketplace-localization-CxSTG9wr.js +0 -1
- package/dist/assets/plus-CYXs3JtZ.js +0 -1
- package/dist/assets/react-8EIEQjMP.js +0 -1
- package/dist/assets/search-DOsLw-P9.js +0 -1
- package/dist/assets/security-config-CM_tQRXQ.js +0 -1
- package/dist/assets/skeleton-GbHLjPC0.js +0 -1
- package/dist/assets/x-Bnco_K8b.js +0 -1
|
@@ -1,13 +1,18 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
import {
|
|
10
|
-
import type {
|
|
1
|
+
import { useRef } from "react";
|
|
2
|
+
import { ArrowLeft, Loader2, X } from "lucide-react";
|
|
3
|
+
import { useStickyBottomScroll } from "@nextclaw/agent-chat-ui";
|
|
4
|
+
import { ChatMessageListContainer } from "@/components/chat/containers/chat-message-list.container";
|
|
5
|
+
import {
|
|
6
|
+
useNcpChildSessionTabsView,
|
|
7
|
+
type ResolvedChildSessionTab,
|
|
8
|
+
} from "@/components/chat/ncp/session-conversation/use-ncp-child-session-tabs-view";
|
|
9
|
+
import { useNcpSessionConversation } from "@/components/chat/ncp/session-conversation/use-ncp-session-conversation";
|
|
10
|
+
import type { ChatChildSessionTab } from "@/components/chat/stores/chat-thread.store";
|
|
11
|
+
import { AgentIdentityAvatar } from "@/components/common/agent-identity";
|
|
12
|
+
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
13
|
+
import { t } from "@/lib/i18n";
|
|
14
|
+
import { cn } from "@/lib/utils";
|
|
15
|
+
import type { ChatToolActionViewModel } from "@nextclaw/agent-chat-ui";
|
|
11
16
|
|
|
12
17
|
type ChatChildSessionPanelProps = {
|
|
13
18
|
tabs: readonly ChatChildSessionTab[];
|
|
@@ -27,39 +32,84 @@ function ChildSessionPanelConversation({
|
|
|
27
32
|
}) {
|
|
28
33
|
const agent = useNcpSessionConversation(sessionKey);
|
|
29
34
|
const messages = agent.visibleMessages;
|
|
35
|
+
const scrollRef = useRef<HTMLDivElement>(null);
|
|
36
|
+
const { onScroll } = useStickyBottomScroll({
|
|
37
|
+
scrollRef,
|
|
38
|
+
resetKey: sessionKey,
|
|
39
|
+
isLoading: agent.isHydrating,
|
|
40
|
+
hasContent: messages.length > 0,
|
|
41
|
+
contentVersion: messages[messages.length - 1] ?? null,
|
|
42
|
+
stickyThresholdPx: 20,
|
|
43
|
+
});
|
|
30
44
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
45
|
+
return (
|
|
46
|
+
<div
|
|
47
|
+
ref={scrollRef}
|
|
48
|
+
onScroll={onScroll}
|
|
49
|
+
className="h-full overflow-y-auto custom-scrollbar"
|
|
50
|
+
>
|
|
51
|
+
{agent.isHydrating ? (
|
|
52
|
+
<div className="flex h-full items-center justify-center text-sm text-gray-500">
|
|
53
|
+
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
54
|
+
{t("chatChildSessionLoading")}
|
|
55
|
+
</div>
|
|
56
|
+
) : agent.hydrateError ? (
|
|
57
|
+
<div className="px-4 py-5 text-sm text-rose-600">
|
|
58
|
+
{agent.hydrateError.message}
|
|
59
|
+
</div>
|
|
60
|
+
) : messages.length === 0 && !agent.isRunning ? (
|
|
61
|
+
<div className="px-4 py-5 text-sm text-gray-500">
|
|
62
|
+
{t("chatChildSessionEmpty")}
|
|
63
|
+
</div>
|
|
64
|
+
) : (
|
|
65
|
+
<div className="px-4 py-5">
|
|
66
|
+
<ChatMessageListContainer
|
|
67
|
+
messages={messages}
|
|
68
|
+
isSending={agent.isRunning}
|
|
69
|
+
onToolAction={onToolAction}
|
|
70
|
+
/>
|
|
71
|
+
</div>
|
|
72
|
+
)}
|
|
73
|
+
</div>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
39
76
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
77
|
+
function ChildSessionPanelMetaChip({ value }: { value: string }) {
|
|
78
|
+
return (
|
|
79
|
+
<span className="inline-flex max-w-full items-center rounded-full border border-gray-200/80 bg-gray-50/90 px-2.5 py-1 text-[11px] font-medium text-gray-600">
|
|
80
|
+
<span className="truncate">{value}</span>
|
|
81
|
+
</span>
|
|
82
|
+
);
|
|
83
|
+
}
|
|
47
84
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
85
|
+
function ChildSessionPanelMetaStrip({ tab }: { tab: ResolvedChildSessionTab }) {
|
|
86
|
+
const metaItems = [
|
|
87
|
+
tab.sessionTypeLabel,
|
|
88
|
+
tab.preferredModel,
|
|
89
|
+
tab.projectName,
|
|
90
|
+
].filter((value): value is string => Boolean(value?.trim()));
|
|
91
|
+
|
|
92
|
+
if (metaItems.length === 0 && !tab.projectRoot) {
|
|
93
|
+
return null;
|
|
54
94
|
}
|
|
55
95
|
|
|
56
96
|
return (
|
|
57
|
-
<div className="
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
97
|
+
<div className="mt-3 space-y-2">
|
|
98
|
+
{metaItems.length > 0 ? (
|
|
99
|
+
<div className="flex flex-wrap gap-1.5">
|
|
100
|
+
{metaItems.map((item) => (
|
|
101
|
+
<ChildSessionPanelMetaChip key={item} value={item} />
|
|
102
|
+
))}
|
|
103
|
+
</div>
|
|
104
|
+
) : null}
|
|
105
|
+
{tab.projectRoot ? (
|
|
106
|
+
<div
|
|
107
|
+
title={tab.projectRoot}
|
|
108
|
+
className="truncate rounded-xl border border-gray-200/70 bg-gray-50/80 px-2.5 py-2 font-mono text-[11px] text-gray-500"
|
|
109
|
+
>
|
|
110
|
+
{tab.projectRoot}
|
|
111
|
+
</div>
|
|
112
|
+
) : null}
|
|
63
113
|
</div>
|
|
64
114
|
);
|
|
65
115
|
}
|
|
@@ -77,7 +127,9 @@ export function ChatChildSessionPanel({
|
|
|
77
127
|
resolvedTabs.find((tab) => tab.sessionKey === activeSessionKey) ??
|
|
78
128
|
resolvedTabs[0] ??
|
|
79
129
|
null;
|
|
80
|
-
const hasParentSession = resolvedTabs.some((tab) =>
|
|
130
|
+
const hasParentSession = resolvedTabs.some((tab) =>
|
|
131
|
+
Boolean(tab.parentSessionKey),
|
|
132
|
+
);
|
|
81
133
|
const shouldShowTabs = resolvedTabs.length > 1;
|
|
82
134
|
|
|
83
135
|
if (!activeTab) {
|
|
@@ -93,18 +145,18 @@ export function ChatChildSessionPanel({
|
|
|
93
145
|
type="button"
|
|
94
146
|
onClick={onBackToParent}
|
|
95
147
|
className={cn(
|
|
96
|
-
|
|
97
|
-
!hasParentSession &&
|
|
148
|
+
"inline-flex items-center gap-1 text-xs font-medium text-gray-600 transition-colors hover:text-gray-900",
|
|
149
|
+
!hasParentSession && "pointer-events-none opacity-0",
|
|
98
150
|
)}
|
|
99
151
|
>
|
|
100
152
|
<ArrowLeft className="h-3.5 w-3.5" />
|
|
101
|
-
<span>{t(
|
|
153
|
+
<span>{t("chatBackToParent")}</span>
|
|
102
154
|
</button>
|
|
103
155
|
<button
|
|
104
156
|
type="button"
|
|
105
157
|
onClick={onClose}
|
|
106
158
|
className="rounded-full border border-gray-200/80 p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-900"
|
|
107
|
-
aria-label={t(
|
|
159
|
+
aria-label={t("chatChildSessionClosePanel")}
|
|
108
160
|
>
|
|
109
161
|
<X className="h-4 w-4" />
|
|
110
162
|
</button>
|
|
@@ -138,20 +190,26 @@ export function ChatChildSessionPanel({
|
|
|
138
190
|
className="h-4 w-4 shrink-0"
|
|
139
191
|
/>
|
|
140
192
|
) : null}
|
|
141
|
-
<span className="max-w-[132px] truncate">
|
|
193
|
+
<span className="max-w-[132px] truncate">
|
|
194
|
+
{tab.title}
|
|
195
|
+
</span>
|
|
142
196
|
</TabsTrigger>
|
|
143
197
|
))}
|
|
144
198
|
</TabsList>
|
|
145
199
|
</Tabs>
|
|
146
200
|
</div>
|
|
147
201
|
) : null}
|
|
202
|
+
<ChildSessionPanelMetaStrip tab={activeTab} />
|
|
148
203
|
</div>
|
|
149
204
|
|
|
150
|
-
<div className="flex-1 min-h-0
|
|
205
|
+
<div className="flex-1 min-h-0">
|
|
151
206
|
{resolvedTabs.map((tab) => (
|
|
152
207
|
<div
|
|
153
208
|
key={tab.sessionKey}
|
|
154
|
-
className={cn(
|
|
209
|
+
className={cn(
|
|
210
|
+
"h-full",
|
|
211
|
+
tab.sessionKey === activeSessionKey ? "block" : "hidden",
|
|
212
|
+
)}
|
|
155
213
|
>
|
|
156
214
|
<ChildSessionPanelConversation
|
|
157
215
|
sessionKey={tab.sessionKey}
|
|
@@ -6,7 +6,9 @@ import {
|
|
|
6
6
|
resolveSelectedModelValue,
|
|
7
7
|
resolveSelectedThinkingLevelValue
|
|
8
8
|
} from '@/components/chat/chat-session-preference-governance';
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
shouldClearPendingProjectRootOverride
|
|
11
|
+
} from '@/components/chat/ncp/NcpChatPage';
|
|
10
12
|
|
|
11
13
|
const modelOptions = [
|
|
12
14
|
{
|
|
@@ -155,33 +157,28 @@ describe('resolveSelectedModelValue', () => {
|
|
|
155
157
|
});
|
|
156
158
|
});
|
|
157
159
|
|
|
158
|
-
describe('
|
|
159
|
-
it('does not
|
|
160
|
+
describe('shouldClearPendingProjectRootOverride', () => {
|
|
161
|
+
it('does not clear an unrelated session project override', () => {
|
|
160
162
|
expect(
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
163
|
+
shouldClearPendingProjectRootOverride({
|
|
164
|
+
pendingProjectRoot: '/tmp/project-alpha',
|
|
165
|
+
pendingProjectRootSessionKey: 'draft-project-alpha',
|
|
166
|
+
sessionKey: 'session-existing',
|
|
167
|
+
selectedSessionProjectRoot: '/tmp/project-alpha'
|
|
164
168
|
})
|
|
165
169
|
).toBe(false);
|
|
166
170
|
});
|
|
167
171
|
|
|
168
|
-
it('
|
|
172
|
+
it('clears the override only after the bound session reflects the same project root', () => {
|
|
169
173
|
expect(
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
174
|
+
shouldClearPendingProjectRootOverride({
|
|
175
|
+
pendingProjectRoot: '/tmp/project-alpha',
|
|
176
|
+
pendingProjectRootSessionKey: 'draft-after-refresh',
|
|
177
|
+
sessionKey: 'draft-after-refresh',
|
|
178
|
+
selectedSessionProjectRoot: '/tmp/project-alpha'
|
|
173
179
|
})
|
|
174
180
|
).toBe(true);
|
|
175
181
|
});
|
|
176
|
-
|
|
177
|
-
it('does not replace the draft session id while staying on the same session', () => {
|
|
178
|
-
expect(
|
|
179
|
-
shouldRefreshDraftSessionId({
|
|
180
|
-
previousSelectedSessionKey: 'session-1',
|
|
181
|
-
nextSelectedSessionKey: 'session-1'
|
|
182
|
-
})
|
|
183
|
-
).toBe(false);
|
|
184
|
-
});
|
|
185
182
|
});
|
|
186
183
|
|
|
187
184
|
describe('resolveRecentSessionPreferredModel', () => {
|
|
@@ -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
|
}
|
|
@@ -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,7 +26,7 @@ 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()
|
|
@@ -35,8 +36,9 @@ describe('ChatSessionListManager', () => {
|
|
|
35
36
|
manager.createSession('codex');
|
|
36
37
|
|
|
37
38
|
expect(streamActionsManager.resetStreamState).toHaveBeenCalledTimes(1);
|
|
38
|
-
expect(uiManager.
|
|
39
|
-
expect(useChatSessionListStore.getState().snapshot.selectedSessionKey).
|
|
39
|
+
expect(uiManager.goToSession).toHaveBeenCalledWith('draft-root-1');
|
|
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,7 +46,7 @@ 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()
|
|
@@ -54,7 +56,45 @@ describe('ChatSessionListManager', () => {
|
|
|
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).
|
|
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,6 +4,7 @@ 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(
|
|
@@ -45,8 +46,9 @@ export class ChatSessionListManager {
|
|
|
45
46
|
useChatSessionListStore.getState().setSnapshot({ listMode: value });
|
|
46
47
|
};
|
|
47
48
|
|
|
48
|
-
createSession = (sessionType?: string, projectRoot?: string | null) => {
|
|
49
|
+
createSession = (sessionType?: string, projectRoot?: string | null): string => {
|
|
49
50
|
const { snapshot } = useChatInputStore.getState();
|
|
51
|
+
const { snapshot: sessionListSnapshot } = useChatSessionListStore.getState();
|
|
50
52
|
const { defaultSessionType: configuredDefaultSessionType } = snapshot;
|
|
51
53
|
const defaultSessionType = configuredDefaultSessionType || 'native';
|
|
52
54
|
const nextSessionType =
|
|
@@ -54,14 +56,26 @@ export class ChatSessionListManager {
|
|
|
54
56
|
? sessionType.trim()
|
|
55
57
|
: defaultSessionType;
|
|
56
58
|
const normalizedProjectRoot = normalizeSessionProjectRootValue(projectRoot);
|
|
59
|
+
const nextSessionKey = sessionListSnapshot.draftSessionKey;
|
|
57
60
|
this.streamActionsManager.resetStreamState();
|
|
58
|
-
useChatSessionListStore.getState().setSnapshot({
|
|
61
|
+
useChatSessionListStore.getState().setSnapshot({
|
|
62
|
+
draftSessionKey: createNcpSessionId()
|
|
63
|
+
});
|
|
59
64
|
useChatInputStore.getState().setSnapshot({
|
|
60
65
|
pendingSessionType: nextSessionType,
|
|
61
66
|
pendingProjectRoot: normalizedProjectRoot,
|
|
62
|
-
pendingProjectRootSessionKey: null
|
|
67
|
+
pendingProjectRootSessionKey: normalizedProjectRoot ? nextSessionKey : null
|
|
63
68
|
});
|
|
64
|
-
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);
|
|
65
79
|
};
|
|
66
80
|
|
|
67
81
|
selectSession = (sessionKey: string) => {
|