@nextclaw/ui 0.12.5 → 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 (113) hide show
  1. package/CHANGELOG.md +25 -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-QUZ3nfmH.js → DocBrowser-Cse_F8Nn.js} +1 -1
  6. package/dist/assets/{DocBrowserContext-CpiIfhJO.js → DocBrowserContext-Bai1WU2H.js} +1 -1
  7. package/dist/assets/{LogoBadge-BUK13xK5.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-BG4T_Pcx.js → McpMarketplacePage-CxPFOgxv.js} +2 -2
  11. package/dist/assets/ModelConfig-3GLqQ5GY.js +1 -0
  12. package/dist/assets/{ProviderScopedModelInput-DGn6sFEN.js → ProviderScopedModelInput-BYNouw-i.js} +1 -1
  13. package/dist/assets/ProvidersList-BR1gJ4Dm.js +1 -0
  14. package/dist/assets/{RemoteAccessPage-ff15qO-c.js → RemoteAccessPage-DyYVWsyK.js} +1 -1
  15. package/dist/assets/{RuntimeConfig-TgPandXF.js → RuntimeConfig-ChdfK4Y_.js} +1 -1
  16. package/dist/assets/SearchConfig-DTeJvp8m.js +1 -0
  17. package/dist/assets/{SecretsConfig-Bew4EF2A.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-CJG8Yz3U.js → book-open-Da4OEPqB.js} +1 -1
  21. package/dist/assets/chat-session-display-CAlPrnlV.js +1 -0
  22. package/dist/assets/{chunk-JZWAC4HX-D5b3Iyas.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-_FMJqZw2.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-B1fpOuON.js → dist-toEYs-MZ.js} +1 -1
  29. package/dist/assets/{external-link-b7gAJWYY.js → external-link-QQ0TC6X4.js} +1 -1
  30. package/dist/assets/{hash-Bhy4TwfZ.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-GMeYU9vc.js → logos-Dzlz30M3.js} +1 -1
  37. package/dist/assets/{page-layout-C8UbWuMt.js → page-layout-D2eRufRQ.js} +1 -1
  38. package/dist/assets/plus-CIXME2pD.js +1 -0
  39. package/dist/assets/{popover-8HSx9wQj.js → popover-BSXxm5bj.js} +1 -1
  40. package/dist/assets/{refresh-ccw-CA4_C7Zg.js → refresh-ccw-B3zMtN-_.js} +1 -1
  41. package/dist/assets/refresh-cw-DlZkIHnJ.js +1 -0
  42. package/dist/assets/{save-BtvMy4lk.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-xp_Ac8ip.js → select-DLYqySQK.js} +1 -1
  46. package/dist/assets/skeleton-CYQJazv6.js +1 -0
  47. package/dist/assets/{status-dot-Cn4Pp7DZ.js → status-dot-DGayudyB.js} +1 -1
  48. package/dist/assets/{switch-BTi6UOij.js → switch-Dz2ScsKx.js} +1 -1
  49. package/dist/assets/{tabs-custom-BiiN8DME.js → tabs-custom-CdKyjiGk.js} +1 -1
  50. package/dist/assets/{trash-2-BpsF0N-r.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-BJIwUZjH.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 -19
  56. package/package.json +5 -5
  57. package/src/App.tsx +2 -0
  58. package/src/api/raw-client.test.ts +37 -0
  59. package/src/api/raw-client.ts +51 -8
  60. package/src/components/chat/ChatConversationPanel.test.tsx +161 -1
  61. package/src/components/chat/ChatSidebar.test.tsx +109 -4
  62. package/src/components/chat/ChatSidebar.tsx +62 -9
  63. package/src/components/chat/chat-child-session-panel.tsx +56 -18
  64. package/src/components/chat/chat-sidebar-session-item.tsx +189 -121
  65. package/src/components/chat/containers/chat-message-list.container.test.tsx +21 -3
  66. package/src/components/chat/containers/chat-message-list.container.tsx +14 -0
  67. package/src/components/chat/managers/chat-session-list.manager.test.ts +34 -0
  68. package/src/components/chat/managers/chat-session-list.manager.ts +13 -0
  69. package/src/components/chat/ncp/ncp-app-client-fetch.test.ts +1 -1
  70. package/src/components/chat/ncp/ncp-app-client-fetch.ts +45 -5
  71. package/src/components/chat/ncp/session-conversation/use-ncp-child-session-tabs-view.ts +18 -5
  72. package/src/components/chat/stores/chat-session-list.store.ts +96 -5
  73. package/src/components/config/ProviderForm.tsx +9 -15
  74. package/src/components/config/desktop-update-config.tsx +230 -0
  75. package/src/components/layout/Sidebar.tsx +6 -1
  76. package/src/components/layout/sidebar.layout.test.tsx +1 -0
  77. package/src/desktop/desktop-update.types.ts +36 -0
  78. package/src/desktop/managers/desktop-update.manager.ts +163 -0
  79. package/src/desktop/stores/desktop-update.store.ts +18 -0
  80. package/src/lib/desktop-update-labels.utils.ts +72 -0
  81. package/src/lib/i18n.chat.ts +13 -0
  82. package/src/lib/i18n.ts +3 -9
  83. package/src/lib/ui-document-title.ts +1 -0
  84. package/src/transport/local.transport.ts +57 -18
  85. package/src/vite-env.d.ts +10 -0
  86. package/dist/assets/ChannelsList-C6-lh55g.js +0 -8
  87. package/dist/assets/ChatPage-DOW0gPc2.js +0 -45
  88. package/dist/assets/DocBrowser-CGyeswYP.js +0 -1
  89. package/dist/assets/MarketplacePage-BDVwhIYE.js +0 -1
  90. package/dist/assets/MarketplacePage-LnKKL3xK.js +0 -49
  91. package/dist/assets/ModelConfig-LtWuogIw.js +0 -1
  92. package/dist/assets/ProvidersList-ma-_MlLo.js +0 -1
  93. package/dist/assets/SearchConfig-C9iBt7pl.js +0 -1
  94. package/dist/assets/SessionsConfig-2r2yAGZg.js +0 -2
  95. package/dist/assets/chat-session-display-DkAC5OMC.js +0 -1
  96. package/dist/assets/config-zvnxSXSP.js +0 -1
  97. package/dist/assets/dist-BCXX7FD-.js +0 -15
  98. package/dist/assets/i18n-DJg9BPYk.js +0 -1
  99. package/dist/assets/index-BoJbxdvZ.css +0 -1
  100. package/dist/assets/index-CtlT4E9Y.js +0 -6
  101. package/dist/assets/infiniteQueryBehavior-CTcVlD9s.js +0 -1
  102. package/dist/assets/loader-circle-B60I0hEk.js +0 -1
  103. package/dist/assets/plus-CR7RfK3H.js +0 -1
  104. package/dist/assets/react-BB4jko2M.js +0 -1
  105. package/dist/assets/search-C60UA27E.js +0 -1
  106. package/dist/assets/security-config-BkFDYZ6j.js +0 -1
  107. package/dist/assets/skeleton-uxz_5h3A.js +0 -1
  108. package/dist/assets/use-infinite-scroll-loader-C8jBv11-.js +0 -1
  109. package/dist/assets/useMutation-BjBOKHj_.js +0 -1
  110. package/dist/assets/x-BfTu-g7D.js +0 -1
  111. package/src/components/chat/ChatSessionsSidebar.tsx +0 -100
  112. /package/dist/assets/{config-hints-WtpHP_DW.js → config-hints-GSUMvmSo.js} +0 -0
  113. /package/dist/assets/{config-layout-LQ10ozRC.js → config-layout-CgBMG7OL.js} +0 -0
@@ -25,6 +25,31 @@ function inferNonJsonHint(endpoint: string, status: number): string | undefined
25
25
  return undefined;
26
26
  }
