@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
@@ -78,10 +78,12 @@ export function ChatSessionWorkspaceFileBreadcrumbs({
78
78
  breadcrumb,
79
79
  leading,
80
80
  onFileOpen,
81
+ trailing,
81
82
  }: {
82
83
  breadcrumb: WorkspaceFileBreadcrumbViewModel;
83
84
  leading?: ReactNode;
84
85
  onFileOpen: (action: ChatFileOpenActionViewModel) => void;
86
+ trailing?: ReactNode;
85
87
  }) {
86
88
  const scrollRef = useRef<HTMLDivElement>(null);
87
89
 
@@ -138,6 +140,7 @@ export function ChatSessionWorkspaceFileBreadcrumbs({
138
140
  </div>
139
141
  ) : null}
140
142
  </div>
143
+ {trailing ? <div className="flex shrink-0 items-center py-1.5 pr-2 pl-0.5">{trailing}</div> : null}
141
144
  </div>
142
145
  );
143
146
  }
@@ -1,4 +1,4 @@
1
- import { useMemo, type ReactNode } from 'react';
1
+ import { useMemo, useRef, type ReactNode, type RefObject } from 'react';
2
2
  import type { ChatFileOpenActionViewModel, ChatFileOperationBlockViewModel } from '@nextclaw/agent-chat-ui';
3
3
  import { NextClawClientError } from '@nextclaw/client-sdk';
4
4
  import { ChatMessageMarkdown, FileOperationCodeSurface } from '@nextclaw/agent-chat-ui';
@@ -23,6 +23,7 @@ import { t } from '@/shared/lib/i18n';
23
23
  import { buildWorkspaceFileBreadcrumb, resolveWorkspaceRelativePath } from '@/shared/lib/session-project';
24
24
  import { cn } from '@/shared/lib/utils';
25
25
  import { resolveWorkspaceFileViewer } from '@/features/chat/features/workspace/utils/chat-workspace-file-viewer.utils';
26
+ import { WorkspaceMarkdownOutline } from './workspace-markdown-outline';
26
27
 
