@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.
Files changed (61) hide show
  1. package/CHANGELOG.md +97 -0
  2. package/dist/assets/{api-BGd3rgv_.js → api-D2xRKmZd.js} +2 -2
  3. package/dist/assets/{app-manager-provider-BuJ_U9eC.js → app-manager-provider-CNaZboG4.js} +1 -1
  4. package/dist/assets/{app-navigation.config-BTdUuqXS.js → app-navigation.config-Ihhrrt--.js} +1 -1
  5. package/dist/assets/{channels-list-page-BrwymXPe.js → channels-list-page-p26lgxLk.js} +1 -1
  6. package/dist/assets/{chat-DGM6K3Qs.js → chat-Dkh2qtuz.js} +8 -8
  7. package/dist/assets/{chat-page-DpmXMWNS.js → chat-page-DoTmE2wx.js} +1 -1
  8. package/dist/assets/{desktop-update-config-BGKiqc6q.js → desktop-update-config-DlpzDfKM.js} +1 -1
  9. package/dist/assets/{dialog-dxsKz7jJ.js → dialog-C3D7Be0p.js} +1 -1
  10. package/dist/assets/{dist-DsYTOyq7.js → dist-CPlbUgwU.js} +1 -1
  11. package/dist/assets/{es2015-V75WQJ2s.js → es2015-xqN1slyW.js} +1 -1
  12. package/dist/assets/{index-BrEdR78s.js → index-pBvbJ5Mt.js} +2 -2
  13. package/dist/assets/marketplace-page-Cql0kDi-.js +1 -0
  14. package/dist/assets/{marketplace-page-CPHxlYL8.js → marketplace-page-m4P5g_Ht.js} +1 -1
  15. package/dist/assets/mcp-marketplace-page-9WVKl1m1.js +1 -0
  16. package/dist/assets/{mcp-marketplace-page-CswPXSjf.js → mcp-marketplace-page-ByzBQZcx.js} +1 -1
  17. package/dist/assets/{model-config-Cmruiqdx.js → model-config-Dbr_0APb.js} +1 -1
  18. package/dist/assets/{notice-card-D1RNsTn_.js → notice-card-BFDbKQDA.js} +1 -1
  19. package/dist/assets/{popover-BMyiifTA.js → popover-B86Dbfhf.js} +1 -1
  20. package/dist/assets/{provider-scoped-model-input-D7ACiMAO.js → provider-scoped-model-input-DFm6N2f7.js} +1 -1
  21. package/dist/assets/{providers-list-gg7LrfuB.js → providers-list-BJcLOjun.js} +1 -1
  22. package/dist/assets/remote-BOxo9iwd.js +1 -0
  23. package/dist/assets/{runtime-config-page-BT_VV41p.js → runtime-config-page-CjLhnbSl.js} +1 -1
  24. package/dist/assets/{search-config-0VTPpz-w.js → search-config-J4Htco-P.js} +1 -1
  25. package/dist/assets/{secrets-config-DwQbLLEy.js → secrets-config-CUdERjco.js} +1 -1
  26. package/dist/assets/{select-DTdzR8j8.js → select-CJ0wbo3D.js} +1 -1
  27. package/dist/assets/{sessions-config-page-CAG7Zevv.js → sessions-config-page-DpK991fs.js} +2 -2
  28. package/dist/assets/{setting-row-CvKngoNI.js → setting-row-D1Yygqp7.js} +1 -1
  29. package/dist/assets/{tag-chip-BywQeHJj.js → tag-chip-FrkmkT8r.js} +1 -1
  30. package/dist/assets/{theme-provider-COAwWFv8.js → theme-provider-0hxjiPc_.js} +1 -1
  31. package/dist/assets/{tooltip-BOYp8Ue7.js → tooltip-Cj4yA0gH.js} +1 -1
  32. package/dist/assets/{use-config-DTwhNDQE.js → use-config-38Ur-89i.js} +1 -1
  33. package/dist/assets/{use-confirm-dialog-oeSqhmrx.js → use-confirm-dialog-DPQThaeU.js} +1 -1
  34. package/dist/assets/{use-infinite-scroll-loader-X3KGuME8.js → use-infinite-scroll-loader-5Gf1xQi7.js} +1 -1
  35. package/dist/assets/{use-viewport-layout-C0NJAVXs.js → use-viewport-layout-D1XzKeip.js} +1 -1
  36. package/dist/index.html +15 -15
  37. package/package.json +9 -9
  38. package/src/features/chat/components/chat-sidebar-session-item.tsx +1 -1
  39. package/src/features/chat/components/conversation/chat-conversation-panel.tsx +2 -2
  40. package/src/features/chat/components/layout/chat-sidebar.test.tsx +74 -0
  41. package/src/features/chat/components/layout/chat-sidebar.tsx +28 -29
  42. package/src/features/chat/hooks/use-hydrated-ncp-agent.test.tsx +6 -0
  43. package/src/features/chat/hooks/use-ncp-agent-runtime.test.tsx +158 -69
  44. package/src/features/chat/hooks/use-ncp-chat-derived-state.ts +2 -2
  45. package/src/features/chat/hooks/use-ncp-chat-page-data.ts +7 -7
  46. package/src/features/chat/hooks/use-ncp-session-conversation.test.tsx +10 -0
  47. package/src/features/chat/hooks/use-ncp-session-conversation.ts +2 -1
  48. package/src/features/chat/hooks/use-selected-session-context-window-indicator.ts +2 -4
  49. package/src/features/chat/managers/chat-session-list.manager.test.ts +19 -16
  50. package/src/features/chat/managers/chat-session-list.manager.ts +20 -24
  51. package/src/features/chat/managers/ncp-chat-input.manager.test.ts +23 -12
  52. package/src/features/chat/managers/ncp-chat-input.manager.ts +4 -2
  53. package/src/features/chat/pages/ncp-chat-page.tsx +23 -13
  54. package/src/features/chat/stores/chat-session-list.store.ts +2 -3
  55. package/src/features/chat/types/chat-stream.types.ts +1 -1
  56. package/src/features/chat/utils/ncp-session-adapter.utils.ts +1 -1
  57. package/src/shared/lib/api/ncp-session-query-cache.test.ts +26 -1
  58. package/src/shared/lib/api/ncp-session-query-cache.ts +5 -1
  59. package/dist/assets/marketplace-page-B2Pm2RDJ.js +0 -1
  60. package/dist/assets/mcp-marketplace-page-BcjVmw36.js +0 -1
  61. 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.23",
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.13",
32
- "@nextclaw/client-sdk": "0.1.3",
33
- "@nextclaw/agent-chat-ui": "0.3.15",
34
- "@nextclaw/shared": "0.1.2",
35
- "@nextclaw/ncp-react": "0.4.28",
36
- "@nextclaw/ncp-http-agent-client": "0.3.20",
37
- "@nextclaw/ncp": "0.5.8",
38
- "@nextclaw/server": "0.12.15"
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.updatedAt)}</span>
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
- const sessionKey = presenter.chatSessionListManager.createSession(sessionType);
277
- if (layoutMode === "mobile") presenter.chatUiManager.goToSession(sessionKey);
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 getSessionUpdatedAtTimestamp(item: NcpSessionListItemView): number {
44
- return new Date(item.session.updatedAt).getTime();
43
+ function getSessionActivityAtTimestamp(item: NcpSessionListItemView): number {
44
+ return new Date(item.session.lastMessageAt ?? item.session.createdAt).getTime();
45
45
  }
46
46
 
47
- function sortSessionItemsByUpdatedAtDesc(items: NcpSessionListItemView[]): NcpSessionListItemView[] {
48
- return [...items].sort((left, right) => getSessionUpdatedAtTimestamp(right) - getSessionUpdatedAtTimestamp(left));
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 { session } = item;
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 = getSessionUpdatedAtTimestamp(item);
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: sortSessionItemsByUpdatedAtDesc(group.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(() => sortSessionItemsByUpdatedAtDesc(items), [items]);
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
- const sessionKey = typeof projectRoot === "string"
252
- ? presenter.chatSessionListManager.createSession(sessionType, projectRoot)
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 { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3
- import { NcpEventType, type NcpEndpointEvent } from "@nextclaw/ncp";
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
- function createEvent(type: NcpEventType, delta: string): NcpEndpointEvent {
7
- return {
8
- type,
9
- payload: {
10
- sessionId: "session-1",
11
- messageId: "assistant-1",
12
- toolCallId: "tool-1",
13
- delta,
14
- },
15
- } as NcpEndpointEvent;
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.useFakeTimers();
132
+ vi.clearAllMocks();
21
133
  });
22
134
 
23
- afterEach(() => {
24
- vi.useRealTimers();
25
- });
26
-
27
- it("batches streamed endpoint events before dispatching them to the manager", async () => {
28
- let subscriber: ((event: NcpEndpointEvent) => void) | null = null;
29
- const snapshot = {
30
- messages: [],
31
- streamingMessage: null,
32
- error: null,
33
- activeRun: null,
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
- renderHook(() =>
55
- useNcpAgentRuntime({
56
- sessionId: "session-1",
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
- expect(subscriber).not.toBeNull();
63
-
153
+ let sendPromise: Promise<void>;
64
154
  act(() => {
65
- subscriber?.(createEvent(NcpEventType.MessageToolCallArgsDelta, '{"path":"src/app.ts",'));
66
- subscriber?.(
67
- createEvent(
68
- NcpEventType.MessageToolCallArgsDelta,
69
- '"content":"console.log(1);"}',
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
- expect(manager.dispatchBatch).not.toHaveBeenCalled();
162
+ rerender({ sessionId: "session-created" });
163
+
164
+ expect(client.stop).not.toHaveBeenCalled();
75
165
 
76
166
  await act(async () => {
77
- vi.advanceTimersByTime(16);
78
- await Promise.resolve();
167
+ client.release();
168
+ await sendPromise;
79
169
  });
80
170
 
81
- expect(manager.dispatchBatch).toHaveBeenCalledTimes(1);
82
- expect(manager.dispatchBatch).toHaveBeenCalledWith([
83
- createEvent(NcpEventType.MessageToolCallArgsDelta, '{"path":"src/app.ts",'),
84
- createEvent(
85
- NcpEventType.MessageToolCallArgsDelta,
86
- '"content":"console.log(1);"}',
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 === currentSessionKey && liveContextWindow) {
13
+ if (selectedSessionKey && liveSessionKey === selectedSessionKey && liveContextWindow) {
16
14
  return buildChatContextWindowIndicator(liveContextWindow);
17
15
  }
18
16
  return null;
19
- }, [currentSessionKey, liveContextWindow, liveSessionKey]);
17
+ }, [liveContextWindow, liveSessionKey, selectedSessionKey]);
20
18
  }