@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
@@ -0,0 +1,189 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { appQueryClient } from '@/app-query-client';
3
+ import { NcpChatThreadManager } from '@/components/chat/ncp/ncp-chat-thread.manager';
4
+ import { useChatSessionListStore } from '@/components/chat/stores/chat-session-list.store';
5
+ import { useChatThreadStore } from '@/components/chat/stores/chat-thread.store';
6
+
7
+ const { deleteNcpSessionMock, deleteSummaryMock } = vi.hoisted(() => ({
8
+ deleteNcpSessionMock: vi.fn(async () => ({ deleted: true, sessionId: 'parent-session-1' })),
9
+ deleteSummaryMock: vi.fn(),
10
+ }));
11
+
12
+ vi.mock('@/api/ncp-session', () => ({
13
+ deleteNcpSession: deleteNcpSessionMock,
14
+ }));
15
+
16
+ vi.mock('@/api/ncp-session-query-cache', () => ({
17
+ deleteNcpSessionSummaryInQueryClient: deleteSummaryMock,
18
+ }));
19
+
20
+ describe('NcpChatThreadManager', () => {
21
+ beforeEach(() => {
22
+ useChatSessionListStore.setState({
23
+ optimisticReadAtBySessionKey: {},
24
+ snapshot: {
25
+ ...useChatSessionListStore.getState().snapshot,
26
+ selectedSessionKey: 'parent-session-1',
27
+ },
28
+ });
29
+ useChatThreadStore.setState({
30
+ snapshot: {
31
+ ...useChatThreadStore.getState().snapshot,
32
+ sessionKey: 'parent-session-1',
33
+ workspacePanelParentKey: null,
34
+ childSessionTabs: [
35
+ {
36
+ sessionKey: 'child-session-1',
37
+ parentSessionKey: 'parent-session-1',
38
+ label: 'Child Session 1',
39
+ agentId: 'reviewer',
40
+ },
41
+ ],
42
+ activeChildSessionKey: null,
43
+ },
44
+ });
45
+ });
46
+
47
+ it('opens the child-session panel for the requested parent session and keeps focus on the chosen child', () => {
48
+ const uiManager = {
49
+ goToSession: vi.fn(),
50
+ goToChatRoot: vi.fn(),
51
+ goToProviders: vi.fn(),
52
+ confirm: vi.fn(),
53
+ } as unknown as ConstructorParameters<typeof NcpChatThreadManager>[0];
54
+
55
+ const manager = new NcpChatThreadManager(
56
+ uiManager,
57
+ {} as ConstructorParameters<typeof NcpChatThreadManager>[1],
58
+ {} as ConstructorParameters<typeof NcpChatThreadManager>[2],
59
+ );
60
+
61
+ manager.openChildSessionPanel({
62
+ parentSessionKey: 'parent-session-1',
63
+ activeChildSessionKey: 'child-session-1',
64
+ });
65
+
66
+ expect(useChatThreadStore.getState().snapshot.workspacePanelParentKey).toBe('parent-session-1');
67
+ expect(useChatThreadStore.getState().snapshot.activeChildSessionKey).toBe('child-session-1');
68
+ expect(uiManager.goToSession).not.toHaveBeenCalled();
69
+ });
70
+
71
+ it('routes to the parent session before opening the child-session panel when needed', () => {
72
+ useChatSessionListStore.setState({
73
+ snapshot: {
74
+ ...useChatSessionListStore.getState().snapshot,
75
+ selectedSessionKey: 'another-session',
76
+ },
77
+ });
78
+ const uiManager = {
79
+ goToSession: vi.fn(),
80
+ goToChatRoot: vi.fn(),
81
+ goToProviders: vi.fn(),
82
+ confirm: vi.fn(),
83
+ } as unknown as ConstructorParameters<typeof NcpChatThreadManager>[0];
84
+
85
+ const manager = new NcpChatThreadManager(
86
+ uiManager,
87
+ {} as ConstructorParameters<typeof NcpChatThreadManager>[1],
88
+ {} as ConstructorParameters<typeof NcpChatThreadManager>[2],
89
+ );
90
+
91
+ manager.openChildSessionPanel({
92
+ parentSessionKey: 'parent-session-1',
93
+ activeChildSessionKey: 'child-session-1',
94
+ });
95
+
96
+ expect(uiManager.goToSession).toHaveBeenCalledWith('parent-session-1');
97
+ });
98
+
99
+ it('keeps preview and diff for the same file as separate workspace tabs', () => {
100
+ const uiManager = {
101
+ goToSession: vi.fn(),
102
+ goToChatRoot: vi.fn(),
103
+ goToProviders: vi.fn(),
104
+ confirm: vi.fn(),
105
+ } as unknown as ConstructorParameters<typeof NcpChatThreadManager>[0];
106
+
107
+ const manager = new NcpChatThreadManager(
108
+ uiManager,
109
+ {} as ConstructorParameters<typeof NcpChatThreadManager>[1],
110
+ {} as ConstructorParameters<typeof NcpChatThreadManager>[2],
111
+ );
112
+
113
+ manager.openFilePreview({
114
+ path: 'README.md',
115
+ label: 'README.md',
116
+ viewMode: 'preview',
117
+ });
118
+ manager.openFilePreview({
119
+ path: 'README.md',
120
+ label: 'README.md',
121
+ viewMode: 'diff',
122
+ beforeText: 'old\n',
123
+ afterText: 'new\n',
124
+ });
125
+
126
+ expect(useChatThreadStore.getState().snapshot.workspaceFileTabs).toEqual(
127
+ expect.arrayContaining([
128
+ expect.objectContaining({
129
+ key: 'parent-session-1::preview::README.md',
130
+ path: 'README.md',
131
+ viewMode: 'preview',
132
+ }),
133
+ expect.objectContaining({
134
+ key: 'parent-session-1::diff::README.md',
135
+ path: 'README.md',
136
+ viewMode: 'diff',
137
+ }),
138
+ ]),
139
+ );
140
+ });
141
+
142
+ it('clears the selected thread state after deleting the current session', async () => {
143
+ const removeQueries = vi.spyOn(appQueryClient, 'removeQueries').mockImplementation(async () => undefined);
144
+ const uiManager = {
145
+ goToSession: vi.fn(),
146
+ goToChatRoot: vi.fn(),
147
+ goToProviders: vi.fn(),
148
+ confirm: vi.fn(async () => true),
149
+ } as unknown as ConstructorParameters<typeof NcpChatThreadManager>[0];
150
+ const sessionListManager = {
151
+ setSelectedSessionKey: vi.fn((value: string | null) => {
152
+ useChatSessionListStore.getState().setSnapshot({
153
+ selectedSessionKey: value,
154
+ });
155
+ }),
156
+ } as unknown as ConstructorParameters<typeof NcpChatThreadManager>[1];
157
+ const streamActionsManager = {
158
+ resetStreamState: vi.fn(),
159
+ } as unknown as ConstructorParameters<typeof NcpChatThreadManager>[2];
160
+ const manager = new NcpChatThreadManager(
161
+ uiManager,
162
+ sessionListManager,
163
+ streamActionsManager,
164
+ );
165
+
166
+ await (manager as unknown as { deleteCurrentSession: () => Promise<void> }).deleteCurrentSession();
167
+
168
+ expect(sessionListManager.setSelectedSessionKey).toHaveBeenCalledWith(null);
169
+ expect(useChatSessionListStore.getState().snapshot.selectedSessionKey).toBeNull();
170
+ expect(useChatThreadStore.getState().snapshot).toMatchObject({
171
+ sessionKey: null,
172
+ canDeleteSession: false,
173
+ messages: [],
174
+ workspacePanelParentKey: null,
175
+ childSessionTabs: [],
176
+ activeChildSessionKey: null,
177
+ workspaceFileTabs: [],
178
+ activeWorkspaceFileKey: null,
179
+ });
180
+ expect(streamActionsManager.resetStreamState).toHaveBeenCalledTimes(1);
181
+ expect(deleteSummaryMock).toHaveBeenCalledWith(appQueryClient, 'parent-session-1');
182
+ expect(removeQueries).toHaveBeenCalledWith({
183
+ queryKey: ['ncp-session-messages', 'parent-session-1'],
184
+ });
185
+ expect(uiManager.goToChatRoot).toHaveBeenCalledWith({ replace: true });
186
+
187
+ removeQueries.mockRestore();
188
+ });
189
+ });
@@ -1,4 +1,8 @@
1
- import type { ChatComposerNode, ChatToolActionViewModel } from '@nextclaw/agent-chat-ui';
1
+ import type {
2
+ ChatComposerNode,
3
+ ChatFileOpenActionViewModel,
4
+ ChatToolActionViewModel,
5
+ } from '@nextclaw/agent-chat-ui';
2
6
  import type { NcpDraftAttachment } from '@nextclaw/ncp-react';
