@nextclaw/ui 0.20.0 → 0.20.2

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 (108) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/dist/assets/{api-C2iYhdTe.js → api-DvpQWjTS.js} +3 -3
  3. package/dist/assets/{appearance-settings-page-BwQDQr0n.js → appearance-settings-page-U0m0ktu3.js} +1 -1
  4. package/dist/assets/{book-open-C0IlT9zs.js → book-open-DRofSTWr.js} +1 -1
  5. package/dist/assets/channels-list-page-BURQarD0.js +8 -0
  6. package/dist/assets/{config-split-page-VDTf3zZb.js → config-split-page-BrAQYTIl.js} +1 -1
  7. package/dist/assets/{confirm-dialog-BI_Vt2s_.js → confirm-dialog-DDJomLnO.js} +1 -1
  8. package/dist/assets/{createLucideIcon-Bydi9Gc5.js → createLucideIcon-DzkNHr5Z.js} +1 -1
  9. package/dist/assets/{desktop-update-config-DVoD6NxG.js → desktop-update-config-BzZFhMv9.js} +1 -1
  10. package/dist/assets/{dist-CmHtkFbs.js → dist-DXbLo2b1.js} +1 -1
  11. package/dist/assets/{dist-A5btQciA.js → dist-NskPB_yW.js} +1 -1
  12. package/dist/assets/doc-browser-DB22tQKU.js +1 -0
  13. package/dist/assets/{doc-browser-context-Bxw0ora-.js → doc-browser-context-CnKV4gJy.js} +1 -1
  14. package/dist/assets/doc-browser-sJjw9fl5.js +1 -0
  15. package/dist/assets/{extensions-CG4voDCh.js → extensions-DvlFMpAs.js} +1 -1
  16. package/dist/assets/{form-actions-lv47uGIB.js → form-actions-UnPEZBkx.js} +1 -1
  17. package/dist/assets/index-Dc_ZMkmo.css +1 -0
  18. package/dist/assets/index-N1HnesBp.js +109 -0
  19. package/dist/assets/loader-circle-BlPXxgDB.js +1 -0
  20. package/dist/assets/mcp-marketplace-page-BIgFTbMq.js +9 -0
  21. package/dist/assets/mcp-marketplace-page-CZN4ovdK.js +1 -0
  22. package/dist/assets/model-config-page-CnSm7kfa.js +1 -0
  23. package/dist/assets/{navigation-link-D0agTj5t.js → navigation-link-NjUdG6Vp.js} +1 -1
  24. package/dist/assets/plus-DVXol9Q5.js +1 -0
  25. package/dist/assets/privacy-settings-page-DVn2HU7W.js +1 -0
  26. package/dist/assets/providers-config-page-CzHJS3eg.js +1 -0
  27. package/dist/assets/react-DCVbGroo.js +8 -0
  28. package/dist/assets/{refresh-cw-DjgY2Eju.js → refresh-cw-CkeninE0.js} +1 -1
  29. package/dist/assets/remote-B2lsJF1c.js +1 -0
  30. package/dist/assets/{rotate-cw-Dot4PVh0.js → rotate-cw-DP33PuXU.js} +1 -1
  31. package/dist/assets/runtime-config-page-D69buyGp.js +1 -0
  32. package/dist/assets/{save-B-jdTe_c.js → save-Cw2Xz-pB.js} +1 -1
  33. package/dist/assets/{search-CEp2AIiV.js → search-C2CFAVvd.js} +1 -1
  34. package/dist/assets/{search-config-page-Dp5guS5O.js → search-config-page-dqNT0Swf.js} +1 -1
  35. package/dist/assets/{secrets-config-page-C7nRMjiv.js → secrets-config-page-De6J3wVx.js} +2 -2
  36. package/dist/assets/security-config-Dcu9C4OL.js +1 -0
  37. package/dist/assets/{select-BNA3cma8.js → select-DSuMhwVP.js} +1 -1
  38. package/dist/assets/skeleton-QoWydNLp.js +1 -0
  39. package/dist/assets/{tag-chip-DmCNi4-9.js → tag-chip-DFBLgsGf.js} +1 -1
  40. package/dist/assets/x-BXvAzpjb.js +1 -0
  41. package/dist/index.html +21 -21
  42. package/package.json +8 -8
  43. package/src/features/chat/components/layout/__tests__/chat-sidebar-read-state.test.tsx +4 -3
  44. package/src/features/chat/components/layout/__tests__/chat-sidebar-session-area.test.tsx +11 -4
  45. package/src/features/chat/components/layout/__tests__/chat-sidebar.test.tsx +17 -17
  46. package/src/features/chat/components/layout/chat-sidebar-desktop-layout.tsx +17 -5
  47. package/src/features/chat/components/layout/chat-sidebar.tsx +28 -21
  48. package/src/features/chat/features/conversation/hooks/__tests__/use-session-conversation-controller.test.tsx +41 -1
  49. package/src/features/chat/features/conversation/hooks/use-session-pending-input-actions.ts +22 -4
  50. package/src/features/chat/features/message/components/chat-message-list.container.tsx +33 -4
  51. package/src/features/chat/features/message/utils/__tests__/chat-message-file-operation.utils.test.ts +186 -0
  52. package/src/features/chat/features/message/utils/__tests__/chat-message-texts.utils.test.ts +9 -0
  53. package/src/features/chat/features/message/utils/__tests__/chat-message-timeline.utils.test.ts +71 -0
  54. package/src/features/chat/features/message/utils/__tests__/chat-message-trigger-details.utils.test.ts +147 -0
  55. package/src/features/chat/features/message/utils/chat-message-part.utils.ts +1 -1
  56. package/src/features/chat/features/message/utils/chat-message-texts.utils.ts +36 -0
  57. package/src/features/chat/features/message/utils/chat-message-trigger-details.utils.ts +118 -0
  58. package/src/features/chat/features/ncp/hooks/__tests__/use-ncp-agent-runtime-queue.test.tsx +50 -0
  59. package/src/features/chat/features/ncp/hooks/__tests__/use-ncp-agent-runtime.test.tsx +90 -1
  60. package/src/features/chat/features/ncp/hooks/__tests__/use-ncp-session-list-view.test.tsx +24 -3
  61. package/src/features/chat/features/ncp/hooks/use-ncp-session-list-view.ts +35 -19
  62. package/src/features/chat/features/session/components/__tests__/chat-sidebar-project-groups.test.tsx +26 -2
  63. package/src/features/chat/features/session/components/__tests__/chat-sidebar-session-item.test.tsx +55 -27
  64. package/src/features/chat/features/session/components/chat-sidebar-context-card.tsx +60 -0
  65. package/src/features/chat/features/session/components/chat-sidebar-project-groups.tsx +95 -54
  66. package/src/features/chat/features/session/components/chat-sidebar-session-entry.tsx +9 -8
  67. package/src/features/chat/features/session/components/chat-sidebar-session-item.tsx +141 -95
  68. package/src/features/chat/features/session/components/chat-sidebar-session-list.tsx +11 -2
  69. package/src/features/chat/features/session/hooks/use-chat-sidebar-context-counts.ts +40 -0
  70. package/src/features/chat/features/session/utils/ncp-session-context-metadata.utils.ts +5 -1
  71. package/src/features/chat/features/workspace/components/__tests__/chat-session-workspace-file-preview.test.tsx +16 -9
  72. package/src/features/chat/features/workspace/components/__tests__/chat-session-workspace-panel-content.test.tsx +5 -5
  73. package/src/features/chat/features/workspace/components/__tests__/workspace-markdown-outline.test.tsx +128 -0
  74. package/src/features/chat/features/workspace/components/chat-session-workspace-file-breadcrumbs.tsx +3 -0
  75. package/src/features/chat/features/workspace/components/chat-session-workspace-file-preview.tsx +20 -2
  76. package/src/features/chat/features/workspace/components/chat-session-workspace-panel-content.tsx +3 -40
  77. package/src/features/chat/features/workspace/components/workspace-markdown-outline.tsx +156 -0
  78. package/src/features/chat/managers/__tests__/chat-completion-notification.manager.test.ts +70 -0
  79. package/src/features/chat/managers/chat-completion-notification.manager.ts +23 -6
  80. package/src/features/settings/pages/__tests__/privacy-settings-page.test.tsx +14 -5
  81. package/src/features/settings/pages/privacy-settings-page.tsx +38 -2
  82. package/src/shared/components/ui/actions/icon-action-button.tsx +1 -8
  83. package/src/shared/components/ui/tooltip.tsx +16 -1
  84. package/src/shared/hooks/use-config.ts +11 -0
  85. package/src/shared/lib/api/types.ts +8 -0
  86. package/src/shared/lib/api/utils/config.utils.ts +5 -0
  87. package/src/shared/lib/i18n/locales/en-US/chat.json +28 -0
  88. package/src/shared/lib/i18n/locales/en-US/core.json +17 -9
  89. package/src/shared/lib/i18n/locales/zh-CN/chat.json +28 -0
  90. package/src/shared/lib/i18n/locales/zh-CN/core.json +17 -9
  91. package/dist/assets/channels-list-page-B0z4bh8e.js +0 -8
  92. package/dist/assets/doc-browser-Cm_tMZ_C.js +0 -1
  93. package/dist/assets/doc-browser-Dto54tCf.js +0 -1
  94. package/dist/assets/index-BTSwNDVi.js +0 -109
  95. package/dist/assets/index-QrdUWHfm.css +0 -1
  96. package/dist/assets/loader-circle-CkaqeEne.js +0 -1
  97. package/dist/assets/mcp-marketplace-page-BSk0qUOX.js +0 -9
  98. package/dist/assets/mcp-marketplace-page-BgZGc-S_.js +0 -1
  99. package/dist/assets/model-config-page-c8RnUGoH.js +0 -1
  100. package/dist/assets/plus-BiMjkv3R.js +0 -1
  101. package/dist/assets/privacy-settings-page-CPkyTvEE.js +0 -1
  102. package/dist/assets/providers-config-page-CGUEb5Dx.js +0 -1
  103. package/dist/assets/react-BMYFkaGi.js +0 -8
  104. package/dist/assets/remote-1LxkpRED.js +0 -1
  105. package/dist/assets/runtime-config-page-C6nzqTHg.js +0 -1
  106. package/dist/assets/security-config-DvHUDxSg.js +0 -1
  107. package/dist/assets/skeleton-CbroEO8Q.js +0 -1
  108. package/dist/assets/x-BTo5-A8h.js +0 -1