27
27
 
28
+ function formatUnknownFetchError(error: unknown): {
29
+ summary: string;
30
+ details: Record<string, unknown>;
31
+ } {
32
+ if (error instanceof Error) {
33
+ const name = error.name?.trim() || 'Error';
34
+ const message = error.message?.trim() || 'Unknown error';
35
+ return {
36
+ summary: `${name}: ${message}`,
37
+ details: {
38
+ errorName: name,
39
+ errorMessage: message,
40
+ ...(error.stack?.trim() ? { errorStack: error.stack.trim() } : {})
41
+ }
42
+ };
43
+ }
44
+ return {
45
+ summary: String(error ?? 'Unknown error'),
46
+ details: {
47
+ errorName: 'NonError',
48
+ errorMessage: String(error ?? 'Unknown error')
49
+ }
50
+ };
51
+ }
52
+
28
53
  export async function requestRawApiResponse<T>(
29
54
  endpoint: string,
30
55
  options: RequestInit = {}
@@ -32,14 +57,32 @@ export async function requestRawApiResponse<T>(
32
57
  const url = `${API_BASE}${endpoint}`;
33
58
  const method = (options.method || 'GET').toUpperCase();
34
59
 
35
- const response = await fetch(url, {
36
- credentials: 'include',
37
- headers: {
38
- 'Content-Type': 'application/json',
39
- ...options.headers
40
- },
41
- ...options
42
- });
60
+ let response: Response;
61
+ try {
62
+ response = await fetch(url, {
63
+ credentials: 'include',
64
+ headers: {
65
+ 'Content-Type': 'application/json',
66
+ ...options.headers
67
+ },
68
+ ...options
69
+ });
70
+ } catch (error) {
71
+ const formatted = formatUnknownFetchError(error);
72
+ return {
73
+ ok: false,
74
+ error: {
75
+ code: 'NETWORK_ERROR',
76
+ message: `Fetch failed on ${method} ${endpoint} | ${formatted.summary}`,
77
+ details: {
78
+ method,
79
+ endpoint,
80
+ url,
81
+ ...formatted.details
82
+ }
83
+ }
84
+ };
85
+ }
43
86
 
44
87
  const text = await response.text();
45
88
  let data: ApiResponse<T> | null = null;
@@ -3,7 +3,9 @@ import userEvent from "@testing-library/user-event";
3
3
  import { beforeEach, describe, expect, it, vi } from "vitest";
4
4
  import { ChatChildSessionPanel } from "@/components/chat/chat-child-session-panel";
5
5
  import { ChatConversationPanel } from "@/components/chat/ChatConversationPanel";
6
+ import type { ResolvedChildSessionTab } from "@/components/chat/ncp/session-conversation/use-ncp-child-session-tabs-view";
6
7
  import { useChatInputStore } from "@/components/chat/stores/chat-input.store";
8
+ import { useChatSessionListStore } from "@/components/chat/stores/chat-session-list.store";
7
9
  import { useChatThreadStore } from "@/components/chat/stores/chat-thread.store";
8
10
 
9
11
  const mocks = vi.hoisted(() => ({
@@ -21,12 +23,13 @@ const mocks = vi.hoisted(() => ({
21
23
  parentSessionKey: "parent-session-1",
22
24
  title: "北京天气",
23
25
  agentId: "weather",
26
+ updatedAt: "2026-04-10T09:00:00.000Z",
24
27
  sessionTypeLabel: "Codex",
25
28
  preferredModel: "openai/gpt-5.3-codex",
26
29
  projectName: "project-alpha",
27
30
  projectRoot: "/Users/demo/project-alpha",
28
31
  },
29
- ],
32
+ ] as ResolvedChildSessionTab[],
30
33
  }));
31
34
 
32
35
  vi.mock("@nextclaw/agent-chat-ui", async (importOriginal) => {
@@ -79,6 +82,19 @@ vi.mock("@/components/chat/presenter/chat-presenter-context", () => ({
79
82
  selectSession: vi.fn(),
80
83
  createSession: mocks.createSession,
81
84
  setSelectedAgentId: mocks.setSelectedAgentId,
85
+ markSessionRead: (
86
+ sessionKey: string | null | undefined,
87
+ updatedAt: string | null | undefined,
88
+ ) =>
89
+ sessionKey
90
+ ? useChatSessionListStore.getState().markSessionRead(
91
+ sessionKey,
92
+ updatedAt,
93
+ )
94
+ : undefined,
95
+ hydrateReadWatermarks: (
96
+ entries: readonly { sessionKey: string; updatedAt: string | null | undefined }[],
97
+ ) => useChatSessionListStore.getState().hydrateReadWatermarks(entries),
82
98
  },
83
99
  chatInputManager: {
84
100
  setPendingSessionType: mocks.setPendingSessionType,
@@ -175,6 +191,13 @@ describe("ChatConversationPanel", () => {
175
191
  activeChildSessionKey: null,
176
192
  },
177
193
  });
194
+ useChatSessionListStore.setState({
195
+ readUpdatedAtBySessionKey: {},
196
+ hasHydratedReadWatermarks: false,
197
+ snapshot: {
198
+ ...useChatSessionListStore.getState().snapshot,
199
+ },
200
+ });
178
201
  });
179
202
 
180
203
  it("shows the draft session type in the conversation header", () => {
@@ -275,6 +298,7 @@ describe("ChatChildSessionPanel", () => {
275
298
  parentSessionKey: "parent-session-1",
276
299
  title: "北京天气",
277
300
  agentId: "weather",
301
+ updatedAt: "2026-04-10T09:00:00.000Z",
278
302
  sessionTypeLabel: "Codex",
279
303
  preferredModel: "openai/gpt-5.3-codex",
280
304
  projectName: "project-alpha",
@@ -320,6 +344,7 @@ describe("ChatChildSessionPanel", () => {
320
344
  parentSessionKey: "parent-session-1",
321
345
  title: "北京天气",
322
346
  agentId: "weather",
347
+ updatedAt: "2026-04-10T09:00:00.000Z",
323
348
  sessionTypeLabel: "Codex",
324
349
  preferredModel: "openai/gpt-5.3-codex",
325
350
  projectName: "project-alpha",
@@ -330,6 +355,7 @@ describe("ChatChildSessionPanel", () => {
330
355
  parentSessionKey: "parent-session-1",
331
356
  title: "上海天气",
332
357
  agentId: "weather",
358
+ updatedAt: "2026-04-10T09:05:00.000Z",
333
359
  sessionTypeLabel: "Claude Code",
334
360
  preferredModel: "anthropic/claude-sonnet-4",
335
361
  projectName: "project-beta",
@@ -373,4 +399,138 @@ describe("ChatChildSessionPanel", () => {
373
399
  expect(tabButtons[0]?.getAttribute("aria-pressed")).toBe("true");
374
400
  expect(tabButtons[1]?.getAttribute("aria-pressed")).toBe("false");
375
401
  });
402
+
403
+ it("shows an unread dot for inactive child tabs until the user opens them", () => {
404
+ mocks.resolvedChildTabs = [
405
+ {
406
+ sessionKey: "child-session-1",
407
+ parentSessionKey: "parent-session-1",
408
+ title: "北京天气",
409
+ agentId: "weather",
410
+ updatedAt: "2026-04-10T09:00:00.000Z",
411
+ sessionTypeLabel: "Codex",
412
+ preferredModel: "openai/gpt-5.3-codex",
413
+ projectName: "project-alpha",
414
+ projectRoot: "/Users/demo/project-alpha",
415
+ },
416
+ {
417
+ sessionKey: "child-session-2",
418
+ parentSessionKey: "parent-session-1",
419
+ title: "上海天气",
420
+ agentId: "weather",
421
+ updatedAt: "2026-04-10T09:05:00.000Z",
422
+ runStatus: "running",
423
+ sessionTypeLabel: "Claude Code",
424
+ preferredModel: "anthropic/claude-sonnet-4",
425
+ projectName: "project-beta",
426
+ projectRoot: "/Users/demo/project-beta",
427
+ },
428
+ ];
429
+
430
+ const { rerender } = render(
431
+ <ChatChildSessionPanel
432
+ tabs={[
433
+ {
434
+ sessionKey: "child-session-1",
435
+ parentSessionKey: "parent-session-1",
436
+ label: "北京天气",
437
+ agentId: "weather",
438
+ },
439
+ {
440
+ sessionKey: "child-session-2",
441
+ parentSessionKey: "parent-session-1",
442
+ label: "上海天气",
443
+ agentId: "weather",
444
+ },
445
+ ]}
446
+ activeSessionKey="child-session-1"
447
+ onSelectSession={vi.fn()}
448
+ onClose={vi.fn()}
449
+ onBackToParent={vi.fn()}
450
+ />,
451
+ );
452
+
453
+ expect(
454
+ screen.queryByLabelText("Session has unread updates"),
455
+ ).toBeNull();
456
+
457
+ mocks.resolvedChildTabs = [
458
+ {
459
+ sessionKey: "child-session-1",
460
+ parentSessionKey: "parent-session-1",
461
+ title: "北京天气",
462
+ agentId: "weather",
463
+ updatedAt: "2026-04-10T09:00:00.000Z",
464
+ sessionTypeLabel: "Codex",
465
+ preferredModel: "openai/gpt-5.3-codex",
466
+ projectName: "project-alpha",
467
+ projectRoot: "/Users/demo/project-alpha",
468
+ },
469
+ {
470
+ sessionKey: "child-session-2",
471
+ parentSessionKey: "parent-session-1",
472
+ title: "上海天气",
473
+ agentId: "weather",
474
+ updatedAt: "2026-04-10T09:05:00.000Z",
475
+ sessionTypeLabel: "Claude Code",
476
+ preferredModel: "anthropic/claude-sonnet-4",
477
+ projectName: "project-beta",
478
+ projectRoot: "/Users/demo/project-beta",
479
+ },
480
+ ];
481
+
482
+ rerender(
483
+ <ChatChildSessionPanel
484
+ tabs={[
485
+ {
486
+ sessionKey: "child-session-1",
487
+ parentSessionKey: "parent-session-1",
488
+ label: "北京天气",
489
+ agentId: "weather",
490
+ },
491
+ {
492
+ sessionKey: "child-session-2",
493
+ parentSessionKey: "parent-session-1",
494
+ label: "上海天气",
495
+ agentId: "weather",
496
+ },
497
+ ]}
498
+ activeSessionKey="child-session-1"
499
+ onSelectSession={vi.fn()}
500
+ onClose={vi.fn()}
501
+ onBackToParent={vi.fn()}
502
+ />,
503
+ );
504
+
505
+ expect(
506
+ screen.getByLabelText("Session has unread updates"),
507
+ ).toBeTruthy();
508
+
509
+ rerender(
510
+ <ChatChildSessionPanel
511
+ tabs={[
512
+ {
513
+ sessionKey: "child-session-1",
514
+ parentSessionKey: "parent-session-1",
515
+ label: "北京天气",
516
+ agentId: "weather",
517
+ },
518
+ {
519
+ sessionKey: "child-session-2",
520
+ parentSessionKey: "parent-session-1",
521
+ label: "上海天气",
522
+ agentId: "weather",
523
+ },
524
+ ]}
525
+ activeSessionKey="child-session-2"
526
+ onSelectSession={vi.fn()}
527
+ onClose={vi.fn()}
528
+ onBackToParent={vi.fn()}
529
+ />,
530
+ );
531
+
532
+ expect(
533
+ screen.queryByLabelText("Session has unread updates"),
534
+ ).toBeNull();
535
+ });
376
536
  });
@@ -18,8 +18,11 @@ const mocks = vi.hoisted(() => ({
18
18
  isLoading: false
19
19
  }));
20
20
 
21
- function createSessionItem(session: NcpSessionListItemView['session']): NcpSessionListItemView {
22
- return { session };
21
+ function createSessionItem(
22
+ session: NcpSessionListItemView['session'],
23
+ runStatus?: NcpSessionListItemView['runStatus'],
24
+ ): NcpSessionListItemView {
25
+ return { session, runStatus };
23
26
  }
24
27
 
25
28
  vi.mock('@/components/chat/presenter/chat-presenter-context', () => ({
@@ -28,7 +31,12 @@ vi.mock('@/components/chat/presenter/chat-presenter-context', () => ({
28
31
  createSession: mocks.createSession,
29
32
  setQuery: mocks.setQuery,
30
33
  setListMode: mocks.setListMode,
31
- selectSession: mocks.selectSession
34
+ selectSession: mocks.selectSession,
35
+ markSessionRead: (sessionKey: string | null | undefined, updatedAt: string | null | undefined) =>
36
+ sessionKey ? useChatSessionListStore.getState().markSessionRead(sessionKey, updatedAt) : undefined,
37
+ hydrateReadWatermarks: (
38
+ entries: readonly { sessionKey: string; updatedAt: string | null | undefined }[],
39
+ ) => useChatSessionListStore.getState().hydrateReadWatermarks(entries)
32
40
  }
33
41
  })
34
42
  }));
@@ -124,10 +132,13 @@ function resetSidebarTestState() {
124
132
  }
125
133
  });
126
134
  useChatSessionListStore.setState({
135
+ readUpdatedAtBySessionKey: {},
136
+ hasHydratedReadWatermarks: false,
127
137
  snapshot: {
128
138
  ...useChatSessionListStore.getState().snapshot,
129
139
  query: '',
130
- listMode: 'time-first'
140
+ listMode: 'time-first',
141
+ selectedSessionKey: null
131
142
  }
132
143
  });
133
144
  }
@@ -496,4 +507,98 @@ describe('ChatSidebar session item interactions', () => {
496
507
  expect(screen.queryByDisplayValue('Should Not Persist')).toBeNull();
497
508
  expect(screen.getByText('Cancelable Label')).not.toBeNull();
498
509
  });
510
+
511
+ it('shows an unread dot only after a non-active session finishes its newer update', () => {
512
+ mocks.sessionItems = [
513
+ createSessionItem({
514
+ key: 'session:ncp-1',
515
+ createdAt: '2026-03-19T09:00:00.000Z',
516
+ updatedAt: '2026-03-19T09:05:00.000Z',
517
+ label: 'Current Task',
518
+ sessionType: 'native',
519
+ sessionTypeMutable: false,
520
+ messageCount: 1
521
+ }),
522
+ createSessionItem({
523
+ key: 'session:ncp-2',
524
+ createdAt: '2026-03-19T10:00:00.000Z',
525
+ updatedAt: '2026-03-19T10:05:00.000Z',
526
+ label: 'Background Task',
527
+ sessionType: 'native',
528
+ sessionTypeMutable: false,
529
+ messageCount: 1
530
+ }, 'running')
531
+ ];
532
+ useChatSessionListStore.setState({
533
+ snapshot: {
534
+ ...useChatSessionListStore.getState().snapshot,
535
+ selectedSessionKey: 'session:ncp-1'
536
+ }
537
+ });
538
+
539
+ const { rerender } = render(
540
+ <MemoryRouter>
541
+ <ChatSidebar />
542
+ </MemoryRouter>
543
+ );
544
+
545
+ expect(screen.queryByLabelText('Session has unread updates')).toBeNull();
546
+
547
+ mocks.sessionItems = [
548
+ mocks.sessionItems[0]!,
549
+ createSessionItem({
550
+ key: 'session:ncp-2',
551
+ createdAt: '2026-03-19T10:00:00.000Z',
552
+ updatedAt: '2026-03-19T10:06:00.000Z',
553
+ label: 'Background Task',
554
+ sessionType: 'native',
555
+ sessionTypeMutable: false,
556
+ messageCount: 2
557
+ }, 'running')
558
+ ];
559
+
560
+ rerender(
561
+ <MemoryRouter>
562
+ <ChatSidebar />
563
+ </MemoryRouter>
564
+ );
565
+
566
+ expect(screen.queryByLabelText('Session has unread updates')).toBeNull();
567
+
568
+ mocks.sessionItems = [
569
+ mocks.sessionItems[0]!,
570
+ createSessionItem({
571
+ key: 'session:ncp-2',
572
+ createdAt: '2026-03-19T10:00:00.000Z',
573
+ updatedAt: '2026-03-19T10:06:00.000Z',
574
+ label: 'Background Task',
575
+ sessionType: 'native',
576
+ sessionTypeMutable: false,
577
+ messageCount: 2
578
+ })
579
+ ];
580
+
581
+ rerender(
582
+ <MemoryRouter>
583
+ <ChatSidebar />
584
+ </MemoryRouter>
585
+ );
586
+
587
+ expect(screen.getByLabelText('Session has unread updates')).toBeTruthy();
588
+
589
+ useChatSessionListStore.setState({
590
+ snapshot: {
591
+ ...useChatSessionListStore.getState().snapshot,
592
+ selectedSessionKey: 'session:ncp-2'
593
+ }
594
+ });
595
+
596
+ rerender(
597
+ <MemoryRouter>
598
+ <ChatSidebar />
599
+ </MemoryRouter>
600
+ );
601
+
602
+ expect(screen.queryByLabelText('Session has unread updates')).toBeNull();
603
+ });
499
604
  });
@@ -1,4 +1,4 @@
1
- import { useMemo, useState } from 'react';
1
+ import { useEffect, useMemo, useState } from 'react';
2
2
  import type { SessionEntryView } from '@/api/types';
3
3
  import { Button } from '@/components/ui/button';
4
4
  import { BrandHeader } from '@/components/common/BrandHeader';
@@ -17,7 +17,10 @@ import { useChatSessionLabel } from '@/components/chat/hooks/use-chat-session-la
17
17
  import { useNcpSessionListView, type NcpSessionListItemView } from '@/components/chat/ncp/use-ncp-session-list-view';
18
18
  import { usePresenter } from '@/components/chat/presenter/chat-presenter-context';
19
19
  import { useChatInputStore } from '@/components/chat/stores/chat-input.store';
20
- import { useChatSessionListStore } from '@/components/chat/stores/chat-session-list.store';
20
+ import {
21
+ shouldShowUnreadSessionIndicator,
22
+ useChatSessionListStore
23
+ } from '@/components/chat/stores/chat-session-list.store';
21
24
  import { useAgents } from '@/hooks/agents/useAgents';
22
25
  import { getSessionProjectName } from '@/lib/session-project/session-project.utils';
23
26
  import { cn } from '@/lib/utils';
@@ -143,6 +146,50 @@ const navItems = [
143
146
  { target: '/agents', label: () => t('agentsPageTitle'), icon: Bot },
144
147
  ];
145
148
 
149
+ function useChatSessionUnreadState(
150
+ items: readonly NcpSessionListItemView[],
151
+ selectedSessionKey: string | null,
152
+ markSessionRead: (sessionKey: string | null | undefined, updatedAt: string | null | undefined) => void,
153
+ hydrateReadWatermarks: (
154
+ entries: readonly { sessionKey: string; updatedAt: string | null | undefined }[],
155
+ ) => void,
156
+ ): Record<string, string> {
157
+ const readUpdatedAtBySessionKey = useChatSessionListStore((state) => state.readUpdatedAtBySessionKey);
158
+ const hasHydratedReadWatermarks = useChatSessionListStore((state) => state.hasHydratedReadWatermarks);
159
+
160
+ useEffect(() => {
161
+ const syncHydratedReadWatermarks = () => {
162
+ if (hasHydratedReadWatermarks || items.length === 0) {
163
+ return;
164
+ }
165
+ hydrateReadWatermarks(
166
+ items.map(({ session }) => ({
167
+ sessionKey: session.key,
168
+ updatedAt: session.updatedAt
169
+ }))
170
+ );
171
+ };
172
+ syncHydratedReadWatermarks();
173
+ }, [hasHydratedReadWatermarks, hydrateReadWatermarks, items]);
174
+
175
+ useEffect(() => {
176
+ const syncSelectedSessionReadState = () => {
177
+ if (!selectedSessionKey) {
178
+ return;
179
+ }
180
+ const selectedItem = items.find(({ session }) => session.key === selectedSessionKey);
181
+ if (!selectedItem) {
182
+ return;
183
+ }
184
+ const { session: selectedSession } = selectedItem;
185
+ markSessionRead(selectedSession.key, selectedSession.updatedAt);
186
+ };
187
+ syncSelectedSessionReadState();
188
+ }, [items, markSessionRead, selectedSessionKey]);
189
+
190
+ return readUpdatedAtBySessionKey;
191
+ }
192
+
146
193
  export function ChatSidebar() {
147
194
  const presenter = usePresenter();
148
195
  const docBrowser = useDocBrowser();
@@ -164,7 +211,6 @@ export function ChatSidebar() {
164
211
  () => new Map((agentsQuery.data?.agents ?? []).map((agent) => [agent.id, agent])),
165
212
  [agentsQuery.data?.agents]
166
213
  );
167
-
168
214
  const sortedItems = useMemo(() => sortSessionItemsByUpdatedAtDesc(items), [items]);
169
215
  const groups = useMemo(() => groupSessionsByDate(sortedItems), [sortedItems]);
170
216
  const projectGroups = useMemo(() => groupSessionsByProject(sortedItems), [sortedItems]);
@@ -174,24 +220,26 @@ export function ChatSidebar() {
174
220
  [defaultSessionType, inputSnapshot.sessionTypeOptions]
175
221
  );
176
222
  const isProjectFirstView = listSnapshot.listMode === 'project-first';
177
-
223
+ const readUpdatedAtBySessionKey = useChatSessionUnreadState(
224
+ items,
225
+ listSnapshot.selectedSessionKey,
226
+ presenter.chatSessionListManager.markSessionRead,
227
+ presenter.chatSessionListManager.hydrateReadWatermarks,
228
+ );
178
229
  const handleLanguageSwitch = (nextLang: I18nLanguage) => {
179
230
  if (language === nextLang) return;
180
231
  setLanguage(nextLang);
181
232
  window.location.reload();
182
233
  };
183
-
184
234
  const startEditingSessionLabel = (session: SessionEntryView) => {
185
235
  setEditingSessionKey(session.key);
186
236
  setDraftLabel(session.label?.trim() ?? '');
187
237
  };
188
-
189
238
  const cancelEditingSessionLabel = () => {
190
239
  setEditingSessionKey(null);
191
240
  setDraftLabel('');
192
241
  setSavingSessionKey(null);
193
242
  };
194
-
195
243
  const saveSessionLabel = async (session: SessionEntryView) => {
196
244
  const normalizedLabel = draftLabel.trim();
197
245
  const currentLabel = session.label?.trim() ?? '';
@@ -211,9 +259,14 @@ export function ChatSidebar() {
211
259
  setSavingSessionKey(null);
212
260
  }
213
261
  };
214
-
215
262
  const renderSessionItem = ({ session, runStatus }: NcpSessionListItemView) => {
216
263
  const active = listSnapshot.selectedSessionKey === session.key;
264
+ const showUnreadDot = shouldShowUnreadSessionIndicator({
265
+ active,
266
+ updatedAt: session.updatedAt,
267
+ readUpdatedAt: readUpdatedAtBySessionKey[session.key],
268
+ runStatus,
269
+ });
217
270
  const context = resolveSessionContextView(session, inputSnapshot.sessionTypeOptions);
218
271
  const isEditing = editingSessionKey === session.key;
219
272
  const isSaving = savingSessionKey === session.key;
@@ -222,6 +275,7 @@ export function ChatSidebar() {
222
275
  key={session.key}
223
276
  session={session}
224
277
  active={active}
278
+ showUnreadDot={showUnreadDot}
225
279
  runStatus={runStatus}
226
280
  context={context}
227
281
  title={sessionTitle(session)}
@@ -239,7 +293,6 @@ export function ChatSidebar() {
239
293
  />
240
294
  );
241
295
  };
242
-
243
296
  return (
244
297
  <aside className="w-[280px] shrink-0 flex flex-col h-full bg-secondary border-r border-gray-200/60">
245
298
  <div className="px-5 pt-5 pb-3">
@@ -1,4 +1,4 @@
1
- import { useRef } from "react";
1
+ import { useEffect, useRef } from "react";
2
2
  import { ArrowLeft, Loader2, X } from "lucide-react";
3
3
  import { useStickyBottomScroll } from "@nextclaw/agent-chat-ui";
4
4
  import { ChatMessageListContainer } from "@/components/chat/containers/chat-message-list.container";
@@ -7,6 +7,11 @@ import {
7
7
  type ResolvedChildSessionTab,
8
8
  } from "@/components/chat/ncp/session-conversation/use-ncp-child-session-tabs-view";
9
9
  import { useNcpSessionConversation } from "@/components/chat/ncp/session-conversation/use-ncp-session-conversation";
10
+ import {
11
+ shouldShowUnreadSessionIndicator,
12
+ useChatSessionListStore,
13
+ } from "@/components/chat/stores/chat-session-list.store";
14
+ import { usePresenter } from "@/components/chat/presenter/chat-presenter-context";
10
15
  import type { ChatChildSessionTab } from "@/components/chat/stores/chat-thread.store";
11
16
  import { AgentIdentityAvatar } from "@/components/common/agent-identity";
12
17
  import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
@@ -122,16 +127,35 @@ export function ChatChildSessionPanel({
122
127
  onBackToParent,
123
128
  onToolAction,
124
129
  }: ChatChildSessionPanelProps) {
130
+ const presenter = usePresenter();
125
131
  const resolvedTabs = useNcpChildSessionTabsView(tabs);
132
+ const readUpdatedAtBySessionKey = useChatSessionListStore(
133
+ (state) => state.readUpdatedAtBySessionKey,
134
+ );
126
135
  const activeTab =
127
136
  resolvedTabs.find((tab) => tab.sessionKey === activeSessionKey) ??
128
137
  resolvedTabs[0] ??
129
138
  null;
139
+ const activeTabSessionKey = activeTab?.sessionKey ?? null;
140
+ const activeTabUpdatedAt = activeTab?.updatedAt?.trim() ?? null;
130
141
  const hasParentSession = resolvedTabs.some((tab) =>
131
142
  Boolean(tab.parentSessionKey),
132
143
  );
133
144
  const shouldShowTabs = resolvedTabs.length > 1;
134
145
 
146
+ useEffect(() => {
147
+ const syncActiveTabReadState = () => {
148
+ if (!activeTabSessionKey || !activeTabUpdatedAt) {
149
+ return;
150
+ }
151
+ presenter.chatSessionListManager.markSessionRead(
152
+ activeTabSessionKey,
153
+ activeTabUpdatedAt,
154
+ );
155
+ };
156
+ syncActiveTabReadState();
157
+ }, [activeTabSessionKey, activeTabUpdatedAt, presenter]);
158
+
135
159
  if (!activeTab) {
136
160
  return null;
137
161
  }
@@ -178,23 +202,37 @@ export function ChatChildSessionPanel({
178
202
  <div className="mt-3 overflow-x-auto custom-scrollbar">
179
203
  <Tabs value={activeSessionKey} onValueChange={onSelectSession}>
180
204
  <TabsList className="h-auto min-w-max justify-start gap-1.5 rounded-none bg-transparent p-0 text-gray-500">
181
- {resolvedTabs.map((tab) => (
182
- <TabsTrigger
183
- key={tab.sessionKey}
184
- value={tab.sessionKey}
185
- className="gap-2 rounded-full border border-gray-200/80 bg-white/85 px-2.5 py-1.5 text-xs font-medium text-gray-600 shadow-none hover:border-primary/30 hover:text-primary data-[state=active]:border-primary/30 data-[state=active]:bg-primary-50/70 data-[state=active]:text-primary data-[state=active]:shadow-sm"
186
- >
187
- {tab.agentId ? (
188
- <AgentIdentityAvatar
189
- agentId={tab.agentId}
190
- className="h-4 w-4 shrink-0"
191
- />
192
- ) : null}
193
- <span className="max-w-[132px] truncate">
194
- {tab.title}
195
- </span>
196
- </TabsTrigger>
197
- ))}
205
+ {resolvedTabs.map((tab) => {
206
+ const showUnreadDot = shouldShowUnreadSessionIndicator({
207
+ active: tab.sessionKey === activeSessionKey,
208
+ updatedAt: tab.updatedAt,
209
+ readUpdatedAt: readUpdatedAtBySessionKey[tab.sessionKey],
210
+ runStatus: tab.runStatus,
211
+ });
212
+ return (
213
+ <TabsTrigger
214
+ key={tab.sessionKey}
215
+ value={tab.sessionKey}
216
+ className="gap-2 rounded-full border border-gray-200/80 bg-white/85 px-2.5 py-1.5 text-xs font-medium text-gray-600 shadow-none hover:border-primary/30 hover:text-primary data-[state=active]:border-primary/30 data-[state=active]:bg-primary-50/70 data-[state=active]:text-primary data-[state=active]:shadow-sm"
217
+ >
218
+ {tab.agentId ? (
219
+ <AgentIdentityAvatar
220
+ agentId={tab.agentId}
221
+ className="h-4 w-4 shrink-0"
222
+ />
223
+ ) : null}
224
+ <span className="max-w-[132px] truncate">
225
+ {tab.title}
226
+ </span>
227
+ {showUnreadDot ? (
228
+ <span
229
+ aria-label={t("chatSessionUnread")}
230
+ className="h-2 w-2 shrink-0 rounded-full bg-primary"
231
+ />
232
+ ) : null}
233
+ </TabsTrigger>
234
+ );
235
+ })}
198
236
  </TabsList>
199
237
  </Tabs>
200
238
  </div>