3
7
  import { createContext, useContext } from 'react';
4
8
  import type { ReactNode } from 'react';
@@ -37,9 +41,16 @@ export type ChatThreadManagerLike = {
37
41
  deleteSession: () => void;
38
42
  createSession: () => void;
39
43
  goToProviders: () => void;
44
+ openChildSessionPanel: (params: {
45
+ parentSessionKey: string;
46
+ activeChildSessionKey?: string | null;
47
+ }) => void;
48
+ openFilePreview: (action: ChatFileOpenActionViewModel) => void;
40
49
  openSessionFromToolAction: (action: ChatToolActionViewModel) => void;
41
50
  selectChildSessionDetail: (sessionKey: string) => void;
42
- closeChildSessionDetail: () => void;
51
+ selectWorkspaceFile: (fileKey: string) => void;
52
+ closeWorkspaceFile: (fileKey: string) => void;
53
+ closeWorkspacePanel: () => void;
43
54
  goToParentSession: () => void;
44
55
  };
45
56
 
@@ -6,6 +6,7 @@ import { ChatSessionHeaderActions } from '@/components/chat/session-header/chat-
6
6
  const mocks = vi.hoisted(() => ({
7
7
  updateSessionProject: vi.fn(),
8
8
  onDeleteSession: vi.fn(),
9
+ onOpenChildSessions: vi.fn(),
9
10
  }));
