@nextclaw/ui 0.12.23 → 0.12.24
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 +97 -0
- package/dist/assets/{api-BGd3rgv_.js → api-D2xRKmZd.js} +2 -2
- package/dist/assets/{app-manager-provider-BuJ_U9eC.js → app-manager-provider-CNaZboG4.js} +1 -1
- package/dist/assets/{app-navigation.config-BTdUuqXS.js → app-navigation.config-Ihhrrt--.js} +1 -1
- package/dist/assets/{channels-list-page-BrwymXPe.js → channels-list-page-p26lgxLk.js} +1 -1
- package/dist/assets/{chat-DGM6K3Qs.js → chat-Dkh2qtuz.js} +8 -8
- package/dist/assets/{chat-page-DpmXMWNS.js → chat-page-DoTmE2wx.js} +1 -1
- package/dist/assets/{desktop-update-config-BGKiqc6q.js → desktop-update-config-DlpzDfKM.js} +1 -1
- package/dist/assets/{dialog-dxsKz7jJ.js → dialog-C3D7Be0p.js} +1 -1
- package/dist/assets/{dist-DsYTOyq7.js → dist-CPlbUgwU.js} +1 -1
- package/dist/assets/{es2015-V75WQJ2s.js → es2015-xqN1slyW.js} +1 -1
- package/dist/assets/{index-BrEdR78s.js → index-pBvbJ5Mt.js} +2 -2
- package/dist/assets/marketplace-page-Cql0kDi-.js +1 -0
- package/dist/assets/{marketplace-page-CPHxlYL8.js → marketplace-page-m4P5g_Ht.js} +1 -1
- package/dist/assets/mcp-marketplace-page-9WVKl1m1.js +1 -0
- package/dist/assets/{mcp-marketplace-page-CswPXSjf.js → mcp-marketplace-page-ByzBQZcx.js} +1 -1
- package/dist/assets/{model-config-Cmruiqdx.js → model-config-Dbr_0APb.js} +1 -1
- package/dist/assets/{notice-card-D1RNsTn_.js → notice-card-BFDbKQDA.js} +1 -1
- package/dist/assets/{popover-BMyiifTA.js → popover-B86Dbfhf.js} +1 -1
- package/dist/assets/{provider-scoped-model-input-D7ACiMAO.js → provider-scoped-model-input-DFm6N2f7.js} +1 -1
- package/dist/assets/{providers-list-gg7LrfuB.js → providers-list-BJcLOjun.js} +1 -1
- package/dist/assets/remote-BOxo9iwd.js +1 -0
- package/dist/assets/{runtime-config-page-BT_VV41p.js → runtime-config-page-CjLhnbSl.js} +1 -1
- package/dist/assets/{search-config-0VTPpz-w.js → search-config-J4Htco-P.js} +1 -1
- package/dist/assets/{secrets-config-DwQbLLEy.js → secrets-config-CUdERjco.js} +1 -1
- package/dist/assets/{select-DTdzR8j8.js → select-CJ0wbo3D.js} +1 -1
- package/dist/assets/{sessions-config-page-CAG7Zevv.js → sessions-config-page-DpK991fs.js} +2 -2
- package/dist/assets/{setting-row-CvKngoNI.js → setting-row-D1Yygqp7.js} +1 -1
- package/dist/assets/{tag-chip-BywQeHJj.js → tag-chip-FrkmkT8r.js} +1 -1
- package/dist/assets/{theme-provider-COAwWFv8.js → theme-provider-0hxjiPc_.js} +1 -1
- package/dist/assets/{tooltip-BOYp8Ue7.js → tooltip-Cj4yA0gH.js} +1 -1
- package/dist/assets/{use-config-DTwhNDQE.js → use-config-38Ur-89i.js} +1 -1
- package/dist/assets/{use-confirm-dialog-oeSqhmrx.js → use-confirm-dialog-DPQThaeU.js} +1 -1
- package/dist/assets/{use-infinite-scroll-loader-X3KGuME8.js → use-infinite-scroll-loader-5Gf1xQi7.js} +1 -1
- package/dist/assets/{use-viewport-layout-C0NJAVXs.js → use-viewport-layout-D1XzKeip.js} +1 -1
- package/dist/index.html +15 -15
- package/package.json +9 -9
- package/src/features/chat/components/chat-sidebar-session-item.tsx +1 -1
- package/src/features/chat/components/conversation/chat-conversation-panel.tsx +2 -2
- package/src/features/chat/components/layout/chat-sidebar.test.tsx +74 -0
- package/src/features/chat/components/layout/chat-sidebar.tsx +28 -29
- package/src/features/chat/hooks/use-hydrated-ncp-agent.test.tsx +6 -0
- package/src/features/chat/hooks/use-ncp-agent-runtime.test.tsx +158 -69
- package/src/features/chat/hooks/use-ncp-chat-derived-state.ts +2 -2
- package/src/features/chat/hooks/use-ncp-chat-page-data.ts +7 -7
- package/src/features/chat/hooks/use-ncp-session-conversation.test.tsx +10 -0
- package/src/features/chat/hooks/use-ncp-session-conversation.ts +2 -1
- package/src/features/chat/hooks/use-selected-session-context-window-indicator.ts +2 -4
- package/src/features/chat/managers/chat-session-list.manager.test.ts +19 -16
- package/src/features/chat/managers/chat-session-list.manager.ts +20 -24
- package/src/features/chat/managers/ncp-chat-input.manager.test.ts +23 -12
- package/src/features/chat/managers/ncp-chat-input.manager.ts +4 -2
- package/src/features/chat/pages/ncp-chat-page.tsx +23 -13
- package/src/features/chat/stores/chat-session-list.store.ts +2 -3
- package/src/features/chat/types/chat-stream.types.ts +1 -1
- package/src/features/chat/utils/ncp-session-adapter.utils.ts +1 -1
- package/src/shared/lib/api/ncp-session-query-cache.test.ts +26 -1
- package/src/shared/lib/api/ncp-session-query-cache.ts +5 -1
- package/dist/assets/marketplace-page-B2Pm2RDJ.js +0 -1
- package/dist/assets/mcp-marketplace-page-BcjVmw36.js +0 -1
- package/dist/assets/remote-Db2M39Cv.js +0 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nextclaw/ui",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.24",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -28,14 +28,14 @@
|
|
|
28
28
|
"tailwind-merge": "^2.5.4",
|
|
29
29
|
"zod": "^3.23.8",
|
|
30
30
|
"zustand": "^5.0.2",
|
|
31
|
-
"@nextclaw/agent-chat": "0.1.
|
|
32
|
-
"@nextclaw/
|
|
33
|
-
"@nextclaw/
|
|
34
|
-
"@nextclaw/
|
|
35
|
-
"@nextclaw/ncp-
|
|
36
|
-
"@nextclaw/ncp-
|
|
37
|
-
"@nextclaw/
|
|
38
|
-
"@nextclaw/
|
|
31
|
+
"@nextclaw/agent-chat": "0.1.14",
|
|
32
|
+
"@nextclaw/agent-chat-ui": "0.3.16",
|
|
33
|
+
"@nextclaw/client-sdk": "0.1.4",
|
|
34
|
+
"@nextclaw/ncp": "0.5.9",
|
|
35
|
+
"@nextclaw/ncp-http-agent-client": "0.3.21",
|
|
36
|
+
"@nextclaw/ncp-react": "0.4.29",
|
|
37
|
+
"@nextclaw/server": "0.12.16",
|
|
38
|
+
"@nextclaw/shared": "0.1.3"
|
|
39
39
|
},
|
|
40
40
|
"devDependencies": {
|
|
41
41
|
"@testing-library/react": "^16.3.0",
|
|
@@ -165,7 +165,7 @@ function ChatSidebarSessionDisplayView({
|
|
|
165
165
|
className="ml-auto h-2 w-2 shrink-0 rounded-full bg-primary"
|
|
166
166
|
/>
|
|
167
167
|
) : (
|
|
168
|
-
<span className="ml-auto shrink-0">{formatDateShort(session.
|
|
168
|
+
<span className="ml-auto shrink-0">{formatDateShort(session.lastMessageAt ?? session.createdAt)}</span>
|
|
169
169
|
)}
|
|
170
170
|
</div>
|
|
171
171
|
</button>
|
|
@@ -273,8 +273,8 @@ export function ChatConversationPanel({
|
|
|
273
273
|
resolveDraftAgent(snapshot.agentId ?? "main"),
|
|
274
274
|
defaultSessionType,
|
|
275
275
|
);
|
|
276
|
-
|
|
277
|
-
if (layoutMode === "mobile") presenter.chatUiManager.
|
|
276
|
+
presenter.chatSessionListManager.createSession(sessionType);
|
|
277
|
+
if (layoutMode === "mobile") presenter.chatUiManager.goToChatRoot();
|
|
278
278
|
};
|
|
279
279
|
const selectDraftAgent = (agentId: string) => {
|
|
280
280
|
presenter.chatSessionListManager.setSelectedAgentId(agentId);
|
|
@@ -341,6 +341,80 @@ describe('ChatSidebar create and list basics', () => {
|
|
|
341
341
|
});
|
|
342
342
|
});
|
|
343
343
|
|
|
344
|
+
describe('ChatSidebar activity ordering', () => {
|
|
345
|
+
beforeEach(resetSidebarTestState);
|
|
346
|
+
|
|
347
|
+
it('orders sessions by last message time and ignores metadata updatedAt changes', () => {
|
|
348
|
+
mocks.sessionItems = [
|
|
349
|
+
createSessionItem({
|
|
350
|
+
key: 'session:message-newer',
|
|
351
|
+
createdAt: '2026-03-19T08:00:00.000Z',
|
|
352
|
+
updatedAt: '2026-03-19T09:00:00.000Z',
|
|
353
|
+
lastMessageAt: '2026-03-19T09:00:00.000Z',
|
|
354
|
+
label: 'Message Newer',
|
|
355
|
+
sessionType: 'native',
|
|
356
|
+
sessionTypeMutable: false,
|
|
357
|
+
messageCount: 1
|
|
358
|
+
}),
|
|
359
|
+
createSessionItem({
|
|
360
|
+
key: 'session:metadata-newer',
|
|
361
|
+
createdAt: '2026-03-19T07:00:00.000Z',
|
|
362
|
+
updatedAt: '2026-03-19T12:00:00.000Z',
|
|
363
|
+
lastMessageAt: '2026-03-19T08:00:00.000Z',
|
|
364
|
+
label: 'Metadata Newer',
|
|
365
|
+
sessionType: 'native',
|
|
366
|
+
sessionTypeMutable: false,
|
|
367
|
+
messageCount: 1
|
|
368
|
+
})
|
|
369
|
+
];
|
|
370
|
+
|
|
371
|
+
render(
|
|
372
|
+
<MemoryRouter>
|
|
373
|
+
<ChatSidebar />
|
|
374
|
+
</MemoryRouter>
|
|
375
|
+
);
|
|
376
|
+
|
|
377
|
+
const messageNewer = screen.getByText('Message Newer');
|
|
378
|
+
const metadataNewer = screen.getByText('Metadata Newer');
|
|
379
|
+
|
|
380
|
+
expect(messageNewer.compareDocumentPosition(metadataNewer) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
it('uses createdAt for sorting sessions without messages', () => {
|
|
384
|
+
mocks.sessionItems = [
|
|
385
|
+
createSessionItem({
|
|
386
|
+
key: 'session:created-older',
|
|
387
|
+
createdAt: '2026-03-19T08:00:00.000Z',
|
|
388
|
+
updatedAt: '2026-03-19T12:00:00.000Z',
|
|
389
|
+
label: 'Created Older',
|
|
390
|
+
sessionType: 'native',
|
|
391
|
+
sessionTypeMutable: false,
|
|
392
|
+
messageCount: 0
|
|
393
|
+
}),
|
|
394
|
+
createSessionItem({
|
|
395
|
+
key: 'session:created-newer',
|
|
396
|
+
createdAt: '2026-03-19T09:00:00.000Z',
|
|
397
|
+
updatedAt: '2026-03-19T10:00:00.000Z',
|
|
398
|
+
label: 'Created Newer',
|
|
399
|
+
sessionType: 'native',
|
|
400
|
+
sessionTypeMutable: false,
|
|
401
|
+
messageCount: 0
|
|
402
|
+
})
|
|
403
|
+
];
|
|
404
|
+
|
|
405
|
+
render(
|
|
406
|
+
<MemoryRouter>
|
|
407
|
+
<ChatSidebar />
|
|
408
|
+
</MemoryRouter>
|
|
409
|
+
);
|
|
410
|
+
|
|
411
|
+
const createdNewer = screen.getByText('Created Newer');
|
|
412
|
+
const createdOlder = screen.getByText('Created Older');
|
|
413
|
+
|
|
414
|
+
expect(createdNewer.compareDocumentPosition(createdOlder) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
|
415
|
+
});
|
|
416
|
+
});
|
|
417
|
+
|
|
344
418
|
describe('ChatSidebar project-first mode', () => {
|
|
345
419
|
beforeEach(resetSidebarTestState);
|
|
346
420
|
|
|
@@ -40,12 +40,12 @@ type DateGroup = {
|
|
|
40
40
|
items: NcpSessionListItemView[];
|
|
41
41
|
};
|
|
42
42
|
|
|
43
|
-
function
|
|
44
|
-
return new Date(item.session.
|
|
43
|
+
function getSessionActivityAtTimestamp(item: NcpSessionListItemView): number {
|
|
44
|
+
return new Date(item.session.lastMessageAt ?? item.session.createdAt).getTime();
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
function
|
|
48
|
-
return [...items].sort((left, right) =>
|
|
47
|
+
function sortSessionItemsByActivityAtDesc(items: NcpSessionListItemView[]): NcpSessionListItemView[] {
|
|
48
|
+
return [...items].sort((left, right) => getSessionActivityAtTimestamp(right) - getSessionActivityAtTimestamp(left));
|
|
49
49
|
}
|
|
50
50
|
|
|
51
51
|
function groupSessionsByDate(items: NcpSessionListItemView[]): DateGroup[] {
|
|
@@ -60,8 +60,7 @@ function groupSessionsByDate(items: NcpSessionListItemView[]): DateGroup[] {
|
|
|
60
60
|
const older: NcpSessionListItemView[] = [];
|
|
61
61
|
|
|
62
62
|
for (const item of items) {
|
|
63
|
-
const
|
|
64
|
-
const ts = new Date(session.updatedAt).getTime();
|
|
63
|
+
const ts = getSessionActivityAtTimestamp(item);
|
|
65
64
|
if (ts >= todayStart) {
|
|
66
65
|
today.push(item);
|
|
67
66
|
} else if (ts >= yesterdayStart) {
|
|
@@ -90,7 +89,7 @@ function groupSessionsByProject(items: NcpSessionListItemView[]): ChatSidebarPro
|
|
|
90
89
|
continue;
|
|
91
90
|
}
|
|
92
91
|
const existingGroup = grouped.get(projectRoot);
|
|
93
|
-
const updatedAt =
|
|
92
|
+
const updatedAt = getSessionActivityAtTimestamp(item);
|
|
94
93
|
if (existingGroup) {
|
|
95
94
|
existingGroup.items.push(item);
|
|
96
95
|
existingGroup.latestUpdatedAt = Math.max(existingGroup.latestUpdatedAt, updatedAt);
|
|
@@ -107,11 +106,28 @@ function groupSessionsByProject(items: NcpSessionListItemView[]): ChatSidebarPro
|
|
|
107
106
|
return [...grouped.values()]
|
|
108
107
|
.map((group) => ({
|
|
109
108
|
...group,
|
|
110
|
-
items:
|
|
109
|
+
items: sortSessionItemsByActivityAtDesc(group.items)
|
|
111
110
|
}))
|
|
112
111
|
.sort((left, right) => right.latestUpdatedAt - left.latestUpdatedAt);
|
|
113
112
|
}
|
|
114
113
|
|
|
114
|
+
function groupChildSessionsByParentKey(items: NcpSessionListItemView[]): Map<string, NcpSessionListItemView[]> {
|
|
115
|
+
const grouped = new Map<string, NcpSessionListItemView[]>();
|
|
116
|
+
for (const item of items) {
|
|
117
|
+
const parentSessionKey = item.session.parentSessionId?.trim();
|
|
118
|
+
if (!parentSessionKey) {
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
const bucket = grouped.get(parentSessionKey) ?? [];
|
|
122
|
+
bucket.push(item);
|
|
123
|
+
grouped.set(parentSessionKey, bucket);
|
|
124
|
+
}
|
|
125
|
+
for (const bucket of grouped.values()) {
|
|
126
|
+
bucket.sort((left, right) => getSessionActivityAtTimestamp(right) - getSessionActivityAtTimestamp(left));
|
|
127
|
+
}
|
|
128
|
+
return grouped;
|
|
129
|
+
}
|
|
130
|
+
|
|
115
131
|
function sessionTitle(session: SessionEntryView): string {
|
|
116
132
|
if (session.label && session.label.trim()) {
|
|
117
133
|
return session.label.trim();
|
|
@@ -183,23 +199,8 @@ export function ChatSidebar({
|
|
|
183
199
|
() => new Map((agentsQuery.data?.agents ?? []).map((agent) => [agent.id, agent])),
|
|
184
200
|
[agentsQuery.data?.agents]
|
|
185
201
|
);
|
|
186
|
-
const sortedItems = useMemo(() =>
|
|
187
|
-
const childSessionsByParentKey = useMemo(() =>
|
|
188
|
-
const grouped = new Map<string, NcpSessionListItemView[]>();
|
|
189
|
-
for (const item of items) {
|
|
190
|
-
const parentSessionKey = item.session.parentSessionId?.trim();
|
|
191
|
-
if (!parentSessionKey) {
|
|
192
|
-
continue;
|
|
193
|
-
}
|
|
194
|
-
const bucket = grouped.get(parentSessionKey) ?? [];
|
|
195
|
-
bucket.push(item);
|
|
196
|
-
grouped.set(parentSessionKey, bucket);
|
|
197
|
-
}
|
|
198
|
-
for (const bucket of grouped.values()) {
|
|
199
|
-
bucket.sort((left, right) => getSessionUpdatedAtTimestamp(right) - getSessionUpdatedAtTimestamp(left));
|
|
200
|
-
}
|
|
201
|
-
return grouped;
|
|
202
|
-
}, [items]);
|
|
202
|
+
const sortedItems = useMemo(() => sortSessionItemsByActivityAtDesc(items), [items]);
|
|
203
|
+
const childSessionsByParentKey = useMemo(() => groupChildSessionsByParentKey(items), [items]);
|
|
203
204
|
const groups = useMemo(() => groupSessionsByDate(sortedItems), [sortedItems]);
|
|
204
205
|
const projectGroups = useMemo(() => groupSessionsByProject(sortedItems), [sortedItems]);
|
|
205
206
|
const defaultSessionType = inputSnapshot.defaultSessionType || 'native';
|
|
@@ -248,10 +249,8 @@ export function ChatSidebar({
|
|
|
248
249
|
/>
|
|
249
250
|
);
|
|
250
251
|
const createSessionAndOpenIfNeeded = (sessionType: string, projectRoot?: string | null) => {
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
: presenter.chatSessionListManager.createSession(sessionType);
|
|
254
|
-
if (isMobileVariant) presenter.chatUiManager.goToSession(sessionKey);
|
|
252
|
+
presenter.chatSessionListManager.createSession(sessionType, typeof projectRoot === "string" ? projectRoot : undefined);
|
|
253
|
+
if (isMobileVariant) presenter.chatUiManager.goToChatRoot();
|
|
255
254
|
};
|
|
256
255
|
|
|
257
256
|
return (
|
|
@@ -7,6 +7,11 @@ const mocks = vi.hoisted(() => ({
|
|
|
7
7
|
manager: {
|
|
8
8
|
reset: vi.fn(),
|
|
9
9
|
hydrate: vi.fn(),
|
|
10
|
+
getSnapshot: vi.fn(() => ({
|
|
11
|
+
messages: [],
|
|
12
|
+
streamingMessage: null,
|
|
13
|
+
activeRun: null,
|
|
14
|
+
})),
|
|
10
15
|
},
|
|
11
16
|
runtime: {
|
|
12
17
|
snapshot: {
|
|
@@ -37,6 +42,7 @@ describe("useHydratedNcpAgent", () => {
|
|
|
37
42
|
beforeEach(() => {
|
|
38
43
|
mocks.manager.reset.mockReset();
|
|
39
44
|
mocks.manager.hydrate.mockReset();
|
|
45
|
+
mocks.manager.getSnapshot.mockClear();
|
|
40
46
|
mocks.runtime.send.mockReset();
|
|
41
47
|
mocks.runtime.abort.mockReset();
|
|
42
48
|
mocks.runtime.streamRun.mockReset();
|
|
@@ -1,90 +1,179 @@
|
|
|
1
|
-
import { act, renderHook } from "@testing-library/react";
|
|
2
|
-
import {
|
|
3
|
-
|
|
1
|
+
import { act, renderHook, waitFor } from "@testing-library/react";
|
|
2
|
+
import {
|
|
3
|
+
type NcpAgentClientEndpoint,
|
|
4
|
+
type NcpAgentSendEnvelope,
|
|
5
|
+
type NcpEndpointEvent,
|
|
6
|
+
type NcpEndpointManifest,
|
|
7
|
+
type NcpEndpointSubscriber,
|
|
8
|
+
NcpEventType,
|
|
9
|
+
} from "@nextclaw/ncp";
|
|
10
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
11
|
+
import { DefaultNcpAgentConversationStateManager } from "../../../../../ncp-packages/nextclaw-ncp-toolkit/src/agent/agent-conversation-state-manager.ts";
|
|
4
12
|
import { useNcpAgentRuntime } from "../../../../../ncp-packages/nextclaw-ncp-react/src/hooks/use-ncp-agent-runtime.ts";
|
|
5
13
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
14
|
+
const now = "2026-05-14T00:00:00.000Z";
|
|
15
|
+
|
|
16
|
+
class DeferredSendClient implements NcpAgentClientEndpoint {
|
|
17
|
+
readonly manifest: NcpEndpointManifest = {
|
|
18
|
+
endpointKind: "agent",
|
|
19
|
+
endpointId: "deferred-send-client",
|
|
20
|
+
version: "0.1.0",
|
|
21
|
+
supportsStreaming: true,
|
|
22
|
+
supportsAbort: true,
|
|
23
|
+
supportsProactiveMessages: false,
|
|
24
|
+
supportsLiveSessionStream: true,
|
|
25
|
+
supportedPartTypes: ["text"],
|
|
26
|
+
expectedLatency: "seconds",
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
readonly stop = vi.fn(async () => {});
|
|
30
|
+
readonly start = vi.fn(async () => {});
|
|
31
|
+
readonly stream = vi.fn(async () => {});
|
|
32
|
+
readonly abort = vi.fn(async () => {});
|
|
33
|
+
private listeners = new Set<NcpEndpointSubscriber>();
|
|
34
|
+
private releaseCompletion: (() => void) | null = null;
|
|
35
|
+
private completionGate = new Promise<void>((resolve) => {
|
|
36
|
+
this.releaseCompletion = resolve;
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
emit = async (event: NcpEndpointEvent): Promise<void> => {
|
|
40
|
+
this.publish(event);
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
subscribe = (listener: NcpEndpointSubscriber): (() => void) => {
|
|
44
|
+
this.listeners.add(listener);
|
|
45
|
+
return () => {
|
|
46
|
+
this.listeners.delete(listener);
|
|
47
|
+
};
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
send = vi.fn(async (_envelope: NcpAgentSendEnvelope): Promise<void> => {
|
|
51
|
+
this.publish({
|
|
52
|
+
type: NcpEventType.MessageSent,
|
|
53
|
+
payload: {
|
|
54
|
+
sessionId: "session-created",
|
|
55
|
+
message: {
|
|
56
|
+
id: "user-1",
|
|
57
|
+
sessionId: "session-created",
|
|
58
|
+
role: "user",
|
|
59
|
+
status: "final",
|
|
60
|
+
parts: [{ type: "text", text: "hello" }],
|
|
61
|
+
timestamp: now,
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
this.publish({
|
|
66
|
+
type: NcpEventType.RunStarted,
|
|
67
|
+
payload: {
|
|
68
|
+
sessionId: "session-created",
|
|
69
|
+
messageId: "assistant-1",
|
|
70
|
+
runId: "run-1",
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
await this.completionGate;
|
|
74
|
+
this.publish({
|
|
75
|
+
type: NcpEventType.MessageTextStart,
|
|
76
|
+
payload: {
|
|
77
|
+
sessionId: "session-created",
|
|
78
|
+
messageId: "assistant-1",
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
this.publish({
|
|
82
|
+
type: NcpEventType.MessageTextDelta,
|
|
83
|
+
payload: {
|
|
84
|
+
sessionId: "session-created",
|
|
85
|
+
messageId: "assistant-1",
|
|
86
|
+
delta: "done",
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
this.publish({
|
|
90
|
+
type: NcpEventType.MessageTextEnd,
|
|
91
|
+
payload: {
|
|
92
|
+
sessionId: "session-created",
|
|
93
|
+
messageId: "assistant-1",
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
this.publish({
|
|
97
|
+
type: NcpEventType.MessageCompleted,
|
|
98
|
+
payload: {
|
|
99
|
+
sessionId: "session-created",
|
|
100
|
+
message: {
|
|
101
|
+
id: "assistant-1",
|
|
102
|
+
sessionId: "session-created",
|
|
103
|
+
role: "assistant",
|
|
104
|
+
status: "final",
|
|
105
|
+
parts: [{ type: "text", text: "done" }],
|
|
106
|
+
timestamp: now,
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
this.publish({
|
|
111
|
+
type: NcpEventType.RunFinished,
|
|
112
|
+
payload: {
|
|
113
|
+
sessionId: "session-created",
|
|
114
|
+
runId: "run-1",
|
|
115
|
+
},
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
release = (): void => {
|
|
120
|
+
this.releaseCompletion?.();
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
private publish = (event: NcpEndpointEvent): void => {
|
|
124
|
+
for (const listener of this.listeners) {
|
|
125
|
+
listener(event);
|
|
126
|
+
}
|
|
127
|
+
};
|
|
16
128
|
}
|
|
17
129
|
|
|
18
130
|
describe("useNcpAgentRuntime", () => {
|
|
19
131
|
beforeEach(() => {
|
|
20
|
-
vi.
|
|
132
|
+
vi.clearAllMocks();
|
|
21
133
|
});
|
|
22
134
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
};
|
|
35
|
-
const client = {
|
|
36
|
-
subscribe: vi.fn((callback: (event: NcpEndpointEvent) => void) => {
|
|
37
|
-
subscriber = callback;
|
|
38
|
-
return () => {
|
|
39
|
-
subscriber = null;
|
|
40
|
-
};
|
|
41
|
-
}),
|
|
42
|
-
stop: vi.fn().mockResolvedValue(undefined),
|
|
43
|
-
send: vi.fn().mockResolvedValue(undefined),
|
|
44
|
-
abort: vi.fn().mockResolvedValue(undefined),
|
|
45
|
-
stream: vi.fn().mockResolvedValue(undefined),
|
|
46
|
-
};
|
|
47
|
-
const manager = {
|
|
48
|
-
getSnapshot: vi.fn(() => snapshot),
|
|
49
|
-
subscribe: vi.fn(() => () => {}),
|
|
50
|
-
dispatch: vi.fn().mockResolvedValue(undefined),
|
|
51
|
-
dispatchBatch: vi.fn().mockResolvedValue(undefined),
|
|
135
|
+
it("keeps the active send stream alive when a new root chat materializes a session id", async () => {
|
|
136
|
+
const client = new DeferredSendClient();
|
|
137
|
+
const manager = new DefaultNcpAgentConversationStateManager();
|
|
138
|
+
const envelope: NcpAgentSendEnvelope = {
|
|
139
|
+
message: {
|
|
140
|
+
id: "user-1",
|
|
141
|
+
role: "user",
|
|
142
|
+
status: "final",
|
|
143
|
+
parts: [{ type: "text", text: "hello" }],
|
|
144
|
+
timestamp: now,
|
|
145
|
+
},
|
|
52
146
|
};
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
client: client as never,
|
|
58
|
-
manager: manager as never,
|
|
59
|
-
}),
|
|
147
|
+
const { result, rerender } = renderHook(
|
|
148
|
+
({ sessionId }: { sessionId?: string }) =>
|
|
149
|
+
useNcpAgentRuntime({ sessionId, client, manager: manager as never }),
|
|
150
|
+
{ initialProps: { sessionId: undefined as string | undefined } },
|
|
60
151
|
);
|
|
61
152
|
|
|
62
|
-
|
|
63
|
-
|
|
153
|
+
let sendPromise: Promise<void>;
|
|
64
154
|
act(() => {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
),
|
|
71
|
-
);
|
|
155
|
+
sendPromise = result.current.send(envelope);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
await waitFor(() => {
|
|
159
|
+
expect(result.current.snapshot.activeRun?.sessionId).toBe("session-created");
|
|
72
160
|
});
|
|
73
161
|
|
|
74
|
-
|
|
162
|
+
rerender({ sessionId: "session-created" });
|
|
163
|
+
|
|
164
|
+
expect(client.stop).not.toHaveBeenCalled();
|
|
75
165
|
|
|
76
166
|
await act(async () => {
|
|
77
|
-
|
|
78
|
-
await
|
|
167
|
+
client.release();
|
|
168
|
+
await sendPromise;
|
|
79
169
|
});
|
|
80
170
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
),
|
|
171
|
+
await waitFor(() => {
|
|
172
|
+
expect(result.current.snapshot.activeRun).toBeNull();
|
|
173
|
+
});
|
|
174
|
+
expect(result.current.visibleMessages.map((message) => message.id)).toEqual([
|
|
175
|
+
"user-1",
|
|
176
|
+
"assistant-1",
|
|
88
177
|
]);
|
|
89
178
|
});
|
|
90
179
|
});
|
|
@@ -113,7 +113,7 @@ export function useNcpChatSnapshotSync(params: {
|
|
|
113
113
|
sessionTypeUnavailableMessage: string | null;
|
|
114
114
|
currentSessionTypeLabel: string;
|
|
115
115
|
currentSessionTypeIcon: ChatSessionTypeOption['icon'];
|
|
116
|
-
sessionKey: string;
|
|
116
|
+
sessionKey: string | null | undefined;
|
|
117
117
|
currentAgentId: string;
|
|
118
118
|
currentAgent: AgentProfileView | null;
|
|
119
119
|
availableAgents: AgentProfileView[];
|
|
@@ -152,7 +152,7 @@ export function useNcpChatSnapshotSync(params: {
|
|
|
152
152
|
sessionTypeUnavailableMessage: params.sessionTypeUnavailableMessage,
|
|
153
153
|
sessionTypeLabel: params.currentSessionTypeLabel,
|
|
154
154
|
sessionTypeIcon: params.currentSessionTypeIcon,
|
|
155
|
-
sessionKey: params.sessionKey,
|
|
155
|
+
sessionKey: params.sessionKey ?? null,
|
|
156
156
|
agentId: params.currentAgentId,
|
|
157
157
|
agentDisplayName: params.currentAgent?.displayName ?? null,
|
|
158
158
|
agentAvatarUrl: params.currentAgent?.avatarUrl ?? null,
|
|
@@ -23,7 +23,7 @@ export type { ChatModelOption } from '@/features/chat/types/chat-input.types';
|
|
|
23
23
|
|
|
24
24
|
type UseNcpChatPageDataParams = {
|
|
25
25
|
query: string;
|
|
26
|
-
sessionKey: string;
|
|
26
|
+
sessionKey: string | null;
|
|
27
27
|
projectRootOverride?: string | null;
|
|
28
28
|
currentSelectedModel: string;
|
|
29
29
|
pendingSessionType: string;
|
|
@@ -76,7 +76,7 @@ function useNcpChatModelOptions(params: {
|
|
|
76
76
|
|
|
77
77
|
function useRecentSessionPreferences(params: {
|
|
78
78
|
sessions: SessionEntryView[];
|
|
79
|
-
sessionKey: string;
|
|
79
|
+
sessionKey: string | null;
|
|
80
80
|
sessionType: string;
|
|
81
81
|
}) {
|
|
82
82
|
const { sessions, sessionKey, sessionType } = params;
|
|
@@ -84,7 +84,7 @@ function useRecentSessionPreferences(params: {
|
|
|
84
84
|
() =>
|
|
85
85
|
resolveRecentSessionPreferredValue<string>({
|
|
86
86
|
sessions,
|
|
87
|
-
selectedSessionKey: sessionKey,
|
|
87
|
+
selectedSessionKey: sessionKey ?? '',
|
|
88
88
|
sessionType,
|
|
89
89
|
readPreference: (session) => session.preferredModel?.trim() || undefined
|
|
90
90
|
}),
|
|
@@ -94,7 +94,7 @@ function useRecentSessionPreferences(params: {
|
|
|
94
94
|
() =>
|
|
95
95
|
resolveRecentSessionPreferredValue<ThinkingLevel>({
|
|
96
96
|
sessions,
|
|
97
|
-
selectedSessionKey: sessionKey,
|
|
97
|
+
selectedSessionKey: sessionKey ?? '',
|
|
98
98
|
sessionType,
|
|
99
99
|
readPreference: (session) => session.preferredThinking ?? undefined
|
|
100
100
|
}),
|
|
@@ -158,7 +158,7 @@ export function useNcpChatPageData(params: UseNcpChatPageDataParams) {
|
|
|
158
158
|
const sessionsQuery = useNcpSessions({ limit: 200 });
|
|
159
159
|
const sessionTypesQuery = useNcpChatSessionTypes();
|
|
160
160
|
const sessionSkillsQuery = useNcpSessionSkills({
|
|
161
|
-
sessionId: sessionKey,
|
|
161
|
+
sessionId: sessionKey ?? null,
|
|
162
162
|
...(Object.prototype.hasOwnProperty.call(params, 'projectRootOverride')
|
|
163
163
|
? { projectRoot: projectRootOverride ?? null }
|
|
164
164
|
: {})
|
|
@@ -219,7 +219,7 @@ export function useNcpChatPageData(params: UseNcpChatPageDataParams) {
|
|
|
219
219
|
|
|
220
220
|
useSyncSelectedModel({
|
|
221
221
|
modelOptions: filteredModelOptions,
|
|
222
|
-
selectedSessionKey: sessionKey,
|
|
222
|
+
selectedSessionKey: sessionKey ?? '',
|
|
223
223
|
selectedSessionExists: Boolean(selectedSession),
|
|
224
224
|
selectedSessionPreferredModel: selectedSession?.preferredModel,
|
|
225
225
|
fallbackPreferredModel: sessionTypeState.selectedSessionTypeOption?.recommendedModel ?? recentSessionPreferredModel,
|
|
@@ -228,7 +228,7 @@ export function useNcpChatPageData(params: UseNcpChatPageDataParams) {
|
|
|
228
228
|
});
|
|
229
229
|
useSyncSelectedThinking({
|
|
230
230
|
supportedThinkingLevels,
|
|
231
|
-
selectedSessionKey: sessionKey,
|
|
231
|
+
selectedSessionKey: sessionKey ?? '',
|
|
232
232
|
selectedSessionExists: Boolean(selectedSession),
|
|
233
233
|
selectedSessionPreferredThinking: selectedSession?.preferredThinking ?? null,
|
|
234
234
|
fallbackPreferredThinking: recentSessionPreferredThinking ?? null,
|
|
@@ -146,6 +146,16 @@ describe("useNcpSessionConversation", () => {
|
|
|
146
146
|
expect(mocks.hydratedCalls[1]?.client).toBe(mocks.clientInstances[1]);
|
|
147
147
|
});
|
|
148
148
|
|
|
149
|
+
it("passes an empty session through without requesting a draft history seed", () => {
|
|
150
|
+
renderHook(() => useNcpSessionConversation(undefined));
|
|
151
|
+
|
|
152
|
+
expect(mocks.useHydratedNcpAgent).toHaveBeenCalledTimes(1);
|
|
153
|
+
expect(mocks.hydratedCalls[0]).toMatchObject({
|
|
154
|
+
sessionId: undefined,
|
|
155
|
+
});
|
|
156
|
+
expect(mocks.fetchNcpSessionMessages).not.toHaveBeenCalled();
|
|
157
|
+
});
|
|
158
|
+
|
|
149
159
|
it("exposes the hydrated session context window without changing the generic ncp agent seed", async () => {
|
|
150
160
|
const contextWindow = {
|
|
151
161
|
usedContextTokens: 42,
|
|
@@ -93,7 +93,7 @@ function useSyncReadyRetryVersion(
|
|
|
93
93
|
}
|
|
94
94
|
|
|
95
95
|
export function useNcpSessionConversation(
|
|
96
|
-
sessionId: string,
|
|
96
|
+
sessionId: string | undefined,
|
|
97
97
|
options: UseNcpSessionConversationOptions = {},
|
|
98
98
|
) {
|
|
99
99
|
const [client] = useState(() => createNcpSessionConversationClient());
|
|
@@ -126,6 +126,7 @@ export function useNcpSessionConversation(
|
|
|
126
126
|
const currentAgentError =
|
|
127
127
|
agent.hydrateError?.message ?? agent.snapshot.error?.message ?? null;
|
|
128
128
|
const readyRetrySignature =
|
|
129
|
+
sessionId &&
|
|
129
130
|
systemStatus.phase === "ready" &&
|
|
130
131
|
isNcpAgentStartupUnavailableErrorMessage(currentAgentError)
|
|
131
132
|
? `${sessionId}:${systemStatus.lastReadyAt ?? 0}`
|
|
@@ -6,15 +6,13 @@ import { buildChatContextWindowIndicator } from '@/features/chat/utils/chat-cont
|
|
|
6
6
|
|
|
7
7
|
export function useSelectedSessionContextWindowIndicator(): ChatContextWindowIndicator | null {
|
|
8
8
|
const selectedSessionKey = useChatSessionListStore((state) => state.snapshot.selectedSessionKey);
|
|
9
|
-
const draftSessionKey = useChatSessionListStore((state) => state.snapshot.draftSessionKey);
|
|
10
9
|
const liveSessionKey = useChatThreadStore((state) => state.snapshot.sessionKey);
|
|
11
10
|
const liveContextWindow = useChatThreadStore((state) => state.snapshot.contextWindow);
|
|
12
|
-
const currentSessionKey = selectedSessionKey ?? draftSessionKey;
|
|
13
11
|
|
|
14
12
|
return useMemo(() => {
|
|
15
|
-
if (liveSessionKey ===
|
|
13
|
+
if (selectedSessionKey && liveSessionKey === selectedSessionKey && liveContextWindow) {
|
|
16
14
|
return buildChatContextWindowIndicator(liveContextWindow);
|
|
17
15
|
}
|
|
18
16
|
return null;
|
|
19
|
-
}, [
|
|
17
|
+
}, [liveContextWindow, liveSessionKey, selectedSessionKey]);
|
|
20
18
|
}
|