@@ -21,7 +21,10 @@ import {
21
21
  useCreateProject,
22
22
  useProjects,
23
23
  } from "@/shared/hooks/use-projects";
24
- import type { ProjectAddExistingRequest, ProjectCreateRequest } from "@/shared/lib/api";
24
+ import type {
25
+ ProjectAddExistingRequest,
26
+ ProjectCreateRequest,
27
+ } from "@/shared/lib/api";
25
28
  import { normalizeSessionProjectRootValue } from "@/shared/lib/session-project";
26
29
  import { cn } from "@/shared/lib/utils";
27
30
  import { LANGUAGE_OPTIONS, t, type I18nLanguage } from "@/shared/lib/i18n";
@@ -50,6 +53,7 @@ import { useChatNewSessionTypePreference } from "@/features/chat/features/sessio
50
53
  import { useViewportLayoutStore } from "@/app/stores/viewport-layout.store";
51
54
  import { SIDEBAR_RAIL_WIDTH_CLASS } from "@/app/components/layout/sidebar-rail.styles";
52
55
  import { ChatProjectAddDialog } from "@/features/chat/features/project/components/chat-project-add-dialog";
56
+ import { useChatSidebarContextCounts } from "@/features/chat/features/session/hooks/use-chat-sidebar-context-counts";
53
57
 
