@nextclaw/ui 0.12.6 → 0.12.8

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 (115) hide show
  1. package/CHANGELOG.md +90 -0
  2. package/dist/assets/{ChannelsList-D8p4OlM6.js → ChannelsList-KIQIxluX.js} +1 -1
  3. package/dist/assets/{DocBrowser-Cse_F8Nn.js → DocBrowser-BMxf9CIK.js} +1 -1
  4. package/dist/assets/DocBrowser-CyDgAtO9.js +1 -0
  5. package/dist/assets/{DocBrowserContext-Bai1WU2H.js → DocBrowserContext-Ce28gRXt.js} +1 -1
  6. package/dist/assets/{LogoBadge-BdxMPc9v.js → LogoBadge-o92MOA2L.js} +1 -1
  7. package/dist/assets/{MarketplacePage-BbpAkllU.js → MarketplacePage-BySqkYDh.js} +1 -1
  8. package/dist/assets/MarketplacePage-C0olZaek.js +1 -0
  9. package/dist/assets/{McpMarketplacePage-CxPFOgxv.js → McpMarketplacePage-DqKaiXO9.js} +1 -1
  10. package/dist/assets/{ModelConfig-3GLqQ5GY.js → ModelConfig-IrmzoslW.js} +1 -1
  11. package/dist/assets/{ProviderScopedModelInput-BYNouw-i.js → ProviderScopedModelInput-CmTIzgI7.js} +1 -1
  12. package/dist/assets/{ProvidersList-BR1gJ4Dm.js → ProvidersList-8_Kalfwl.js} +1 -1
  13. package/dist/assets/{RemoteAccessPage-DyYVWsyK.js → RemoteAccessPage-CyQlSjPf.js} +1 -1
  14. package/dist/assets/RuntimeConfig-Bk0uYBhf.js +1 -0
  15. package/dist/assets/{SearchConfig-DTeJvp8m.js → SearchConfig-DNBR-UbE.js} +1 -1
  16. package/dist/assets/{SecretsConfig-CCYO6NcV.js → SecretsConfig-Ba1RPJaG.js} +1 -1
  17. package/dist/assets/{SessionsConfig-Du39vDgt.js → SessionsConfig-Doqp5ghH.js} +1 -1
  18. package/dist/assets/{app-query-client-Dr5d-K8d.js → app-query-client-DniXoIN5.js} +1 -1
  19. package/dist/assets/{book-open-Da4OEPqB.js → book-open-DocgeQtR.js} +1 -1
  20. package/dist/assets/chat-page-Bph8M5zo.js +58 -0
  21. package/dist/assets/chat-session-display-CoN3Wmn-.js +1 -0
  22. package/dist/assets/{chunk-JZWAC4HX-CoFVxHXV.js → chunk-JZWAC4HX-BvKvh1R8.js} +1 -1
  23. package/dist/assets/{client-CSk58DcF.js → client-CVqPF5ie.js} +1 -1
  24. package/dist/assets/{config-D8KzikVB.js → config-Bop2oB18.js} +1 -1
  25. package/dist/assets/{createLucideIcon-83gaZMtv.js → createLucideIcon-DVv8taGY.js} +1 -1
  26. package/dist/assets/desktop-update-config-1KBrqLBC.js +1 -0
  27. package/dist/assets/{dist-toEYs-MZ.js → dist-Da5Gm_pO.js} +1 -1
  28. package/dist/assets/{dist-aTmhMDVh.js → dist-DmAlInRu.js} +1 -1
  29. package/dist/assets/{external-link-QQ0TC6X4.js → external-link-DFjw3x1B.js} +1 -1
  30. package/dist/assets/{hash-DaFBEkmi.js → hash-DJtaCejM.js} +1 -1
  31. package/dist/assets/i18n-CwHZ-9vt.js +1 -0
  32. package/dist/assets/{index-CE4N7ItL.css → index-DafCdM4F.css} +1 -1
  33. package/dist/assets/{index-riX7Sg0_.js → index-DdksE6U3.js} +3 -3
  34. package/dist/assets/{infiniteQueryBehavior-BmHX_ayZ.js → infiniteQueryBehavior-DHSEQ3OH.js} +1 -1
  35. package/dist/assets/loader-circle-PsSP0H9n.js +1 -0
  36. package/dist/assets/{logos-Dzlz30M3.js → logos-DEFUIR12.js} +1 -1
  37. package/dist/assets/{page-layout-D2eRufRQ.js → page-layout-Da3i3r6G.js} +1 -1
  38. package/dist/assets/play-DBQbBxTA.js +1 -0
  39. package/dist/assets/plus-DUOVbsyQ.js +1 -0
  40. package/dist/assets/{popover-BSXxm5bj.js → popover-C_mWOFzI.js} +1 -1
  41. package/dist/assets/{refresh-ccw-B3zMtN-_.js → refresh-ccw-D6HkNtfz.js} +1 -1
  42. package/dist/assets/{refresh-cw-DlZkIHnJ.js → refresh-cw-DRcvRrnc.js} +1 -1
  43. package/dist/assets/rotate-cw-BmDKfXtH.js +1 -0
  44. package/dist/assets/{save-Us9fg4Sj.js → save-DHGmi2e9.js} +1 -1
  45. package/dist/assets/search-MChQRYR1.js +1 -0
  46. package/dist/assets/{security-config-BGWYwxNr.js → security-config-CbXfPZzr.js} +1 -1
  47. package/dist/assets/{select-DLYqySQK.js → select-Caud8QvU.js} +1 -1
  48. package/dist/assets/skeleton-B-4vRq_Z.js +1 -0
  49. package/dist/assets/{status-dot-DGayudyB.js → status-dot-DurKKSwA.js} +1 -1
  50. package/dist/assets/{switch-Dz2ScsKx.js → switch-0rmPBRKI.js} +1 -1
  51. package/dist/assets/{tabs-custom-CdKyjiGk.js → tabs-custom-5JLVL6v8.js} +1 -1
  52. package/dist/assets/{trash-2-Db-mZOZs.js → trash-2-C6caKPoz.js} +1 -1
  53. package/dist/assets/{use-infinite-scroll-loader-DBJX5hj0.js → use-infinite-scroll-loader-dwnaa_qi.js} +1 -1
  54. package/dist/assets/{useConfirmDialog-DL0a-oGC.js → useConfirmDialog-mMeWD_yo.js} +1 -1
  55. package/dist/assets/{useMutation-BdZm-9PL.js → useMutation-BmxxvCNf.js} +1 -1
  56. package/dist/assets/x-DuMhMATD.js +1 -0
  57. package/dist/index.html +20 -20
  58. package/package.json +6 -6
  59. package/src/api/runtime-control.ts +34 -0
  60. package/src/api/runtime-control.types.ts +58 -0
  61. package/src/api/types.ts +13 -0
  62. package/src/{App.test.tsx → app.test.tsx} +1 -1
  63. package/src/{App.tsx → app.tsx} +1 -1
  64. package/src/components/chat/ChatConversationPanel.test.tsx +78 -16
  65. package/src/components/chat/ChatSidebar.test.tsx +36 -7
  66. package/src/components/chat/ChatSidebar.tsx +19 -26
  67. package/src/components/chat/chat-child-session-panel.tsx +16 -8
  68. package/src/components/chat/chat-page-runtime.test.ts +1 -1
  69. package/src/components/chat/{ChatPage.tsx → chat-page.tsx} +1 -1
  70. package/src/components/chat/managers/chat-session-list.manager.test.ts +82 -31
  71. package/src/components/chat/managers/chat-session-list.manager.ts +79 -14
  72. package/src/components/chat/managers/chat-ui.manager.ts +2 -0
  73. package/src/components/chat/ncp/README.md +1 -1
  74. package/src/components/chat/ncp/ncp-chat-input.manager.ts +7 -1
  75. package/src/components/chat/ncp/ncp-chat-page-data.test.ts +1 -1
  76. package/src/components/chat/ncp/ncp-session-adapter.test.ts +5 -1
  77. package/src/components/chat/ncp/ncp-session-adapter.ts +12 -0
  78. package/src/components/chat/ncp/session-conversation/use-ncp-child-session-tabs-view.ts +4 -0
  79. package/src/components/chat/ncp/tests/ncp-chat-input.manager.test.ts +99 -0
  80. package/src/components/chat/stores/chat-session-list.store.ts +25 -54
  81. package/src/components/common/ProviderScopedModelInput.tsx +12 -2
  82. package/src/components/config/ModelConfig.test.tsx +108 -2
  83. package/src/components/config/RuntimeConfig.tsx +14 -6
  84. package/src/components/config/desktop-update-config.test.tsx +85 -0
  85. package/src/components/config/desktop-update-config.tsx +44 -3
  86. package/src/components/config/runtime-control-card.test.tsx +255 -0
  87. package/src/components/config/runtime-control-card.tsx +301 -0
  88. package/src/components/config/runtime-presence-card.test.tsx +154 -0
  89. package/src/components/config/runtime-presence-card.tsx +163 -0
  90. package/src/desktop/desktop-update.types.ts +25 -0
  91. package/src/desktop/managers/desktop-presence.manager.ts +91 -0
  92. package/src/desktop/managers/desktop-update.manager.ts +37 -1
  93. package/src/desktop/stores/desktop-presence.store.ts +18 -0
  94. package/src/desktop/stores/desktop-update.store.ts +7 -1
  95. package/src/hooks/use-runtime-control.ts +24 -0
  96. package/src/lib/desktop-update-labels.utils.ts +28 -2
  97. package/src/lib/i18n.runtime-control.ts +120 -0
  98. package/src/lib/i18n.ts +2 -4
  99. package/src/main.tsx +1 -1
  100. package/src/runtime-control/runtime-control.manager.ts +118 -0
  101. package/dist/assets/ChatPage-A45t1Rmf.js +0 -58
  102. package/dist/assets/DocBrowser-B2MpsnU9.js +0 -1
  103. package/dist/assets/MarketplacePage-BNZ3Jx5d.js +0 -1
  104. package/dist/assets/RuntimeConfig-ChdfK4Y_.js +0 -1
  105. package/dist/assets/chat-session-display-CAlPrnlV.js +0 -1
  106. package/dist/assets/desktop-update-config-CfoVwf-w.js +0 -1
  107. package/dist/assets/i18n-C3jb83S6.js +0 -1
  108. package/dist/assets/loader-circle-BjMg63eu.js +0 -1
  109. package/dist/assets/plus-CIXME2pD.js +0 -1
  110. package/dist/assets/search-B_Qr0f6C.js +0 -1
  111. package/dist/assets/skeleton-CYQJazv6.js +0 -1
  112. package/dist/assets/x-B8Tho_xC.js +0 -1
  113. /package/dist/assets/{config-hints-GSUMvmSo.js → config-hints-BZoDjXye.js} +0 -0
  114. /package/dist/assets/{config-layout-CgBMG7OL.js → config-layout-DmlGaay2.js} +0 -0
  115. /package/src/components/chat/ncp/{NcpChatPage.tsx → ncp-chat-page.tsx} +0 -0
