@nextclaw/ui 0.12.22 → 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 (117) hide show
  1. package/CHANGELOG.md +103 -0
  2. package/dist/assets/{api-lwyw9j7i.js → api-D2xRKmZd.js} +5 -5
  3. package/dist/assets/app-manager-provider-CNaZboG4.js +1 -0
  4. package/dist/assets/{app-navigation.config-DgiR0c5_.js → app-navigation.config-Ihhrrt--.js} +1 -1
  5. package/dist/assets/{book-open-DgLqYpNY.js → book-open-DDlN5MvX.js} +1 -1
  6. package/dist/assets/{channels-list-page-Dl839n02.js → channels-list-page-p26lgxLk.js} +2 -2
  7. package/dist/assets/{chat-DwUf7AKR.js → chat-Dkh2qtuz.js} +8 -8
  8. package/dist/assets/{chat-page-B-FvPmA7.js → chat-page-DoTmE2wx.js} +1 -1
  9. package/dist/assets/{chunk-JZWAC4HX-u4uYphxM.js → chunk-JZWAC4HX-Kydj4yEz.js} +1 -1
  10. package/dist/assets/{config-split-page-BMRGuCJQ.js → config-split-page-DIOCjj2Q.js} +1 -1
  11. package/dist/assets/{createLucideIcon-BZkY6emz.js → createLucideIcon-BLMK3QUd.js} +1 -1
  12. package/dist/assets/{desktop-update-config-D5g_gPak.js → desktop-update-config-DlpzDfKM.js} +1 -1
  13. package/dist/assets/{dialog-CdtCU2xX.js → dialog-C3D7Be0p.js} +1 -1
  14. package/dist/assets/{dist-CuqvE--P.js → dist-CPlbUgwU.js} +1 -1
  15. package/dist/assets/{doc-browser-BUlCkZo2.js → doc-browser-C8FM5fC0.js} +1 -1
  16. package/dist/assets/doc-browser-RJUOL_GO.js +1 -0
  17. package/dist/assets/{doc-browser-context-DfLHAWbG.js → doc-browser-context-BJuMaI3o.js} +1 -1
  18. package/dist/assets/{doc-browser-CzCV73NJ.js → doc-browser-p82AdNO-.js} +1 -1
  19. package/dist/assets/{es2015-yYU5Ad5w.js → es2015-xqN1slyW.js} +1 -1
  20. package/dist/assets/{external-link-Sw3ah_JD.js → external-link-DwfSfTLB.js} +1 -1
  21. package/dist/assets/{folder-D7-VTnkz.js → folder-CeJKPx5P.js} +1 -1
  22. package/dist/assets/{hash-zajSTDXZ.js → hash-BqxRTZW5.js} +1 -1
  23. package/dist/assets/i18n-DnTGDIRw.js +1 -0
  24. package/dist/assets/{index-Doxyk7L2.js → index-pBvbJ5Mt.js} +2 -2
  25. package/dist/assets/{key-round-CnI1mc9F.js → key-round-CJ5gDAAG.js} +1 -1
  26. package/dist/assets/loader-circle-fd-vQKtW.js +1 -0
  27. package/dist/assets/{logo-badge-BQgKnVtz.js → logo-badge-KAe-7d8c.js} +1 -1
  28. package/dist/assets/{logos-CqVm0q0W.js → logos-C4sYP1Vl.js} +1 -1
  29. package/dist/assets/marketplace-page-Cql0kDi-.js +1 -0
  30. package/dist/assets/{marketplace-page-CawcdL6Y.js → marketplace-page-m4P5g_Ht.js} +1 -1
  31. package/dist/assets/mcp-marketplace-page-9WVKl1m1.js +1 -0
  32. package/dist/assets/{mcp-marketplace-page-DEGfJ_70.js → mcp-marketplace-page-ByzBQZcx.js} +1 -1
  33. package/dist/assets/message-square-z_osm9c0.js +1 -0
  34. package/dist/assets/{model-config-r-1RPSrZ.js → model-config-Dbr_0APb.js} +1 -1
  35. package/dist/assets/{notice-card-BPtCVEKW.js → notice-card-BFDbKQDA.js} +1 -1
  36. package/dist/assets/play-Dv6Nr1Ew.js +1 -0
  37. package/dist/assets/plus-D8eKFY7h.js +1 -0
  38. package/dist/assets/{popover-jbfQhYQh.js → popover-B86Dbfhf.js} +1 -1
  39. package/dist/assets/{provider-scoped-model-input-gdk2lmRi.js → provider-scoped-model-input-DFm6N2f7.js} +1 -1
  40. package/dist/assets/{providers-list-DpISIr3M.js → providers-list-BJcLOjun.js} +1 -1
  41. package/dist/assets/{refresh-ccw-Bii4w8aB.js → refresh-ccw-ByVwmnN_.js} +1 -1
  42. package/dist/assets/{refresh-cw-BxojR62w.js → refresh-cw-PcqoYB3K.js} +1 -1
  43. package/dist/assets/remote-BOxo9iwd.js +1 -0
  44. package/dist/assets/{rotate-cw-1Xqa7LZ8.js → rotate-cw-BZ2JObNs.js} +1 -1
  45. package/dist/assets/runtime-config-page-CjLhnbSl.js +1 -0
  46. package/dist/assets/{save--BVI5wZX.js → save-euRxl8pI.js} +1 -1
  47. package/dist/assets/{search-vChioOoe.js → search-CLd7m0M7.js} +1 -1
  48. package/dist/assets/{search-config-BWqz8nqY.js → search-config-J4Htco-P.js} +1 -1
  49. package/dist/assets/{secrets-config-CjzSNg0Y.js → secrets-config-CUdERjco.js} +1 -1
  50. package/dist/assets/{select-Cw5Zkb1w.js → select-CJ0wbo3D.js} +1 -1
  51. package/dist/assets/{sessions-config-page-beoDPtII.js → sessions-config-page-DpK991fs.js} +2 -2
  52. package/dist/assets/{setting-row-Cjl2d40s.js → setting-row-D1Yygqp7.js} +1 -1
  53. package/dist/assets/{settings-CiRChctQ.js → settings-drbWqzA4.js} +1 -1
  54. package/dist/assets/skeleton-BK1SOSRA.js +1 -0
  55. package/dist/assets/{sparkles-D1ZKWdm4.js → sparkles-DVfeSVJQ.js} +1 -1
  56. package/dist/assets/{status-dot-Dv_hiUVa.js → status-dot-ChvPCib9.js} +1 -1
  57. package/dist/assets/{tabs-custom-CsACkVji.js → tabs-custom-Hia_ong0.js} +1 -1
  58. package/dist/assets/{tag-chip-CoWHxYJj.js → tag-chip-FrkmkT8r.js} +1 -1
  59. package/dist/assets/theme-provider-0hxjiPc_.js +2 -0
  60. package/dist/assets/{tooltip-GYzH-Hfq.js → tooltip-Cj4yA0gH.js} +1 -1
  61. package/dist/assets/{trash-2-rY9ZteZX.js → trash-2-CBsHCfqq.js} +1 -1
  62. package/dist/assets/{use-config-BhJHD3-G.js → use-config-38Ur-89i.js} +1 -1
  63. package/dist/assets/{use-confirm-dialog-Bqgy3Gi-.js → use-confirm-dialog-DPQThaeU.js} +1 -1
  64. package/dist/assets/{use-infinite-scroll-loader-BfexitoF.js → use-infinite-scroll-loader-5Gf1xQi7.js} +1 -1
  65. package/dist/assets/{use-viewport-layout-D33zVbr5.js → use-viewport-layout-D1XzKeip.js} +1 -1
  66. package/dist/assets/x-CM-XDMpk.js +1 -0
  67. package/dist/index.html +39 -39
  68. package/package.json +9 -9
  69. package/src/features/account/hooks/use-auth.test.ts +7 -5
  70. package/src/features/account/hooks/use-auth.ts +23 -20
  71. package/src/features/chat/components/chat-sidebar-session-item.tsx +1 -1
  72. package/src/features/chat/components/conversation/chat-conversation-panel.tsx +2 -2
  73. package/src/features/chat/components/layout/chat-sidebar.test.tsx +74 -0
  74. package/src/features/chat/components/layout/chat-sidebar.tsx +28 -29
  75. package/src/features/chat/hooks/use-hydrated-ncp-agent.test.tsx +6 -0
  76. package/src/features/chat/hooks/use-ncp-agent-runtime.test.tsx +158 -69
  77. package/src/features/chat/hooks/use-ncp-chat-derived-state.ts +2 -2
  78. package/src/features/chat/hooks/use-ncp-chat-page-data.ts +7 -7
  79. package/src/features/chat/hooks/use-ncp-session-conversation.test.tsx +10 -0
  80. package/src/features/chat/hooks/use-ncp-session-conversation.ts +2 -1
  81. package/src/features/chat/hooks/use-selected-session-context-window-indicator.ts +2 -4
  82. package/src/features/chat/managers/chat-session-list.manager.test.ts +19 -16
  83. package/src/features/chat/managers/chat-session-list.manager.ts +20 -24
  84. package/src/features/chat/managers/ncp-chat-input.manager.test.ts +23 -12
  85. package/src/features/chat/managers/ncp-chat-input.manager.ts +4 -2
  86. package/src/features/chat/pages/ncp-chat-page.tsx +23 -13
  87. package/src/features/chat/stores/chat-session-list.store.ts +2 -3
  88. package/src/features/chat/types/chat-stream.types.ts +1 -1
  89. package/src/features/chat/utils/ncp-session-adapter.utils.ts +1 -1
  90. package/src/features/system-status/hooks/use-system-status.ts +6 -28
  91. package/src/features/system-status/index.ts +2 -1
  92. package/src/features/system-status/managers/system-status.manager.bootstrap-polling.test.ts +14 -4
  93. package/src/features/system-status/managers/system-status.manager.test.ts +2 -8
  94. package/src/features/system-status/managers/system-status.manager.ts +20 -30
  95. package/src/shared/components/common/brand-header.test.tsx +84 -3
  96. package/src/shared/components/common/brand-header.tsx +37 -39
  97. package/src/shared/lib/api/managers/client.manager.ts +30 -2
  98. package/src/shared/lib/api/ncp-session-query-cache.test.ts +26 -1
  99. package/src/shared/lib/api/ncp-session-query-cache.ts +5 -1
  100. package/src/shared/lib/api/utils/config.utils.ts +6 -4
  101. package/src/shared/lib/i18n/desktop-update-labels.utils.ts +3 -1
  102. package/src/shared/lib/transport/index.ts +1 -0
  103. package/src/shared/lib/transport/transport.types.ts +20 -0
  104. package/dist/assets/app-manager-provider-C0ONQxUg.js +0 -1
  105. package/dist/assets/doc-browser-Doh2541x.js +0 -1
  106. package/dist/assets/i18n-C5Mibli1.js +0 -1
  107. package/dist/assets/loader-circle-B5i8oMMY.js +0 -1
  108. package/dist/assets/marketplace-page-BRHkZaO5.js +0 -1
  109. package/dist/assets/mcp-marketplace-page-CL7BF4dD.js +0 -1
  110. package/dist/assets/message-square-D6Z4NwpG.js +0 -1
  111. package/dist/assets/play-D8WJLnJe.js +0 -1
  112. package/dist/assets/plus-Di0KAkiO.js +0 -1
  113. package/dist/assets/remote-BnRNqMlb.js +0 -1
  114. package/dist/assets/runtime-config-page-DQ8YY8Lc.js +0 -1
  115. package/dist/assets/skeleton-CFQRIUzt.js +0 -1
  116. package/dist/assets/theme-provider-B5XReW_-.js +0 -1
  117. package/dist/assets/x-DpTzXQcX.js +0 -1
