@nextclaw/ui 0.12.4 → 0.12.6

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 (149) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/dist/assets/ChannelsList-D8p4OlM6.js +8 -0
  3. package/dist/assets/ChatPage-A45t1Rmf.js +58 -0
  4. package/dist/assets/DocBrowser-B2MpsnU9.js +1 -0
  5. package/dist/assets/{DocBrowser-NSzgVKka.js → DocBrowser-Cse_F8Nn.js} +1 -1
  6. package/dist/assets/{DocBrowserContext-DpgVdRgk.js → DocBrowserContext-Bai1WU2H.js} +1 -1
  7. package/dist/assets/{LogoBadge-CHS4YNLw.js → LogoBadge-BdxMPc9v.js} +1 -1
  8. package/dist/assets/MarketplacePage-BNZ3Jx5d.js +1 -0
  9. package/dist/assets/MarketplacePage-BbpAkllU.js +49 -0
  10. package/dist/assets/McpMarketplacePage-CxPFOgxv.js +40 -0
  11. package/dist/assets/ModelConfig-3GLqQ5GY.js +1 -0
  12. package/dist/assets/ProviderScopedModelInput-BYNouw-i.js +1 -0
  13. package/dist/assets/ProvidersList-BR1gJ4Dm.js +1 -0
  14. package/dist/assets/{RemoteAccessPage-yfbrveNQ.js → RemoteAccessPage-DyYVWsyK.js} +1 -1
  15. package/dist/assets/RuntimeConfig-ChdfK4Y_.js +1 -0
  16. package/dist/assets/SearchConfig-DTeJvp8m.js +1 -0
  17. package/dist/assets/{SecretsConfig-CLFSSoTl.js → SecretsConfig-CCYO6NcV.js} +2 -2
  18. package/dist/assets/SessionsConfig-Du39vDgt.js +2 -0
  19. package/dist/assets/app-query-client-Dr5d-K8d.js +1 -0
  20. package/dist/assets/{book-open-C7TAghTk.js → book-open-Da4OEPqB.js} +1 -1
  21. package/dist/assets/chat-session-display-CAlPrnlV.js +1 -0
  22. package/dist/assets/{chunk-JZWAC4HX-DbL4EmiT.js → chunk-JZWAC4HX-CoFVxHXV.js} +1 -1
  23. package/dist/assets/client-CSk58DcF.js +7 -0
  24. package/dist/assets/config-D8KzikVB.js +1 -0
  25. package/dist/assets/{createLucideIcon-BRLFtf-8.js → createLucideIcon-83gaZMtv.js} +1 -1
  26. package/dist/assets/desktop-update-config-CfoVwf-w.js +1 -0
  27. package/dist/assets/dist-aTmhMDVh.js +9 -0
  28. package/dist/assets/{dist-DP-JKR4G.js → dist-toEYs-MZ.js} +1 -1
  29. package/dist/assets/{external-link-BkJkiWbH.js → external-link-QQ0TC6X4.js} +1 -1
  30. package/dist/assets/{hash-CbP6-6R9.js → hash-DaFBEkmi.js} +1 -1
  31. package/dist/assets/i18n-C3jb83S6.js +1 -0
  32. package/dist/assets/index-CE4N7ItL.css +1 -0
  33. package/dist/assets/index-riX7Sg0_.js +6 -0
  34. package/dist/assets/infiniteQueryBehavior-BmHX_ayZ.js +1 -0
  35. package/dist/assets/loader-circle-BjMg63eu.js +1 -0
  36. package/dist/assets/{logos-N3dbS6-I.js → logos-Dzlz30M3.js} +1 -1
  37. package/dist/assets/{page-layout-DyuvlNrg.js → page-layout-D2eRufRQ.js} +1 -1
  38. package/dist/assets/plus-CIXME2pD.js +1 -0
  39. package/dist/assets/{popover-BKKWGUaG.js → popover-BSXxm5bj.js} +1 -1
  40. package/dist/assets/{refresh-ccw-BGMdiNGq.js → refresh-ccw-B3zMtN-_.js} +1 -1
  41. package/dist/assets/refresh-cw-DlZkIHnJ.js +1 -0
  42. package/dist/assets/{save-Dh4GQzzX.js → save-Us9fg4Sj.js} +1 -1
  43. package/dist/assets/search-B_Qr0f6C.js +1 -0
  44. package/dist/assets/security-config-BGWYwxNr.js +1 -0
  45. package/dist/assets/{select-BtIi5fnh.js → select-DLYqySQK.js} +1 -1
  46. package/dist/assets/skeleton-CYQJazv6.js +1 -0
  47. package/dist/assets/{status-dot-C4O-2jZP.js → status-dot-DGayudyB.js} +1 -1
  48. package/dist/assets/{switch-DPegGIa_.js → switch-Dz2ScsKx.js} +1 -1
  49. package/dist/assets/{tabs-custom-x5GZexrF.js → tabs-custom-CdKyjiGk.js} +1 -1
  50. package/dist/assets/{trash-2-CU3LYIpQ.js → trash-2-Db-mZOZs.js} +1 -1
  51. package/dist/assets/use-infinite-scroll-loader-DBJX5hj0.js +1 -0
  52. package/dist/assets/{useConfirmDialog-S5WsGOGf.js → useConfirmDialog-DL0a-oGC.js} +1 -1
  53. package/dist/assets/useMutation-BdZm-9PL.js +1 -0
  54. package/dist/assets/x-B8Tho_xC.js +1 -0
  55. package/dist/index.html +20 -18
  56. package/package.json +6 -6
  57. package/src/App.tsx +2 -0
  58. package/src/account/components/account-panel.tsx +46 -4
  59. package/src/account/managers/account.manager.ts +19 -4
  60. package/src/api/raw-client.test.ts +37 -0
  61. package/src/api/raw-client.ts +51 -8
  62. package/src/api/remote.ts +9 -0
  63. package/src/api/remote.types.ts +5 -0
  64. package/src/components/chat/ChatConversationPanel.test.tsx +344 -142
  65. package/src/components/chat/ChatSidebar.test.tsx +109 -4
  66. package/src/components/chat/ChatSidebar.tsx +62 -9
  67. package/src/components/chat/adapters/chat-message-tool-agent-id.test.ts +11 -11
  68. package/src/components/chat/adapters/chat-message.adapter.test.ts +43 -6
  69. package/src/components/chat/adapters/chat-message.session-request-tool-card.ts +182 -44
  70. package/src/components/chat/adapters/chat-message.session-spawn-tool-card.test.ts +104 -0
  71. package/src/components/chat/chat-child-session-panel.tsx +155 -59
  72. package/src/components/chat/chat-page-runtime.test.ts +16 -19
  73. package/src/components/chat/chat-session-preference-sync.test.ts +13 -0
  74. package/src/components/chat/chat-session-preference-sync.ts +9 -7
  75. package/src/components/chat/chat-sidebar-session-item.tsx +189 -121
  76. package/src/components/chat/containers/chat-message-list.container.test.tsx +21 -3
  77. package/src/components/chat/containers/chat-message-list.container.tsx +14 -0
  78. package/src/components/chat/hooks/use-chat-session-project.test.tsx +5 -5
  79. package/src/components/chat/hooks/use-chat-session-project.ts +0 -5
  80. package/src/components/chat/hooks/use-chat-session-update.test.tsx +75 -0
  81. package/src/components/chat/hooks/use-chat-session-update.ts +4 -2
  82. package/src/components/chat/managers/chat-session-list.manager.test.ts +79 -5
  83. package/src/components/chat/managers/chat-session-list.manager.ts +31 -4
  84. package/src/components/chat/ncp/NcpChatPage.tsx +32 -51
  85. package/src/components/chat/ncp/ncp-app-client-fetch.test.ts +1 -1
  86. package/src/components/chat/ncp/ncp-app-client-fetch.ts +45 -5
  87. package/src/components/chat/ncp/ncp-chat-input.manager.ts +3 -5
  88. package/src/components/chat/ncp/ncp-chat-page-data.ts +0 -1
  89. package/src/components/chat/ncp/ncp-chat.presenter.ts +1 -11
  90. package/src/components/chat/ncp/session-conversation/use-ncp-child-session-tabs-view.ts +35 -9
  91. package/src/components/chat/stores/chat-session-list.store.ts +99 -5
  92. package/src/components/chat/useChatSessionTypeState.test.tsx +0 -3
  93. package/src/components/chat/useChatSessionTypeState.ts +3 -5
  94. package/src/components/config/ChannelsList.test.tsx +68 -0
  95. package/src/components/config/ChannelsList.tsx +22 -4
  96. package/src/components/config/ProviderForm.tsx +9 -15
  97. package/src/components/config/ProvidersList.tsx +17 -3
  98. package/src/components/config/desktop-update-config.tsx +230 -0
  99. package/src/components/config/providers-list.test.tsx +68 -0
  100. package/src/components/layout/Sidebar.tsx +19 -14
  101. package/src/components/layout/sidebar.layout.test.tsx +33 -1
  102. package/src/components/marketplace/MarketplacePage.tsx +30 -30
  103. package/src/components/marketplace/marketplace-page-parts.tsx +16 -24
  104. package/src/components/marketplace/mcp/McpMarketplacePage.tsx +28 -26
  105. package/src/desktop/desktop-update.types.ts +36 -0
  106. package/src/desktop/managers/desktop-update.manager.ts +163 -0
  107. package/src/desktop/stores/desktop-update.store.ts +18 -0
  108. package/src/hooks/marketplace-list-pages.ts +27 -0
  109. package/src/hooks/use-infinite-scroll-loader.ts +88 -0
  110. package/src/hooks/useMarketplace.ts +14 -3
  111. package/src/hooks/useMcpMarketplace.ts +14 -3
  112. package/src/lib/desktop-update-labels.utils.ts +72 -0
  113. package/src/lib/i18n.chat.ts +13 -0
  114. package/src/lib/i18n.remote.ts +15 -0
  115. package/src/lib/i18n.ts +3 -9
  116. package/src/lib/ui-document-title.ts +1 -0
  117. package/src/transport/local.transport.ts +57 -18
  118. package/src/vite-env.d.ts +10 -0
  119. package/dist/assets/ChannelsList-CobWeI2V.js +0 -8
  120. package/dist/assets/ChatPage-ZIdFFVAv.js +0 -43
  121. package/dist/assets/DocBrowser-D55C0iyl.js +0 -1
  122. package/dist/assets/MarketplacePage-BFYsRss_.js +0 -49
  123. package/dist/assets/MarketplacePage-DII-q-Y1.js +0 -1
  124. package/dist/assets/McpMarketplacePage-CPqsGJzz.js +0 -40
  125. package/dist/assets/ModelConfig-Bvuo_IpS.js +0 -1
  126. package/dist/assets/ProviderScopedModelInput-BfY8rGsf.js +0 -1
  127. package/dist/assets/ProvidersList-3tlaqwSS.js +0 -1
  128. package/dist/assets/RuntimeConfig-CAd5Kta3.js +0 -1
  129. package/dist/assets/SearchConfig-DFwgaAa7.js +0 -1
  130. package/dist/assets/SessionsConfig-vYrvc2Fk.js +0 -2
  131. package/dist/assets/chat-session-display-5dVFkJyw.js +0 -1
  132. package/dist/assets/config-CMiW0yaK.js +0 -1
  133. package/dist/assets/dist-BFc_H-lY.js +0 -15
  134. package/dist/assets/i18n-C_2dKw6w.js +0 -1
  135. package/dist/assets/index-ChUXhq0G.css +0 -1
  136. package/dist/assets/index-DAE8Srx-.js +0 -6
  137. package/dist/assets/label-D8yyejJS.js +0 -1
  138. package/dist/assets/loader-circle-B0sKKO29.js +0 -1
  139. package/dist/assets/marketplace-localization-CxSTG9wr.js +0 -1
  140. package/dist/assets/plus-CYXs3JtZ.js +0 -1
  141. package/dist/assets/react-8EIEQjMP.js +0 -1
  142. package/dist/assets/search-DOsLw-P9.js +0 -1
  143. package/dist/assets/security-config-CM_tQRXQ.js +0 -1
  144. package/dist/assets/skeleton-GbHLjPC0.js +0 -1
  145. package/dist/assets/useMutation-DSinpgEq.js +0 -1
  146. package/dist/assets/x-Bnco_K8b.js +0 -1
  147. package/src/components/chat/ChatSessionsSidebar.tsx +0 -100
  148. /package/dist/assets/{config-hints-WtpHP_DW.js → config-hints-GSUMvmSo.js} +0 -0
  149. /package/dist/assets/{config-layout-LQ10ozRC.js → config-layout-CgBMG7OL.js} +0 -0
