@nextclaw/ui 0.12.7 → 0.12.9

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 (171) hide show
  1. package/CHANGELOG.md +85 -0
  2. package/dist/assets/ChannelsList-Ita2Zm1_.js +8 -0
  3. package/dist/assets/{DocBrowser-Cse_F8Nn.js → DocBrowser-6ReNjvzF.js} +1 -1
  4. package/dist/assets/DocBrowser-BNwbPHf4.js +1 -0
  5. package/dist/assets/{DocBrowserContext-Bai1WU2H.js → DocBrowserContext-B6SpA7Qs.js} +1 -1
  6. package/dist/assets/{LogoBadge-BdxMPc9v.js → LogoBadge-ByNLYg65.js} +1 -1
  7. package/dist/assets/MarketplacePage-CjX2MWww.js +1 -0
  8. package/dist/assets/{MarketplacePage-BbpAkllU.js → MarketplacePage-D0sDlYX4.js} +1 -1
  9. package/dist/assets/McpMarketplacePage-BGKJm1sJ.js +40 -0
  10. package/dist/assets/{ModelConfig-3GLqQ5GY.js → ModelConfig-BzZenCH-.js} +1 -1
  11. package/dist/assets/{ProviderScopedModelInput-BYNouw-i.js → ProviderScopedModelInput-Da7khnBA.js} +1 -1
  12. package/dist/assets/{ProvidersList-BR1gJ4Dm.js → ProvidersList-BbVzRxjY.js} +1 -1
  13. package/dist/assets/RemoteAccessPage-BaDH_X1Q.js +1 -0
  14. package/dist/assets/RuntimeConfig-F_XKGgLm.js +1 -0
  15. package/dist/assets/{SearchConfig-DTeJvp8m.js → SearchConfig-BGkzXQP-.js} +1 -1
  16. package/dist/assets/{SecretsConfig-CCYO6NcV.js → SecretsConfig-D281Rotl.js} +2 -2
  17. package/dist/assets/{SessionsConfig-Du39vDgt.js → SessionsConfig-ChHQ7M5c.js} +2 -2
  18. package/dist/assets/{app-query-client-Dr5d-K8d.js → app-query-client-VnFElj4E.js} +1 -1
  19. package/dist/assets/{book-open-Da4OEPqB.js → book-open-BdcxxoQu.js} +1 -1
  20. package/dist/assets/chat-page-Doe0yTtB.js +58 -0
  21. package/dist/assets/chat-session-display-cw78aiI_.js +1 -0
  22. package/dist/assets/{chunk-JZWAC4HX-CoFVxHXV.js → chunk-JZWAC4HX-DK5HPmIK.js} +1 -1
  23. package/dist/assets/{client-CSk58DcF.js → client-_i4MU2bB.js} +1 -1
  24. package/dist/assets/{config-D8KzikVB.js → config-DtIQwrHF.js} +1 -1
  25. package/dist/assets/{createLucideIcon-83gaZMtv.js → createLucideIcon-BSeTgkZW.js} +1 -1
  26. package/dist/assets/desktop-update-config-Dpcf4BKG.js +1 -0
  27. package/dist/assets/{dist-toEYs-MZ.js → dist-6TrrnPCR.js} +1 -1
  28. package/dist/assets/{dist-aTmhMDVh.js → dist-ccBFUi-o.js} +1 -1
  29. package/dist/assets/download-BhDxnyvU.js +1 -0
  30. package/dist/assets/{external-link-QQ0TC6X4.js → external-link-BgErLCNT.js} +1 -1
  31. package/dist/assets/{hash-DaFBEkmi.js → hash-Bl7dr_UG.js} +1 -1
  32. package/dist/assets/i18n-eDHeDY0n.js +1 -0
  33. package/dist/assets/index-CF9xve0E.js +6 -0
  34. package/dist/assets/index-FgA52VBt.css +1 -0
  35. package/dist/assets/{infiniteQueryBehavior-BmHX_ayZ.js → infiniteQueryBehavior-ZDS92Qpp.js} +1 -1
  36. package/dist/assets/loader-circle-ACM1s51e.js +1 -0
  37. package/dist/assets/{logos-Dzlz30M3.js → logos-x89HbrZ4.js} +1 -1
  38. package/dist/assets/{page-layout-D2eRufRQ.js → page-layout-vZnghcFy.js} +1 -1
  39. package/dist/assets/play-CFUwCA2E.js +1 -0
  40. package/dist/assets/plus-rYsv72JG.js +1 -0
  41. package/dist/assets/{popover-BSXxm5bj.js → popover-Bg1VoTZ6.js} +1 -1
  42. package/dist/assets/{refresh-ccw-B3zMtN-_.js → refresh-ccw-DT98i__E.js} +1 -1
  43. package/dist/assets/{refresh-cw-DlZkIHnJ.js → refresh-cw-C47QSEwg.js} +1 -1
  44. package/dist/assets/rotate-cw-JtFzpNn6.js +1 -0
  45. package/dist/assets/{save-Us9fg4Sj.js → save-3S6-H3Xw.js} +1 -1
  46. package/dist/assets/search-3kFR_zh9.js +1 -0
  47. package/dist/assets/{security-config-BGWYwxNr.js → security-config-BWaiARNk.js} +1 -1
  48. package/dist/assets/{select-DLYqySQK.js → select-DJ2MUjBB.js} +1 -1
  49. package/dist/assets/skeleton-ByQepn0M.js +1 -0
  50. package/dist/assets/{status-dot-DGayudyB.js → status-dot-vbanNPFU.js} +1 -1
  51. package/dist/assets/{switch-Dz2ScsKx.js → switch-BsLtHOH-.js} +1 -1
  52. package/dist/assets/{tabs-custom-CdKyjiGk.js → tabs-custom-D3HYMt6k.js} +1 -1
  53. package/dist/assets/{trash-2-Db-mZOZs.js → trash-2-G48scll7.js} +1 -1
  54. package/dist/assets/{use-infinite-scroll-loader-DBJX5hj0.js → use-infinite-scroll-loader-DkNhD-42.js} +1 -1
  55. package/dist/assets/{useConfirmDialog-DL0a-oGC.js → useConfirmDialog-BkvTN-vd.js} +1 -1
  56. package/dist/assets/{useMutation-BdZm-9PL.js → useMutation-CBWjE2uj.js} +1 -1
  57. package/dist/assets/x-ByDbItbq.js +1 -0
  58. package/dist/index.html +95 -21
  59. package/dist/manifest.webmanifest +30 -0
  60. package/dist/offline.html +102 -0
  61. package/dist/pwa-192.png +0 -0
  62. package/dist/pwa-512.png +0 -0
  63. package/dist/sw.js +80 -0
  64. package/index.html +73 -1
  65. package/package.json +6 -6
  66. package/public/manifest.webmanifest +30 -0
  67. package/public/offline.html +102 -0
  68. package/public/pwa-192.png +0 -0
  69. package/public/pwa-512.png +0 -0
  70. package/public/sw.js +80 -0
  71. package/src/api/runtime-control.ts +34 -0
  72. package/src/api/runtime-control.types.ts +58 -0
  73. package/src/api/server-path.ts +27 -4
  74. package/src/api/types.ts +30 -10
  75. package/src/{App.test.tsx → app.test.tsx} +1 -1
  76. package/src/{App.tsx → app.tsx} +10 -1
  77. package/src/components/chat/ChatSidebar.test.tsx +79 -8
  78. package/src/components/chat/ChatSidebar.tsx +43 -26
  79. package/src/components/chat/adapters/chat-message.summary-truncation.test.ts +66 -0
  80. package/src/components/chat/adapters/file-operation/card.ts +9 -0
  81. package/src/components/chat/adapters/file-operation/diff.ts +14 -0
  82. package/src/components/chat/{ChatConversationPanel.test.tsx → chat-conversation-panel.test.tsx} +118 -155
  83. package/src/components/chat/chat-conversation-panel.tsx +412 -0
  84. package/src/components/chat/chat-page-runtime.test.ts +1 -1
  85. package/src/components/chat/chat-page-shell.tsx +1 -1
  86. package/src/components/chat/{ChatPage.tsx → chat-page.tsx} +1 -1
  87. package/src/components/chat/chat-session-workspace-file-preview.test.tsx +91 -0
  88. package/src/components/chat/chat-session-workspace-file-preview.tsx +307 -0
  89. package/src/components/chat/chat-session-workspace-panel-nav.tsx +197 -0
  90. package/src/components/chat/chat-session-workspace-panel.tsx +318 -0
  91. package/src/components/chat/chat-sidebar-session-item.tsx +32 -2
  92. package/src/components/chat/containers/chat-message-list.container.test.tsx +49 -0
  93. package/src/components/chat/containers/chat-message-list.container.tsx +4 -0
  94. package/src/components/chat/managers/chat-session-list.manager.test.ts +94 -31
  95. package/src/components/chat/managers/chat-session-list.manager.ts +86 -14
  96. package/src/components/chat/managers/chat-ui.manager.ts +2 -0
  97. package/src/components/chat/ncp/README.md +1 -1
  98. package/src/components/chat/ncp/ncp-chat-input.manager.ts +7 -1
  99. package/src/components/chat/ncp/ncp-chat-page-data.test.ts +1 -1
  100. package/src/components/chat/ncp/{NcpChatPage.tsx → ncp-chat-page.tsx} +7 -7
  101. package/src/components/chat/ncp/ncp-chat-thread.manager.ts +179 -41
  102. package/src/components/chat/ncp/ncp-session-adapter.test.ts +40 -2
  103. package/src/components/chat/ncp/ncp-session-adapter.ts +29 -0
  104. package/src/components/chat/ncp/page/ncp-chat-derived-state.ts +54 -11
  105. package/src/components/chat/ncp/session-conversation/use-ncp-child-session-tabs-view.ts +4 -0
  106. package/src/components/chat/ncp/tests/ncp-chat-input.manager.test.ts +99 -0
  107. package/src/components/chat/ncp/tests/ncp-chat-thread.manager.test.ts +189 -0
  108. package/src/components/chat/presenter/chat-presenter-context.tsx +13 -2
  109. package/src/components/chat/session-header/chat-session-header-actions.test.tsx +26 -0
  110. package/src/components/chat/session-header/chat-session-header-actions.tsx +19 -1
  111. package/src/components/chat/stores/chat-session-list.store.ts +25 -54
  112. package/src/components/chat/stores/chat-thread.store.ts +24 -0
  113. package/src/components/common/ProviderScopedModelInput.tsx +12 -2
  114. package/src/components/config/ModelConfig.test.tsx +108 -2
  115. package/src/components/config/RuntimeConfig.tsx +154 -7
  116. package/src/components/config/desktop-update-config.test.tsx +85 -0
  117. package/src/components/config/desktop-update-config.tsx +44 -3
  118. package/src/components/config/runtime-control-card.test.tsx +255 -0
  119. package/src/components/config/runtime-control-card.tsx +301 -0
  120. package/src/components/config/runtime-presence-card.test.tsx +154 -0
  121. package/src/components/config/runtime-presence-card.tsx +163 -0
  122. package/src/components/layout/AppLayout.tsx +1 -1
  123. package/src/components/providers/ThemeProvider.tsx +5 -0
  124. package/src/desktop/desktop-update.types.ts +25 -0
  125. package/src/desktop/managers/desktop-presence.manager.ts +91 -0
  126. package/src/desktop/managers/desktop-update.manager.ts +37 -1
  127. package/src/desktop/stores/desktop-presence.store.ts +18 -0
  128. package/src/desktop/stores/desktop-update.store.ts +7 -1
  129. package/src/hooks/server-path/use-server-path-read.ts +20 -0
  130. package/src/hooks/use-runtime-control.ts +24 -0
  131. package/src/lib/chat-message.ts +14 -3
  132. package/src/lib/desktop-update-labels.utils.ts +28 -2
  133. package/src/lib/i18n.chat.ts +12 -1
  134. package/src/lib/i18n.pwa.ts +62 -0
  135. package/src/lib/i18n.runtime-control.ts +120 -0
  136. package/src/lib/i18n.ts +4 -6
  137. package/src/main.tsx +1 -1
  138. package/src/pwa/components/pwa-install-entry.test.tsx +110 -0
  139. package/src/pwa/components/pwa-install-entry.tsx +205 -0
  140. package/src/pwa/managers/pwa-install.manager.test.ts +160 -0
  141. package/src/pwa/managers/pwa-install.manager.ts +232 -0
  142. package/src/pwa/managers/pwa-runtime.manager.ts +196 -0
  143. package/src/pwa/managers/pwa-shell-theme.manager.test.ts +30 -0
  144. package/src/pwa/managers/pwa-shell-theme.manager.ts +46 -0
  145. package/src/pwa/pwa-install-banner.storage.ts +55 -0
  146. package/src/pwa/pwa.types.ts +22 -0
  147. package/src/pwa/register-pwa.ts +14 -0
  148. package/src/pwa/stores/pwa.store.ts +17 -0
  149. package/src/runtime-control/runtime-control.manager.ts +118 -0
  150. package/src/vite-env.d.ts +9 -0
  151. package/dist/assets/ChannelsList-D8p4OlM6.js +0 -8
  152. package/dist/assets/ChatPage-A45t1Rmf.js +0 -58
  153. package/dist/assets/DocBrowser-B2MpsnU9.js +0 -1
  154. package/dist/assets/MarketplacePage-BNZ3Jx5d.js +0 -1
  155. package/dist/assets/McpMarketplacePage-CxPFOgxv.js +0 -40
  156. package/dist/assets/RemoteAccessPage-DyYVWsyK.js +0 -1
  157. package/dist/assets/RuntimeConfig-ChdfK4Y_.js +0 -1
  158. package/dist/assets/chat-session-display-CAlPrnlV.js +0 -1
  159. package/dist/assets/desktop-update-config-CfoVwf-w.js +0 -1
  160. package/dist/assets/i18n-C3jb83S6.js +0 -1
  161. package/dist/assets/index-CE4N7ItL.css +0 -1
  162. package/dist/assets/index-riX7Sg0_.js +0 -6
  163. package/dist/assets/loader-circle-BjMg63eu.js +0 -1
  164. package/dist/assets/plus-CIXME2pD.js +0 -1
  165. package/dist/assets/search-B_Qr0f6C.js +0 -1
  166. package/dist/assets/skeleton-CYQJazv6.js +0 -1
  167. package/dist/assets/x-B8Tho_xC.js +0 -1
  168. package/src/components/chat/ChatConversationPanel.tsx +0 -256
  169. package/src/components/chat/chat-child-session-panel.tsx +0 -262
  170. /package/dist/assets/{config-hints-GSUMvmSo.js → config-hints-BhTmc9P1.js} +0 -0
  171. /package/dist/assets/{config-layout-CgBMG7OL.js → config-layout-CHs0mAaR.js} +0 -0