27
28
  function inferPreviewKind(params: {
28
29
  path: string;
@@ -157,6 +158,7 @@ function WorkspacePreviewBody({
157
158
  fileBasePath,
158
159
  onFileOpen,
159
160
  onHtmlContentHeightChange,
161
+ markdownScrollRef,
160
162
  previewBlock,
161
163
  previewKind,
162
164
  previewViewer,
@@ -173,6 +175,7 @@ function WorkspacePreviewBody({
173
175
  fileBasePath: string | null;
174
176
  onFileOpen: (action: ChatFileOpenActionViewModel) => void;
175
177
  onHtmlContentHeightChange?: (height: number) => void;
178
+ markdownScrollRef: RefObject<HTMLDivElement>;
176
179
  previewBlock: ChatFileOperationBlockViewModel | null;
177
180
  previewKind: 'text' | 'markdown' | 'binary';
178
181
  previewViewer: 'source' | 'rendered' | null;
@@ -221,7 +224,7 @@ function WorkspacePreviewBody({
221
224
 
222
225
  if (previewKind === 'markdown' && previewViewer !== 'source' && previewText) {
223
226
  return (
224
- <div className="h-full overflow-auto custom-scrollbar px-5 py-4">
227
+ <div ref={markdownScrollRef} className="h-full overflow-auto custom-scrollbar px-5 py-4">
225
228
  <ChatMessageMarkdown
226
229
  text={previewText}
227
230
  role="assistant"
@@ -266,6 +269,7 @@ export function ChatSessionWorkspaceFilePreview({
266
269
  onFileOpen,
267
270
  onTextExcerptAdd,
268
271
  }: ChatSessionWorkspaceFilePreviewProps) {
272
+ const markdownScrollRef = useRef<HTMLDivElement>(null);
269
273
  const isPreviewMode = file.viewMode === 'preview';
270
274
  const suppliedContentUrl = file.contentUrl?.trim() || null;
271
275
  const usesServerPath = isPreviewMode && !suppliedContentUrl;
@@ -360,6 +364,7 @@ export function ChatSessionWorkspaceFilePreview({
360
364
  fileBasePath={sessionWorkingDir}
361
365
  onFileOpen={onFileOpen}
362
366
  onHtmlContentHeightChange={onHtmlContentHeightChange}
367
+ markdownScrollRef={markdownScrollRef}
363
368
  previewBlock={previewBlock}
364
369
  previewKind={previewKind}
365
370
  previewViewer={previewViewer}
@@ -377,6 +382,19 @@ export function ChatSessionWorkspaceFilePreview({
377
382
  breadcrumb={breadcrumb}
378
383
  leading={breadcrumbLeading}
379
384
  onFileOpen={onFileOpen}
385
+ trailing={
386
+ isPreviewMode &&
387
+ previewKind === 'markdown' &&
388
+ previewViewer !== 'source' &&
389
+ previewText &&
390
+ !contentUrl ? (
391
+ <WorkspaceMarkdownOutline
392
+ contentKey={previewText}
393
+ documentKey={resolvedPath}
394
+ scrollContainerRef={markdownScrollRef}
395
+ />
396
+ ) : undefined
397
+ }
380
398
  />
381
399
  ) : null}
382
400
 
@@ -202,40 +202,6 @@ function WorkspaceProjectFiles({
202
202
  );
203
203
  }
204
204
 
205
- function ChildSessionMetaChip({ value }: { value: string }) {
206
- return (
207
- <span className="inline-flex max-w-full shrink-0 items-center rounded border border-gray-200 bg-gray-50 px-1.5 py-0.5 text-[10px] font-medium text-gray-600">
208
- <span className="truncate">{value}</span>
209
- </span>
210
- );
211
- }
212
-
213
- function ChildSessionMetaStrip({ tab }: { tab: ResolvedChildSessionTab }) {
214
- const metaItems = [tab.sessionTypeLabel, tab.preferredModel, tab.projectName].filter((value): value is string =>
215
- Boolean(value?.trim()),
216
- );
217
-
218
- if (metaItems.length === 0 && !tab.projectRoot) {
219
- return null;
220
- }
221
-
222
- return (
223
- <div className="flex min-h-9 min-w-0 items-center gap-1.5 border-b border-gray-200/70 px-3 py-1.5 text-[11px] text-gray-500">
224
- {metaItems.map((item) => (
225
- <ChildSessionMetaChip key={item} value={item} />
226
- ))}
227
- {tab.projectRoot ? (
228
- <span
229
- title={tab.projectRoot}
230
- className="min-w-0 flex-1 truncate rounded border border-gray-200 bg-gray-50 px-1.5 py-0.5 font-mono text-[10px] text-gray-500"
231
- >
232
- {tab.projectRoot}
233
- </span>
234
- ) : null}
235
- </div>
236
- );
237
- }
238
-
239
205
  function WorkspaceSideChatDraftHeader() {
240
206
  return (
241
207
  <div className="border-b border-gray-200/70 px-4 py-3">
@@ -307,12 +273,9 @@ function WorkspaceSelectedContent({
307
273
 
308
274
  if (activeSelection.kind === 'child-session') {
309
275
  return (
310
- <>
311
- <ChildSessionMetaStrip tab={activeSelection.tab} />
312
- <div className="flex min-h-0 flex-1 flex-col">
313
- <SessionConversationArea sessionKey={activeSelection.tab.sessionKey} />
314
- </div>
315
- </>
276
+ <div className="flex min-h-0 flex-1 flex-col">
277
+ <SessionConversationArea sessionKey={activeSelection.tab.sessionKey} />
278
+ </div>
316
279
  );
317
280
  }
318
281
 
@@ -0,0 +1,156 @@
1
+ import {
2
+ useEffect,
3
+ useRef,
4
+ useState,
5
+ type RefObject,
6
+ } from "react";
7
+ import { ListTree } from "lucide-react";
8
+ import { ChatPopoverContent } from "@/features/chat/components/chat-popover-content";
9
+ import { IconActionButton } from "@/shared/components/ui/actions/icon-action-button";
10
+ import { Popover, PopoverTrigger } from "@/shared/components/ui/popover";
11
+ import { t } from "@/shared/lib/i18n";
12
+
13
+ const HEADING_SELECTOR = "h1, h2, h3, h4, h5, h6";
14
+ const HEADING_INDEX_ATTRIBUTE = "data-workspace-markdown-heading-index";
15
+ const HEADING_TOP_GAP = 16;
16
+
17
+ type WorkspaceMarkdownOutlineItem = {
18
+ depth: number;
19
+ index: number;
20
+ label: string;
21
+ };
22
+
23
+ function collectWorkspaceMarkdownOutline(
24
+ container: HTMLElement,
25
+ ): WorkspaceMarkdownOutlineItem[] {
26
+ const levelStack: number[] = [];
27
+ const items: WorkspaceMarkdownOutlineItem[] = [];
28
+
29
+ container
30
+ .querySelectorAll<HTMLElement>(HEADING_SELECTOR)
31
+ .forEach((heading) => {
32
+ heading.removeAttribute(HEADING_INDEX_ATTRIBUTE);
33
+ const label = heading.textContent?.replace(/\s+/g, " ").trim();
34
+ if (!label) return;
35
+
36
+ const level = Number(heading.tagName.slice(1));
37
+ while (
38
+ levelStack.length > 0 &&
39
+ levelStack[levelStack.length - 1]! >= level
40
+ ) {
41
+ levelStack.pop();
42
+ }
43
+
44
+ const index = items.length;
45
+ heading.setAttribute(HEADING_INDEX_ATTRIBUTE, String(index));
46
+ items.push({ depth: levelStack.length, index, label });
47
+ levelStack.push(level);
48
+ });
49
+
50
+ return items;
51
+ }
52
+
53
+ export function WorkspaceMarkdownOutline({
54
+ contentKey,
55
+ documentKey,
56
+ scrollContainerRef,
57
+ }: {
58
+ contentKey: string;
59
+ documentKey: string;
60
+ scrollContainerRef: RefObject<HTMLDivElement>;
61
+ }) {
62
+ const [items, setItems] = useState<WorkspaceMarkdownOutlineItem[]>([]);
63
+ const [open, setOpen] = useState(false);
64
+ const outlineScrollRef = useRef<HTMLElement | null>(null);
65
+ const outlineScrollTopRef = useRef(0);
66
+
67
+ useEffect(() => {
68
+ outlineScrollTopRef.current = 0;
69
+ setOpen(false);
70
+ const container = scrollContainerRef.current;
71
+ if (!container) {
72
+ setItems([]);
73
+ return;
74
+ }
75
+
76
+ setItems(collectWorkspaceMarkdownOutline(container));
77
+ return () => {
78
+ container
79
+ .querySelectorAll<HTMLElement>(`[${HEADING_INDEX_ATTRIBUTE}]`)
80
+ .forEach((heading) => heading.removeAttribute(HEADING_INDEX_ATTRIBUTE));
81
+ };
82
+ }, [contentKey, documentKey, scrollContainerRef]);
83
+
84
+ if (items.length === 0) return null;
85
+
86
+ const label = t("chatWorkspaceMarkdownOutline");
87
+ const handleOpenChange = (nextOpen: boolean) => {
88
+ if (!nextOpen && outlineScrollRef.current) {
89
+ outlineScrollTopRef.current = outlineScrollRef.current.scrollTop;
90
+ }
91
+ setOpen(nextOpen);
92
+ };
93
+ const handleSelect = (index: number) => {
94
+ const container = scrollContainerRef.current;
95
+ const heading = container?.querySelector<HTMLElement>(
96
+ `[${HEADING_INDEX_ATTRIBUTE}="${index}"]`,
97
+ );
98
+ if (!container || !heading) return;
99
+
100
+ handleOpenChange(false);
101
+ const top =
102
+ container.scrollTop +
103
+ heading.getBoundingClientRect().top -
104
+ container.getBoundingClientRect().top;
105
+ container.scrollTo({
106
+ top: Math.max(0, top - HEADING_TOP_GAP),
107
+ });
108
+ };
109
+
110
+ return (
111
+ <Popover open={open} onOpenChange={handleOpenChange}>
112
+ <PopoverTrigger asChild>
113
+ <IconActionButton
114
+ icon={<ListTree className="h-3.5 w-3.5" />}
115
+ label={label}
116
+ size="sm"
117
+ tone="surface"
118
+ className="rounded-sm data-[state=open]:bg-gray-200/80 data-[state=open]:text-gray-900"
119
+ />
120
+ </PopoverTrigger>
121
+
122
+ <ChatPopoverContent
123
+ align="end"
124
+ className="w-[min(20rem,calc(100vw-1.5rem))] rounded-lg p-1.5"
125
+ data-testid="workspace-markdown-outline-popover"
126
+ >
127
+ <nav
128
+ ref={(node) => {
129
+ outlineScrollRef.current = node;
130
+ if (node) node.scrollTop = outlineScrollTopRef.current;
131
+ }}
132
+ aria-label={label}
133
+ className="max-h-80 overflow-y-auto custom-scrollbar"
134
+ >
135
+ <ul className="space-y-0.5">
136
+ {items.map((item) => (
137
+ <li key={item.index}>
138
+ <button
139
+ type="button"
140
+ className="block w-full truncate rounded-md py-1.5 pr-2 text-left text-xs leading-4 text-gray-600 transition-colors hover:bg-gray-100 hover:text-gray-950 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-border active:bg-gray-200"
141
+ style={{
142
+ paddingInlineStart: `${0.5 + item.depth * 0.75}rem`,
143
+ }}
144
+ title={item.label}
145
+ onClick={() => handleSelect(item.index)}
146
+ >
147
+ {item.label}
148
+ </button>
149
+ </li>
150
+ ))}
151
+ </ul>
152
+ </nav>
153
+ </ChatPopoverContent>
154
+ </Popover>
155
+ );
156
+ }
@@ -117,6 +117,76 @@ describe("ChatCompletionNotificationManager", () => {
117
117
  expect(show).toHaveBeenCalledOnce();
118
118
  });
119
119
 
120
+ it("notifies only for human-triggered background runs", () => {
121
+ manager.start();
122
+ emit(createCompletedEvent(createMessage({
123
+ id: "assistant-agent",
124
+ metadata: {
125
+ run_trigger: {
126
+ version: 1,
127
+ actor: "agent",
128
+ source: "sessions_spawn",
129
+ triggeredAt: "2026-08-05T09:59:00.000Z",
130
+ targetRunId: "run-agent",
131
+ },
132
+ },
133
+ })));
134
+ emit(createCompletedEvent(createMessage({
135
+ id: "assistant-automation",
136
+ metadata: {
137
+ run_trigger: {
138
+ version: 1,
139
+ actor: "automation",
140
+ source: "observation",
141
+ triggeredAt: "2026-08-05T09:59:00.000Z",
142
+ targetRunId: "run-automation",
143
+ },
144
+ },
145
+ })));
146
+ emit(createCompletedEvent(createMessage({
147
+ id: "assistant-human",
148
+ metadata: {
149
+ run_trigger: {
150
+ version: 1,
151
+ actor: "human",
152
+ source: "ui-http",
153
+ triggeredAt: "2026-08-05T09:59:00.000Z",
154
+ targetRunId: "run-human",
155
+ },
156
+ },
157
+ })));
158
+
159
+ expect(show).toHaveBeenCalledOnce();
160
+ expect(show).toHaveBeenCalledWith(expect.objectContaining({
161
+ id: "chat-reply:assistant-human",
162
+ }));
163
+ });
164
+
165
+ it("silences legacy child replies without changing legacy top-level behavior", () => {
166
+ useChatQueryStore.setState({
167
+ snapshot: {
168
+ sessionsQuery: {
169
+ data: {
170
+ sessions: [{
171
+ sessionId: "session-background",
172
+ messageCount: 2,
173
+ updatedAt: "2026-08-05T10:00:00.000Z",
174
+ metadata: { parent_session_id: "parent-session" },
175
+ }],
176
+ total: 1,
177
+ },
178
+ } as never,
179
+ },
180
+ });
181
+ manager.start();
182
+ emit(createCompletedEvent());
183
+ expect(show).not.toHaveBeenCalled();
184
+
185
+ useChatQueryStore.setState({ snapshot: {} });
186
+ emit(createCompletedEvent(createMessage({ id: "legacy-top-level" })));
187
+ expect(show).toHaveBeenCalledOnce();
188
+ });
189
+
120
190
  it("ignores non-final, non-assistant, hidden, and unrelated events", () => {
121
191
  manager.start();
122
192
  emit(createCompletedEvent(createMessage({ status: "streaming" })));
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  isHiddenNcpMessage,
3
3
  NcpEventType,
4
+ readNcpRunTriggerMetadata,
4
5
  type NcpEndpointEvent,
5
6
  type NcpMessage,
6
7
  } from "@nextclaw/ncp";
@@ -16,6 +17,14 @@ import { t } from "@/shared/lib/i18n";
16
17
  const NOTIFICATION_PREVIEW_MAX_CHARACTERS = 120;
17
18
  const NOTIFIED_MESSAGE_HISTORY_LIMIT = 200;
18
19
 
20
+ function isChildSessionMetadata(
21
+ metadata: Record<string, unknown> | null | undefined,
22
+ ): boolean {
23
+ return [metadata?.parent_session_id, metadata?.parentSessionId].some(
24
+ (value) => typeof value === "string" && value.trim().length > 0,
25
+ );
26
+ }
27
+
19
28
  function buildNotificationPreview(value: string): string {
20
29
  const normalized = value
21
30
  .replace(/\r\n?/gu, "\n")
@@ -119,7 +128,18 @@ export class ChatCompletionNotificationManager {
119
128
  return;
120
129
  }
121
130
 
122
- const title = this.resolveSessionTitle(sessionId);
131
+ const summary = this.resolveSessionSummary(sessionId);
132
+ const trigger = readNcpRunTriggerMetadata(message.metadata);
133
+ if (
134
+ (trigger && trigger.actor !== "human") ||
135
+ (!trigger && summary && isChildSessionMetadata(summary.metadata))
136
+ ) {
137
+ return;
138
+ }
139
+
140
+ const title = summary
141
+ ? sessionDisplayName(adaptNcpSessionSummary(summary))
142
+ : t("chatBackgroundReplyFallbackTitle");
123
143
  const preview = readAssistantReplyPreview(message);
124
144
  this.notificationManager.show({
125
145
  id: `chat-reply:${message.id}`,
@@ -130,13 +150,10 @@ export class ChatCompletionNotificationManager {
130
150
  });
131
151
  };
132
152
 
133
- private readonly resolveSessionTitle = (sessionId: string): string => {
153
+ private readonly resolveSessionSummary = (sessionId: string) => {
134
154
  const summaries =
135
155
  useChatQueryStore.getState().snapshot.sessionsQuery?.data?.sessions ?? [];
136
- const summary = summaries.find((item) => item.sessionId === sessionId);
137
- return summary
138
- ? sessionDisplayName(adaptNcpSessionSummary(summary))
139
- : t("chatBackgroundReplyFallbackTitle");
156
+ return summaries.find((item) => item.sessionId === sessionId);
140
157
  };
141
158
 
142
159
  private readonly rememberHandledMessage = (messageId: string): void => {
@@ -6,7 +6,16 @@ const mocks = vi.hoisted(() => ({ mutate: vi.fn() }));
6
6
 
7
7
  vi.mock('@/shared/hooks/use-config', () => ({
8
8
  useConfig: () => ({
9
- data: { productAnalytics: { enabled: false, audience: 'external' } },
9
+ data: { productAnalytics: { schemaVersion: 2, enabled: true, audience: 'external' } },
10
+ isLoading: false
11
+ }),
12
+ useProductAnalyticsStatus: () => ({
13
+ data: {
14
+ lastAttemptAt: '2026-08-25T12:00:00.000Z',
15
+ lastSuccessAt: '2026-08-25T12:00:01.000Z',
16
+ lastError: null,
17
+ pendingReceiptCount: 0
18
+ },
10
19
  isLoading: false
11
20
  }),
12
21
  useUpdateProductAnalytics: () => ({
@@ -18,13 +27,13 @@ vi.mock('@/shared/hooks/use-config', () => ({
18
27
  describe('PrivacySettingsPage', () => {
19
28
  beforeEach(() => mocks.mutate.mockClear());
20
29
 
21
- it('requires explicit opt-in and saves the selected test audience', () => {
30
+ it('shows anonymous reporting enabled by default and saves opt-out and test audience', () => {
22
31
  render(<PrivacySettingsPage />);
23
32
 
24
- const shareSwitch = screen.getByRole('switch', { name: 'Share product activity' });
25
- expect(shareSwitch.getAttribute('aria-checked')).toBe('false');
33
+ const shareSwitch = screen.getByRole('switch', { name: 'Send anonymous usage analytics' });
34
+ expect(shareSwitch.getAttribute('aria-checked')).toBe('true');
26
35
  fireEvent.click(shareSwitch);
27
- expect(mocks.mutate).toHaveBeenCalledWith({ data: { enabled: true } });
36
+ expect(mocks.mutate).toHaveBeenCalledWith({ data: { enabled: false } });
28
37
 
29
38
  fireEvent.click(screen.getByRole('combobox', { name: 'Analytics audience' }));
30
39
  fireEvent.click(screen.getByRole('option', { name: 'QA testing' }));
@@ -2,14 +2,19 @@ import { SettingsPage } from '@/shared/components/settings/settings-page';
2
2
  import { SettingRow, SettingsGroup, SettingsSection } from '@/shared/components/settings/setting-row';
3
3
  import { Switch } from '@/shared/components/ui/switch';
4
4
  import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/components/ui/select';
5
- import { useConfig, useUpdateProductAnalytics } from '@/shared/hooks/use-config';
5
+ import {
6
+ useConfig,
7
+ useProductAnalyticsStatus,
8
+ useUpdateProductAnalytics
9
+ } from '@/shared/hooks/use-config';
6
10
  import type { ProductAnalyticsAudience } from '@/shared/lib/api';
7
11
  import { t } from '@/shared/lib/i18n';
8
12
 
9
13
  export function PrivacySettingsPage(): JSX.Element {
10
14
  const configQuery = useConfig();
15
+ const statusQuery = useProductAnalyticsStatus();
11
16
  const updateMutation = useUpdateProductAnalytics();
12
- const enabled = configQuery.data?.productAnalytics.enabled ?? false;
17
+ const enabled = configQuery.data?.productAnalytics.enabled ?? true;
13
18
  const audience = configQuery.data?.productAnalytics.audience ?? 'external';
14
19
 
15
20
  const updateEnabled = (nextEnabled: boolean) => {
@@ -78,6 +83,37 @@ export function PrivacySettingsPage(): JSX.Element {
78
83
  />
79
84
  </SettingsGroup>
80
85
  </SettingsSection>
86
+
87
+ <SettingsSection title={t('productAnalyticsStatusSectionTitle')}>
88
+ <SettingsGroup>
89
+ <SettingRow
90
+ layout='stacked'
91
+ title={t('productAnalyticsStatusTitle')}
92
+ description={formatProductAnalyticsStatus(statusQuery.data, statusQuery.isLoading)}
93
+ />
94
+ {statusQuery.data?.lastError ? (
95
+ <SettingRow
96
+ layout='stacked'
97
+ title={t('productAnalyticsLastErrorTitle')}
98
+ description={statusQuery.data.lastError}
99
+ />
100
+ ) : null}
101
+ </SettingsGroup>
102
+ </SettingsSection>
81
103
  </SettingsPage>
82
104
  );
83
105
  }
106
+
107
+ function formatProductAnalyticsStatus(
108
+ status: ReturnType<typeof useProductAnalyticsStatus>['data'],
109
+ isLoading: boolean,
110
+ ): string {
111
+ if (isLoading) return t('productAnalyticsStatusLoading');
112
+ if (!status?.lastAttemptAt) return t('productAnalyticsStatusNever');
113
+ const attemptedAt = new Date(status.lastAttemptAt).toLocaleString();
114
+ if (!status.lastSuccessAt) {
115
+ return `${t('productAnalyticsLastAttemptLabel')} ${attemptedAt} · ${status.pendingReceiptCount} ${t('productAnalyticsPendingLabel')}`;
116
+ }
117
+ const succeededAt = new Date(status.lastSuccessAt).toLocaleString();
118
+ return `${t('productAnalyticsLastSuccessLabel')} ${succeededAt} · ${status.pendingReceiptCount} ${t('productAnalyticsPendingLabel')}`;
119
+ }
@@ -75,14 +75,7 @@ const IconActionButton = React.forwardRef<HTMLButtonElement, IconActionButtonPro
75
75
  return (
76
76
  <TooltipProvider delayDuration={250}>
77
77
  <Tooltip disableHoverableContent>
78
- <TooltipTrigger
79
- asChild
80
- onFocus={(event) => {
81
- if (!event.currentTarget.matches(':focus-visible')) {
82
- event.preventDefault();
83
- }
84
- }}
85
- >
78
+ <TooltipTrigger asChild>
86
79
  {disabled ? <span className="inline-flex">{button}</span> : button}
87
80
  </TooltipTrigger>
88
81
  <TooltipContent side={tooltipSide} className="text-xs">{content}</TooltipContent>
@@ -7,7 +7,22 @@ const TooltipProvider = TooltipPrimitive.Provider;
7
7
 
8
8
  const Tooltip = TooltipPrimitive.Root;
9
9
 
10
- const TooltipTrigger = TooltipPrimitive.Trigger;
10
+ const TooltipTrigger = React.forwardRef<
11
+ React.ElementRef<typeof TooltipPrimitive.Trigger>,
12
+ React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Trigger>
13
+ >(({ onFocus, ...props }, ref) => (
14
+ <TooltipPrimitive.Trigger
15
+ ref={ref}
16
+ onFocus={(event) => {
17
+ onFocus?.(event);
18
+ if (!event.defaultPrevented && !event.currentTarget.matches(':focus-visible')) {
19
+ event.preventDefault();
20
+ }
21
+ }}
22
+ {...props}
23
+ />
24
+ ));
25
+ TooltipTrigger.displayName = TooltipPrimitive.Trigger.displayName;
11
26
 
12
27
  const TooltipContent = React.forwardRef<
13
28
  React.ElementRef<typeof TooltipPrimitive.Content>,
@@ -19,6 +19,7 @@ import {
19
19
  updateChannel,
20
20
  updateRuntime,
21
21
  updateProductAnalytics,
22
+ fetchProductAnalyticsStatus,
22
23
  updateSecrets,
23
24
  executeConfigAction
24
25
  } from '@/shared/lib/api';
@@ -43,6 +44,15 @@ export function useConfigMeta() {
43
44
  });
44
45
  }
45
46
 
47
+ export function useProductAnalyticsStatus() {
48
+ return useQuery({
49
+ queryKey: ['product-analytics-status'],
50
+ queryFn: fetchProductAnalyticsStatus,
51
+ staleTime: 15_000,
52
+ refetchOnWindowFocus: true
53
+ });
54
+ }
55
+
46
56
  export function useProviders() {
47
57
  return useQuery({
48
58
  queryKey: ['providers'],
@@ -108,6 +118,7 @@ export function useUpdateProductAnalytics() {
108
118
  queryClient.setQueryData<ConfigView>(['config'], (current) => current
109
119
  ? { ...current, productAnalytics }
110
120
  : current);
121
+ queryClient.invalidateQueries({ queryKey: ['product-analytics-status'] });
111
122
  toast.success(t('configSavedApplied'));
112
123
  },
113
124
  onError: (error: Error) => {
@@ -638,6 +638,7 @@ export type SecretsConfigUpdate = {
638
638
  export type ProductAnalyticsAudience = 'external' | 'internal' | 'qa';
639
639
 
640
640
  export type ProductAnalyticsView = {
641
+ schemaVersion: 2;
641
642
  enabled: boolean;
642
643
  audience: ProductAnalyticsAudience;
643
644
  };
@@ -647,6 +648,13 @@ export type ProductAnalyticsConfigUpdate = {
647
648
  audience?: ProductAnalyticsAudience;
648
649
  };
649
650
 
651
+ export type ProductAnalyticsStatusView = {
652
+ lastAttemptAt: string | null;
653
+ lastSuccessAt: string | null;
654
+ lastError: string | null;
655
+ pendingReceiptCount: number;
656
+ };
657
+
650
658
  export type ChannelConfigUpdate = Record<string, unknown>;
651
659
 
652
660
  export type ConfigView = {
@@ -31,6 +31,7 @@ import type {
31
31
  ProviderCreateResult,
32
32
  ProviderDeleteResult,
33
33
  ProductAnalyticsConfigUpdate,
34
+ ProductAnalyticsStatusView,
34
35
  ProductAnalyticsView,
35
36
  RuntimeConfigUpdate,
36
37
  SecretsConfigUpdate,
@@ -178,6 +179,10 @@ export async function updateProductAnalytics(
178
179
  return await nextclawClient.config.updateProductAnalytics(data);
179
180
  }
180
181
 
182
+ export async function fetchProductAnalyticsStatus(): Promise<ProductAnalyticsStatusView> {
183
+ return await nextclawClient.config.fetchProductAnalyticsStatus();
184
+ }
185
+
181
186
  export async function executeConfigAction(
182
187
  actionId: string,
183
188
  data: ConfigActionExecuteRequest