@nextclaw/ui 0.20.1 → 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 (95) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/dist/assets/{api-BBFMpRGl.js → api-DvpQWjTS.js} +3 -3
  3. package/dist/assets/{appearance-settings-page-C1Erayzl.js → appearance-settings-page-U0m0ktu3.js} +1 -1
  4. package/dist/assets/{book-open-BhTIpBKg.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-By7p-4X-.js → config-split-page-BrAQYTIl.js} +1 -1
  7. package/dist/assets/{confirm-dialog-xpl1Yl7r.js → confirm-dialog-DDJomLnO.js} +1 -1
  8. package/dist/assets/{createLucideIcon-B_8khHn8.js → createLucideIcon-DzkNHr5Z.js} +1 -1
  9. package/dist/assets/{desktop-update-config-CGFfa7-Z.js → desktop-update-config-BzZFhMv9.js} +1 -1
  10. package/dist/assets/{dist-CXN62DSs.js → dist-DXbLo2b1.js} +1 -1
  11. package/dist/assets/{dist-DdE-kCbi.js → dist-NskPB_yW.js} +1 -1
  12. package/dist/assets/doc-browser-DB22tQKU.js +1 -0
  13. package/dist/assets/{doc-browser-context-B8NCuMv5.js → doc-browser-context-CnKV4gJy.js} +1 -1
  14. package/dist/assets/doc-browser-sJjw9fl5.js +1 -0
  15. package/dist/assets/{extensions-CMctxyTf.js → extensions-DvlFMpAs.js} +1 -1
  16. package/dist/assets/{form-actions-BFgTdxqI.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-BwWrm4k1.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-B7NmPJ_Z.js → refresh-cw-CkeninE0.js} +1 -1
  29. package/dist/assets/remote-B2lsJF1c.js +1 -0
  30. package/dist/assets/{rotate-cw-B0ajMaHu.js → rotate-cw-DP33PuXU.js} +1 -1
  31. package/dist/assets/runtime-config-page-D69buyGp.js +1 -0
  32. package/dist/assets/{save-DWnYYpgQ.js → save-Cw2Xz-pB.js} +1 -1
  33. package/dist/assets/{search-D6pX7FC-.js → search-C2CFAVvd.js} +1 -1
  34. package/dist/assets/{search-config-page-Cx3o_675.js → search-config-page-dqNT0Swf.js} +1 -1
  35. package/dist/assets/{secrets-config-page-tyFl7Rvt.js → secrets-config-page-De6J3wVx.js} +2 -2
  36. package/dist/assets/security-config-Dcu9C4OL.js +1 -0
  37. package/dist/assets/{select-qEtFTWB5.js → select-DSuMhwVP.js} +1 -1
  38. package/dist/assets/skeleton-QoWydNLp.js +1 -0
  39. package/dist/assets/{tag-chip-vcjAYdV_.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 +7 -7
  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/utils/__tests__/chat-message-trigger-details.utils.test.ts +35 -0
  51. package/src/features/chat/features/ncp/hooks/__tests__/use-ncp-agent-runtime-queue.test.tsx +50 -0
  52. package/src/features/chat/features/ncp/hooks/__tests__/use-ncp-session-list-view.test.tsx +24 -3
  53. package/src/features/chat/features/ncp/hooks/use-ncp-session-list-view.ts +35 -19
  54. package/src/features/chat/features/session/components/__tests__/chat-sidebar-project-groups.test.tsx +26 -2
  55. package/src/features/chat/features/session/components/__tests__/chat-sidebar-session-item.test.tsx +55 -27
  56. package/src/features/chat/features/session/components/chat-sidebar-context-card.tsx +60 -0
  57. package/src/features/chat/features/session/components/chat-sidebar-project-groups.tsx +95 -54
  58. package/src/features/chat/features/session/components/chat-sidebar-session-entry.tsx +9 -8
  59. package/src/features/chat/features/session/components/chat-sidebar-session-item.tsx +141 -95
  60. package/src/features/chat/features/session/components/chat-sidebar-session-list.tsx +11 -2
  61. package/src/features/chat/features/session/hooks/use-chat-sidebar-context-counts.ts +40 -0
  62. package/src/features/chat/features/workspace/components/__tests__/chat-session-workspace-file-preview.test.tsx +16 -9
  63. package/src/features/chat/features/workspace/components/__tests__/workspace-markdown-outline.test.tsx +128 -0
  64. package/src/features/chat/features/workspace/components/chat-session-workspace-file-breadcrumbs.tsx +3 -0
  65. package/src/features/chat/features/workspace/components/chat-session-workspace-file-preview.tsx +20 -2
  66. package/src/features/chat/features/workspace/components/workspace-markdown-outline.tsx +156 -0
  67. package/src/features/settings/pages/__tests__/privacy-settings-page.test.tsx +14 -5
  68. package/src/features/settings/pages/privacy-settings-page.tsx +38 -2
  69. package/src/shared/components/ui/actions/icon-action-button.tsx +1 -8
  70. package/src/shared/components/ui/tooltip.tsx +16 -1
  71. package/src/shared/hooks/use-config.ts +11 -0
  72. package/src/shared/lib/api/types.ts +8 -0
  73. package/src/shared/lib/api/utils/config.utils.ts +5 -0
  74. package/src/shared/lib/i18n/locales/en-US/chat.json +7 -0
  75. package/src/shared/lib/i18n/locales/en-US/core.json +17 -9
  76. package/src/shared/lib/i18n/locales/zh-CN/chat.json +7 -0
  77. package/src/shared/lib/i18n/locales/zh-CN/core.json +17 -9
  78. package/dist/assets/channels-list-page-OTCkjMOW.js +0 -8
  79. package/dist/assets/doc-browser-5xrHcQ2z.js +0 -1
  80. package/dist/assets/doc-browser-c2yO477-.js +0 -1
  81. package/dist/assets/index-DUCA8m2w.js +0 -109
  82. package/dist/assets/index-QrdUWHfm.css +0 -1
  83. package/dist/assets/loader-circle-CbdTrSp3.js +0 -1
  84. package/dist/assets/mcp-marketplace-page-DGve0muV.js +0 -9
  85. package/dist/assets/mcp-marketplace-page-_37VzG2q.js +0 -1
  86. package/dist/assets/model-config-page-DyC5H0bu.js +0 -1
  87. package/dist/assets/plus-DbeE4r-Q.js +0 -1
  88. package/dist/assets/privacy-settings-page--MOnhAZ6.js +0 -1
  89. package/dist/assets/providers-config-page-ChL3FUvK.js +0 -1
  90. package/dist/assets/react-DVMmnPWz.js +0 -8
  91. package/dist/assets/remote-DFoxj4ls.js +0 -1
  92. package/dist/assets/runtime-config-page-BQdT3nmd.js +0 -1
  93. package/dist/assets/security-config-CUTUm6JT.js +0 -1
  94. package/dist/assets/skeleton-7MYPzn8w.js +0 -1
  95. package/dist/assets/x-DThaS5ZL.js +0 -1
@@ -0,0 +1,40 @@
1
+ import { useMemo } from "react";
2
+ import { useCronJobs } from "@/features/cron";
3
+ import type { NcpSessionListItemView } from "@/features/chat/features/ncp/hooks/use-ncp-session-list-view";
4
+
5
+ export function useChatSidebarContextCounts(
6
+ allItems: readonly NcpSessionListItemView[],
7
+ ) {
8
+ const cronQuery = useCronJobs({ all: true });
9
+
10
+ return useMemo(() => {
11
+ const projectRootBySessionKey = new Map(
12
+ allItems.flatMap(({ session }) => {
13
+ const projectRoot = session.projectRoot?.trim();
14
+ return projectRoot ? [[session.key, projectRoot] as const] : [];
15
+ }),
16
+ );
17
+ const cronJobCountBySessionKey = new Map<string, number>();
18
+ const cronJobCountByProjectRoot = new Map<string, number>();
19
+
20
+ for (const job of cronQuery.data?.jobs ?? []) {
21
+ const sessionKey = job.payload.sessionId?.trim();
22
+ if (!sessionKey) {
23
+ continue;
24
+ }
25
+ cronJobCountBySessionKey.set(
26
+ sessionKey,
27
+ (cronJobCountBySessionKey.get(sessionKey) ?? 0) + 1,
28
+ );
29
+ const projectRoot = projectRootBySessionKey.get(sessionKey);
30
+ if (projectRoot) {
31
+ cronJobCountByProjectRoot.set(
32
+ projectRoot,
33
+ (cronJobCountByProjectRoot.get(projectRoot) ?? 0) + 1,
34
+ );
35
+ }
36
+ }
37
+
38
+ return { cronJobCountByProjectRoot, cronJobCountBySessionKey };
39
+ }, [allItems, cronQuery.data?.jobs]);
40
+ }
@@ -1,4 +1,5 @@
1
1
  import { fireEvent, render, screen, waitFor } from "@testing-library/react";
2
+ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
2
3
  import type { ReactNode } from "react";
3
4
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4
5
  import type {
@@ -123,16 +124,21 @@ function renderWorkspaceFilePreview({
123
124
  sessionWorkingDir = "/tmp",
124
125
  showBreadcrumbs,
125
126
  }: RenderWorkspaceFilePreviewOptions = {}) {
127
+ const queryClient = new QueryClient({
128
+ defaultOptions: { queries: { retry: false } },
129
+ });
126
130
  const view = render(
127
- <ChatSessionWorkspaceFilePreview
128
- file={buildWorkspaceFile(file ?? {})}
129
- sessionProjectRoot={sessionProjectRoot}
130
- sessionWorkingDir={sessionWorkingDir}
131
- refreshVersion={refreshVersion}
132
- showBreadcrumbs={showBreadcrumbs}
133
- onHtmlContentHeightChange={onHtmlContentHeightChange}
134
- onFileOpen={onFileOpen}
135
- />,
131
+ <QueryClientProvider client={queryClient}>
132
+ <ChatSessionWorkspaceFilePreview
133
+ file={buildWorkspaceFile(file ?? {})}
134
+ sessionProjectRoot={sessionProjectRoot}
135
+ sessionWorkingDir={sessionWorkingDir}
136
+ refreshVersion={refreshVersion}
137
+ showBreadcrumbs={showBreadcrumbs}
138
+ onHtmlContentHeightChange={onHtmlContentHeightChange}
139
+ onFileOpen={onFileOpen}
140
+ />
141
+ </QueryClientProvider>,
136
142
  );
137
143
  return { ...view, onFileOpen };
138
144
  }
@@ -580,6 +586,7 @@ describe("ChatSessionWorkspaceFilePreview text rendering", () => {
580
586
 
581
587
  expect(screen.queryByTestId("markdown-preview")).toBeNull();
582
588
  expect(screen.getByTestId("file-code-surface")).toBeTruthy();
589
+ expect(screen.queryByRole("button", { name: t("chatWorkspaceMarkdownOutline") })).toBeNull();
583
590
  });
584
591
 
585
592
  it("keeps HTML files in the source preview when preview viewer is automatic", () => {
@@ -0,0 +1,128 @@
1
+ import { fireEvent, render, screen, waitFor } from "@testing-library/react";
2
+ import { createRef } from "react";
3
+ import { describe, expect, it, vi } from "vitest";
4
+ import { WorkspaceMarkdownOutline } from "@/features/chat/features/workspace/components/workspace-markdown-outline";
5
+ import { t } from "@/shared/lib/i18n";
6
+
7
+ describe("WorkspaceMarkdownOutline", () => {
8
+ it("does not render an outline action when the document has no headings", async () => {
9
+ const scrollContainerRef = createRef<HTMLDivElement>();
10
+ render(
11
+ <>
12
+ <WorkspaceMarkdownOutline
13
+ contentKey="plain fixture"
14
+ documentKey="notes.md"
15
+ scrollContainerRef={scrollContainerRef}
16
+ />
17
+ <div ref={scrollContainerRef}>Plain paragraph</div>
18
+ </>,
19
+ );
20
+
21
+ await waitFor(() => {
22
+ expect(
23
+ screen.queryByRole("button", {
24
+ name: t("chatWorkspaceMarkdownOutline"),
25
+ }),
26
+ ).toBeNull();
27
+ });
28
+ });
29
+
30
+ it("preserves outline scroll state and instantly jumps to the selected rendered heading", async () => {
31
+ const scrollContainerRef = createRef<HTMLDivElement>();
32
+ const scrollTo = vi.fn();
33
+ const { rerender } = render(
34
+ <>
35
+ <WorkspaceMarkdownOutline
36
+ contentKey="outline fixture"
37
+ documentKey="README.md"
38
+ scrollContainerRef={scrollContainerRef}
39
+ />
40
+ <div ref={scrollContainerRef}>
41
+ <h1>Overview</h1>
42
+ <h3>
43
+ Install <em>quickly</em>
44
+ </h3>
45
+ <h2>Overview</h2>
46
+ </div>
47
+ </>,
48
+ );
49
+
50
+ const outlineTrigger = await screen.findByRole("button", {
51
+ name: t("chatWorkspaceMarkdownOutline"),
52
+ });
53
+ const scrollContainer = scrollContainerRef.current!;
54
+ const headings = screen.getAllByRole("heading");
55
+ Object.defineProperty(scrollContainer, "scrollTop", {
56
+ configurable: true,
57
+ value: 40,
58
+ writable: true,
59
+ });
60
+ Object.defineProperty(scrollContainer, "scrollTo", {
61
+ configurable: true,
62
+ value: scrollTo,
63
+ });
64
+ vi.spyOn(scrollContainer, "getBoundingClientRect").mockReturnValue({
65
+ top: 100,
66
+ } as DOMRect);
67
+ vi.spyOn(headings[1]!, "getBoundingClientRect").mockReturnValue({
68
+ top: 420,
69
+ } as DOMRect);
70
+
71
+ fireEvent.click(outlineTrigger);
72
+
73
+ expect(
74
+ screen.getByTestId("workspace-markdown-outline-popover").className,
75
+ ).toContain("w-[min(20rem,calc(100vw-1.5rem))]");
76
+ const outlineNavigation = screen.getByRole("navigation");
77
+ expect(outlineNavigation.className).toContain("max-h-80");
78
+ expect(screen.getAllByRole("button", { name: "Overview" })).toHaveLength(2);
79
+ const nestedHeading = screen.getByRole("button", {
80
+ name: "Install quickly",
81
+ });
82
+ expect(nestedHeading.style.paddingInlineStart).toBe("1.25rem");
83
+
84
+ outlineNavigation.scrollTop = 176;
85
+ fireEvent.click(outlineTrigger);
86
+ await waitFor(() => {
87
+ expect(
88
+ screen.queryByTestId("workspace-markdown-outline-popover"),
89
+ ).toBeNull();
90
+ });
91
+ fireEvent.click(outlineTrigger);
92
+ expect((await screen.findByRole("navigation")).scrollTop).toBe(176);
93
+ fireEvent.click(outlineTrigger);
94
+ rerender(
95
+ <>
96
+ <WorkspaceMarkdownOutline
97
+ contentKey="outline fixture"
98
+ documentKey="docs/other.md"
99
+ scrollContainerRef={scrollContainerRef}
100
+ />
101
+ <div ref={scrollContainerRef}>
102
+ <h1>Overview</h1>
103
+ <h3>
104
+ Install <em>quickly</em>
105
+ </h3>
106
+ <h2>Overview</h2>
107
+ </div>
108
+ </>,
109
+ );
110
+ fireEvent.click(outlineTrigger);
111
+ expect((await screen.findByRole("navigation")).scrollTop).toBe(0);
112
+
113
+ fireEvent.click(screen.getByRole("button", { name: "Install quickly" }));
114
+
115
+ expect(scrollTo).toHaveBeenCalledWith({ top: 344 });
116
+ await waitFor(() => {
117
+ expect(
118
+ screen.queryByTestId("workspace-markdown-outline-popover"),
119
+ ).toBeNull();
120
+ });
121
+ expect(document.activeElement).toBe(outlineTrigger);
122
+ expect(
123
+ document.querySelector(
124
+ '[role="tooltip"][data-state="delayed-open"], [role="tooltip"][data-state="instant-open"]',
125
+ ),
126
+ ).toBeNull();
127
+ });
128
+ });
@@ -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
 
@@ -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
+ }
@@ -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