@@ -2,9 +2,20 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
2
  import { ChatSessionListManager } from '@/components/chat/managers/chat-session-list.manager';
3
3
  import { useChatInputStore } from '@/components/chat/stores/chat-input.store';
4
4
  import { useChatSessionListStore } from '@/components/chat/stores/chat-session-list.store';
5
+ import { useChatThreadStore } from '@/components/chat/stores/chat-thread.store';
6
+
7
+ const mocks = vi.hoisted(() => ({
8
+ updateNcpSession: vi.fn(),
9
+ }));
10
+
11
+ vi.mock('@/api/ncp-session', () => ({
12
+ updateNcpSession: mocks.updateNcpSession,
13
+ }));
5
14
 
6
15
  describe('ChatSessionListManager', () => {
7
16
  beforeEach(() => {
17
+ mocks.updateNcpSession.mockReset();
18
+ mocks.updateNcpSession.mockResolvedValue({});
8
19
  useChatInputStore.setState({
9
20
  snapshot: {
10
21
  ...useChatInputStore.getState().snapshot,
@@ -15,8 +26,7 @@ describe('ChatSessionListManager', () => {
15
26
  }
16
27
  });
17
28
  useChatSessionListStore.setState({
18
- readUpdatedAtBySessionKey: {},
19
- hasHydratedReadWatermarks: false,
29
+ optimisticReadAtBySessionKey: {},
20
30
  snapshot: {
21
31
  ...useChatSessionListStore.getState().snapshot,
22
32
  selectedSessionKey: 'session-1',
@@ -24,22 +34,33 @@ describe('ChatSessionListManager', () => {
24
34
  listMode: 'time-first'
25
35
  }
26
36
  });
37
+ useChatThreadStore.setState({
38
+ snapshot: {
39
+ ...useChatThreadStore.getState().snapshot,
40
+ workspacePanelParentKey: 'session-1',
41
+ activeChildSessionKey: 'child-session-1',
42
+ activeWorkspaceFileKey: 'session-1::/tmp/demo.md',
43
+ },
44
+ });
27
45
  });
28
46
 
29
47
  it('applies the requested session type when creating a session', () => {
30
48
  const uiManager = {
31
- goToSession: vi.fn()
49
+ goToChatRoot: vi.fn(),
50
+ goToSession: vi.fn(),
51
+ isAtChatRoot: vi.fn(() => true),
32
52
  } as unknown as ConstructorParameters<typeof ChatSessionListManager>[0];
33
53
  const streamActionsManager = {
34
54
  resetStreamState: vi.fn()
35
55
  } as unknown as ConstructorParameters<typeof ChatSessionListManager>[1];
36
56
 
37
57
  const manager = new ChatSessionListManager(uiManager, streamActionsManager);
38
- manager.createSession('codex');
58
+ const sessionKey = manager.createSession('codex');
39
59
 
40
60
  expect(streamActionsManager.resetStreamState).toHaveBeenCalledTimes(1);
41
- expect(uiManager.goToSession).toHaveBeenCalledWith('draft-root-1');
42
- expect(useChatSessionListStore.getState().snapshot.selectedSessionKey).toBe('session-1');
61
+ expect(uiManager.goToChatRoot).toHaveBeenCalledTimes(1);
62
+ expect(useChatSessionListStore.getState().snapshot.selectedSessionKey).toBeNull();
63
+ expect(useChatSessionListStore.getState().snapshot.draftSessionKey).toBe(sessionKey);
43
64
  expect(useChatSessionListStore.getState().snapshot.draftSessionKey).not.toBe('draft-root-1');
44
65
  expect(useChatInputStore.getState().snapshot.pendingSessionType).toBe('codex');
45
66
  expect(useChatInputStore.getState().snapshot.pendingProjectRoot).toBeNull();
@@ -48,20 +69,22 @@ describe('ChatSessionListManager', () => {
48
69
 
49
70
  it('hydrates the draft project root when creating a session inside a project group', () => {
50
71
  const uiManager = {
51
- goToSession: vi.fn()
72
+ goToChatRoot: vi.fn(),
73
+ goToSession: vi.fn(),
74
+ isAtChatRoot: vi.fn(() => true),
52
75
  } as unknown as ConstructorParameters<typeof ChatSessionListManager>[0];
53
76
  const streamActionsManager = {
54
77
  resetStreamState: vi.fn()
55
78
  } as unknown as ConstructorParameters<typeof ChatSessionListManager>[1];
56
79
 
57
80
  const manager = new ChatSessionListManager(uiManager, streamActionsManager);
58
- manager.createSession('native', '/tmp/project-alpha');
81
+ const sessionKey = manager.createSession('native', '/tmp/project-alpha');
59
82
 
60
83
  expect(useChatInputStore.getState().snapshot.pendingProjectRoot).toBe('/tmp/project-alpha');
61
- expect(useChatInputStore.getState().snapshot.pendingProjectRootSessionKey).toBe('draft-root-1');
84
+ expect(useChatInputStore.getState().snapshot.pendingProjectRootSessionKey).toBe(sessionKey);
62
85
  });
63
86
 
64
- it('promotes the current root draft when send flow needs a concrete session key', () => {
87
+ it('reuses the current root draft when send flow needs a concrete session key', () => {
65
88
  useChatSessionListStore.setState({
66
89
  snapshot: {
67
90
  ...useChatSessionListStore.getState().snapshot,
@@ -70,7 +93,9 @@ describe('ChatSessionListManager', () => {
70
93
  }
71
94
  });
72
95
  const uiManager = {
73
- goToSession: vi.fn()
96
+ goToChatRoot: vi.fn(),
97
+ goToSession: vi.fn(),
98
+ isAtChatRoot: vi.fn(() => true),
74
99
  } as unknown as ConstructorParameters<typeof ChatSessionListManager>[0];
75
100
  const streamActionsManager = {
76
101
  resetStreamState: vi.fn()
@@ -80,28 +105,33 @@ describe('ChatSessionListManager', () => {
80
105
  const sessionKey = manager.ensureDraftSession('native');
81
106
 
82
107
  expect(sessionKey).toBe('draft-root-2');
83
- expect(uiManager.goToSession).toHaveBeenCalledWith('draft-root-2');
108
+ expect(uiManager.goToChatRoot).not.toHaveBeenCalled();
109
+ expect(uiManager.goToSession).not.toHaveBeenCalled();
84
110
  expect(useChatSessionListStore.getState().snapshot.selectedSessionKey).toBeNull();
85
111
  });
86
112
 
87
113
  it('does not eagerly replace the old selected session before the route finishes switching', () => {
88
114
  const uiManager = {
89
- goToSession: vi.fn()
115
+ goToChatRoot: vi.fn(),
116
+ goToSession: vi.fn(),
117
+ isAtChatRoot: vi.fn(() => true),
90
118
  } as unknown as ConstructorParameters<typeof ChatSessionListManager>[0];
91
119
  const streamActionsManager = {
92
120
  resetStreamState: vi.fn()
93
121
  } as unknown as ConstructorParameters<typeof ChatSessionListManager>[1];
94
122
 
95
123
  const manager = new ChatSessionListManager(uiManager, streamActionsManager);
96
- manager.createSession('native', '/tmp/project-alpha');
124
+ const sessionKey = manager.createSession('native', '/tmp/project-alpha');
97
125
 
98
- expect(useChatSessionListStore.getState().snapshot.selectedSessionKey).toBe('session-1');
99
- expect(useChatInputStore.getState().snapshot.pendingProjectRootSessionKey).toBe('draft-root-1');
126
+ expect(useChatSessionListStore.getState().snapshot.selectedSessionKey).toBeNull();
127
+ expect(useChatInputStore.getState().snapshot.pendingProjectRootSessionKey).toBe(sessionKey);
100
128
  });
101
129
 
102
130
  it('delegates existing-session selection to routing without eagerly mutating the selected session state', () => {
103
131
  const uiManager = {
104
- goToSession: vi.fn()
132
+ goToChatRoot: vi.fn(),
133
+ goToSession: vi.fn(),
134
+ isAtChatRoot: vi.fn(() => true),
105
135
  } as unknown as ConstructorParameters<typeof ChatSessionListManager>[0];
106
136
  const streamActionsManager = {
107
137
  resetStreamState: vi.fn()
@@ -112,10 +142,15 @@ describe('ChatSessionListManager', () => {
112
142
 
113
143
  expect(uiManager.goToSession).toHaveBeenCalledWith('session-2');
114
144
  expect(useChatSessionListStore.getState().snapshot.selectedSessionKey).toBe('session-1');
145
+ expect(useChatThreadStore.getState().snapshot.workspacePanelParentKey).toBeNull();
146
+ expect(useChatThreadStore.getState().snapshot.activeChildSessionKey).toBeNull();
147
+ expect(useChatThreadStore.getState().snapshot.activeWorkspaceFileKey).toBeNull();
115
148
  });
116
149
 
117
150
  it('updates the sidebar list mode without touching other session list state', () => {
118
- const uiManager = {} as ConstructorParameters<typeof ChatSessionListManager>[0];
151
+ const uiManager = {
152
+ isAtChatRoot: vi.fn(() => true),
153
+ } as unknown as ConstructorParameters<typeof ChatSessionListManager>[0];
119
154
  const streamActionsManager = {} as ConstructorParameters<typeof ChatSessionListManager>[1];
120
155
 
121
156
  const manager = new ChatSessionListManager(uiManager, streamActionsManager);
@@ -127,33 +162,61 @@ describe('ChatSessionListManager', () => {
127
162
 
128
163
  it('marks a session as read through the session list owner boundary', () => {
129
164
  const manager = new ChatSessionListManager(
130
- {} as ConstructorParameters<typeof ChatSessionListManager>[0],
165
+ {
166
+ isAtChatRoot: vi.fn(() => true),
167
+ } as unknown as ConstructorParameters<typeof ChatSessionListManager>[0],
131
168
  {} as ConstructorParameters<typeof ChatSessionListManager>[1]
132
169
  );
133
170
 
134
171
  manager.markSessionRead('session-2', '2026-04-10T10:00:00.000Z');
135
172
 
136
- expect(useChatSessionListStore.getState().readUpdatedAtBySessionKey['session-2']).toBe(
173
+ expect(useChatSessionListStore.getState().optimisticReadAtBySessionKey['session-2']).toBe(
137
174
  '2026-04-10T10:00:00.000Z'
138
175
  );
176
+ expect(mocks.updateNcpSession).toHaveBeenCalledWith('session-2', {
177
+ uiReadAt: '2026-04-10T10:00:00.000Z'
178
+ });
139
179
  });
140
180
 
141
- it('hydrates the initial unread baseline through the session list owner boundary', () => {
181
+ it('skips persisting read state when the backend already has the same watermark', () => {
142
182
  const manager = new ChatSessionListManager(
143
- {} as ConstructorParameters<typeof ChatSessionListManager>[0],
183
+ {
184
+ isAtChatRoot: vi.fn(() => true),
185
+ } as unknown as ConstructorParameters<typeof ChatSessionListManager>[0],
144
186
  {} as ConstructorParameters<typeof ChatSessionListManager>[1]
145
187
  );
146
188
 
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(
189
+ manager.markSessionRead(
190
+ 'session-2',
191
+ '2026-04-10T10:00:00.000Z',
155
192
  '2026-04-10T10:00:00.000Z'
156
193
  );
157
- expect(useChatSessionListStore.getState().hasHydratedReadWatermarks).toBe(true);
194
+
195
+ expect(useChatSessionListStore.getState().optimisticReadAtBySessionKey['session-2']).toBeUndefined();
196
+ expect(mocks.updateNcpSession).not.toHaveBeenCalled();
197
+ });
198
+
199
+ it('promotes the active root draft to a concrete session route after the draft has been sent successfully', () => {
200
+ useChatSessionListStore.setState({
201
+ snapshot: {
202
+ ...useChatSessionListStore.getState().snapshot,
203
+ selectedSessionKey: null,
204
+ draftSessionKey: 'draft-root-2',
205
+ }
206
+ });
207
+ const uiManager = {
208
+ goToChatRoot: vi.fn(),
209
+ goToSession: vi.fn(),
210
+ isAtChatRoot: vi.fn(() => true),
211
+ } as unknown as ConstructorParameters<typeof ChatSessionListManager>[0];
212
+ const streamActionsManager = {
213
+ resetStreamState: vi.fn()
214
+ } as unknown as ConstructorParameters<typeof ChatSessionListManager>[1];
215
+ const manager = new ChatSessionListManager(uiManager, streamActionsManager);
216
+
217
+ manager.ensureDraftSession('native');
218
+ manager.promoteRootDraftSessionRoute('draft-root-2');
219
+
220
+ expect(uiManager.goToSession).toHaveBeenCalledWith('draft-root-2', { replace: true });
158
221
  });
159
222
  });
@@ -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,24 @@ 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
+ workspacePanelParentKey: null,
29
+ childSessionTabs: [],
30
+ activeChildSessionKey: null,
31
+ activeWorkspaceFileKey: null,
32
+ });
33
+ };
34
+
15
35
  private resolveUpdateValue = <T>(prev: T, next: SetStateAction<T>): T => {
16
36
  if (typeof next === 'function') {
17
37
  return (next as (value: T) => T)(prev);
@@ -19,6 +39,22 @@ export class ChatSessionListManager {
19
39
  return next;
20
40
  };
21
41
 
42
+ private shouldPersistReadAt = (
43
+ sessionKey: string,
44
+ readAt: string,
45
+ currentReadAt?: string | null,
46
+ ): boolean => {
47
+ const optimisticReadAt = useChatSessionListStore.getState().optimisticReadAtBySessionKey[sessionKey];
48
+ const effectiveCurrentReadAt =
49
+ optimisticReadAt && currentReadAt
50
+ ? (optimisticReadAt.localeCompare(currentReadAt) > 0 ? optimisticReadAt : currentReadAt)
51
+ : optimisticReadAt ?? currentReadAt ?? undefined;
52
+ if (!effectiveCurrentReadAt) {
53
+ return true;
54
+ }
55
+ return readAt.localeCompare(effectiveCurrentReadAt) > 0;
56
+ };
57
+
22
58
  setSelectedAgentId = (next: SetStateAction<string>) => {
23
59
  const prev = useChatSessionListStore.getState().snapshot.selectedAgentId;
24
60
  const value = this.resolveUpdateValue(prev, next);
@@ -46,22 +82,25 @@ export class ChatSessionListManager {
46
82
  useChatSessionListStore.getState().setSnapshot({ listMode: value });
47
83
  };
48
84
 
49
- markSessionRead = (sessionKey: string | null | undefined, updatedAt: string | null | undefined) => {
50
- if (!sessionKey) {
85
+ markSessionRead = (
86
+ sessionKey: string | null | undefined,
87
+ readAt: string | null | undefined,
88
+ currentReadAt?: string | null,
89
+ ) => {
90
+ const normalizedSessionKey = sessionKey?.trim();
91
+ const normalizedReadAt = readAt?.trim();
92
+ if (!normalizedSessionKey || !normalizedReadAt) {
51
93
  return;
52
94
  }
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);
95
+ if (!this.shouldPersistReadAt(normalizedSessionKey, normalizedReadAt, currentReadAt)) {
96
+ return;
97
+ }
98
+ useChatSessionListStore.getState().markSessionRead(normalizedSessionKey, normalizedReadAt);
99
+ void updateNcpSession(normalizedSessionKey, { uiReadAt: normalizedReadAt }).catch(() => undefined);
60
100
  };
61
101
 
62
102
  createSession = (sessionType?: string, projectRoot?: string | null): string => {
63
103
  const { snapshot } = useChatInputStore.getState();
64
- const { snapshot: sessionListSnapshot } = useChatSessionListStore.getState();
65
104
  const { defaultSessionType: configuredDefaultSessionType } = snapshot;
66
105
  const defaultSessionType = configuredDefaultSessionType || 'native';
67
106
  const nextSessionType =
@@ -69,17 +108,19 @@ export class ChatSessionListManager {
69
108
  ? sessionType.trim()
70
109
  : defaultSessionType;
71
110
  const normalizedProjectRoot = normalizeSessionProjectRootValue(projectRoot);
72
- const nextSessionKey = sessionListSnapshot.draftSessionKey;
111
+ const nextSessionKey = createNcpSessionId();
73
112
  this.streamActionsManager.resetStreamState();
74
113
  useChatSessionListStore.getState().setSnapshot({
75
- draftSessionKey: createNcpSessionId()
114
+ selectedSessionKey: null,
115
+ draftSessionKey: nextSessionKey
76
116
  });
117
+ this.syncDraftThreadState(nextSessionKey);
77
118
  useChatInputStore.getState().setSnapshot({
78
119
  pendingSessionType: nextSessionType,
79
120
  pendingProjectRoot: normalizedProjectRoot,
80
121
  pendingProjectRootSessionKey: normalizedProjectRoot ? nextSessionKey : null
81
122
  });
82
- this.uiManager.goToSession(nextSessionKey);
123
+ this.uiManager.goToChatRoot();
83
124
  return nextSessionKey;
84
125
  };
85
126
 
@@ -88,10 +129,41 @@ export class ChatSessionListManager {
88
129
  if (snapshot.selectedSessionKey) {
89
130
  return snapshot.selectedSessionKey;
90
131
  }
91
- return this.createSession(sessionType);
132
+ const normalizedSessionType =
133
+ typeof sessionType === 'string' && sessionType.trim().length > 0
134
+ ? sessionType.trim()
135
+ : null;
136
+ this.syncDraftThreadState(snapshot.draftSessionKey);
137
+ if (normalizedSessionType) {
138
+ useChatInputStore.getState().setSnapshot({ pendingSessionType: normalizedSessionType });
139
+ }
140
+ return snapshot.draftSessionKey;
141
+ };
142
+
143
+ promoteRootDraftSessionRoute = (sessionKey: string) => {
144
+ const normalizedSessionKey = sessionKey.trim();
145
+ if (!normalizedSessionKey) {
146
+ return;
147
+ }
148
+ const { snapshot } = useChatSessionListStore.getState();
149
+ const { sessionKey: currentThreadSessionKey } = useChatThreadStore.getState().snapshot;
150
+ if (
151
+ snapshot.selectedSessionKey !== null ||
152
+ snapshot.draftSessionKey !== normalizedSessionKey ||
153
+ currentThreadSessionKey !== normalizedSessionKey ||
154
+ !this.uiManager.isAtChatRoot()
155
+ ) {
156
+ return;
157
+ }
158
+ this.uiManager.goToSession(normalizedSessionKey, { replace: true });
92
159
  };
93
160
 
94
161
  selectSession = (sessionKey: string) => {
162
+ useChatThreadStore.getState().setSnapshot({
163
+ workspacePanelParentKey: null,
164
+ activeChildSessionKey: null,
165
+ activeWorkspaceFileKey: null,
166
+ });
95
167
  this.uiManager.goToSession(sessionKey);
96
168
  };
97
169
 
@@ -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
 
@@ -2,6 +2,7 @@ import {
2
2
  useEffect,
3
3
  useMemo,
4
4
  useRef,
5
+ useState,
5
6
  } from "react";
6
7
  import {
7
8
  buildNcpRequestEnvelope,
@@ -98,11 +99,7 @@ export function shouldClearPendingProjectRootOverride(params: {
98
99
  }
99
100
 
100
101
  export function NcpChatPage({ view }: ChatPageProps) {
101
- const presenterRef = useRef<NcpChatPresenter | null>(null);
102
- if (!presenterRef.current) {
103
- presenterRef.current = new NcpChatPresenter();
104
- }
105
- const presenter = presenterRef.current;
102
+ const [presenter] = useState(() => new NcpChatPresenter());
106
103
  const query = useChatSessionListStore((state) => state.snapshot.query);
107
104
  const selectedSessionKey = useChatSessionListStore(
108
105
  (state) => state.snapshot.selectedSessionKey,
@@ -306,8 +303,10 @@ export function NcpChatPage({ view }: ChatPageProps) {
306
303
  currentAgentId,
307
304
  currentAgent,
308
305
  parentSession,
309
- currentSessionTypeLabel
306
+ currentSessionTypeLabel,
307
+ currentChildSessionTabs,
310
308
  } = useNcpChatDerivedState({
309
+ sessionKey,
311
310
  selectedSession,
312
311
  selectedAgentId,
313
312
  availableAgents,
@@ -352,7 +351,8 @@ export function NcpChatPage({ view }: ChatPageProps) {
352
351
  threadRef,
353
352
  agent,
354
353
  isAwaitingAssistantOutput,
355
- parentSession
354
+ parentSession,
355
+ childSessionTabs: currentChildSessionTabs,
356
356
  });
357
357
 
358
358
  return (