@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,34 @@
1
+ import { api } from './client';
2
+ import type { RuntimeControlActionResult, RuntimeControlView } from './runtime-control.types';
3
+
4
+ export async function fetchRuntimeControl(): Promise<RuntimeControlView> {
5
+ const response = await api.get<RuntimeControlView>('/api/runtime/control');
6
+ if (!response.ok) {
7
+ throw new Error(response.error.message);
8
+ }
9
+ return response.data;
10
+ }
11
+
12
+ export async function startRuntimeService(): Promise<RuntimeControlActionResult> {
13
+ const response = await api.post<RuntimeControlActionResult>('/api/runtime/control/start-service', {});
14
+ if (!response.ok) {
15
+ throw new Error(response.error.message);
16
+ }
17
+ return response.data;
18
+ }
19
+
20
+ export async function restartRuntimeService(): Promise<RuntimeControlActionResult> {
21
+ const response = await api.post<RuntimeControlActionResult>('/api/runtime/control/restart-service', {});
22
+ if (!response.ok) {
23
+ throw new Error(response.error.message);
24
+ }
25
+ return response.data;
26
+ }
27
+
28
+ export async function stopRuntimeService(): Promise<RuntimeControlActionResult> {
29
+ const response = await api.post<RuntimeControlActionResult>('/api/runtime/control/stop-service', {});
30
+ if (!response.ok) {
31
+ throw new Error(response.error.message);
32
+ }
33
+ return response.data;
34
+ }
@@ -0,0 +1,58 @@
1
+ export type RuntimeControlEnvironment =
2
+ | 'desktop-embedded'
3
+ | 'managed-local-service'
4
+ | 'self-hosted-web'
5
+ | 'shared-web';
6
+
7
+ export type RuntimeLifecycleState =
8
+ | 'healthy'
9
+ | 'starting-service'
10
+ | 'restarting-service'
11
+ | 'stopping-service'
12
+ | 'restarting-app'
13
+ | 'recovering'
14
+ | 'unavailable'
15
+ | 'failed';
16
+
17
+ export type RuntimeActionImpact = 'none' | 'brief-ui-disconnect' | 'full-app-relaunch';
18
+
19
+ export type RuntimeActionCapability = {
20
+ available: boolean;
21
+ requiresConfirmation: boolean;
22
+ impact: RuntimeActionImpact;
23
+ reasonIfUnavailable?: string;
24
+ };
25
+
26
+ export type RuntimeServiceState =
27
+ | 'running'
28
+ | 'stopped'
29
+ | 'starting'
30
+ | 'stopping'
31
+ | 'restarting'
32
+ | 'unknown';
33
+
34
+ export type RuntimeControlView = {
35
+ environment: RuntimeControlEnvironment;
36
+ lifecycle: RuntimeLifecycleState;
37
+ serviceState: RuntimeServiceState;
38
+ canStartService: RuntimeActionCapability;
39
+ canRestartService: RuntimeActionCapability;
40
+ canStopService: RuntimeActionCapability;
41
+ canRestartApp: RuntimeActionCapability;
42
+ ownerLabel?: string;
43
+ managementHint?: string;
44
+ message?: string;
45
+ };
46
+
47
+ export type RuntimeControlAction =
48
+ | 'start-service'
49
+ | 'restart-service'
50
+ | 'stop-service'
51
+ | 'restart-app';
52
+
53
+ export type RuntimeControlActionResult = {
54
+ accepted: boolean;
55
+ action: RuntimeControlAction;
56
+ lifecycle: RuntimeLifecycleState;
57
+ message: string;
58
+ };
@@ -1,15 +1,17 @@
1
1
  import { api } from './client';
2
- import type { ServerPathBrowseView } from './types';
2
+ import type { ServerPathBrowseView, ServerPathReadView } from './types';
3
3
 