@@ -129,15 +129,15 @@ export function ChatChildSessionPanel({
129
129
  }: ChatChildSessionPanelProps) {
130
130
  const presenter = usePresenter();
131
131
  const resolvedTabs = useNcpChildSessionTabsView(tabs);
132
- const readUpdatedAtBySessionKey = useChatSessionListStore(
133
- (state) => state.readUpdatedAtBySessionKey,
132
+ const optimisticReadAtBySessionKey = useChatSessionListStore(
133
+ (state) => state.optimisticReadAtBySessionKey,
134
134
  );
135
135
  const activeTab =
136
136
  resolvedTabs.find((tab) => tab.sessionKey === activeSessionKey) ??
137
137
  resolvedTabs[0] ??
138
138
  null;
139
139
  const activeTabSessionKey = activeTab?.sessionKey ?? null;
140
- const activeTabUpdatedAt = activeTab?.updatedAt?.trim() ?? null;
140
+ const activeTabReadAt = activeTab?.lastMessageAt?.trim() ?? null;
141
141
  const hasParentSession = resolvedTabs.some((tab) =>
142
142
  Boolean(tab.parentSessionKey),
143
143
  );
@@ -145,16 +145,17 @@ export function ChatChildSessionPanel({
145
145
 
146
146
  useEffect(() => {
147
147
  const syncActiveTabReadState = () => {
148
- if (!activeTabSessionKey || !activeTabUpdatedAt) {
148
+ if (!activeTabSessionKey || !activeTabReadAt) {
149
149
  return;
150
150
  }
151
151
  presenter.chatSessionListManager.markSessionRead(
152
152
  activeTabSessionKey,
153
- activeTabUpdatedAt,
153
+ activeTabReadAt,
154
+ activeTab?.readAt ?? null,
154
155
  );
155
156
  };
156
157
  syncActiveTabReadState();
157
- }, [activeTabSessionKey, activeTabUpdatedAt, presenter]);
158
+ }, [activeTab?.readAt, activeTabReadAt, activeTabSessionKey, presenter]);
158
159
 
159
160
  if (!activeTab) {
160
161
  return null;
@@ -203,10 +204,17 @@ export function ChatChildSessionPanel({
203
204
  <Tabs value={activeSessionKey} onValueChange={onSelectSession}>
204
205
  <TabsList className="h-auto min-w-max justify-start gap-1.5 rounded-none bg-transparent p-0 text-gray-500">
205
206
  {resolvedTabs.map((tab) => {
207
+ const optimisticReadAt = optimisticReadAtBySessionKey[tab.sessionKey];
208
+ const effectiveReadAt =
209
+ optimisticReadAt && tab.readAt
210
+ ? (optimisticReadAt.localeCompare(tab.readAt) > 0
211
+ ? optimisticReadAt
212
+ : tab.readAt)
213
+ : optimisticReadAt ?? tab.readAt;
206
214
  const showUnreadDot = shouldShowUnreadSessionIndicator({
207
215
  active: tab.sessionKey === activeSessionKey,
208
- updatedAt: tab.updatedAt,
209
- readUpdatedAt: readUpdatedAtBySessionKey[tab.sessionKey],
216
+ lastMessageAt: tab.lastMessageAt,
217
+ readAt: effectiveReadAt,
210
218
  runStatus: tab.runStatus,
211
219
  });
212
220
  return (
@@ -8,7 +8,7 @@ import {
8
8
  } from '@/components/chat/chat-session-preference-governance';
9
9
  import {
10
10
  shouldClearPendingProjectRootOverride
11
- } from '@/components/chat/ncp/NcpChatPage';
11
+ } from '@/components/chat/ncp/ncp-chat-page';
12
12
 
13
13
  const modelOptions = [
14
14
  {
@@ -1,5 +1,5 @@
1
1
  import type { ChatPageProps } from '@/components/chat/chat-page-shell';
2
- import { NcpChatPage } from '@/components/chat/ncp/NcpChatPage';
2
+ import { NcpChatPage } from '@/components/chat/ncp/ncp-chat-page';
3
3
 
4
4
  export function ChatPage({ view }: ChatPageProps) {
5
5
  return <NcpChatPage view={view} />;
@@ -3,8 +3,18 @@ import { ChatSessionListManager } from '@/components/chat/managers/chat-session-
3
3
  import { useChatInputStore } from '@/components/chat/stores/chat-input.store';
4
4
  import { useChatSessionListStore } from '@/components/chat/stores/chat-session-list.store';
5
5
 
6
+ const mocks = vi.hoisted(() => ({
7
+ updateNcpSession: vi.fn(),
8
+ }));
9
+
10
+ vi.mock('@/api/ncp-session', () => ({
11
+ updateNcpSession: mocks.updateNcpSession,
12
+ }));
13
+
6
14
  describe('ChatSessionListManager', () => {
7
15
  beforeEach(() => {
16
+ mocks.updateNcpSession.mockReset();
17
+ mocks.updateNcpSession.mockResolvedValue({});
8
18
  useChatInputStore.setState({
9
19
  snapshot: {
10
20
  ...useChatInputStore.getState().snapshot,
@@ -15,8 +25,7 @@ describe('ChatSessionListManager', () => {
15
25
  }
16
26
  });
17
27
  useChatSessionListStore.setState({
18
- readUpdatedAtBySessionKey: {},
19
- hasHydratedReadWatermarks: false,
28
+ optimisticReadAtBySessionKey: {},
20
29
  snapshot: {
21
30
  ...useChatSessionListStore.getState().snapshot,
22
31
  selectedSessionKey: 'session-1',
@@ -28,18 +37,21 @@ describe('ChatSessionListManager', () => {
28
37
 
29
38
  it('applies the requested session type when creating a session', () => {
30
39
  const uiManager = {
31
- goToSession: vi.fn()
40
+ goToChatRoot: vi.fn(),
41
+ goToSession: vi.fn(),
42
+ isAtChatRoot: vi.fn(() => true),
32
43
  } as unknown as ConstructorParameters<typeof ChatSessionListManager>[0];
33
44
  const streamActionsManager = {
34
45
  resetStreamState: vi.fn()
35
46
  } as unknown as ConstructorParameters<typeof ChatSessionListManager>[1];
36
47
 
37
48
  const manager = new ChatSessionListManager(uiManager, streamActionsManager);
38
- manager.createSession('codex');
49
+ const sessionKey = manager.createSession('codex');
39
50
 
40
51
  expect(streamActionsManager.resetStreamState).toHaveBeenCalledTimes(1);
41
- expect(uiManager.goToSession).toHaveBeenCalledWith('draft-root-1');
42
- expect(useChatSessionListStore.getState().snapshot.selectedSessionKey).toBe('session-1');
52
+ expect(uiManager.goToChatRoot).toHaveBeenCalledTimes(1);
53
+ expect(useChatSessionListStore.getState().snapshot.selectedSessionKey).toBeNull();
54
+ expect(useChatSessionListStore.getState().snapshot.draftSessionKey).toBe(sessionKey);
43
55
  expect(useChatSessionListStore.getState().snapshot.draftSessionKey).not.toBe('draft-root-1');
44
56
  expect(useChatInputStore.getState().snapshot.pendingSessionType).toBe('codex');
45
57
  expect(useChatInputStore.getState().snapshot.pendingProjectRoot).toBeNull();
@@ -48,20 +60,22 @@ describe('ChatSessionListManager', () => {
48
60
 
49
61
  it('hydrates the draft project root when creating a session inside a project group', () => {
50
62
  const uiManager = {
51
- goToSession: vi.fn()
63
+ goToChatRoot: vi.fn(),
64
+ goToSession: vi.fn(),
65
+ isAtChatRoot: vi.fn(() => true),
52
66
  } as unknown as ConstructorParameters<typeof ChatSessionListManager>[0];
53
67
  const streamActionsManager = {
54
68
  resetStreamState: vi.fn()
55
69
  } as unknown as ConstructorParameters<typeof ChatSessionListManager>[1];
56
70
 
57
71
  const manager = new ChatSessionListManager(uiManager, streamActionsManager);
58
- manager.createSession('native', '/tmp/project-alpha');
72
+ const sessionKey = manager.createSession('native', '/tmp/project-alpha');
59
73
 
60
74
  expect(useChatInputStore.getState().snapshot.pendingProjectRoot).toBe('/tmp/project-alpha');
61
- expect(useChatInputStore.getState().snapshot.pendingProjectRootSessionKey).toBe('draft-root-1');
75
+ expect(useChatInputStore.getState().snapshot.pendingProjectRootSessionKey).toBe(sessionKey);
62
76
  });
63
77
 
64
- it('promotes the current root draft when send flow needs a concrete session key', () => {
78
+ it('reuses the current root draft when send flow needs a concrete session key', () => {
65
79
  useChatSessionListStore.setState({
66
80
  snapshot: {
67
81
  ...useChatSessionListStore.getState().snapshot,
@@ -70,7 +84,9 @@ describe('ChatSessionListManager', () => {
70
84
  }
71
85
  });
72
86
  const uiManager = {
73
- goToSession: vi.fn()
87
+ goToChatRoot: vi.fn(),
88
+ goToSession: vi.fn(),
89
+ isAtChatRoot: vi.fn(() => true),
74
90
  } as unknown as ConstructorParameters<typeof ChatSessionListManager>[0];
75
91
  const streamActionsManager = {
76
92
  resetStreamState: vi.fn()
@@ -80,28 +96,33 @@ describe('ChatSessionListManager', () => {
80
96
  const sessionKey = manager.ensureDraftSession('native');
81
97
 
82
98
  expect(sessionKey).toBe('draft-root-2');
83
- expect(uiManager.goToSession).toHaveBeenCalledWith('draft-root-2');
99
+ expect(uiManager.goToChatRoot).not.toHaveBeenCalled();
100
+ expect(uiManager.goToSession).not.toHaveBeenCalled();
84
101
  expect(useChatSessionListStore.getState().snapshot.selectedSessionKey).toBeNull();
85
102
  });
86
103
 
87
104
  it('does not eagerly replace the old selected session before the route finishes switching', () => {
88
105
  const uiManager = {
89
- goToSession: vi.fn()
106
+ goToChatRoot: vi.fn(),
107
+ goToSession: vi.fn(),
108
+ isAtChatRoot: vi.fn(() => true),
90
109
  } as unknown as ConstructorParameters<typeof ChatSessionListManager>[0];
91
110
  const streamActionsManager = {
92
111
  resetStreamState: vi.fn()
93
112
  } as unknown as ConstructorParameters<typeof ChatSessionListManager>[1];
94
113
 
95
114
  const manager = new ChatSessionListManager(uiManager, streamActionsManager);
96
- manager.createSession('native', '/tmp/project-alpha');
115
+ const sessionKey = manager.createSession('native', '/tmp/project-alpha');
97
116
 
98
- expect(useChatSessionListStore.getState().snapshot.selectedSessionKey).toBe('session-1');
99
- expect(useChatInputStore.getState().snapshot.pendingProjectRootSessionKey).toBe('draft-root-1');
117
+ expect(useChatSessionListStore.getState().snapshot.selectedSessionKey).toBeNull();
118
+ expect(useChatInputStore.getState().snapshot.pendingProjectRootSessionKey).toBe(sessionKey);
100
119
  });
101
120
 
102
121
  it('delegates existing-session selection to routing without eagerly mutating the selected session state', () => {
103
122
  const uiManager = {
104
- goToSession: vi.fn()
123
+ goToChatRoot: vi.fn(),
124
+ goToSession: vi.fn(),
125
+ isAtChatRoot: vi.fn(() => true),
105
126
  } as unknown as ConstructorParameters<typeof ChatSessionListManager>[0];
106
127
  const streamActionsManager = {
107
128
  resetStreamState: vi.fn()
@@ -115,7 +136,9 @@ describe('ChatSessionListManager', () => {
115
136
  });
116
137
 
117
138
  it('updates the sidebar list mode without touching other session list state', () => {
118
- const uiManager = {} as ConstructorParameters<typeof ChatSessionListManager>[0];
139
+ const uiManager = {
140
+ isAtChatRoot: vi.fn(() => true),
141
+ } as unknown as ConstructorParameters<typeof ChatSessionListManager>[0];
119
142
  const streamActionsManager = {} as ConstructorParameters<typeof ChatSessionListManager>[1];
120
143
 
121
144
  const manager = new ChatSessionListManager(uiManager, streamActionsManager);
@@ -127,33 +150,61 @@ describe('ChatSessionListManager', () => {
127
150
 
128
151
  it('marks a session as read through the session list owner boundary', () => {
129
152
  const manager = new ChatSessionListManager(
130
- {} as ConstructorParameters<typeof ChatSessionListManager>[0],
153
+ {
154
+ isAtChatRoot: vi.fn(() => true),
155
+ } as unknown as ConstructorParameters<typeof ChatSessionListManager>[0],
131
156
  {} as ConstructorParameters<typeof ChatSessionListManager>[1]
132
157
  );
133
158
 
134
159
  manager.markSessionRead('session-2', '2026-04-10T10:00:00.000Z');
135
160
 
136
- expect(useChatSessionListStore.getState().readUpdatedAtBySessionKey['session-2']).toBe(
161
+ expect(useChatSessionListStore.getState().optimisticReadAtBySessionKey['session-2']).toBe(
137
162
  '2026-04-10T10:00:00.000Z'
138
163
  );
164
+ expect(mocks.updateNcpSession).toHaveBeenCalledWith('session-2', {
165
+ uiReadAt: '2026-04-10T10:00:00.000Z'
166
+ });
139
167
  });
140
168
 
141
- it('hydrates the initial unread baseline through the session list owner boundary', () => {
169
+ it('skips persisting read state when the backend already has the same watermark', () => {
142
170
  const manager = new ChatSessionListManager(
143
- {} as ConstructorParameters<typeof ChatSessionListManager>[0],
171
+ {
172
+ isAtChatRoot: vi.fn(() => true),
173
+ } as unknown as ConstructorParameters<typeof ChatSessionListManager>[0],
144
174
  {} as ConstructorParameters<typeof ChatSessionListManager>[1]
145
175
  );
146
176
 
147
- manager.hydrateReadWatermarks([
148
- {
149
- sessionKey: 'session-2',
150
- updatedAt: '2026-04-10T10:00:00.000Z'
151
- }
152
- ]);
153
-
154
- expect(useChatSessionListStore.getState().readUpdatedAtBySessionKey['session-2']).toBe(
177
+ manager.markSessionRead(
178
+ 'session-2',
179
+ '2026-04-10T10:00:00.000Z',
155
180
  '2026-04-10T10:00:00.000Z'
156
181
  );
157
- expect(useChatSessionListStore.getState().hasHydratedReadWatermarks).toBe(true);
182
+
183
+ expect(useChatSessionListStore.getState().optimisticReadAtBySessionKey['session-2']).toBeUndefined();
184
+ expect(mocks.updateNcpSession).not.toHaveBeenCalled();
185
+ });
186
+
187
+ it('promotes the active root draft to a concrete session route after the draft has been sent successfully', () => {
188
+ useChatSessionListStore.setState({
189
+ snapshot: {
190
+ ...useChatSessionListStore.getState().snapshot,
191
+ selectedSessionKey: null,
192
+ draftSessionKey: 'draft-root-2',
193
+ }
194
+ });
195
+ const uiManager = {
196
+ goToChatRoot: vi.fn(),
197
+ goToSession: vi.fn(),
198
+ isAtChatRoot: vi.fn(() => true),
199
+ } as unknown as ConstructorParameters<typeof ChatSessionListManager>[0];
200
+ const streamActionsManager = {
201
+ resetStreamState: vi.fn()
202
+ } as unknown as ConstructorParameters<typeof ChatSessionListManager>[1];
203
+ const manager = new ChatSessionListManager(uiManager, streamActionsManager);
204
+
205
+ manager.ensureDraftSession('native');
206
+ manager.promoteRootDraftSessionRoute('draft-root-2');
207
+
208
+ expect(uiManager.goToSession).toHaveBeenCalledWith('draft-root-2', { replace: true });
158
209
  });
159
210
  });
@@ -1,10 +1,12 @@
1
1
  import { useChatSessionListStore } from '@/components/chat/stores/chat-session-list.store';
2
2
  import { useChatInputStore } from '@/components/chat/stores/chat-input.store';
3
+ import { useChatThreadStore } from '@/components/chat/stores/chat-thread.store';
3
4
  import type { ChatUiManager } from '@/components/chat/managers/chat-ui.manager';
4
5
  import type { SetStateAction } from 'react';
5
6
  import type { ChatStreamActionsManager } from '@/components/chat/managers/chat-stream-actions.manager';
6
7
  import { normalizeSessionProjectRootValue } from '@/lib/session-project/session-project.utils';
7
8
  import { createNcpSessionId } from '@/components/chat/ncp/ncp-session-adapter';
9
+ import { updateNcpSession } from '@/api/ncp-session';
8
10
 
9
11
  export class ChatSessionListManager {
10
12
  constructor(
@@ -12,6 +14,22 @@ export class ChatSessionListManager {
12
14
  private streamActionsManager: ChatStreamActionsManager
13
15
  ) {}
14
16
 
17
+ private syncDraftThreadState = (sessionKey: string) => {
18
+ useChatThreadStore.getState().setSnapshot({
19
+ sessionKey,
20
+ sessionDisplayName: undefined,
21
+ canDeleteSession: false,
22
+ isHistoryLoading: false,
23
+ messages: [],
24
+ isSending: false,
25
+ isAwaitingAssistantOutput: false,
26
+ parentSessionKey: null,
27
+ parentSessionLabel: null,
28
+ childSessionTabs: [],
29
+ activeChildSessionKey: null,
30
+ });
31
+ };
32
+
15
33
  private resolveUpdateValue = <T>(prev: T, next: SetStateAction<T>): T => {
16
34
  if (typeof next === 'function') {
17
35
  return (next as (value: T) => T)(prev);
@@ -19,6 +37,22 @@ export class ChatSessionListManager {
19
37
  return next;
20
38
  };
21
39
 
40
+ private shouldPersistReadAt = (
41
+ sessionKey: string,
42
+ readAt: string,
43
+ currentReadAt?: string | null,
44
+ ): boolean => {
45
+ const optimisticReadAt = useChatSessionListStore.getState().optimisticReadAtBySessionKey[sessionKey];
46
+ const effectiveCurrentReadAt =
47
+ optimisticReadAt && currentReadAt
48
+ ? (optimisticReadAt.localeCompare(currentReadAt) > 0 ? optimisticReadAt : currentReadAt)
49
+ : optimisticReadAt ?? currentReadAt ?? undefined;
50
+ if (!effectiveCurrentReadAt) {
51
+ return true;
52
+ }
53
+ return readAt.localeCompare(effectiveCurrentReadAt) > 0;
54
+ };
55
+
22
56
  setSelectedAgentId = (next: SetStateAction<string>) => {
23
57
  const prev = useChatSessionListStore.getState().snapshot.selectedAgentId;
24
58
  const value = this.resolveUpdateValue(prev, next);
@@ -46,22 +80,25 @@ export class ChatSessionListManager {
46
80
  useChatSessionListStore.getState().setSnapshot({ listMode: value });
47
81
  };
48
82
 
49
- markSessionRead = (sessionKey: string | null | undefined, updatedAt: string | null | undefined) => {
50
- if (!sessionKey) {
83
+ markSessionRead = (
84
+ sessionKey: string | null | undefined,
85
+ readAt: string | null | undefined,
86
+ currentReadAt?: string | null,
87
+ ) => {
88
+ const normalizedSessionKey = sessionKey?.trim();
89
+ const normalizedReadAt = readAt?.trim();
90
+ if (!normalizedSessionKey || !normalizedReadAt) {
51
91
  return;
52
92
  }
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);
93
+ if (!this.shouldPersistReadAt(normalizedSessionKey, normalizedReadAt, currentReadAt)) {
94
+ return;
95
+ }
96
+ useChatSessionListStore.getState().markSessionRead(normalizedSessionKey, normalizedReadAt);
97
+ void updateNcpSession(normalizedSessionKey, { uiReadAt: normalizedReadAt }).catch(() => undefined);
60
98
  };
61
99
 
62
100
  createSession = (sessionType?: string, projectRoot?: string | null): string => {
63
101
  const { snapshot } = useChatInputStore.getState();
64
- const { snapshot: sessionListSnapshot } = useChatSessionListStore.getState();
65
102
  const { defaultSessionType: configuredDefaultSessionType } = snapshot;
66
103
  const defaultSessionType = configuredDefaultSessionType || 'native';
67
104
  const nextSessionType =
@@ -69,17 +106,19 @@ export class ChatSessionListManager {
69
106
  ? sessionType.trim()
70
107
  : defaultSessionType;
71
108
  const normalizedProjectRoot = normalizeSessionProjectRootValue(projectRoot);
72
- const nextSessionKey = sessionListSnapshot.draftSessionKey;
109
+ const nextSessionKey = createNcpSessionId();
73
110
  this.streamActionsManager.resetStreamState();
74
111
  useChatSessionListStore.getState().setSnapshot({
75
- draftSessionKey: createNcpSessionId()
112
+ selectedSessionKey: null,
113
+ draftSessionKey: nextSessionKey
76
114
  });
115
+ this.syncDraftThreadState(nextSessionKey);
77
116
  useChatInputStore.getState().setSnapshot({
78
117
  pendingSessionType: nextSessionType,
79
118
  pendingProjectRoot: normalizedProjectRoot,
80
119
  pendingProjectRootSessionKey: normalizedProjectRoot ? nextSessionKey : null
81
120
  });
82
- this.uiManager.goToSession(nextSessionKey);
121
+ this.uiManager.goToChatRoot();
83
122
  return nextSessionKey;
84
123
  };
85
124
 
@@ -88,7 +127,33 @@ export class ChatSessionListManager {
88
127
  if (snapshot.selectedSessionKey) {
89
128
  return snapshot.selectedSessionKey;
90
129
  }
91
- return this.createSession(sessionType);
130
+ const normalizedSessionType =
131
+ typeof sessionType === 'string' && sessionType.trim().length > 0
132
+ ? sessionType.trim()
133
+ : null;
134
+ this.syncDraftThreadState(snapshot.draftSessionKey);
135
+ if (normalizedSessionType) {
136
+ useChatInputStore.getState().setSnapshot({ pendingSessionType: normalizedSessionType });
137
+ }
138
+ return snapshot.draftSessionKey;
139
+ };
140
+
141
+ promoteRootDraftSessionRoute = (sessionKey: string) => {
142
+ const normalizedSessionKey = sessionKey.trim();
143
+ if (!normalizedSessionKey) {
144
+ return;
145
+ }
146
+ const { snapshot } = useChatSessionListStore.getState();
147
+ const { sessionKey: currentThreadSessionKey } = useChatThreadStore.getState().snapshot;
148
+ if (
149
+ snapshot.selectedSessionKey !== null ||
150
+ snapshot.draftSessionKey !== normalizedSessionKey ||
151
+ currentThreadSessionKey !== normalizedSessionKey ||
152
+ !this.uiManager.isAtChatRoot()
153
+ ) {
154
+ return;
155
+ }
156
+ this.uiManager.goToSession(normalizedSessionKey, { replace: true });
92
157
  };
93
158
 
94
159
  selectSession = (sessionKey: string) => {
@@ -59,6 +59,8 @@ export class ChatUiManager {
59
59
  this.navigateTo('/chat', options);
60
60
  };
61
61
 
62
+ isAtChatRoot = () => this.state.pathname === '/chat';
63
+
62
64
  goToSession = (sessionKey: string, options?: NavigateOptions) => {
63
65
  this.navigateTo(buildSessionPath(sessionKey), options);
64
66
  };
@@ -1,3 +1,3 @@
1
1
  ## 子树边界豁免
2
2
 
3
- - 原因:`chat/ncp/` 目录是 NCP 聊天运行时的装配子树,需要并列保留页面数据、派生状态、输入/线程 manager、session adapter 与测试文件。本次新增派生状态模块是为了拆短 `NcpChatPage.tsx`,属于职责下沉,而不是继续把复杂度堆回页面壳。
3
+ - 原因:`chat/ncp/` 目录是 NCP 聊天运行时的装配子树,需要并列保留页面数据、派生状态、输入/线程 manager、session adapter 与测试文件。本次新增派生状态模块是为了拆短 `ncp-chat-page.tsx`,属于职责下沉,而不是继续把复杂度堆回页面壳。
@@ -15,6 +15,7 @@ import {
15
15
  } from '@/components/chat/chat-composer-state';
16
16
  import { useChatInputStore } from '@/components/chat/stores/chat-input.store';
17
17
  import { useChatSessionListStore } from '@/components/chat/stores/chat-session-list.store';
18
+ import { useChatThreadStore } from '@/components/chat/stores/chat-thread.store';
18
19
  import type { ChatInputSnapshot } from '@/components/chat/stores/chat-input.store';
19
20
  import type { ChatStreamActionsManager } from '@/components/chat/managers/chat-stream-actions.manager';
20
21
  import type { ChatUiManager } from '@/components/chat/managers/chat-ui.manager';
@@ -170,6 +171,7 @@ export class NcpChatInputManager {
170
171
  send = async () => {
171
172
  const inputSnapshot = useChatInputStore.getState().snapshot;
172
173
  const sessionSnapshot = useChatSessionListStore.getState().snapshot;
174
+ const threadSnapshot = useChatThreadStore.getState().snapshot;
173
175
  const message = inputSnapshot.draft.trim();
174
176
  const attachments = inputSnapshot.attachments;
175
177
  const parts = deriveNcpMessagePartsFromComposer(inputSnapshot.composerNodes, attachments);
@@ -180,7 +182,10 @@ export class NcpChatInputManager {
180
182
  return;
181
183
  }
182
184
  const { selectedSkills: requestedSkills, composerNodes } = inputSnapshot;
183
- const sessionKey = sessionSnapshot.selectedSessionKey ?? this.sessionListManager.ensureDraftSession(inputSnapshot.selectedSessionType);
185
+ const sessionKey =
186
+ threadSnapshot.sessionKey ??
187
+ sessionSnapshot.selectedSessionKey ??
188
+ this.sessionListManager.ensureDraftSession(inputSnapshot.selectedSessionType);
184
189
  this.setComposerNodes(createInitialChatComposerNodes());
185
190
  await this.streamActionsManager.sendMessage({
186
191
  message,
@@ -196,6 +201,7 @@ export class NcpChatInputManager {
196
201
  restoreDraftOnError: true,
197
202
  composerNodes
198
203
  });
204
+ this.sessionListManager.promoteRootDraftSessionRoute(sessionKey);
199
205
  };
200
206
 
201
207
  stop = async () => {
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from 'vitest';
2
- import { buildNcpSendMetadata } from '@/components/chat/ncp/NcpChatPage';
2
+ import { buildNcpSendMetadata } from '@/components/chat/ncp/ncp-chat-page';
3
3
  import { filterModelOptionsBySessionType } from '@/components/chat/ncp/ncp-chat-page-data';
4
4
  import type { ChatModelOption } from '@/components/chat/chat-input.types';
5
5
 
@@ -21,12 +21,14 @@ describe('adaptNcpSessionSummary', () => {
21
21
  const adapted = adaptNcpSessionSummary(
22
22
  createSummary({
23
23
  agentId: 'engineer',
24
+ lastMessageAt: '2026-03-18T00:00:00.000Z',
24
25
  metadata: {
25
26
  label: 'NCP Planning Thread',
26
27
  model: 'openai/gpt-5',
27
28
  preferred_thinking: 'medium',
28
29
  project_root: '/Users/demo/workspace/project-alpha',
29
- session_type: 'native'
30
+ session_type: 'native',
31
+ ui_last_read_at: '2026-03-17T23:59:00.000Z'
30
32
  }
31
33
  })
32
34
  );
@@ -39,6 +41,8 @@ describe('adaptNcpSessionSummary', () => {
39
41
  preferredThinking: 'medium',
40
42
  projectRoot: '/Users/demo/workspace/project-alpha',
41
43
  projectName: 'project-alpha',
44
+ lastMessageAt: '2026-03-18T00:00:00.000Z',
45
+ readAt: '2026-03-17T23:59:00.000Z',
42
46
  sessionType: 'native',
43
47
  sessionTypeMutable: false,
44
48
  isChildSession: false,
@@ -80,6 +80,14 @@ function readNcpSessionProjectRoot(summary: NcpSessionSummaryView): string | nul
80
80
  return normalizeSessionProjectRootValue(metadata.project_root ?? metadata.projectRoot);
81
81
  }
82
82
 
83
+ function readNcpSessionReadAt(summary: NcpSessionSummaryView): string | null {
84
+ const metadata = readMetadata(summary);
85
+ if (!metadata) {
86
+ return null;
87
+ }
88
+ return readOptionalString(metadata.ui_last_read_at);
89
+ }
90
+
83
91
  function readNcpSessionType(summary: NcpSessionSummaryView): string {
84
92
  const metadata = readMetadata(summary);
85
93
  if (!metadata) {
@@ -249,6 +257,8 @@ export function adaptNcpSessionSummary(summary: NcpSessionSummaryView): SessionE
249
257
  const preferredModel = readNcpSessionPreferredModel(summary);
250
258
  const preferredThinking = readNcpSessionPreferredThinking(summary);
251
259
  const projectRoot = readNcpSessionProjectRoot(summary);
260
+ const readAt = readNcpSessionReadAt(summary);
261
+ const lastMessageAt = readOptionalString(summary.lastMessageAt);
252
262
  const projectName = getSessionProjectName(projectRoot);
253
263
  const context = parseSessionContext(summary.sessionId);
254
264
  const parentSessionId = readNcpParentSessionId(summary);
@@ -258,6 +268,8 @@ export function adaptNcpSessionSummary(summary: NcpSessionSummaryView): SessionE
258
268
  key: summary.sessionId,
259
269
  createdAt: summary.updatedAt,
260
270
  updatedAt: summary.updatedAt,
271
+ ...(lastMessageAt ? { lastMessageAt } : {}),
272
+ ...(readAt ? { readAt } : {}),
261
273
  ...(typeof summary.agentId === 'string' && summary.agentId.trim().length > 0 ? { agentId: summary.agentId.trim() } : {}),
262
274
  ...(label ? { label } : {}),
263
275
  ...context,
@@ -13,6 +13,8 @@ export type ResolvedChildSessionTab = {
13
13
  title: string;
14
14
  agentId: string | null;
15
15
  updatedAt: string | null;
16
+ lastMessageAt: string | null;
17
+ readAt: string | null;
16
18
  runStatus?: SessionRunStatus;
17
19
  sessionTypeLabel: string | null;
18
20
  preferredModel: string | null;
@@ -64,6 +66,8 @@ export function useNcpChildSessionTabsView(
64
66
  title: resolveChildSessionTitle(tab, session),
65
67
  agentId,
66
68
  updatedAt: session?.updatedAt ?? null,
69
+ lastMessageAt: session?.lastMessageAt ?? null,
70
+ readAt: session?.readAt ?? null,
67
71
  runStatus: summary?.status === "running" ? "running" : undefined,
68
72
  sessionTypeLabel: session?.sessionType
69
73
  ? resolveSessionTypeLabel(session.sessionType)