@@ -4,6 +4,7 @@ import type { ChatUiManager } from '@/components/chat/managers/chat-ui.manager';
4
4
  import type { SetStateAction } from 'react';
5
5
  import type { ChatStreamActionsManager } from '@/components/chat/managers/chat-stream-actions.manager';
6
6
  import { normalizeSessionProjectRootValue } from '@/lib/session-project/session-project.utils';
7
+ import { createNcpSessionId } from '@/components/chat/ncp/ncp-session-adapter';
7
8
 
8
9
  export class ChatSessionListManager {
9
10
  constructor(
@@ -45,8 +46,22 @@ export class ChatSessionListManager {
45
46
  useChatSessionListStore.getState().setSnapshot({ listMode: value });
46
47
  };
47
48
 
48
- createSession = (sessionType?: string, projectRoot?: string | null) => {
49
+ markSessionRead = (sessionKey: string | null | undefined, updatedAt: string | null | undefined) => {
50
+ if (!sessionKey) {
51
+ return;
52
+ }
53
+ useChatSessionListStore.getState().markSessionRead(sessionKey, updatedAt);
54
+ };
55
+
56
+ hydrateReadWatermarks = (
57
+ entries: readonly { sessionKey: string; updatedAt: string | null | undefined }[],
58
+ ) => {
59
+ useChatSessionListStore.getState().hydrateReadWatermarks(entries);
60
+ };
61
+
62
+ createSession = (sessionType?: string, projectRoot?: string | null): string => {
49
63
  const { snapshot } = useChatInputStore.getState();
64
+ const { snapshot: sessionListSnapshot } = useChatSessionListStore.getState();
50
65
  const { defaultSessionType: configuredDefaultSessionType } = snapshot;
51
66
  const defaultSessionType = configuredDefaultSessionType || 'native';
52
67
  const nextSessionType =
@@ -54,14 +69,26 @@ export class ChatSessionListManager {
54
69
  ? sessionType.trim()
55
70
  : defaultSessionType;
56
71
  const normalizedProjectRoot = normalizeSessionProjectRootValue(projectRoot);
72
+ const nextSessionKey = sessionListSnapshot.draftSessionKey;
57
73
  this.streamActionsManager.resetStreamState();
58
- useChatSessionListStore.getState().setSnapshot({ selectedSessionKey: null });
74
+ useChatSessionListStore.getState().setSnapshot({
75
+ draftSessionKey: createNcpSessionId()
76
+ });
59
77
  useChatInputStore.getState().setSnapshot({
60
78
  pendingSessionType: nextSessionType,
61
79
  pendingProjectRoot: normalizedProjectRoot,
62
- pendingProjectRootSessionKey: null
80
+ pendingProjectRootSessionKey: normalizedProjectRoot ? nextSessionKey : null
63
81
  });
64
- this.uiManager.goToChatRoot();
82
+ this.uiManager.goToSession(nextSessionKey);
83
+ return nextSessionKey;
84
+ };
85
+
86
+ ensureDraftSession = (sessionType?: string): string => {
87
+ const { snapshot } = useChatSessionListStore.getState();
88
+ if (snapshot.selectedSessionKey) {
89
+ return snapshot.selectedSessionKey;
90
+ }
91
+ return this.createSession(sessionType);
65
92
  };
66
93
 
67
94
  selectSession = (sessionKey: string) => {
@@ -2,7 +2,6 @@ import {
2
2
  useEffect,
3
3
  useMemo,
4
4
  useRef,
5
- useState,
6
5
  } from "react";
7
6
  import {
8
7
  buildNcpRequestEnvelope,
@@ -22,7 +21,6 @@ import {
22
21
  } from "@/components/chat/chat-session-route";
23
22
  import { useNcpChatPageData } from "@/components/chat/ncp/ncp-chat-page-data";
24
23
  import { NcpChatPresenter } from "@/components/chat/ncp/ncp-chat.presenter";
25
- import { createNcpSessionId } from "@/components/chat/ncp/ncp-session-adapter";
26
24
  import { useNcpSessionConversation } from "@/components/chat/ncp/session-conversation/use-ncp-session-conversation";
27
25
  import { useNcpChatDerivedState, useNcpChatSnapshotSync } from "@/components/chat/ncp/page/ncp-chat-derived-state";
28
26
  import { ChatPresenterProvider } from "@/components/chat/presenter/chat-presenter-context";
@@ -79,26 +77,39 @@ export function buildNcpSendMetadata(payload: {
79
77
  return metadata;
80
78
  }
81
79
 
82
- export function shouldRefreshDraftSessionId(params: {
83
- previousSelectedSessionKey: string | null | undefined;
84
- nextSelectedSessionKey: string | null;
80
+ export function shouldClearPendingProjectRootOverride(params: {
81
+ pendingProjectRoot: string | null;
82
+ pendingProjectRootSessionKey: string | null;
83
+ sessionKey: string | null | undefined;
84
+ selectedSessionProjectRoot: string | null | undefined;
85
85
  }): boolean {
86
+ const {
87
+ pendingProjectRoot,
88
+ pendingProjectRootSessionKey,
89
+ sessionKey,
90
+ selectedSessionProjectRoot,
91
+ } = params;
86
92
  return (
87
- params.nextSelectedSessionKey === null &&
88
- params.previousSelectedSessionKey !== undefined &&
89
- params.previousSelectedSessionKey !== null
93
+ pendingProjectRoot !== null &&
94
+ pendingProjectRootSessionKey !== null &&
95
+ sessionKey === pendingProjectRootSessionKey &&
96
+ (selectedSessionProjectRoot ?? null) === pendingProjectRoot
90
97
  );
91
98
  }
92
99
 
93
100
  export function NcpChatPage({ view }: ChatPageProps) {
94
- const [presenter] = useState(() => new NcpChatPresenter());
95
- const [draftSessionId, setDraftSessionId] = useState(() =>
96
- createNcpSessionId(),
97
- );
101
+ const presenterRef = useRef<NcpChatPresenter | null>(null);
102
+ if (!presenterRef.current) {
103
+ presenterRef.current = new NcpChatPresenter();
104
+ }
105
+ const presenter = presenterRef.current;
98
106
  const query = useChatSessionListStore((state) => state.snapshot.query);
99
107
  const selectedSessionKey = useChatSessionListStore(
100
108
  (state) => state.snapshot.selectedSessionKey,
101
109
  );
110
+ const draftSessionKey = useChatSessionListStore(
111
+ (state) => state.snapshot.draftSessionKey,
112
+ );
102
113
  const selectedAgentId = useChatSessionListStore(
103
114
  (state) => state.snapshot.selectedAgentId,
104
115
  );
@@ -123,21 +134,14 @@ export function NcpChatPage({ view }: ChatPageProps) {
123
134
  }>();
124
135
  const threadRef = useRef<HTMLDivElement | null>(null);
125
136
  const selectedSessionKeyRef = useRef<string | null>(selectedSessionKey);
126
- const previousSelectedSessionKeyRef = useRef<string | null | undefined>(
127
- undefined,
128
- );
129
137
  const routeSessionKey = useMemo(
130
138
  () => parseSessionKeyFromRoute(routeSessionIdParam),
131
139
  [routeSessionIdParam],
132
140
  );
133
- const sessionKey = selectedSessionKey ?? draftSessionId;
134
- const hasDraftProjectRootOverride =
135
- pendingProjectRoot !== null &&
136
- pendingProjectRootSessionKey === null &&
137
- selectedSessionKey === null;
141
+ const sessionKey = routeSessionKey ?? selectedSessionKey ?? draftSessionKey;
138
142
  const hasSessionProjectRootOverride =
139
143
  pendingProjectRoot !== null &&
140
- (pendingProjectRootSessionKey === sessionKey || hasDraftProjectRootOverride);
144
+ pendingProjectRootSessionKey === sessionKey;
141
145
  const sessionProjectRootOverride = hasSessionProjectRootOverride
142
146
  ? pendingProjectRoot
143
147
  : undefined;
@@ -168,25 +172,6 @@ export function NcpChatPage({ view }: ChatPageProps) {
168
172
 
169
173
  const agent = useNcpSessionConversation(sessionKey);
170
174
 
171
- useEffect(() => {
172
- presenter.setDraftSessionId(draftSessionId);
173
- }, [draftSessionId, presenter]);
174
-
175
- useEffect(() => {
176
- if (
177
- shouldRefreshDraftSessionId({
178
- previousSelectedSessionKey:
179
- previousSelectedSessionKeyRef.current,
180
- nextSelectedSessionKey: selectedSessionKey,
181
- })
182
- ) {
183
- const nextDraftSessionId = createNcpSessionId();
184
- setDraftSessionId(nextDraftSessionId);
185
- presenter.setDraftSessionId(nextDraftSessionId);
186
- }
187
- previousSelectedSessionKeyRef.current = selectedSessionKey;
188
- }, [presenter, selectedSessionKey]);
189
-
190
175
  const effectiveSessionProjectRoot = hasSessionProjectRootOverride
191
176
  ? pendingProjectRoot
192
177
  : (selectedSession?.projectRoot ?? null);
@@ -215,10 +200,7 @@ export function NcpChatPage({ view }: ChatPageProps) {
215
200
  thinkingLevel: payload.thinkingLevel,
216
201
  sessionType: payload.sessionType,
217
202
  projectRoot:
218
- payload.sessionKey === pendingProjectRootSessionKey ||
219
- (pendingProjectRoot !== null &&
220
- pendingProjectRootSessionKey === null &&
221
- selectedSessionKey === null)
203
+ payload.sessionKey === pendingProjectRootSessionKey
222
204
  ? pendingProjectRoot
223
205
  : (selectedSession?.projectRoot ?? null),
224
206
  requestedSkills: payload.requestedSkills,
@@ -279,15 +261,14 @@ export function NcpChatPage({ view }: ChatPageProps) {
279
261
  ]);
280
262
 
281
263
  useEffect(() => {
282
- const matchesPendingProjectSession =
283
- pendingProjectRootSessionKey === null
284
- ? selectedSessionKey !== null
285
- : pendingProjectRootSessionKey === selectedSession?.key;
286
264
  if (
287
265
  !selectedSession ||
288
- pendingProjectRoot === null ||
289
- !matchesPendingProjectSession ||
290
- (selectedSession.projectRoot ?? null) !== pendingProjectRoot
266
+ !shouldClearPendingProjectRootOverride({
267
+ pendingProjectRoot,
268
+ pendingProjectRootSessionKey,
269
+ sessionKey: selectedSession.key,
270
+ selectedSessionProjectRoot: selectedSession.projectRoot ?? null,
271
+ })
291
272
  ) {
292
273
  return;
293
274
  }
@@ -55,7 +55,7 @@ describe('ncp-app-client-fetch', () => {
55
55
  'content-type': 'application/json'
56
56
  },
57
57
  body: JSON.stringify({ sessionId: 's1' })
58
- })).rejects.toThrow('Failed to fetch');
58
+ })).rejects.toThrow('NCP fetch failed for POST http://127.0.0.1:55667/api/ncp/agent/abort: Error: Failed to fetch');
59
59
  });
60
60
 
61
61
  it('preserves native SSE request headers', async () => {
@@ -1,9 +1,49 @@
1
1
  type FetchLike = typeof fetch;
2
2
 
3
+ function formatFetchTarget(input: RequestInfo | URL): string {
4
+ if (typeof input === 'string') {
5
+ return input;
6
+ }
7
+ if (input instanceof URL) {
8
+ return input.toString();
9
+ }
10
+ return input.url;
11
+ }
12
+
13
+ function formatUnknownFetchError(error: unknown): string {
14
+ if (error instanceof Error) {
15
+ const name = error.name?.trim();
16
+ const message = error.message?.trim();
17
+ if (name && message) {
18
+ return `${name}: ${message}`;
19
+ }
20
+ return message || name || 'Unknown Error';
21
+ }
22
+ return String(error ?? 'Unknown error');
23
+ }
24
+
25
+ function createErrorWithCause(message: string, cause: unknown): Error {
26
+ const error = new Error(message) as Error & { cause?: unknown };
27
+ if (cause !== undefined) {
28
+ error.cause = cause;
29
+ }
30
+ return error;
31
+ }
32
+
3
33
  export function createNcpAppClientFetch(): FetchLike {
4
- return (input, init) =>
5
- fetch(input, {
6
- credentials: 'include',
7
- ...init
8
- });
34
+ return async (input, init) => {
35
+ try {
36
+ return await fetch(input, {
37
+ credentials: 'include',
38
+ ...init
39
+ });
40
+ } catch (error) {
41
+ const method = (init?.method || 'GET').toUpperCase();
42
+ const target = formatFetchTarget(input);
43
+ throw createErrorWithCause(
44
+ `NCP fetch failed for ${method} ${target}: ${formatUnknownFetchError(error)}`,
45
+ error
46
+ );
47
+ }
48
+ };
9
49
  }
@@ -18,6 +18,7 @@ import { useChatSessionListStore } from '@/components/chat/stores/chat-session-l
18
18
  import type { ChatInputSnapshot } from '@/components/chat/stores/chat-input.store';
19
19
  import type { ChatStreamActionsManager } from '@/components/chat/managers/chat-stream-actions.manager';
20
20
  import type { ChatUiManager } from '@/components/chat/managers/chat-ui.manager';
21
+ import type { ChatSessionListManager } from '@/components/chat/managers/chat-session-list.manager';
21
22
  import { ChatSessionPreferenceSync } from '@/components/chat/chat-session-preference-sync';
22
23
  import { chatRecentModelsManager } from '@/components/chat/chat-recent-models.manager';
23
24
  import { chatRecentSkillsManager } from '@/components/chat/chat-recent-skills.manager';
@@ -40,7 +41,7 @@ export class NcpChatInputManager {
40
41
  constructor(
41
42
  private uiManager: ChatUiManager,
42
43
  private streamActionsManager: ChatStreamActionsManager,
43
- private getDraftSessionId: () => string
44
+ private sessionListManager: ChatSessionListManager
44
45
  ) {}
45
46
 
46
47
  private hasSnapshotChanges = (patch: Partial<ChatInputSnapshot>): boolean => {
@@ -179,10 +180,7 @@ export class NcpChatInputManager {
179
180
  return;
180
181
  }
181
182
  const { selectedSkills: requestedSkills, composerNodes } = inputSnapshot;
182
- const sessionKey = sessionSnapshot.selectedSessionKey ?? this.getDraftSessionId();
183
- if (!sessionSnapshot.selectedSessionKey) {
184
- this.uiManager.goToSession(sessionKey, { replace: true });
185
- }
183
+ const sessionKey = sessionSnapshot.selectedSessionKey ?? this.sessionListManager.ensureDraftSession(inputSnapshot.selectedSessionType);
186
184
  this.setComposerNodes(createInitialChatComposerNodes());
187
185
  await this.streamActionsManager.sendMessage({
188
186
  message,
@@ -116,7 +116,6 @@ export function useNcpChatPageData(params: UseNcpChatPageDataParams) {
116
116
  );
117
117
  const sessionTypeState = useChatSessionTypeState({
118
118
  selectedSession,
119
- selectedSessionKey: params.sessionKey,
120
119
  pendingSessionType: params.pendingSessionType,
121
120
  setPendingSessionType: params.setPendingSessionType,
122
121
  sessionTypesData: sessionTypesQuery.data
@@ -11,21 +11,11 @@ export class NcpChatPresenter {
11
11
  chatInputManager = new NcpChatInputManager(
12
12
  this.chatUiManager,
13
13
  this.chatStreamActionsManager,
14
- () => this.getDraftSessionId()
14
+ this.chatSessionListManager
15
15
  );
16
16
  chatThreadManager = new NcpChatThreadManager(
17
17
  this.chatUiManager,
18
18
  this.chatSessionListManager,
19
19
  this.chatStreamActionsManager
20
20
  );
21
-
22
- private draftSessionId = '';
23
-
24
- setDraftSessionId = (sessionId: string) => {
25
- this.draftSessionId = sessionId;
26
- };
27
-
28
- private getDraftSessionId(): string {
29
- return this.draftSessionId;
30
- }
31
21
  }
@@ -1,15 +1,23 @@
1
- import { useMemo } from 'react';
2
- import type { SessionEntryView } from '@/api/types';
3
- import { sessionDisplayName } from '@/components/chat/chat-session-display';
4
- import { adaptNcpSessionSummaries } from '@/components/chat/ncp/ncp-session-adapter';
5
- import type { ChatChildSessionTab } from '@/components/chat/stores/chat-thread.store';
6
- import { useNcpSessions } from '@/hooks/useConfig';
1
+ import { useMemo } from "react";
2
+ import type { SessionEntryView } from "@/api/types";
3
+ import { sessionDisplayName } from "@/components/chat/chat-session-display";
4
+ import { adaptNcpSessionSummaries } from "@/components/chat/ncp/ncp-session-adapter";
5
+ import { resolveSessionTypeLabel } from "@/components/chat/useChatSessionTypeState";
6
+ import type { ChatChildSessionTab } from "@/components/chat/stores/chat-thread.store";
7
+ import { useNcpSessions } from "@/hooks/useConfig";
8
+ import type { SessionRunStatus } from "@/lib/session-run-status";
7
9
 
8
10
  export type ResolvedChildSessionTab = {
9
11
  sessionKey: string;
10
12
  parentSessionKey: string | null;
11
13
  title: string;
12
14
  agentId: string | null;
15
+ updatedAt: string | null;
16
+ runStatus?: SessionRunStatus;
17
+ sessionTypeLabel: string | null;
18
+ preferredModel: string | null;
19
+ projectName: string | null;
20
+ projectRoot: string | null;
13
21
  };
14
22
 
15
23
  function resolveChildSessionTitle(
@@ -29,24 +37,42 @@ export function useNcpChildSessionTabsView(
29
37
  tabs: readonly ChatChildSessionTab[],
30
38
  ): ResolvedChildSessionTab[] {
31
39
  const sessionsQuery = useNcpSessions({ limit: 200 });
40
+ const summaries = useMemo(
41
+ () => sessionsQuery.data?.sessions ?? [],
42
+ [sessionsQuery.data?.sessions],
43
+ );
32
44
 
33
45
  const sessionByKey = useMemo(() => {
34
- const sessions = adaptNcpSessionSummaries(sessionsQuery.data?.sessions ?? []);
46
+ const sessions = adaptNcpSessionSummaries(summaries);
35
47
  return new Map(sessions.map((session) => [session.key, session]));
36
- }, [sessionsQuery.data?.sessions]);
48
+ }, [summaries]);
49
+
50
+ const summaryByKey = useMemo(
51
+ () => new Map(summaries.map((summary) => [summary.sessionId, summary])),
52
+ [summaries],
53
+ );
37
54
 
38
55
  return useMemo(
39
56
  () =>
40
57
  tabs.map((tab) => {
41
58
  const session = sessionByKey.get(tab.sessionKey) ?? null;
59
+ const summary = summaryByKey.get(tab.sessionKey) ?? null;
42
60
  const agentId = tab.agentId?.trim() || session?.agentId || null;
43
61
  return {
44
62
  sessionKey: tab.sessionKey,
45
63
  parentSessionKey: tab.parentSessionKey,
46
64
  title: resolveChildSessionTitle(tab, session),
47
65
  agentId,
66
+ updatedAt: session?.updatedAt ?? null,
67
+ runStatus: summary?.status === "running" ? "running" : undefined,
68
+ sessionTypeLabel: session?.sessionType
69
+ ? resolveSessionTypeLabel(session.sessionType)
70
+ : null,
71
+ preferredModel: session?.preferredModel?.trim() || null,
72
+ projectName: session?.projectName?.trim() || null,
73
+ projectRoot: session?.projectRoot?.trim() || null,
48
74
  };
49
75
  }),
50
- [sessionByKey, tabs],
76
+ [sessionByKey, summaryByKey, tabs],
51
77
  );
52
78
  }
@@ -1,33 +1,127 @@
1
- import { create } from 'zustand';
1
+ import { create, type StateCreator } from 'zustand';
2
+ import { createNcpSessionId } from '@/components/chat/ncp/ncp-session-adapter';
3
+ import type { SessionRunStatus } from '@/lib/session-run-status';
2
4
 
3
5
  export type ChatSessionListMode = 'time-first' | 'project-first';
4
6
 
5
7
  export type ChatSessionListSnapshot = {
6
8
  selectedSessionKey: string | null;
9
+ draftSessionKey: string;
7
10
  selectedAgentId: string;
8
11
  query: string;
9
12
  listMode: ChatSessionListMode;
10
13
  };
11
14
 
15
+ export function hasUnreadSessionUpdate(
16
+ updatedAt: string | null | undefined,
17
+ readUpdatedAt: string | undefined,
18
+ ): boolean {
19
+ const normalizedUpdatedAt = updatedAt?.trim();
20
+ if (!normalizedUpdatedAt) {
21
+ return false;
22
+ }
23
+ const normalizedReadUpdatedAt = readUpdatedAt?.trim();
24
+ if (!normalizedReadUpdatedAt) {
25
+ return true;
26
+ }
27
+ return normalizedUpdatedAt.localeCompare(normalizedReadUpdatedAt) > 0;
28
+ }
29
+
30
+ export function shouldShowUnreadSessionIndicator(params: {
31
+ active: boolean;
32
+ updatedAt: string | null | undefined;
33
+ readUpdatedAt: string | undefined;
34
+ runStatus?: SessionRunStatus;
35
+ }): boolean {
36
+ const { active, readUpdatedAt, runStatus, updatedAt } = params;
37
+ if (active || runStatus === 'running') {
38
+ return false;
39
+ }
40
+ return hasUnreadSessionUpdate(updatedAt, readUpdatedAt);
41
+ }
42
+
12
43
  type ChatSessionListStore = {
13
44
  snapshot: ChatSessionListSnapshot;
45
+ readUpdatedAtBySessionKey: Record<string, string>;
46
+ hasHydratedReadWatermarks: boolean;
14
47
  setSnapshot: (patch: Partial<ChatSessionListSnapshot>) => void;
48
+ markSessionRead: (sessionKey: string, updatedAt: string | null | undefined) => void;
49
+ hydrateReadWatermarks: (
50
+ entries: readonly { sessionKey: string; updatedAt: string | null | undefined }[],
51
+ ) => void;
15
52
  };
16
53
 
54
+ type ChatSessionListStoreSet = Parameters<StateCreator<ChatSessionListStore>>[0];
55
+
17
56
  const initialSnapshot: ChatSessionListSnapshot = {
18
57
  selectedSessionKey: null,
58
+ draftSessionKey: createNcpSessionId(),
19
59
  selectedAgentId: 'main',
20
60
  query: '',
21
61
  listMode: 'time-first'
22
62
  };
23
63
 
24
- export const useChatSessionListStore = create<ChatSessionListStore>((set) => ({
25
- snapshot: initialSnapshot,
26
- setSnapshot: (patch) =>
64
+ function createSetSnapshotAction(set: ChatSessionListStoreSet) {
65
+ return (patch: Partial<ChatSessionListSnapshot>) =>
27
66
  set((state) => ({
28
67
  snapshot: {
29
68
  ...state.snapshot,
30
69
  ...patch
31
70
  }
32
- }))
71
+ }));
72
+ }
73
+
74
+ function createMarkSessionReadAction(set: ChatSessionListStoreSet) {
75
+ return (sessionKey: string, updatedAt: string | null | undefined) =>
76
+ set((state) => {
77
+ const normalizedSessionKey = sessionKey.trim();
78
+ const normalizedUpdatedAt = updatedAt?.trim();
79
+ if (!normalizedSessionKey || !normalizedUpdatedAt) {
80
+ return state;
81
+ }
82
+ if (state.readUpdatedAtBySessionKey[normalizedSessionKey] === normalizedUpdatedAt) {
83
+ return state;
84
+ }
85
+ return {
86
+ ...state,
87
+ readUpdatedAtBySessionKey: {
88
+ ...state.readUpdatedAtBySessionKey,
89
+ [normalizedSessionKey]: normalizedUpdatedAt
90
+ }
91
+ };
92
+ });
93
+ }
94
+
95
+ function createHydrateReadWatermarksAction(set: ChatSessionListStoreSet) {
96
+ return (
97
+ entries: readonly { sessionKey: string; updatedAt: string | null | undefined }[],
98
+ ) =>
99
+ set((state) => {
100
+ if (state.hasHydratedReadWatermarks) {
101
+ return state;
102
+ }
103
+ const nextReadUpdatedAtBySessionKey = { ...state.readUpdatedAtBySessionKey };
104
+ for (const entry of entries) {
105
+ const normalizedSessionKey = entry.sessionKey.trim();
106
+ const normalizedUpdatedAt = entry.updatedAt?.trim();
107
+ if (!normalizedSessionKey || !normalizedUpdatedAt || nextReadUpdatedAtBySessionKey[normalizedSessionKey]) {
108
+ continue;
109
+ }
110
+ nextReadUpdatedAtBySessionKey[normalizedSessionKey] = normalizedUpdatedAt;
111
+ }
112
+ return {
113
+ ...state,
114
+ hasHydratedReadWatermarks: true,
115
+ readUpdatedAtBySessionKey: nextReadUpdatedAtBySessionKey
116
+ };
117
+ });
118
+ }
119
+
120
+ export const useChatSessionListStore = create<ChatSessionListStore>((set) => ({
121
+ snapshot: initialSnapshot,
122
+ readUpdatedAtBySessionKey: {},
123
+ hasHydratedReadWatermarks: false,
124
+ setSnapshot: createSetSnapshotAction(set),
125
+ markSessionRead: createMarkSessionReadAction(set),
126
+ hydrateReadWatermarks: createHydrateReadWatermarksAction(set)
33
127
  }));
@@ -17,7 +17,6 @@ describe('useChatSessionTypeState', () => {
17
17
  const { result } = renderHook(() =>
18
18
  useChatSessionTypeState({
19
19
  selectedSession: null,
20
- selectedSessionKey: null,
21
20
  pendingSessionType: 'codex-sdk',
22
21
  setPendingSessionType,
23
22
  sessionTypesData: {
@@ -40,7 +39,6 @@ describe('useChatSessionTypeState', () => {
40
39
  renderHook(() =>
41
40
  useChatSessionTypeState({
42
41
  selectedSession: null,
43
- selectedSessionKey: null,
44
42
  pendingSessionType: '',
45
43
  setPendingSessionType,
46
44
  sessionTypesData: {
@@ -62,7 +60,6 @@ describe('useChatSessionTypeState', () => {
62
60
  const { result } = renderHook(() =>
63
61
  useChatSessionTypeState({
64
62
  selectedSession: null,
65
- selectedSessionKey: null,
66
63
  pendingSessionType: 'claude',
67
64
  setPendingSessionType,
68
65
  sessionTypesData: {
@@ -22,7 +22,6 @@ export type ChatSessionTypeOption = {
22
22
 
23
23
  type UseChatSessionTypeStateParams = {
24
24
  selectedSession: SessionEntryView | null;
25
- selectedSessionKey: string | null;
26
25
  pendingSessionType: string;
27
26
  setPendingSessionType: Dispatch<SetStateAction<string>>;
28
27
  sessionTypesData?: {
@@ -114,7 +113,6 @@ export function useChatSessionTypeState(params: UseChatSessionTypeStateParams):
114
113
  } {
115
114
  const {
116
115
  selectedSession,
117
- selectedSessionKey,
118
116
  pendingSessionType,
119
117
  setPendingSessionType,
120
118
  sessionTypesData
@@ -164,7 +162,7 @@ export function useChatSessionTypeState(params: UseChatSessionTypeStateParams):
164
162
  );
165
163
 
166
164
  useEffect(() => {
167
- if (selectedSessionKey) {
165
+ if (selectedSession) {
168
166
  return;
169
167
  }
170
168
  const rawPending = typeof pendingSessionType === 'string' ? pendingSessionType.trim() : '';
@@ -181,9 +179,9 @@ export function useChatSessionTypeState(params: UseChatSessionTypeStateParams):
181
179
  return;
182
180
  }
183
181
  setPendingSessionType(defaultSessionType);
184
- }, [defaultSessionType, pendingSessionType, selectedSessionKey, setPendingSessionType]);
182
+ }, [defaultSessionType, pendingSessionType, selectedSession, setPendingSessionType]);
185
183
 
186
- const canEditSessionType = !selectedSessionKey || Boolean(selectedSession?.sessionTypeMutable);
184
+ const canEditSessionType = !selectedSession || Boolean(selectedSession.sessionTypeMutable);
187
185
  const availableSessionTypeSet = useMemo(
188
186
  () => new Set(runtimeSessionTypeOptions.map((option) => option.value)),
189
187
  [runtimeSessionTypeOptions]