@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.
- package/CHANGELOG.md +36 -0
- package/dist/assets/{api-C2iYhdTe.js → api-DvpQWjTS.js} +3 -3
- package/dist/assets/{appearance-settings-page-BwQDQr0n.js → appearance-settings-page-U0m0ktu3.js} +1 -1
- package/dist/assets/{book-open-C0IlT9zs.js → book-open-DRofSTWr.js} +1 -1
- package/dist/assets/channels-list-page-BURQarD0.js +8 -0
- package/dist/assets/{config-split-page-VDTf3zZb.js → config-split-page-BrAQYTIl.js} +1 -1
- package/dist/assets/{confirm-dialog-BI_Vt2s_.js → confirm-dialog-DDJomLnO.js} +1 -1
- package/dist/assets/{createLucideIcon-Bydi9Gc5.js → createLucideIcon-DzkNHr5Z.js} +1 -1
- package/dist/assets/{desktop-update-config-DVoD6NxG.js → desktop-update-config-BzZFhMv9.js} +1 -1
- package/dist/assets/{dist-CmHtkFbs.js → dist-DXbLo2b1.js} +1 -1
- package/dist/assets/{dist-A5btQciA.js → dist-NskPB_yW.js} +1 -1
- package/dist/assets/doc-browser-DB22tQKU.js +1 -0
- package/dist/assets/{doc-browser-context-Bxw0ora-.js → doc-browser-context-CnKV4gJy.js} +1 -1
- package/dist/assets/doc-browser-sJjw9fl5.js +1 -0
- package/dist/assets/{extensions-CG4voDCh.js → extensions-DvlFMpAs.js} +1 -1
- package/dist/assets/{form-actions-lv47uGIB.js → form-actions-UnPEZBkx.js} +1 -1
- package/dist/assets/index-Dc_ZMkmo.css +1 -0
- package/dist/assets/index-N1HnesBp.js +109 -0
- package/dist/assets/loader-circle-BlPXxgDB.js +1 -0
- package/dist/assets/mcp-marketplace-page-BIgFTbMq.js +9 -0
- package/dist/assets/mcp-marketplace-page-CZN4ovdK.js +1 -0
- package/dist/assets/model-config-page-CnSm7kfa.js +1 -0
- package/dist/assets/{navigation-link-D0agTj5t.js → navigation-link-NjUdG6Vp.js} +1 -1
- package/dist/assets/plus-DVXol9Q5.js +1 -0
- package/dist/assets/privacy-settings-page-DVn2HU7W.js +1 -0
- package/dist/assets/providers-config-page-CzHJS3eg.js +1 -0
- package/dist/assets/react-DCVbGroo.js +8 -0
- package/dist/assets/{refresh-cw-DjgY2Eju.js → refresh-cw-CkeninE0.js} +1 -1
- package/dist/assets/remote-B2lsJF1c.js +1 -0
- package/dist/assets/{rotate-cw-Dot4PVh0.js → rotate-cw-DP33PuXU.js} +1 -1
- package/dist/assets/runtime-config-page-D69buyGp.js +1 -0
- package/dist/assets/{save-B-jdTe_c.js → save-Cw2Xz-pB.js} +1 -1
- package/dist/assets/{search-CEp2AIiV.js → search-C2CFAVvd.js} +1 -1
- package/dist/assets/{search-config-page-Dp5guS5O.js → search-config-page-dqNT0Swf.js} +1 -1
- package/dist/assets/{secrets-config-page-C7nRMjiv.js → secrets-config-page-De6J3wVx.js} +2 -2
- package/dist/assets/security-config-Dcu9C4OL.js +1 -0
- package/dist/assets/{select-BNA3cma8.js → select-DSuMhwVP.js} +1 -1
- package/dist/assets/skeleton-QoWydNLp.js +1 -0
- package/dist/assets/{tag-chip-DmCNi4-9.js → tag-chip-DFBLgsGf.js} +1 -1
- package/dist/assets/x-BXvAzpjb.js +1 -0
- package/dist/index.html +21 -21
- package/package.json +8 -8
- package/src/features/chat/components/layout/__tests__/chat-sidebar-read-state.test.tsx +4 -3
- package/src/features/chat/components/layout/__tests__/chat-sidebar-session-area.test.tsx +11 -4
- package/src/features/chat/components/layout/__tests__/chat-sidebar.test.tsx +17 -17
- package/src/features/chat/components/layout/chat-sidebar-desktop-layout.tsx +17 -5
- package/src/features/chat/components/layout/chat-sidebar.tsx +28 -21
- package/src/features/chat/features/conversation/hooks/__tests__/use-session-conversation-controller.test.tsx +41 -1
- package/src/features/chat/features/conversation/hooks/use-session-pending-input-actions.ts +22 -4
- package/src/features/chat/features/message/components/chat-message-list.container.tsx +33 -4
- package/src/features/chat/features/message/utils/__tests__/chat-message-file-operation.utils.test.ts +186 -0
- package/src/features/chat/features/message/utils/__tests__/chat-message-texts.utils.test.ts +9 -0
- package/src/features/chat/features/message/utils/__tests__/chat-message-timeline.utils.test.ts +71 -0
- package/src/features/chat/features/message/utils/__tests__/chat-message-trigger-details.utils.test.ts +147 -0
- package/src/features/chat/features/message/utils/chat-message-part.utils.ts +1 -1
- package/src/features/chat/features/message/utils/chat-message-texts.utils.ts +36 -0
- package/src/features/chat/features/message/utils/chat-message-trigger-details.utils.ts +118 -0
- package/src/features/chat/features/ncp/hooks/__tests__/use-ncp-agent-runtime-queue.test.tsx +50 -0
- package/src/features/chat/features/ncp/hooks/__tests__/use-ncp-agent-runtime.test.tsx +90 -1
- package/src/features/chat/features/ncp/hooks/__tests__/use-ncp-session-list-view.test.tsx +24 -3
- package/src/features/chat/features/ncp/hooks/use-ncp-session-list-view.ts +35 -19
- package/src/features/chat/features/session/components/__tests__/chat-sidebar-project-groups.test.tsx +26 -2
- package/src/features/chat/features/session/components/__tests__/chat-sidebar-session-item.test.tsx +55 -27
- package/src/features/chat/features/session/components/chat-sidebar-context-card.tsx +60 -0
- package/src/features/chat/features/session/components/chat-sidebar-project-groups.tsx +95 -54
- package/src/features/chat/features/session/components/chat-sidebar-session-entry.tsx +9 -8
- package/src/features/chat/features/session/components/chat-sidebar-session-item.tsx +141 -95
- package/src/features/chat/features/session/components/chat-sidebar-session-list.tsx +11 -2
- package/src/features/chat/features/session/hooks/use-chat-sidebar-context-counts.ts +40 -0
- package/src/features/chat/features/session/utils/ncp-session-context-metadata.utils.ts +5 -1
- package/src/features/chat/features/workspace/components/__tests__/chat-session-workspace-file-preview.test.tsx +16 -9
- package/src/features/chat/features/workspace/components/__tests__/chat-session-workspace-panel-content.test.tsx +5 -5
- package/src/features/chat/features/workspace/components/__tests__/workspace-markdown-outline.test.tsx +128 -0
- package/src/features/chat/features/workspace/components/chat-session-workspace-file-breadcrumbs.tsx +3 -0
- package/src/features/chat/features/workspace/components/chat-session-workspace-file-preview.tsx +20 -2
- package/src/features/chat/features/workspace/components/chat-session-workspace-panel-content.tsx +3 -40
- package/src/features/chat/features/workspace/components/workspace-markdown-outline.tsx +156 -0
- package/src/features/chat/managers/__tests__/chat-completion-notification.manager.test.ts +70 -0
- package/src/features/chat/managers/chat-completion-notification.manager.ts +23 -6
- package/src/features/settings/pages/__tests__/privacy-settings-page.test.tsx +14 -5
- package/src/features/settings/pages/privacy-settings-page.tsx +38 -2
- package/src/shared/components/ui/actions/icon-action-button.tsx +1 -8
- package/src/shared/components/ui/tooltip.tsx +16 -1
- package/src/shared/hooks/use-config.ts +11 -0
- package/src/shared/lib/api/types.ts +8 -0
- package/src/shared/lib/api/utils/config.utils.ts +5 -0
- package/src/shared/lib/i18n/locales/en-US/chat.json +28 -0
- package/src/shared/lib/i18n/locales/en-US/core.json +17 -9
- package/src/shared/lib/i18n/locales/zh-CN/chat.json +28 -0
- package/src/shared/lib/i18n/locales/zh-CN/core.json +17 -9
- package/dist/assets/channels-list-page-B0z4bh8e.js +0 -8
- package/dist/assets/doc-browser-Cm_tMZ_C.js +0 -1
- package/dist/assets/doc-browser-Dto54tCf.js +0 -1
- package/dist/assets/index-BTSwNDVi.js +0 -109
- package/dist/assets/index-QrdUWHfm.css +0 -1
- package/dist/assets/loader-circle-CkaqeEne.js +0 -1
- package/dist/assets/mcp-marketplace-page-BSk0qUOX.js +0 -9
- package/dist/assets/mcp-marketplace-page-BgZGc-S_.js +0 -1
- package/dist/assets/model-config-page-c8RnUGoH.js +0 -1
- package/dist/assets/plus-BiMjkv3R.js +0 -1
- package/dist/assets/privacy-settings-page-CPkyTvEE.js +0 -1
- package/dist/assets/providers-config-page-CGUEb5Dx.js +0 -1
- package/dist/assets/react-BMYFkaGi.js +0 -8
- package/dist/assets/remote-1LxkpRED.js +0 -1
- package/dist/assets/runtime-config-page-C6nzqTHg.js +0 -1
- package/dist/assets/security-config-DvHUDxSg.js +0 -1
- package/dist/assets/skeleton-CbroEO8Q.js +0 -1
- package/dist/assets/x-BTo5-A8h.js +0 -1
package/src/features/chat/features/workspace/components/chat-session-workspace-file-breadcrumbs.tsx
CHANGED
|
@@ -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
|
}
|
package/src/features/chat/features/workspace/components/chat-session-workspace-file-preview.tsx
CHANGED
|
@@ -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
|
|
package/src/features/chat/features/workspace/components/chat-session-workspace-panel-content.tsx
CHANGED
|
@@ -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
|
-
<
|
|
312
|
-
|
|
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
|
|
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
|
|
153
|
+
private readonly resolveSessionSummary = (sessionId: string) => {
|
|
134
154
|
const summaries =
|
|
135
155
|
useChatQueryStore.getState().snapshot.sessionsQuery?.data?.sessions ?? [];
|
|
136
|
-
|
|
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:
|
|
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('
|
|
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: '
|
|
25
|
-
expect(shareSwitch.getAttribute('aria-checked')).toBe('
|
|
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:
|
|
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 {
|
|
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 ??
|
|
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 =
|
|
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
|