54
58
  type ChatSidebarVariant = "desktop" | "mobile";
55
59
 
@@ -116,7 +120,9 @@ export function ChatSidebar({
116
120
  const projectsQuery = useProjects();
117
121
  const projectCreateMutation = useCreateProject();
118
122
  const projectAddExistingMutation = useAddExistingProject();
119
- const { isLoading, items } = useNcpSessionListView();
123
+ const { allItems, isLoading, items } = useNcpSessionListView();
124
+ const { cronJobCountByProjectRoot, cronJobCountBySessionKey } =
125
+ useChatSidebarContextCounts(allItems);
120
126
  const { language, setLanguage } = useI18n();
121
127
  const { theme, setTheme } = useTheme();
122
128
  const currentThemeLabel = t(
@@ -160,21 +166,27 @@ export function ChatSidebar({
160
166
  [items],
161
167
  );
162
168
  const childSessionsByParentKey = useMemo(
163
- () => groupChildSessionsByParentKey(items),
164
- [items],
169
+ () => groupChildSessionsByParentKey(allItems),
170
+ [allItems],
165
171
  );
166
172
  const groups = useMemo(
167
173
  () => groupSessionsByDate(sortedItems, pinnedSessionKeys),
168
174
  [pinnedSessionKeys, sortedItems],
169
175
  );
170
176
  const projectGroups = useMemo(
171
- () => groupSessionsByProject(
172
- sortedItems,
173
- pinnedSessionKeys,
177
+ () =>
178
+ groupSessionsByProject(
179
+ sortedItems,
180
+ pinnedSessionKeys,
181
+ pinnedProjectRoots,
182
+ projectsQuery.data?.projects ?? [],
183
+ ),
184
+ [
174
185
  pinnedProjectRoots,
175
- projectsQuery.data?.projects ?? [],
176
- ),
177
- [pinnedProjectRoots, pinnedSessionKeys, projectsQuery.data?.projects, sortedItems],
186
+ pinnedSessionKeys,
187
+ projectsQuery.data?.projects,
188
+ sortedItems,
189
+ ],
178
190
  );
179
191
  const sessionTypeOptions = useMemo(
180
192
  () => buildSessionTypeOptions(sessionTypesData?.options ?? []),
@@ -220,6 +232,7 @@ export function ChatSidebar({
220
232
  optimisticReadAtBySessionKey={optimisticReadAtBySessionKey}
221
233
  agentsById={agentsById}
222
234
  childSessionsByParentKey={childSessionsByParentKey}
235
+ cronJobCount={cronJobCountBySessionKey.get(item.session.key) ?? 0}
223
236
  editingSessionKey={editingSessionKey}
224
237
  draftLabel={draftLabel}
225
238
  savingSessionKey={savingSessionKey}
@@ -227,12 +240,6 @@ export function ChatSidebar({
227
240
  isPinned={pinnedSessionKeys.has(item.session.key)}
228
241
  sessionTitle={getSessionTitle}
229
242
  onSelectSession={presenter.chatSessionListManager.selectSession}
230
- onOpenChildSessions={(parentSessionKey, activeChildSessionKey) =>
231
- presenter.chatThreadManager.openChildSessionPanel({
232
- parentSessionKey,
233
- activeChildSessionKey,
234
- })
235
- }
236
243
  onStartEditingSessionLabel={startEditingSessionLabel}
237
244
  onDraftLabelChange={setDraftLabel}
238
245
  onSaveSessionLabel={saveSessionLabel}
@@ -256,7 +263,9 @@ export function ChatSidebar({
256
263
  projectAddExistingMutation.reset();
257
264
  setIsProjectAddOpen(true);
258
265
  };
259
- const createProjectFromSidebar = async (input: ProjectCreateRequest): Promise<void> => {
266
+ const createProjectFromSidebar = async (
267
+ input: ProjectCreateRequest,
268
+ ): Promise<void> => {
260
269
  await projectCreateMutation.mutateAsync(input);
261
270
  setIsProjectAddOpen(false);
262
271
  };
@@ -274,10 +283,7 @@ export function ChatSidebar({
274
283
  isMobileVariant
275
284
  ? "flex-1 overflow-hidden"
276
285
  : shouldCollapse
277
- ? cn(
278
- SIDEBAR_RAIL_WIDTH_CLASS,
279
- "shrink-0",
280
- )
286
+ ? cn(SIDEBAR_RAIL_WIDTH_CLASS, "shrink-0")
281
287
  : "w-[280px] shrink-0",
282
288
  )}
283
289
  data-sidebar-collapsed={shouldCollapse ? "true" : "false"}
@@ -339,6 +345,7 @@ export function ChatSidebar({
339
345
  onAddProject={openProjectAdd}
340
346
  onSelectMode={presenter.chatSessionListManager.setListMode}
341
347
  projectGroups={projectGroups}
348
+ projectCronJobCountByRoot={cronJobCountByProjectRoot}
342
349
  renderSessionItem={renderSessionItem}
343
350
  sessionTypeOptions={sessionTypeOptions}
344
351
  />
@@ -1,6 +1,6 @@
1
1
  import { act, renderHook, waitFor } from '@testing-library/react';
2
2
  import { describe, expect, it, vi } from 'vitest';
3
- import type { UiNcpSessionQueuedInputView } from '@nextclaw/client-sdk';
3
+ import { NextClawClientError, type UiNcpSessionQueuedInputView } from '@nextclaw/client-sdk';
4
4
  import type { NcpAgentSendEnvelope, NcpMessage, NcpRunHandle } from '@nextclaw/ncp';
5
5
 
6
6
  import { useSessionConversationController } from '@/features/chat/features/conversation/hooks/use-session-conversation-controller';
@@ -448,3 +448,43 @@ describe('useSessionConversationController backend run queue', () => {
448
448
  expect(params.onSessionMaterialized).not.toHaveBeenCalled();
449
449
  });
450
450
  });
451
+
452
+ describe('useSessionConversationController stale queue reconciliation', () => {
453
+ it('reconciles repeated stale queued actions without exposing their not-found errors', async () => {
454
+ const queuedInput = createQueuedInput();
455
+ const params = createControllerParams({ isRunning: true, queuedInputs: [queuedInput] });
456
+ params.runQueue.steerQueuedInput.mockRejectedValue(new NextClawClientError({
457
+ code: 'NOT_FOUND',
458
+ message: `queued input not found in session session-1: ${queuedInput.id}`,
459
+ status: 404,
460
+ }));
461
+ const { result } = renderHook(() => useSessionConversationController(params));
462
+
463
+ act(() => result.current.steerQueuedInput(queuedInput.id));
464
+ await waitFor(() => expect(params.runQueue.refreshPendingInputs).toHaveBeenCalledTimes(1));
465
+
466
+ act(() => result.current.steerQueuedInput(queuedInput.id));
467
+ await waitFor(() => expect(params.runQueue.refreshPendingInputs).toHaveBeenCalledTimes(2));
468
+
469
+ expect(params.setSendError).toHaveBeenLastCalledWith(null);
470
+ expect(params.setSendError).not.toHaveBeenCalledWith(expect.stringContaining('queued input not found'));
471
+ });
472
+
473
+ it('keeps a real steering failure visible', async () => {
474
+ const queuedInput = createQueuedInput();
475
+ const params = createControllerParams({ isRunning: true, queuedInputs: [queuedInput] });
476
+ params.runQueue.steerQueuedInput.mockRejectedValue(new NextClawClientError({
477
+ code: 'STEER_UNAVAILABLE',
478
+ message: 'The active runtime cannot accept this input at the next safe step.',
479
+ status: 409,
480
+ }));
481
+ const { result } = renderHook(() => useSessionConversationController(params));
482
+
483
+ act(() => result.current.steerQueuedInput(queuedInput.id));
484
+
485
+ await waitFor(() => expect(params.setSendError).toHaveBeenCalledWith(
486
+ 'The active runtime cannot accept this input at the next safe step.',
487
+ ));
488
+ expect(params.runQueue.refreshPendingInputs).not.toHaveBeenCalled();
489
+ });
490
+ });
@@ -1,7 +1,8 @@
1
1
  import { useCallback, useMemo, useState } from 'react';
2
- import type {
3
- UiNcpSessionPendingInputView,
4
- UiNcpSessionQueuedInputView,
2
+ import {
3
+ NextClawClientError,
4
+ type UiNcpSessionPendingInputView,
5
+ type UiNcpSessionQueuedInputView,
5
6
  } from '@nextclaw/client-sdk';
6
7
  import type { NcpAgentSendEnvelope } from '@nextclaw/ncp';
7
8
 
@@ -20,10 +21,17 @@ type ComposerDraftSnapshot = Pick<
20
21
 
21
22
  type SessionRunQueue = {
22
23
  readonly inputs: readonly UiNcpSessionQueuedInputView[];
24
+ readonly refreshPendingInputs: () => Promise<readonly UiNcpSessionPendingInputView[]>;
23
25
  readonly removeQueuedInput: (id: string) => Promise<UiNcpSessionQueuedInputView | null>;
24
26
  readonly steerQueuedInput: (id: string) => Promise<UiNcpSessionPendingInputView | null>;
25
27
  };
26
28
 
29
+ function isStaleQueuedInputError(error: unknown): boolean {
30
+ return error instanceof NextClawClientError
31
+ && error.status === 404
32
+ && error.code === 'NOT_FOUND';
33
+ }
34
+
27
35
  export type SubmittingQueuedInput = {
28
36
  readonly attachments: readonly SessionQueuedInputAttachmentPreview[];
29
37
  readonly id: string;
@@ -127,7 +135,17 @@ export function useQueuedInputActions(params: {
127
135
  const steerQueuedInput = useCallback((id: string) => {
128
136
  void runQueue.steerQueuedInput(id).then((steered) => {
129
137
  if (steered) setSendError(null);
130
- }).catch((error) => {
138
+ }).catch(async (error) => {
139
+ if (isStaleQueuedInputError(error)) {
140
+ try {
141
+ await runQueue.refreshPendingInputs();
142
+ setSendError(null);
143
+ return;
144
+ } catch (refreshError) {
145
+ setSendError(refreshError instanceof Error ? refreshError.message : String(refreshError));
146
+ return;
147
+ }
148
+ }
131
149
  setSendError(error instanceof Error ? error.message : String(error));
132
150
  });
133
151
  }, [runQueue, setSendError]);
@@ -20,9 +20,14 @@ import {
20
20
  } from "@/features/chat/features/message/utils/chat-message.utils";
21
21
  import { buildChatMessageProcessSummary } from "@/features/chat/features/message/utils/chat-message-process-summary.utils";
22
22
  import { buildChatMessageExecutionPresentation } from "@/features/chat/features/message/utils/chat-message-execution-summary.utils";
23
+ import {
24
+ buildChatMessageTriggerDetails,
25
+ mergeChatMessageMoreActions,
26
+ } from "@/features/chat/features/message/utils/chat-message-trigger-details.utils";
23
27
  import {
24
28
  buildChatMessageAdapterTexts,
25
29
  buildChatMessageExecutionLabels,
30
+ buildChatMessageTriggerLabels,
26
31
  buildChatMessageTexts,
27
32
  } from "@/features/chat/features/message/utils/chat-message-texts.utils";
28
33
  import {
@@ -94,6 +99,7 @@ class ChatMessageViewModelAdapter {
94
99
  language: Parameters<typeof formatDateTime>[1];
95
100
  processSummaryLabel: string | null;
96
101
  executionPresentationKey: string | null;
102
+ triggerDetailsKey: string | null;
97
103
  viewModel: ChatMessageViewModel;
98
104
  }
99
105
  >();
@@ -101,13 +107,14 @@ class ChatMessageViewModelAdapter {
101
107
  adapt = (params: {
102
108
  continuationRunning: boolean;
103
109
  executionLabels: ReturnType<typeof buildChatMessageExecutionLabels>;
110
+ triggerLabels: ReturnType<typeof buildChatMessageTriggerLabels>;
104
111
  language: Parameters<typeof formatDateTime>[1];
105
112
  processedLabel: string;
106
113
  rawMessages: readonly NcpMessage[];
107
114
  texts: ReturnType<typeof buildChatMessageAdapterTexts>;
108
115
  }): ChatMessageViewModel[] => {
109
116
  const {
110
- continuationRunning, executionLabels, language, processedLabel, rawMessages, texts,
117
+ continuationRunning, executionLabels, triggerLabels, language, processedLabel, rawMessages, texts,
111
118
  } = params;
112
119
  return projectVisibleChatMessages(rawMessages, { continuationRunning }).map((message) => {
113
120
  const processSummary = buildChatMessageProcessSummary({
@@ -127,12 +134,28 @@ class ChatMessageViewModelAdapter {
127
134
  labels: executionLabels,
128
135
  });
129
136
  const executionPresentationKey = executionPresentation?.cacheKey ?? null;
137
+ const triggerDetails = buildChatMessageTriggerDetails({
138
+ message,
139
+ labels: triggerLabels,
140
+ });
141
+ const triggerDetailsKey = triggerDetails
142
+ ? JSON.stringify({
143
+ runTrigger: message.metadata?.run_trigger,
144
+ runSpec: message.metadata?.run_spec,
145
+ aiExecution: message.metadata?.ai_execution,
146
+ })
147
+ : null;
148
+ const moreActions = mergeChatMessageMoreActions(
149
+ executionPresentation?.moreActions,
150
+ triggerDetails,
151
+ );
130
152
  const cached = this.cache.get(message);
131
153
  if (
132
154
  cached &&
133
155
  cached.language === language &&
134
156
  cached.processSummaryLabel === processSummaryLabel &&
135
- cached.executionPresentationKey === executionPresentationKey
157
+ cached.executionPresentationKey === executionPresentationKey &&
158
+ cached.triggerDetailsKey === triggerDetailsKey
136
159
  ) {
137
160
  return cached.viewModel;
138
161
  }
@@ -147,7 +170,7 @@ class ChatMessageViewModelAdapter {
147
170
  inlineTokens: readInlineTokensFromMetadata(message.metadata),
148
171
  processSummary,
149
172
  executionSummaryLabel: executionPresentation?.summaryLabel,
150
- moreActions: executionPresentation?.moreActions,
173
+ moreActions,
151
174
  },
152
175
  parts: adaptNcpMessagePartsForChat(message.parts) as ChatMessageSource["parts"],
153
176
  };
@@ -160,6 +183,7 @@ class ChatMessageViewModelAdapter {
160
183
  language,
161
184
  processSummaryLabel,
162
185
  executionPresentationKey,
186
+ triggerDetailsKey,
163
187
  viewModel,
164
188
  });
165
189
  return viewModel;
@@ -295,17 +319,22 @@ export function ChatMessageListContainer({
295
319
  () => buildChatMessageExecutionLabels(language),
296
320
  [language],
297
321
  );
322
+ const triggerLabels = useMemo(
323
+ () => buildChatMessageTriggerLabels(language),
324
+ [language],
325
+ );
298
326
  const adaptedMessages = useMemo(
299
327
  () =>
300
328
  chatMessageViewModelAdapter.adapt({
301
329
  continuationRunning: isSending,
302
330
  executionLabels,
331
+ triggerLabels,
303
332
  language,
304
333
  processedLabel: t("chatProcessSummaryProcessed"),
305
334
  rawMessages,
306
335
  texts,
307
336
  }),
308
- [executionLabels, isSending, language, rawMessages, texts],
337
+ [executionLabels, isSending, language, rawMessages, texts, triggerLabels],
309
338
  );
310
339
  const { handleMessageAction, messages, renderMessageContent } =
311
340
  useChatMessageActions({
@@ -154,6 +154,12 @@ it("uses structured edit-file result line numbers after the tool finishes", () =
154
154
  },
155
155
  },
156
156
  });
157
+ expect(adapted[0]?.parts[0]).not.toMatchObject({
158
+ type: "tool-card",
159
+ card: {
160
+ outputData: expect.anything(),
161
+ },
162
+ });
157
163
  });
158
164
 
159
165
  it("builds write-file previews from partial native args before the JSON is complete", () => {
@@ -261,6 +267,12 @@ it("keeps completed write-file cards in preview mode instead of falling back to
261
267
  output: "Wrote 3906 bytes to games/snake.html",
262
268
  },
263
269
  });
270
+ expect(adapted[0]?.parts[0]).not.toMatchObject({
271
+ type: "tool-card",
272
+ card: {
273
+ outputData: expect.anything(),
274
+ },
275
+ });
264
276
  });
265
277
 
266
278
  it("renders codex file_change results as structured diff previews", () => {
@@ -337,4 +349,178 @@ it("renders codex file_change results as structured diff previews", () => {
337
349
  },
338
350
  },
339
351
  });
352
+ expect(adapted[0]?.parts[0]).not.toMatchObject({
353
+ type: "tool-card",
354
+ card: {
355
+ outputData: expect.anything(),
356
+ },
357
+ });
358
+ });
359
+
360
+ it("uses a read-file preview as the single visible representation of file content", () => {
361
+ const adapted = adapt([
362
+ {
363
+ id: "assistant-read-result",
364
+ role: "assistant",
365
+ parts: [
366
+ {
367
+ type: "tool-invocation",
368
+ toolInvocation: {
369
+ status: ToolInvocationStatus.RESULT,
370
+ toolCallId: "read-result-1",
371
+ toolName: "read_file",
372
+ args: JSON.stringify({ path: "docs/readme.md" }),
373
+ parsedArgs: { path: "docs/readme.md" },
374
+ result: "1: first line\n2: second line\n</content>",
375
+ },
376
+ },
377
+ ],
378
+ },
379
+ ] as unknown as ChatMessageSource[]);
380
+
381
+ expect(adapted[0]?.parts[0]).toMatchObject({
382
+ type: "tool-card",
383
+ card: {
384
+ toolName: "read_file",
385
+ summary: "docs/readme.md",
386
+ fileOperation: {
387
+ blocks: [
388
+ {
389
+ display: "preview",
390
+ path: "docs/readme.md",
391
+ lines: expect.arrayContaining([
392
+ expect.objectContaining({ text: "1: first line" }),
393
+ expect.objectContaining({ text: "2: second line" }),
394
+ ]),
395
+ },
396
+ ],
397
+ },
398
+ },
399
+ });
400
+ expect(adapted[0]?.parts[0]).not.toMatchObject({
401
+ type: "tool-card",
402
+ card: {
403
+ outputData: expect.anything(),
404
+ },
405
+ });
406
+ });
407
+
408
+ it("uses an apply-patch preview as the single visible representation of the patch", () => {
409
+ const patch = [
410
+ "*** Begin Patch",
411
+ "*** Update File: src/app.ts",
412
+ "@@",
413
+ "-const color = 'red';",
414
+ "+const color = 'blue';",
415
+ "*** End Patch",
416
+ ].join("\n");
417
+ const adapted = adapt([
418
+ {
419
+ id: "assistant-apply-patch-result",
420
+ role: "assistant",
421
+ parts: [
422
+ {
423
+ type: "tool-invocation",
424
+ toolInvocation: {
425
+ status: ToolInvocationStatus.RESULT,
426
+ toolCallId: "apply-patch-result-1",
427
+ toolName: "apply_patch",
428
+ args: patch,
429
+ result: "Done!",
430
+ },
431
+ },
432
+ ],
433
+ },
434
+ ] as unknown as ChatMessageSource[]);
435
+
436
+ expect(adapted[0]?.parts[0]).toMatchObject({
437
+ type: "tool-card",
438
+ card: {
439
+ toolName: "apply_patch",
440
+ summary: "src/app.ts",
441
+ fileOperation: {
442
+ blocks: [
443
+ {
444
+ path: "src/app.ts",
445
+ lines: [
446
+ expect.objectContaining({ kind: "remove", text: "const color = 'red';" }),
447
+ expect.objectContaining({ kind: "add", text: "const color = 'blue';" }),
448
+ ],
449
+ },
450
+ ],
451
+ },
452
+ },
453
+ });
454
+ expect(adapted[0]?.parts[0]).not.toMatchObject({
455
+ type: "tool-card",
456
+ card: {
457
+ outputData: expect.anything(),
458
+ },
459
+ });
460
+ });
461
+
462
+ it("keeps raw file-tool results when no structured preview can be built", () => {
463
+ const adapted = adapt([
464
+ {
465
+ id: "assistant-read-result-without-path",
466
+ role: "assistant",
467
+ parts: [
468
+ {
469
+ type: "tool-invocation",
470
+ toolInvocation: {
471
+ status: ToolInvocationStatus.RESULT,
472
+ toolCallId: "read-result-without-path-1",
473
+ toolName: "read_file",
474
+ result: "raw file content",
475
+ },
476
+ },
477
+ ],
478
+ },
479
+ ] as unknown as ChatMessageSource[]);
480
+
481
+ expect(adapted[0]?.parts[0]).toMatchObject({
482
+ type: "tool-card",
483
+ card: {
484
+ toolName: "read_file",
485
+ outputData: "raw file content",
486
+ },
487
+ });
488
+ });
489
+
490
+ it("keeps file-tool errors visible even when input can build a preview", () => {
491
+ const adapted = adapt([
492
+ {
493
+ id: "assistant-write-error",
494
+ role: "assistant",
495
+ parts: [
496
+ {
497
+ type: "tool-invocation",
498
+ toolInvocation: {
499
+ status: ToolInvocationStatus.RESULT,
500
+ toolCallId: "write-error-1",
501
+ toolName: "write_file",
502
+ parsedArgs: {
503
+ path: "src/app.ts",
504
+ content: "const color = 'blue';",
505
+ },
506
+ error: "Permission denied",
507
+ result: "write failed",
508
+ },
509
+ },
510
+ ],
511
+ },
512
+ ] as unknown as ChatMessageSource[]);
513
+
514
+ expect(adapted[0]?.parts[0]).toMatchObject({
515
+ type: "tool-card",
516
+ card: {
517
+ toolName: "write_file",
518
+ statusTone: "error",
519
+ output: "Permission denied",
520
+ outputData: "write failed",
521
+ fileOperation: {
522
+ blocks: [expect.objectContaining({ path: "src/app.ts" })],
523
+ },
524
+ },
525
+ });
340
526
  });
@@ -1,6 +1,7 @@
1
1
  import { expect, it, vi } from "vitest";
2
2
  import {
3
3
  buildChatMessageExecutionLabels,
4
+ buildChatMessageTriggerLabels,
4
5
  buildChatMessageTexts,
5
6
  } from "@/features/chat/features/message/utils/chat-message-texts.utils";
6
7
 
@@ -25,6 +26,14 @@ it("provides localized status keys for built-in tool categories", () => {
25
26
  });
26
27
  });
27
28
 
29
+ it("provides localized labels for run trigger details", () => {
30
+ expect(buildChatMessageTriggerLabels("zh")).toMatchObject({
31
+ viewTrigger: "chatRunTriggerView",
32
+ fields: { sourceModel: "chatRunTriggerSourceModel" },
33
+ actors: { agent: "chatRunTriggerActorAgent" },
34
+ });
35
+ });
36
+
28
37
  it("provides localized labels for the AI execution metadata action", () => {
29
38
  expect(buildChatMessageExecutionLabels("zh")).toMatchObject({
30
39
  moreActions: "chatMessageMoreActions",
@@ -125,7 +125,9 @@ describe("chat message timeline visibility", () => {
125
125
  })).toBe(false);
126
126
  expect(isVisibleChatMessage(visibleMessage)).toBe(true);
127
127
  });
128
+ });
128
129
 
130
+ describe("chat message continuation projection", () => {
129
131
  it("projects continued assistant output into the interrupted message", () => {
130
132
  const messages = projectVisibleChatMessages([
131
133
  {
@@ -253,3 +255,72 @@ describe("chat message timeline visibility", () => {
253
255
  ]).map((message) => message.id)).toEqual(["user-1", "assistant-1"]);
254
256
  });
255
257
  });
258
+
259
+ describe("child session timeline visibility", () => {
260
+ it("does not repeat inherited compaction markers", () => {
261
+ const childMessage = {
262
+ ...visibleMessage,
263
+ id: "child-user",
264
+ role: "user" as const,
265
+ timestamp: "2026-08-24T15:50:23.415Z",
266
+ parts: [{ type: "text" as const, text: "child request" }],
267
+ };
268
+ const inheritedCompactions = Array.from({ length: 5 }, (_, index) => ({
269
+ ...visibleMessage,
270
+ id: `child-session:inherited:${index + 1}`,
271
+ role: "service" as const,
272
+ timestamp: `2026-08-24T15:0${index}:00.000Z`,
273
+ parts: [{ type: "text" as const, text: "Earlier context was auto-compacted" }],
274
+ metadata: {
275
+ inherited_from_session_id: "parent-session",
276
+ inherited_from_message_id: `parent-compaction-${index + 1}`,
277
+ nextclaw_timeline_kind: "context_compaction",
278
+ checkpoint: {
279
+ id: "parent-checkpoint",
280
+ status: "compressed",
281
+ summary: "Compressed parent context",
282
+ coveredMessageCount: (index + 1) * 100,
283
+ coveredSessionMessageCount: (index + 1) * 100,
284
+ originalEstimatedTokens: 76_000,
285
+ projectedEstimatedTokens: 51_000,
286
+ createdAt: "2026-08-24T15:00:00.000Z",
287
+ updatedAt: `2026-08-24T15:0${index}:00.000Z`,
288
+ },
289
+ },
290
+ } satisfies NcpMessage));
291
+ const items = buildChatMessageTimelineItems({
292
+ rawMessages: [...inheritedCompactions, childMessage],
293
+ messages: [{ id: childMessage.id } as never],
294
+ });
295
+
296
+ expect(items.map((item) => item.kind)).toEqual([
297
+ "context-inheritance",
298
+ "message",
299
+ ]);
300
+
301
+ const childCompaction = {
302
+ ...inheritedCompactions[0]!,
303
+ id: "child-compaction",
304
+ metadata: {
305
+ ...inheritedCompactions[0]!.metadata,
306
+ inherited_from_session_id: undefined,
307
+ inherited_from_message_id: undefined,
308
+ },
309
+ } satisfies NcpMessage;
310
+ const childAssistant = {
311
+ ...visibleMessage,
312
+ id: "child-assistant",
313
+ timestamp: "2026-08-24T15:51:00.000Z",
314
+ };
315
+
316
+ expect(buildChatMessageTimelineItems({
317
+ rawMessages: [...inheritedCompactions, childMessage, childCompaction, childAssistant],
318
+ messages: [{ id: childMessage.id } as never, { id: childAssistant.id } as never],
319
+ }).map((item) => item.kind)).toEqual([
320
+ "context-inheritance",
321
+ "message",
322
+ "compaction",
323
+ "message",
324
+ ]);
325
+ });
326
+ });