@@ -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
  }
@@ -1,4 +1,5 @@
1
1
  import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import type * as SharedApi from '@/shared/lib/api';
2
3
  import { ChatSessionListManager } from '@/features/chat/managers/chat-session-list.manager';
3
4
  import { useChatInputStore } from '@/features/chat/stores/chat-input.store';
4
5
  import { useChatSessionListStore } from '@/features/chat/stores/chat-session-list.store';
@@ -9,7 +10,7 @@ const mocks = vi.hoisted(() => ({
9
10
  }));
10
11
 
11
12
  vi.mock('@/shared/lib/api', async (importOriginal) => {
12
- const actual = await importOriginal<typeof import('@/shared/lib/api')>();
13
+ const actual = await importOriginal<typeof SharedApi>();
13
14
  return {
14
15
  ...actual,
15
16
  updateNcpSession: mocks.updateNcpSession,
@@ -59,13 +60,13 @@ describe('ChatSessionListManager', () => {
59
60
  } as unknown as ConstructorParameters<typeof ChatSessionListManager>[1];
60
61
 
61
62
  const manager = new ChatSessionListManager(uiManager, streamActionsManager);
62
- const sessionKey = manager.createSession('codex');
63
+ manager.createSession('codex');
63
64
 
64
65
  expect(streamActionsManager.resetStreamState).toHaveBeenCalledTimes(1);
65
66
  expect(uiManager.goToChatRoot).toHaveBeenCalledTimes(1);
66
67
  expect(useChatSessionListStore.getState().snapshot.selectedSessionKey).toBeNull();
67
- expect(useChatSessionListStore.getState().snapshot.draftSessionKey).toBe(sessionKey);
68
- expect(useChatSessionListStore.getState().snapshot.draftSessionKey).not.toBe('draft-root-1');
68
+ expect(useChatSessionListStore.getState().snapshot.draftSessionKey).toBeNull();
69
+ expect(useChatThreadStore.getState().snapshot.sessionKey).toBeNull();
69
70
  expect(useChatInputStore.getState().snapshot.pendingSessionType).toBe('codex');
70
71
  expect(useChatInputStore.getState().snapshot.pendingProjectRoot).toBeNull();
71
72
  expect(useChatInputStore.getState().snapshot.pendingProjectRootSessionKey).toBeNull();
@@ -82,14 +83,14 @@ describe('ChatSessionListManager', () => {
82
83
  } as unknown as ConstructorParameters<typeof ChatSessionListManager>[1];
83
84
 
84
85
  const manager = new ChatSessionListManager(uiManager, streamActionsManager);
85
- const sessionKey = manager.startAgentDraftChat('researcher', 'codex');
86
+ manager.startAgentDraftChat('researcher', 'codex');
86
87
 
87
88
  expect(streamActionsManager.resetStreamState).toHaveBeenCalledTimes(1);
88
89
  expect(uiManager.goToChatRoot).toHaveBeenCalledTimes(1);
89
90
  expect(useChatSessionListStore.getState().snapshot.selectedAgentId).toBe('researcher');
90
91
  expect(useChatSessionListStore.getState().snapshot.selectedSessionKey).toBeNull();
91
- expect(useChatSessionListStore.getState().snapshot.draftSessionKey).toBe(sessionKey);
92
- expect(useChatThreadStore.getState().snapshot.sessionKey).toBe(sessionKey);
92
+ expect(useChatSessionListStore.getState().snapshot.draftSessionKey).toBeNull();
93
+ expect(useChatThreadStore.getState().snapshot.sessionKey).toBeNull();
93
94
  expect(useChatInputStore.getState().snapshot.pendingSessionType).toBe('codex');
94
95
  expect(useChatInputStore.getState().snapshot.pendingProjectRoot).toBeNull();
95
96
  expect(useChatInputStore.getState().snapshot.pendingProjectRootSessionKey).toBeNull();
@@ -106,13 +107,13 @@ describe('ChatSessionListManager', () => {
106
107
  } as unknown as ConstructorParameters<typeof ChatSessionListManager>[1];
107
108
 
108
109
  const manager = new ChatSessionListManager(uiManager, streamActionsManager);
109
- const sessionKey = manager.createSession('native', '/tmp/project-alpha');
110
+ manager.createSession('native', '/tmp/project-alpha');
110
111
 
111
112
  expect(useChatInputStore.getState().snapshot.pendingProjectRoot).toBe('/tmp/project-alpha');
112
- expect(useChatInputStore.getState().snapshot.pendingProjectRootSessionKey).toBe(sessionKey);
113
+ expect(useChatInputStore.getState().snapshot.pendingProjectRootSessionKey).toBeNull();
113
114
  });
114
115
 
115
- it('reuses the current root draft when send flow needs a concrete session key', () => {
116
+ it('keeps the root draft key empty when send flow has no concrete session yet', () => {
116
117
  useChatSessionListStore.setState({
117
118
  snapshot: {
118
119
  ...useChatSessionListStore.getState().snapshot,
@@ -132,7 +133,7 @@ describe('ChatSessionListManager', () => {
132
133
  const manager = new ChatSessionListManager(uiManager, streamActionsManager);
133
134
  const sessionKey = manager.ensureDraftSession('native');
134
135
 
135
- expect(sessionKey).toBe('draft-root-2');
136
+ expect(sessionKey).toBeNull();
136
137
  expect(uiManager.goToChatRoot).not.toHaveBeenCalled();
137
138
  expect(uiManager.goToSession).not.toHaveBeenCalled();
138
139
  expect(useChatSessionListStore.getState().snapshot.selectedSessionKey).toBeNull();
@@ -149,10 +150,10 @@ describe('ChatSessionListManager', () => {
149
150
  } as unknown as ConstructorParameters<typeof ChatSessionListManager>[1];
150
151
 
151
152
  const manager = new ChatSessionListManager(uiManager, streamActionsManager);
152
- const sessionKey = manager.createSession('native', '/tmp/project-alpha');
153
+ manager.createSession('native', '/tmp/project-alpha');
153
154
 
154
155
  expect(useChatSessionListStore.getState().snapshot.selectedSessionKey).toBeNull();
155
- expect(useChatInputStore.getState().snapshot.pendingProjectRootSessionKey).toBe(sessionKey);
156
+ expect(useChatInputStore.getState().snapshot.pendingProjectRootSessionKey).toBeNull();
156
157
  });
157
158
 
158
159
  it('delegates existing-session selection to routing without eagerly mutating the selected session state', () => {
@@ -224,7 +225,7 @@ describe('ChatSessionListManager', () => {
224
225
  expect(mocks.updateNcpSession).not.toHaveBeenCalled();
225
226
  });
226
227
 
227
- it('promotes the active root draft to a concrete session route after the draft has been sent successfully', () => {
228
+ it('routes to the backend-materialized root session after the first send starts', () => {
228
229
  useChatSessionListStore.setState({
229
230
  snapshot: {
230
231
  ...useChatSessionListStore.getState().snapshot,
@@ -243,8 +244,10 @@ describe('ChatSessionListManager', () => {
243
244
  const manager = new ChatSessionListManager(uiManager, streamActionsManager);
244
245
 
245
246
  manager.ensureDraftSession('native');
246
- manager.promoteRootDraftSessionRoute('draft-root-2');
247
+ manager.materializeRootSessionRoute('ncp-materialized-session');
247
248
 
248
- expect(uiManager.goToSession).toHaveBeenCalledWith('draft-root-2', { replace: true });
249
+ expect(useChatSessionListStore.getState().snapshot.selectedSessionKey).toBe('ncp-materialized-session');
250
+ expect(useChatThreadStore.getState().snapshot.sessionKey).toBe('ncp-materialized-session');
251
+ expect(uiManager.goToSession).toHaveBeenCalledWith('ncp-materialized-session', { replace: true });
249
252
  });
250
253
  });
@@ -5,7 +5,6 @@ import type { ChatUiManager } from '@/features/chat/managers/chat-ui.manager';
5
5
  import type { SetStateAction } from 'react';
6
6
  import type { ChatStreamActionsManager } from '@/features/chat/managers/chat-stream-actions.manager';
7
7
  import { normalizeSessionProjectRootValue } from '@/shared/lib/session-project';
8
- import { createNcpSessionId } from '@/features/chat/utils/ncp-session-adapter.utils';
9
8
  import { updateNcpSession } from '@/shared/lib/api';
10
9
  export class ChatSessionListManager {
11
10
  constructor(
@@ -13,9 +12,9 @@ export class ChatSessionListManager {
13
12
  private streamActionsManager: ChatStreamActionsManager
14
13
  ) {}
15
14
 
16
- private syncDraftThreadState = (sessionKey: string) => {
15
+ private syncDraftThreadState = () => {
17
16
  useChatThreadStore.getState().setSnapshot({
18
- sessionKey,
17
+ sessionKey: null,
19
18
  sessionDisplayName: undefined,
20
19
  canDeleteSession: false,
21
20
  isHistoryLoading: false,
@@ -97,7 +96,7 @@ export class ChatSessionListManager {
97
96
  void updateNcpSession(normalizedSessionKey, { uiReadAt: normalizedReadAt }).catch(() => undefined);
98
97
  };
99
98
 
100
- createSession = (sessionType?: string, projectRoot?: string | null): string => {
99
+ createSession = (sessionType?: string, projectRoot?: string | null): void => {
101
100
  const { snapshot } = useChatInputStore.getState();
102
101
  const { defaultSessionType: configuredDefaultSessionType } = snapshot;
103
102
  const defaultSessionType = configuredDefaultSessionType || 'native';
@@ -106,30 +105,27 @@ export class ChatSessionListManager {
106
105
  ? sessionType.trim()
107
106
  : defaultSessionType;
108
107
  const normalizedProjectRoot = normalizeSessionProjectRootValue(projectRoot);
109
- const nextSessionKey = createNcpSessionId();
110
108
  this.streamActionsManager.resetStreamState();
111
109
  useChatSessionListStore.getState().setSnapshot({
112
110
  selectedSessionKey: null,
113
- draftSessionKey: nextSessionKey
111
+ draftSessionKey: null
114
112
  });
115
- this.syncDraftThreadState(nextSessionKey);
113
+ this.syncDraftThreadState();
116
114
  useChatInputStore.getState().setSnapshot({
117
115
  pendingSessionType: nextSessionType,
118
116
  pendingProjectRoot: normalizedProjectRoot,
119
- pendingProjectRootSessionKey: normalizedProjectRoot ? nextSessionKey : null
117
+ pendingProjectRootSessionKey: null
120
118
  });
121
119
  this.uiManager.goToChatRoot();
122
- return nextSessionKey;
123
120
  };
124
121
 
125
- startAgentDraftChat = (agentId: string, sessionType: string): string => {
122
+ startAgentDraftChat = (agentId: string, sessionType: string): void => {
126
123
  const normalizedAgentId = agentId.trim() || 'main';
127
- const nextSessionKey = this.createSession(sessionType);
124
+ this.createSession(sessionType);
128
125
  this.setSelectedAgentId(normalizedAgentId);
129
- return nextSessionKey;
130
126
  };
131
127
 
132
- ensureDraftSession = (sessionType?: string): string => {
128
+ ensureDraftSession = (sessionType?: string): string | null => {
133
129
  const { snapshot } = useChatSessionListStore.getState();
134
130
  if (snapshot.selectedSessionKey) {
135
131
  return snapshot.selectedSessionKey;
@@ -138,28 +134,28 @@ export class ChatSessionListManager {
138
134
  typeof sessionType === 'string' && sessionType.trim().length > 0
139
135
  ? sessionType.trim()
140
136
  : null;
141
- this.syncDraftThreadState(snapshot.draftSessionKey);
137
+ this.syncDraftThreadState();
142
138
  if (normalizedSessionType) {
143
139
  useChatInputStore.getState().setSnapshot({ pendingSessionType: normalizedSessionType });
144
140
  }
145
- return snapshot.draftSessionKey;
141
+ return null;
146
142
  };
147
143
 
148
- promoteRootDraftSessionRoute = (sessionKey: string) => {
144
+ materializeRootSessionRoute = (sessionKey: string) => {
149
145
  const normalizedSessionKey = sessionKey.trim();
150
146
  if (!normalizedSessionKey) {
151
147
  return;
152
148
  }
153
- const { snapshot } = useChatSessionListStore.getState();
154
- const { sessionKey: currentThreadSessionKey } = useChatThreadStore.getState().snapshot;
155
- if (
156
- snapshot.selectedSessionKey !== null ||
157
- snapshot.draftSessionKey !== normalizedSessionKey ||
158
- currentThreadSessionKey !== normalizedSessionKey ||
159
- !this.uiManager.isAtChatRoot()
160
- ) {
149
+ if (!this.uiManager.isAtChatRoot()) {
161
150
  return;
162
151
  }
152
+ useChatSessionListStore.getState().setSnapshot({
153
+ selectedSessionKey: normalizedSessionKey,
154
+ draftSessionKey: null,
155
+ });
156
+ useChatThreadStore.getState().setSnapshot({
157
+ sessionKey: normalizedSessionKey,
158
+ });
163
159
  this.uiManager.goToSession(normalizedSessionKey, { replace: true });
164
160
  };
165
161