4
4
  export async function fetchServerPathBrowse(params?: {
5
5
  path?: string | null;
6
6
  includeFiles?: boolean;
7
7
  }): Promise<ServerPathBrowseView> {
8
+ const path = typeof params?.path === 'string' ? params.path.trim() : '';
9
+ const includeFiles = Boolean(params?.includeFiles);
8
10
  const query = new URLSearchParams();
9
- if (typeof params?.path === 'string' && params.path.trim().length > 0) {
10
- query.set('path', params.path.trim());
11
+ if (path) {
12
+ query.set('path', path);
11
13
  }
12
- if (params?.includeFiles) {
14
+ if (includeFiles) {
13
15
  query.set('includeFiles', '1');
14
16
  }
15
17
  const suffix = query.toString();
@@ -21,3 +23,24 @@ export async function fetchServerPathBrowse(params?: {
21
23
  }
22
24
  return response.data;
23
25
  }
26
+
27
+ export async function fetchServerPathRead(params: {
28
+ path: string;
29
+ basePath?: string | null;
30
+ }): Promise<ServerPathReadView> {
31
+ const { path } = params;
32
+ const basePath =
33
+ typeof params.basePath === 'string' ? params.basePath.trim() : '';
34
+ const query = new URLSearchParams();
35
+ query.set('path', path.trim());
36
+ if (basePath) {
37
+ query.set('basePath', basePath);
38
+ }
39
+ const response = await api.get<ServerPathReadView>(
40
+ `/api/server-paths/read?${query.toString()}`
41
+ );
42
+ if (!response.ok) {
43
+ throw new Error(response.error.message);
44
+ }
45
+ return response.data;
46
+ }
package/src/api/types.ts CHANGED
@@ -174,6 +174,16 @@ export type {
174
174
  RemoteSettingsUpdateRequest,
175
175
  RemoteSettingsView
176
176
  } from './remote.types';
177
+ export type {
178
+ RuntimeActionCapability,
179
+ RuntimeActionImpact,
180
+ RuntimeControlAction,
181
+ RuntimeControlEnvironment,
182
+ RuntimeControlView,
183
+ RuntimeLifecycleState,
184
+ RuntimeServiceState,
185
+ RuntimeControlActionResult
186
+ } from './runtime-control.types';
177
187
 
178
188
  export type AgentProfileView = {
179
189
  id: string;
@@ -236,10 +246,19 @@ export type SessionConfigView = {
236
246
  dmScope?: "main" | "per-peer" | "per-channel-peer" | "per-account-channel-peer";
237
247
  };
238
248
 
249
+ export type RuntimeEntryView = {
250
+ enabled?: boolean;
251
+ label?: string;
252
+ type: string;
253
+ config?: Record<string, unknown>;
254
+ };
255
+
239
256
  export type SessionEntryView = {
240
257
  key: string;
241
258
  createdAt: string;
242
259
  updatedAt: string;
260
+ lastMessageAt?: string;
261
+ readAt?: string;
243
262
  agentId?: string;
244
263
  label?: string;
245
264
  channel?: string;
@@ -331,20 +350,13 @@ export type SessionPatchUpdate = {
331
350
  preferredThinking?: ThinkingLevel | null;
332
351
  sessionType?: string | null;
333
352
  projectRoot?: string | null;
353
+ uiReadAt?: string | null;
334
354
  clearHistory?: boolean;
335
355
  };
336
356
 
337
- export type ServerPathEntryView = {
338
- name: string;
339
- path: string;
340
- kind: "directory" | "file";
341
- hidden: boolean;
342
- };
357
+ export type ServerPathEntryView = { name: string; path: string; kind: "directory" | "file"; hidden: boolean };
343
358
 
344
- export type ServerPathBreadcrumbView = {
345
- label: string;
346
- path: string;
347
- };
359
+ export type ServerPathBreadcrumbView = { label: string; path: string };
348
360
 
349
361
  export type ServerPathBrowseView = {
350
362
  currentPath: string;
@@ -354,6 +366,8 @@ export type ServerPathBrowseView = {
354
366
  entries: ServerPathEntryView[];
355
367
  };
356
368
 
369
+ export type ServerPathReadView = { requestedPath: string; resolvedPath: string; kind: "text" | "markdown" | "binary"; sizeBytes: number; truncated: boolean; text?: string; languageHint?: string | null };
370
+
357
371
  export type {
358
372
  ChatSessionTypeCtaView,
359
373
  ChatSessionTypeOptionView,
@@ -418,6 +432,9 @@ export type RuntimeConfigUpdate = {
418
432
  engine?: string;
419
433
  engineConfig?: Record<string, unknown>;
420
434
  };
435
+ runtimes?: {
436
+ entries?: Record<string, RuntimeEntryView> | null;
437
+ };
421
438
  list?: AgentProfileView[];
422
439
  };
423
440
  bindings?: AgentBindingView[];
@@ -487,6 +504,9 @@ export type ConfigView = {
487
504
  contextTokens?: number;
488
505
  maxToolIterations?: number;
489
506
  };
507
+ runtimes?: {
508
+ entries?: Record<string, RuntimeEntryView>;
509
+ };
490
510
  list?: AgentProfileView[];
491
511
  context?: {
492
512
  bootstrap?: {
@@ -3,7 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
3
3
  import { MemoryRouter } from 'react-router-dom';
4
4
  import { I18nProvider } from '@/components/providers/I18nProvider';
5
5
  import { ThemeProvider } from '@/components/providers/ThemeProvider';
6
- import AppContent from '@/App';
6
+ import AppContent from '@/app';
7
7
 
8
8
  const mocks = vi.hoisted(() => ({
9
9
  refetch: vi.fn(),
@@ -7,11 +7,14 @@ import { AppLayout } from '@/components/layout/AppLayout';
7
7
  import { isTransientAuthStatusBootstrapError, useAuthStatus } from '@/hooks/use-auth';
8
8
  import { useRealtimeQueryBridge } from '@/hooks/use-realtime-query-bridge';
9
9
  import { AppPresenterProvider } from '@/presenter/app-presenter-context';
10
+ import { PwaInstallBanner, PwaUpdateBanner } from '@/pwa/components/pwa-install-entry';
11
+ import { startNextClawPwa } from '@/pwa/register-pwa';
10
12
  import { Toaster } from 'sonner';
11
13
  import { Routes, Route, Navigate } from 'react-router-dom';
14
+ import { useEffect } from 'react';
12
15
 
13
16
  const ModelConfigPage = lazy(async () => ({ default: (await import('@/components/config/ModelConfig')).ModelConfig }));
14
- const ChatPage = lazy(async () => ({ default: (await import('@/components/chat/ChatPage')).ChatPage }));
17
+ const ChatPage = lazy(async () => ({ default: (await import('@/components/chat/chat-page')).ChatPage }));
15
18
  const SearchConfigPage = lazy(async () => ({ default: (await import('@/components/config/SearchConfig')).SearchConfig }));
16
19
  const ProvidersListPage = lazy(async () => ({ default: (await import('@/components/config/ProvidersList')).ProvidersList }));
17
20
  const ChannelsListPage = lazy(async () => ({ default: (await import('@/components/config/ChannelsList')).ChannelsList }));
@@ -89,9 +92,15 @@ function AuthGate() {
89
92
  }
90
93
 
91
94
  export default function AppContent() {
95
+ useEffect(() => {
96
+ startNextClawPwa();
97
+ }, []);
98
+
92
99
  return (
93
100
  <QueryClientProvider client={appQueryClient}>
94
101
  <AuthGate />
102
+ <PwaInstallBanner />
103
+ <PwaUpdateBanner />
95
104
  <Toaster position="top-right" richColors />
96
105
  </QueryClientProvider>
97
106
  );
@@ -11,6 +11,7 @@ const mocks = vi.hoisted(() => ({
11
11
  setQuery: vi.fn(),
12
12
  setListMode: vi.fn(),
13
13
  selectSession: vi.fn(),
14
+ openChildSessionPanel: vi.fn(),
14
15
  docOpen: vi.fn(),
15
16
  updateNcpSession: vi.fn(),
16
17
  agents: [] as Array<{ id: string; displayName?: string; avatarUrl?: string | null }>,
@@ -32,12 +33,14 @@ vi.mock('@/components/chat/presenter/chat-presenter-context', () => ({
32
33
  setQuery: mocks.setQuery,
33
34
  setListMode: mocks.setListMode,
34
35
  selectSession: mocks.selectSession,
35
- markSessionRead: (sessionKey: string | null | undefined, updatedAt: string | null | undefined) =>
36
- sessionKey ? useChatSessionListStore.getState().markSessionRead(sessionKey, updatedAt) : undefined,
37
- hydrateReadWatermarks: (
38
- entries: readonly { sessionKey: string; updatedAt: string | null | undefined }[],
39
- ) => useChatSessionListStore.getState().hydrateReadWatermarks(entries)
40
- }
36
+ markSessionRead: (
37
+ sessionKey: string | null | undefined,
38
+ readAt: string | null | undefined,
39
+ ) => (sessionKey ? useChatSessionListStore.getState().markSessionRead(sessionKey, readAt) : undefined),
40
+ },
41
+ chatThreadManager: {
42
+ openChildSessionPanel: mocks.openChildSessionPanel,
43
+ },
41
44
  })
42
45
  }));
43
46
 
@@ -114,6 +117,7 @@ function resetSidebarTestState() {
114
117
  mocks.setQuery.mockReset();
115
118
  mocks.setListMode.mockReset();
116
119
  mocks.selectSession.mockReset();
120
+ mocks.openChildSessionPanel.mockReset();
117
121
  mocks.docOpen.mockReset();
118
122
  mocks.updateNcpSession.mockReset();
119
123
  mocks.updateNcpSession.mockResolvedValue({});
@@ -132,8 +136,7 @@ function resetSidebarTestState() {
132
136
  }
133
137
  });
134
138
  useChatSessionListStore.setState({
135
- readUpdatedAtBySessionKey: {},
136
- hasHydratedReadWatermarks: false,
139
+ optimisticReadAtBySessionKey: {},
137
140
  snapshot: {
138
141
  ...useChatSessionListStore.getState().snapshot,
139
142
  query: '',
@@ -514,6 +517,8 @@ describe('ChatSidebar session item interactions', () => {
514
517
  key: 'session:ncp-1',
515
518
  createdAt: '2026-03-19T09:00:00.000Z',
516
519
  updatedAt: '2026-03-19T09:05:00.000Z',
520
+ lastMessageAt: '2026-03-19T09:05:00.000Z',
521
+ readAt: '2026-03-19T09:05:00.000Z',
517
522
  label: 'Current Task',
518
523
  sessionType: 'native',
519
524
  sessionTypeMutable: false,
@@ -523,6 +528,8 @@ describe('ChatSidebar session item interactions', () => {
523
528
  key: 'session:ncp-2',
524
529
  createdAt: '2026-03-19T10:00:00.000Z',
525
530
  updatedAt: '2026-03-19T10:05:00.000Z',
531
+ lastMessageAt: '2026-03-19T10:05:00.000Z',
532
+ readAt: '2026-03-19T10:05:00.000Z',
526
533
  label: 'Background Task',
527
534
  sessionType: 'native',
528
535
  sessionTypeMutable: false,
@@ -550,6 +557,8 @@ describe('ChatSidebar session item interactions', () => {
550
557
  key: 'session:ncp-2',
551
558
  createdAt: '2026-03-19T10:00:00.000Z',
552
559
  updatedAt: '2026-03-19T10:06:00.000Z',
560
+ lastMessageAt: '2026-03-19T10:06:00.000Z',
561
+ readAt: '2026-03-19T10:05:00.000Z',
553
562
  label: 'Background Task',
554
563
  sessionType: 'native',
555
564
  sessionTypeMutable: false,
@@ -571,6 +580,8 @@ describe('ChatSidebar session item interactions', () => {
571
580
  key: 'session:ncp-2',
572
581
  createdAt: '2026-03-19T10:00:00.000Z',
573
582
  updatedAt: '2026-03-19T10:06:00.000Z',
583
+ lastMessageAt: '2026-03-19T10:06:00.000Z',
584
+ readAt: '2026-03-19T10:05:00.000Z',
574
585
  label: 'Background Task',
575
586
  sessionType: 'native',
576
587
  sessionTypeMutable: false,
@@ -601,4 +612,64 @@ describe('ChatSidebar session item interactions', () => {
601
612
 
602
613
  expect(screen.queryByLabelText('Session has unread updates')).toBeNull();
603
614
  });
615
+
616
+ it('does not show an unread dot for sessions without a persisted ui read baseline', () => {
617
+ mocks.sessionItems = [
618
+ createSessionItem({
619
+ key: 'session:ncp-legacy',
620
+ createdAt: '2026-03-19T09:00:00.000Z',
621
+ updatedAt: '2026-03-19T09:05:00.000Z',
622
+ lastMessageAt: '2026-03-19T09:05:00.000Z',
623
+ label: 'Legacy Session',
624
+ sessionType: 'native',
625
+ sessionTypeMutable: false,
626
+ messageCount: 1
627
+ })
628
+ ];
629
+
630
+ render(
631
+ <MemoryRouter>
632
+ <ChatSidebar />
633
+ </MemoryRouter>
634
+ );
635
+
636
+ expect(screen.queryByLabelText('Session has unread updates')).toBeNull();
637
+ });
638
+
639
+ it('opens the child-session browser from a parent session row', () => {
640
+ mocks.sessionItems = [
641
+ createSessionItem({
642
+ key: 'session:parent-1',
643
+ createdAt: '2026-03-19T09:00:00.000Z',
644
+ updatedAt: '2026-03-19T09:05:00.000Z',
645
+ label: 'Parent Task',
646
+ sessionType: 'native',
647
+ sessionTypeMutable: false,
648
+ messageCount: 1
649
+ }),
650
+ createSessionItem({
651
+ key: 'session:child-1',
652
+ createdAt: '2026-03-19T09:06:00.000Z',
653
+ updatedAt: '2026-03-19T09:07:00.000Z',
654
+ label: 'Child Task',
655
+ sessionType: 'native',
656
+ sessionTypeMutable: false,
657
+ messageCount: 1,
658
+ parentSessionId: 'session:parent-1'
659
+ })
660
+ ];
661
+
662
+ render(
663
+ <MemoryRouter>
664
+ <ChatSidebar />
665
+ </MemoryRouter>
666
+ );
667
+
668
+ fireEvent.click(screen.getByLabelText('View child sessions'));
669
+
670
+ expect(mocks.openChildSessionPanel).toHaveBeenCalledWith({
671
+ parentSessionKey: 'session:parent-1',
672
+ activeChildSessionKey: 'session:child-1',
673
+ });
674
+ });
604
675
  });
@@ -149,28 +149,13 @@ const navItems = [
149
149
  function useChatSessionUnreadState(
150
150
  items: readonly NcpSessionListItemView[],
151
151
  selectedSessionKey: string | null,
152
- markSessionRead: (sessionKey: string | null | undefined, updatedAt: string | null | undefined) => void,
153
- hydrateReadWatermarks: (
154
- entries: readonly { sessionKey: string; updatedAt: string | null | undefined }[],
152
+ markSessionRead: (
153
+ sessionKey: string | null | undefined,
154
+ readAt: string | null | undefined,
155
+ currentReadAt?: string | null,
155
156
  ) => void,
156
157
  ): Record<string, string> {
157
- const readUpdatedAtBySessionKey = useChatSessionListStore((state) => state.readUpdatedAtBySessionKey);
158
- const hasHydratedReadWatermarks = useChatSessionListStore((state) => state.hasHydratedReadWatermarks);
159
-
160
- useEffect(() => {
161
- const syncHydratedReadWatermarks = () => {
162
- if (hasHydratedReadWatermarks || items.length === 0) {
163
- return;
164
- }
165
- hydrateReadWatermarks(
166
- items.map(({ session }) => ({
167
- sessionKey: session.key,
168
- updatedAt: session.updatedAt
169
- }))
170
- );
171
- };
172
- syncHydratedReadWatermarks();
173
- }, [hasHydratedReadWatermarks, hydrateReadWatermarks, items]);
158
+ const optimisticReadAtBySessionKey = useChatSessionListStore((state) => state.optimisticReadAtBySessionKey);
174
159
 
175
160
  useEffect(() => {
176
161
  const syncSelectedSessionReadState = () => {
@@ -182,12 +167,16 @@ function useChatSessionUnreadState(
182
167
  return;
183
168
  }
184
169
  const { session: selectedSession } = selectedItem;
185
- markSessionRead(selectedSession.key, selectedSession.updatedAt);
170
+ markSessionRead(
171
+ selectedSession.key,
172
+ selectedSession.lastMessageAt,
173
+ selectedSession.readAt,
174
+ );
186
175
  };
187
176
  syncSelectedSessionReadState();
188
177
  }, [items, markSessionRead, selectedSessionKey]);
189
178
 
190
- return readUpdatedAtBySessionKey;
179
+ return optimisticReadAtBySessionKey;
191
180
  }
192
181
 
193
182
  export function ChatSidebar() {
@@ -212,6 +201,22 @@ export function ChatSidebar() {
212
201
  [agentsQuery.data?.agents]
213
202
  );
214
203
  const sortedItems = useMemo(() => sortSessionItemsByUpdatedAtDesc(items), [items]);
204
+ const childSessionsByParentKey = useMemo(() => {
205
+ const grouped = new Map<string, NcpSessionListItemView[]>();
206
+ for (const item of items) {
207
+ const parentSessionKey = item.session.parentSessionId?.trim();
208
+ if (!parentSessionKey) {
209
+ continue;
210
+ }
211
+ const bucket = grouped.get(parentSessionKey) ?? [];
212
+ bucket.push(item);
213
+ grouped.set(parentSessionKey, bucket);
214
+ }
215
+ for (const bucket of grouped.values()) {
216
+ bucket.sort((left, right) => getSessionUpdatedAtTimestamp(right) - getSessionUpdatedAtTimestamp(left));
217
+ }
218
+ return grouped;
219
+ }, [items]);
215
220
  const groups = useMemo(() => groupSessionsByDate(sortedItems), [sortedItems]);
216
221
  const projectGroups = useMemo(() => groupSessionsByProject(sortedItems), [sortedItems]);
217
222
  const defaultSessionType = inputSnapshot.defaultSessionType || 'native';
@@ -220,11 +225,10 @@ export function ChatSidebar() {
220
225
  [defaultSessionType, inputSnapshot.sessionTypeOptions]
221
226
  );
222
227
  const isProjectFirstView = listSnapshot.listMode === 'project-first';
223
- const readUpdatedAtBySessionKey = useChatSessionUnreadState(
228
+ const optimisticReadAtBySessionKey = useChatSessionUnreadState(
224
229
  items,
225
230
  listSnapshot.selectedSessionKey,
226
231
  presenter.chatSessionListManager.markSessionRead,
227
- presenter.chatSessionListManager.hydrateReadWatermarks,
228
232
  );
229
233
  const handleLanguageSwitch = (nextLang: I18nLanguage) => {
230
234
  if (language === nextLang) return;
@@ -261,15 +265,21 @@ export function ChatSidebar() {
261
265
  };
262
266
  const renderSessionItem = ({ session, runStatus }: NcpSessionListItemView) => {
263
267
  const active = listSnapshot.selectedSessionKey === session.key;
268
+ const optimisticReadAt = optimisticReadAtBySessionKey[session.key];
269
+ const effectiveReadAt =
270
+ optimisticReadAt && session.readAt
271
+ ? (optimisticReadAt.localeCompare(session.readAt) > 0 ? optimisticReadAt : session.readAt)
272
+ : optimisticReadAt ?? session.readAt;
264
273
  const showUnreadDot = shouldShowUnreadSessionIndicator({
265
274
  active,
266
- updatedAt: session.updatedAt,
267
- readUpdatedAt: readUpdatedAtBySessionKey[session.key],
275
+ lastMessageAt: session.lastMessageAt,
276
+ readAt: effectiveReadAt,
268
277
  runStatus,
269
278
  });
270
279
  const context = resolveSessionContextView(session, inputSnapshot.sessionTypeOptions);
271
280
  const isEditing = editingSessionKey === session.key;
272
281
  const isSaving = savingSessionKey === session.key;
282
+ const childSessions = childSessionsByParentKey.get(session.key) ?? [];
273
283
  return (
274
284
  <ChatSidebarSessionItem
275
285
  key={session.key}
@@ -282,10 +292,17 @@ export function ChatSidebar() {
282
292
  agentId={session.agentId ?? null}
283
293
  agentLabel={session.agentId ? (agentsById.get(session.agentId)?.displayName ?? session.agentId) : null}
284
294
  agentAvatarUrl={session.agentId ? (agentsById.get(session.agentId)?.avatarUrl ?? null) : null}
295
+ childSessionCount={childSessions.length}
285
296
  isEditing={isEditing}
286
297
  draftLabel={draftLabel}
287
298
  isSaving={isSaving}
288
299
  onSelect={() => presenter.chatSessionListManager.selectSession(session.key)}
300
+ onOpenChildSessions={() =>
301
+ presenter.chatThreadManager.openChildSessionPanel({
302
+ parentSessionKey: session.key,
303
+ activeChildSessionKey: childSessions[0]?.session.key ?? null,
304
+ })
305
+ }
289
306
  onStartEditing={() => startEditingSessionLabel(session)}
290
307
  onDraftLabelChange={setDraftLabel}
291
308
  onSave={() => saveSessionLabel(session)}
@@ -0,0 +1,66 @@
1
+ import { ToolInvocationStatus, type UiMessage } from "@nextclaw/agent-chat";
2
+ import { adaptChatMessages } from "@/components/chat/adapters/chat-message.adapter";
3
+ import type { ChatMessageSource } from "@/components/chat/adapters/chat-message.adapter";
4
+ import type { ChatMessagePartViewModel } from "@nextclaw/agent-chat-ui";
5
+
6
+ const defaultTexts = {
7
+ roleLabels: {
8
+ user: "You",
9
+ assistant: "Assistant",
10
+ tool: "Tool",
11
+ system: "System",
12
+ fallback: "Message",
13
+ },
14
+ reasoningLabel: "Reasoning",
15
+ toolCallLabel: "Tool Call",
16
+ toolResultLabel: "Tool Result",
17
+ toolInputLabel: "Input",
18
+ toolNoOutputLabel: "No output",
19
+ toolOutputLabel: "Output",
20
+ toolStatusPreparingLabel: "Preparing",
21
+ toolStatusRunningLabel: "Running",
22
+ toolStatusCompletedLabel: "Completed",
23
+ toolStatusFailedLabel: "Failed",
24
+ toolStatusCancelledLabel: "Cancelled",
25
+ imageAttachmentLabel: "Image attachment",
26
+ fileAttachmentLabel: "File attachment",
27
+ unknownPartLabel: "Unknown Part",
28
+ };
29
+
30
+ function adapt(uiMessages: UiMessage[]) {
31
+ return adaptChatMessages({
32
+ uiMessages: uiMessages as unknown as ChatMessageSource[],
33
+ formatTimestamp: (value) => `formatted:${value}`,
34
+ texts: defaultTexts,
35
+ });
36
+ }
37
+
38
+ it("truncates long structured tool summaries into a single-line ellipsis detail", () => {
39
+ const adapted = adapt([
40
+ {
41
+ id: "assistant-long-tool-summary",
42
+ role: "assistant",
43
+ parts: [
44
+ {
45
+ type: "tool-invocation",
46
+ toolInvocation: {
47
+ status: ToolInvocationStatus.PARTIAL_CALL,
48
+ toolCallId: "call-long-tool-summary",
49
+ toolName: "terminal",
50
+ args: JSON.stringify({
51
+ command:
52
+ "ls -la /Users/peiwang/.nextclaw/workspace/skills/bird/snapshots/archive/releases/2026-04-17/builds/current/output 2>/dev/null | sed -n '1,160p' && echo 'done'",
53
+ }),
54
+ },
55
+ },
56
+ ],
57
+ },
58
+ ]);
59
+
60
+ const summary = (
61
+ adapted[0]?.parts[0] as Extract<ChatMessagePartViewModel, { type: "tool-card" }> | undefined
62
+ )?.card.summary;
63
+
64
+ expect(summary?.startsWith("command: ls -la /Users/peiwang/.nextclaw/workspace/skills/bird/")).toBe(true);
65
+ expect(summary?.endsWith("…")).toBe(true);
66
+ });
@@ -54,7 +54,16 @@ function finalizeParsedBlocks(
54
54
  display: block.display,
55
55
  ...(block.caption ? { caption: block.caption } : {}),
56
56
  lines: block.lines,
57
+ ...(block.fullLines ? { fullLines: block.fullLines } : {}),
57
58
  ...(block.rawText ? { rawText: block.rawText } : {}),
59
+ ...(block.beforeText ? { beforeText: block.beforeText } : {}),
60
+ ...(block.afterText ? { afterText: block.afterText } : {}),
61
+ ...(typeof block.oldStartLine === "number"
62
+ ? { oldStartLine: block.oldStartLine }
63
+ : {}),
64
+ ...(typeof block.newStartLine === "number"
65
+ ? { newStartLine: block.newStartLine }
66
+ : {}),
58
67
  ...(block.truncated ? { truncated: true } : {}),
59
68
  }))
60
69
  .filter((block) => block.lines.length > 0 || Boolean(block.rawText));
@@ -13,7 +13,12 @@ export type ParsedBlock = {
13
13
  display: "preview" | "diff";
14
14
  caption?: string;
15
15
  lines: ChatFileOperationLineViewModel[];
16
+ fullLines?: ChatFileOperationLineViewModel[];
16
17
  rawText?: string;
18
+ beforeText?: string;
19
+ afterText?: string;
20
+ oldStartLine?: number;
21
+ newStartLine?: number;
17
22
  truncated?: boolean;
18
23
  };
19
24
 
@@ -114,6 +119,9 @@ export function buildRawPreviewBlock(params: {
114
119
  lines,
115
120
  }),
116
121
  lines,
122
+ rawText: previewText,
123
+ oldStartLine,
124
+ newStartLine,
117
125
  };
118
126
  }
119
127
 
@@ -144,6 +152,11 @@ export function buildFullReplaceBlock(params: {
144
152
  lines,
145
153
  }),
146
154
  lines: limited.lines,
155
+ ...(limited.truncated ? { fullLines: lines } : {}),
156
+ ...(params.beforeText != null ? { beforeText: params.beforeText } : {}),
157
+ ...(params.afterText != null ? { afterText: params.afterText } : {}),
158
+ ...(typeof oldStartLine === "number" ? { oldStartLine } : {}),
159
+ ...(typeof newStartLine === "number" ? { newStartLine } : {}),
147
160
  truncated: limited.truncated,
148
161
  };
149
162
  }
@@ -162,6 +175,7 @@ function buildParsedPatchBlock(params: {
162
175
  lines: params.lines,
163
176
  }),
164
177
  lines: limited.lines,
178
+ ...(limited.truncated ? { fullLines: params.lines } : {}),
165
179
  truncated: limited.truncated,
166
180
  };
167
181
  }