10
11
 
11
12
  vi.mock('@/components/chat/hooks/use-chat-session-project', () => ({
@@ -20,6 +21,7 @@ describe('ChatSessionHeaderActions', () => {
20
21
  beforeEach(() => {
21
22
  mocks.updateSessionProject.mockReset();
22
23
  mocks.onDeleteSession.mockReset();
24
+ mocks.onOpenChildSessions.mockReset();
23
25
  });
24
26
 
25
27
  it('keeps only the set-project action in the more-actions menu when a project is already attached', async () => {
@@ -31,6 +33,8 @@ describe('ChatSessionHeaderActions', () => {
31
33
  canDeleteSession
32
34
  isDeletePending={false}
33
35
  projectRoot="/tmp/project-alpha"
36
+ childSessionCount={0}
37
+ onOpenChildSessions={mocks.onOpenChildSessions}
34
38
  onDeleteSession={mocks.onDeleteSession}
35
39
  />
36
40
  );
@@ -51,6 +55,8 @@ describe('ChatSessionHeaderActions', () => {
51
55
  canDeleteSession={false}
52
56
  isDeletePending={false}
53
57
  projectRoot={null}
58
+ childSessionCount={0}
59
+ onOpenChildSessions={mocks.onOpenChildSessions}
54
60
  onDeleteSession={mocks.onDeleteSession}
55
61
  />
56
62
  );
@@ -60,4 +66,24 @@ describe('ChatSessionHeaderActions', () => {
60
66
  expect(screen.getByText('Set Project Directory')).toBeTruthy();
61
67
  expect(screen.queryByText('Clear Project Directory')).toBeNull();
62
68
  });
69
+
70
+ it('shows a dedicated child-session entry button when the current session has child sessions', async () => {
71
+ const user = userEvent.setup();
72
+
73
+ render(
74
+ <ChatSessionHeaderActions
75
+ sessionKey="session-children"
76
+ canDeleteSession
77
+ isDeletePending={false}
78
+ projectRoot={null}
79
+ childSessionCount={2}
80
+ onOpenChildSessions={mocks.onOpenChildSessions}
81
+ onDeleteSession={mocks.onDeleteSession}
82
+ />
83
+ );
84
+
85
+ await user.click(screen.getByRole('button', { name: 'View child sessions' }));
86
+
87
+ expect(mocks.onOpenChildSessions).toHaveBeenCalledTimes(1);
88
+ });
63
89
  });
@@ -1,5 +1,5 @@
1
1
  import { useState } from 'react';
2
- import { FolderOpen, MoreHorizontal, Trash2 } from 'lucide-react';
2
+ import { FolderOpen, GitBranch, MoreHorizontal, Trash2 } from 'lucide-react';
3
3
  import { Button } from '@/components/ui/button';
4
4
  import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
5
5
  import { useChatSessionProject } from '@/components/chat/hooks/use-chat-session-project';
@@ -12,6 +12,8 @@ type ChatSessionHeaderActionsProps = {
12
12
  canDeleteSession: boolean;
13
13
  isDeletePending: boolean;
14
14
  projectRoot?: string | null;
15
+ childSessionCount?: number;
16
+ onOpenChildSessions?: () => void;
15
17
  onDeleteSession: () => void;
16
18
  };
17
19
 
@@ -20,6 +22,8 @@ export function ChatSessionHeaderActions({
20
22
  canDeleteSession,
21
23
  isDeletePending,
22
24
  projectRoot,
25
+ childSessionCount = 0,
26
+ onOpenChildSessions,
23
27
  onDeleteSession,
24
28
  }: ChatSessionHeaderActionsProps) {
25
29
  const updateSessionProject = useChatSessionProject();
@@ -46,6 +50,20 @@ export function ChatSessionHeaderActions({
46
50
 
47
51
  return (
48
52
  <>
53
+ {childSessionCount > 0 && onOpenChildSessions ? (
54
+ <Button
55
+ type="button"
56
+ variant="ghost"
57
+ size="icon"
58
+ className="rounded-lg shrink-0 text-gray-400 hover:text-gray-700"
59
+ aria-label={t('chatSessionOpenChildSessions')}
60
+ title={t('chatSessionOpenChildSessions')}
61
+ onClick={onOpenChildSessions}
62
+ disabled={isBusy}
63
+ >
64
+ <GitBranch className="h-4 w-4" />
65
+ </Button>
66
+ ) : null}
49
67
  <Popover open={isMenuOpen} onOpenChange={setIsMenuOpen}>
50
68
  <PopoverTrigger asChild>
51
69
  <Button
@@ -13,42 +13,39 @@ export type ChatSessionListSnapshot = {
13
13
  };
14
14
 
15
15
  export function hasUnreadSessionUpdate(
16
- updatedAt: string | null | undefined,
17
- readUpdatedAt: string | undefined,
16
+ lastMessageAt: string | null | undefined,
17
+ readAt: string | undefined,
18
18
  ): boolean {
19
- const normalizedUpdatedAt = updatedAt?.trim();
20
- if (!normalizedUpdatedAt) {
19
+ const normalizedLastMessageAt = lastMessageAt?.trim();
20
+ if (!normalizedLastMessageAt) {
21
21
  return false;
22
22
  }
23
- const normalizedReadUpdatedAt = readUpdatedAt?.trim();
24
- if (!normalizedReadUpdatedAt) {
25
- return true;
23
+ const normalizedReadAt = readAt?.trim();
24
+ if (!normalizedReadAt) {
25
+ // Until this client establishes a read watermark, avoid guessing unread state.
26
+ return false;
26
27
  }
27
- return normalizedUpdatedAt.localeCompare(normalizedReadUpdatedAt) > 0;
28
+ return normalizedLastMessageAt.localeCompare(normalizedReadAt) > 0;
28
29
  }
29
30
 
30
31
  export function shouldShowUnreadSessionIndicator(params: {
31
32
  active: boolean;
32
- updatedAt: string | null | undefined;
33
- readUpdatedAt: string | undefined;
33
+ lastMessageAt: string | null | undefined;
34
+ readAt: string | undefined;
34
35
  runStatus?: SessionRunStatus;
35
36
  }): boolean {
36
- const { active, readUpdatedAt, runStatus, updatedAt } = params;
37
+ const { active, readAt, runStatus, lastMessageAt } = params;
37
38
  if (active || runStatus === 'running') {
38
39
  return false;
39
40
  }
40
- return hasUnreadSessionUpdate(updatedAt, readUpdatedAt);
41
+ return hasUnreadSessionUpdate(lastMessageAt, readAt);
41
42
  }
42
43
 
43
44
  type ChatSessionListStore = {
44
45
  snapshot: ChatSessionListSnapshot;
45
- readUpdatedAtBySessionKey: Record<string, string>;
46
- hasHydratedReadWatermarks: boolean;
46
+ optimisticReadAtBySessionKey: Record<string, string>;
47
47
  setSnapshot: (patch: Partial<ChatSessionListSnapshot>) => void;
48
- markSessionRead: (sessionKey: string, updatedAt: string | null | undefined) => void;
49
- hydrateReadWatermarks: (
50
- entries: readonly { sessionKey: string; updatedAt: string | null | undefined }[],
51
- ) => void;
48
+ markSessionRead: (sessionKey: string, readAt: string | null | undefined) => void;
52
49
  };
53
50
 
54
51
  type ChatSessionListStoreSet = Parameters<StateCreator<ChatSessionListStore>>[0];
@@ -72,56 +69,30 @@ function createSetSnapshotAction(set: ChatSessionListStoreSet) {
72
69
  }
73
70
 
74
71
  function createMarkSessionReadAction(set: ChatSessionListStoreSet) {
75
- return (sessionKey: string, updatedAt: string | null | undefined) =>
72
+ return (sessionKey: string, readAt: string | null | undefined) =>
76
73
  set((state) => {
77
74
  const normalizedSessionKey = sessionKey.trim();
78
- const normalizedUpdatedAt = updatedAt?.trim();
79
- if (!normalizedSessionKey || !normalizedUpdatedAt) {
75
+ const normalizedReadAt = readAt?.trim();
76
+ if (!normalizedSessionKey || !normalizedReadAt) {
80
77
  return state;
81
78
  }
82
- if (state.readUpdatedAtBySessionKey[normalizedSessionKey] === normalizedUpdatedAt) {
79
+ const previousReadAt = state.optimisticReadAtBySessionKey[normalizedSessionKey];
80
+ if (previousReadAt && previousReadAt.localeCompare(normalizedReadAt) >= 0) {
83
81
  return state;
84
82
  }
85
83
  return {
86
84
  ...state,
87
- readUpdatedAtBySessionKey: {
88
- ...state.readUpdatedAtBySessionKey,
89
- [normalizedSessionKey]: normalizedUpdatedAt
90
- }
91
- };
92
- });
93
- }
94
-
95
- function createHydrateReadWatermarksAction(set: ChatSessionListStoreSet) {
96
- return (
97
- entries: readonly { sessionKey: string; updatedAt: string | null | undefined }[],
98
- ) =>
99
- set((state) => {
100
- if (state.hasHydratedReadWatermarks) {
101
- return state;
102
- }
103
- const nextReadUpdatedAtBySessionKey = { ...state.readUpdatedAtBySessionKey };
104
- for (const entry of entries) {
105
- const normalizedSessionKey = entry.sessionKey.trim();
106
- const normalizedUpdatedAt = entry.updatedAt?.trim();
107
- if (!normalizedSessionKey || !normalizedUpdatedAt || nextReadUpdatedAtBySessionKey[normalizedSessionKey]) {
108
- continue;
85
+ optimisticReadAtBySessionKey: {
86
+ ...state.optimisticReadAtBySessionKey,
87
+ [normalizedSessionKey]: normalizedReadAt
109
88
  }
110
- nextReadUpdatedAtBySessionKey[normalizedSessionKey] = normalizedUpdatedAt;
111
- }
112
- return {
113
- ...state,
114
- hasHydratedReadWatermarks: true,
115
- readUpdatedAtBySessionKey: nextReadUpdatedAtBySessionKey
116
89
  };
117
90
  });
118
91
  }
119
92
 
120
93
  export const useChatSessionListStore = create<ChatSessionListStore>((set) => ({
121
94
  snapshot: initialSnapshot,
122
- readUpdatedAtBySessionKey: {},
123
- hasHydratedReadWatermarks: false,
95
+ optimisticReadAtBySessionKey: {},
124
96
  setSnapshot: createSetSnapshotAction(set),
125
- markSessionRead: createMarkSessionReadAction(set),
126
- hydrateReadWatermarks: createHydrateReadWatermarksAction(set)
97
+ markSessionRead: createMarkSessionReadAction(set)
127
98
  }));
@@ -1,6 +1,7 @@
1
1
  import { create } from 'zustand';
2
2
  import type { MutableRefObject } from 'react';
3
3
  import type { NcpMessage } from '@nextclaw/ncp';
4
+ import type { ChatFileOperationLineViewModel } from '@nextclaw/agent-chat-ui';
4
5
  import type { ChatModelOption } from '@/components/chat/chat-input.types';
5
6
  import type { AgentProfileView } from '@/api/types';
6
7
 
@@ -11,6 +12,23 @@ export type ChatChildSessionTab = {
11
12
  agentId?: string | null;
12
13
  };
13
14
 
15
+ export type ChatWorkspaceFileTab = {
16
+ key: string;
17
+ parentSessionKey: string | null;
18
+ path: string;
19
+ label?: string | null;
20
+ viewMode: 'preview' | 'diff';
21
+ line?: number | null;
22
+ column?: number | null;
23
+ rawText?: string | null;
24
+ beforeText?: string | null;
25
+ afterText?: string | null;
26
+ patchText?: string | null;
27
+ oldStartLine?: number | null;
28
+ newStartLine?: number | null;
29
+ fullLines?: ChatFileOperationLineViewModel[];
30
+ };
31
+
14
32
  export type ChatThreadSnapshot = {
15
33
  isProviderStateResolved: boolean;
16
34
  modelOptions: ChatModelOption[];
@@ -34,8 +52,11 @@ export type ChatThreadSnapshot = {
34
52
  isAwaitingAssistantOutput: boolean;
35
53
  parentSessionKey?: string | null;
36
54
  parentSessionLabel?: string | null;
55
+ workspacePanelParentKey?: string | null;
37
56
  childSessionTabs: ChatChildSessionTab[];
38
57
  activeChildSessionKey?: string | null;
58
+ workspaceFileTabs: ChatWorkspaceFileTab[];
59
+ activeWorkspaceFileKey?: string | null;
39
60
  };
40
61
 
41
62
  type ChatThreadStore = {
@@ -66,8 +87,11 @@ const initialSnapshot: ChatThreadSnapshot = {
66
87
  isAwaitingAssistantOutput: false,
67
88
  parentSessionKey: null,
68
89
  parentSessionLabel: null,
90
+ workspacePanelParentKey: null,
69
91
  childSessionTabs: [],
70
92
  activeChildSessionKey: null,
93
+ workspaceFileTabs: [],
94
+ activeWorkspaceFileKey: null,
71
95
  };
72
96
 
73
97
  export const useChatThreadStore = create<ChatThreadStore>((set) => ({
@@ -64,6 +64,11 @@ export function ProviderScopedModelInput({
64
64
  setModelId(currentModel);
65
65
  return;
66
66
  }
67
+ if (!currentModel) {
68
+ setModelId('');
69
+ setProviderName((currentProvider) => (providerMap.has(currentProvider) ? currentProvider : ''));
70
+ return;
71
+ }
67
72
  const matchedProvider = findProviderByModel(currentModel, providerCatalog);
68
73
  const effectiveProvider = matchedProvider ?? '';
69
74
  const aliases = providerMap.get(effectiveProvider)?.aliases ?? [];
@@ -72,9 +77,14 @@ export function ProviderScopedModelInput({
72
77
  }, [hasProviders, providerCatalog, providerMap, value]);
73
78
 
74
79
  const handleProviderChange = (nextProvider: string) => {
80
+ const nextProviderModel = normalizeModelOptions(providerMap.get(nextProvider)?.models ?? [])[0] ?? '';
75
81
  setProviderName(nextProvider);
76
- setModelId('');
77
- onChange('');
82
+ setModelId(nextProviderModel);
83
+ if (!nextProviderModel) {
84
+ onChange('');
85
+ return;
86
+ }
87
+ onChange(composeProviderModel(providerMap.get(nextProvider)?.prefix ?? nextProvider, nextProviderModel));
78
88
  };
79
89
 
80
90
  const handleModelChange = (nextModelId: string) => {
@@ -1,5 +1,7 @@
1
- import { render, screen, waitFor } from '@testing-library/react';
1
+ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
2
2
  import userEvent from '@testing-library/user-event';
3
+ import { useState } from 'react';
4
+ import { ProviderScopedModelInput } from '@/components/common/ProviderScopedModelInput';
3
5
  import { ModelConfig } from '@/components/config/ModelConfig';
4
6
  import { setLanguage } from '@/lib/i18n';
5
7
 
@@ -19,7 +21,7 @@ const mocks = vi.hoisted(() => ({
19
21
  apiKeySet: true,
20
22
  models: ['gpt-5.2']
21
23
  }
22
- }
24
+ } as Record<string, { enabled: boolean; apiKeySet: boolean; models: string[] }>
23
25
  },
24
26
  isLoading: false
25
27
  },
@@ -58,6 +60,9 @@ describe('ModelConfig', () => {
58
60
  beforeEach(() => {
59
61
  mocks.mutate.mockReset();
60
62
  setLanguage('en');
63
+ HTMLElement.prototype.hasPointerCapture = vi.fn(() => false);
64
+ HTMLElement.prototype.setPointerCapture = vi.fn();
65
+ HTMLElement.prototype.releasePointerCapture = vi.fn();
61
66
  mocks.configQuery.data = {
62
67
  agents: {
63
68
  defaults: {
@@ -82,9 +87,42 @@ describe('ModelConfig', () => {
82
87
  defaultModels: ['openai/gpt-5.2'],
83
88
  keywords: [],
84
89
  envKey: 'OPENAI_API_KEY'
90
+ },
91
+ {
92
+ name: 'deepseek',
93
+ displayName: 'DeepSeek',
94
+ modelPrefix: 'deepseek',
95
+ defaultModels: ['deepseek/deepseek-chat', 'deepseek/deepseek-reasoner'],
96
+ keywords: [],
97
+ envKey: 'DEEPSEEK_API_KEY'
98
+ },
99
+ {
100
+ name: 'customhub',
101
+ displayName: 'CustomHub',
102
+ modelPrefix: 'customhub',
103
+ defaultModels: [],
104
+ keywords: [],
105
+ envKey: 'CUSTOMHUB_API_KEY'
85
106
  }
86
107
  ]
87
108
  };
109
+ mocks.configQuery.data.providers = {
110
+ openai: {
111
+ enabled: true,
112
+ apiKeySet: true,
113
+ models: ['gpt-5.2']
114
+ },
115
+ deepseek: {
116
+ enabled: true,
117
+ apiKeySet: true,
118
+ models: ['deepseek-chat', 'deepseek-reasoner']
119
+ },
120
+ customhub: {
121
+ enabled: true,
122
+ apiKeySet: true,
123
+ models: []
124
+ }
125
+ };
88
126
  });
89
127
 
90
128
  it('submits the workspace together with the selected model', async () => {
@@ -136,4 +174,72 @@ describe('ModelConfig', () => {
136
174
  });
137
175
  });
138
176
  });
177
+
178
+ it('switches to the new provider without clearing the selection and auto-fills its first model', async () => {
179
+ const user = userEvent.setup();
180
+
181
+ render(<ModelConfig />);
182
+
183
+ const providerTrigger = screen.getByRole('combobox');
184
+ fireEvent.keyDown(providerTrigger, { key: 'ArrowDown' });
185
+ await user.click(screen.getByRole('option', { name: 'DeepSeek' }));
186
+ await user.click(screen.getByRole('button', { name: /save/i }));
187
+
188
+ await waitFor(() => {
189
+ expect(mocks.mutate).toHaveBeenCalledWith({
190
+ model: 'deepseek/deepseek-chat',
191
+ workspace: '~/old-workspace'
192
+ });
193
+ });
194
+
195
+ expect(providerTrigger.textContent).toContain('DeepSeek');
196
+ expect(screen.getByDisplayValue('deepseek-chat')).toBeTruthy();
197
+ });
198
+
199
+ it('keeps the provider selected when the shared input switches to a provider without preset models', async () => {
200
+ const user = userEvent.setup();
201
+
202
+ function Harness() {
203
+ const [value, setValue] = useState('openai/gpt-5.2');
204
+
205
+ return (
206
+ <ProviderScopedModelInput
207
+ value={value}
208
+ onChange={setValue}
209
+ providerCatalog={[
210
+ {
211
+ name: 'openai',
212
+ displayName: 'OpenAI',
213
+ prefix: 'openai',
214
+ aliases: ['openai'],
215
+ models: ['gpt-5.2'],
216
+ modelThinking: {},
217
+ configured: true
218
+ },
219
+ {
220
+ name: 'customhub',
221
+ displayName: 'CustomHub',
222
+ prefix: 'customhub',
223
+ aliases: ['customhub'],
224
+ models: [],
225
+ modelThinking: {},
226
+ configured: true
227
+ }
228
+ ]}
229
+ />
230
+ );
231
+ }
232
+
233
+ render(<Harness />);
234
+
235
+ const providerTrigger = screen.getByRole('combobox');
236
+ fireEvent.keyDown(providerTrigger, { key: 'ArrowDown' });
237
+ await user.click(screen.getByRole('option', { name: 'CustomHub' }));
238
+
239
+ const modelInput = screen.getByPlaceholderText('provider/model');
240
+ await user.type(modelInput, 'reasoner-v1');
241
+
242
+ expect(providerTrigger.textContent).toContain('CustomHub');
243
+ expect(screen.getByDisplayValue('reasoner-v1')).toBeTruthy();
244
+ });
139
245
  });