@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.
- package/CHANGELOG.md +18 -0
- package/dist/assets/{api-BBFMpRGl.js → api-DvpQWjTS.js} +3 -3
- package/dist/assets/{appearance-settings-page-C1Erayzl.js → appearance-settings-page-U0m0ktu3.js} +1 -1
- package/dist/assets/{book-open-BhTIpBKg.js → book-open-DRofSTWr.js} +1 -1
- package/dist/assets/channels-list-page-BURQarD0.js +8 -0
- package/dist/assets/{config-split-page-By7p-4X-.js → config-split-page-BrAQYTIl.js} +1 -1
- package/dist/assets/{confirm-dialog-xpl1Yl7r.js → confirm-dialog-DDJomLnO.js} +1 -1
- package/dist/assets/{createLucideIcon-B_8khHn8.js → createLucideIcon-DzkNHr5Z.js} +1 -1
- package/dist/assets/{desktop-update-config-CGFfa7-Z.js → desktop-update-config-BzZFhMv9.js} +1 -1
- package/dist/assets/{dist-CXN62DSs.js → dist-DXbLo2b1.js} +1 -1
- package/dist/assets/{dist-DdE-kCbi.js → dist-NskPB_yW.js} +1 -1
- package/dist/assets/doc-browser-DB22tQKU.js +1 -0
- package/dist/assets/{doc-browser-context-B8NCuMv5.js → doc-browser-context-CnKV4gJy.js} +1 -1
- package/dist/assets/doc-browser-sJjw9fl5.js +1 -0
- package/dist/assets/{extensions-CMctxyTf.js → extensions-DvlFMpAs.js} +1 -1
- package/dist/assets/{form-actions-BFgTdxqI.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-BwWrm4k1.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-B7NmPJ_Z.js → refresh-cw-CkeninE0.js} +1 -1
- package/dist/assets/remote-B2lsJF1c.js +1 -0
- package/dist/assets/{rotate-cw-B0ajMaHu.js → rotate-cw-DP33PuXU.js} +1 -1
- package/dist/assets/runtime-config-page-D69buyGp.js +1 -0
- package/dist/assets/{save-DWnYYpgQ.js → save-Cw2Xz-pB.js} +1 -1
- package/dist/assets/{search-D6pX7FC-.js → search-C2CFAVvd.js} +1 -1
- package/dist/assets/{search-config-page-Cx3o_675.js → search-config-page-dqNT0Swf.js} +1 -1
- package/dist/assets/{secrets-config-page-tyFl7Rvt.js → secrets-config-page-De6J3wVx.js} +2 -2
- package/dist/assets/security-config-Dcu9C4OL.js +1 -0
- package/dist/assets/{select-qEtFTWB5.js → select-DSuMhwVP.js} +1 -1
- package/dist/assets/skeleton-QoWydNLp.js +1 -0
- package/dist/assets/{tag-chip-vcjAYdV_.js → tag-chip-DFBLgsGf.js} +1 -1
- package/dist/assets/x-BXvAzpjb.js +1 -0
- package/dist/index.html +21 -21
- package/package.json +7 -7
- 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/utils/__tests__/chat-message-trigger-details.utils.test.ts +35 -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-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/workspace/components/__tests__/chat-session-workspace-file-preview.test.tsx +16 -9
- 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/workspace-markdown-outline.tsx +156 -0
- 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 +7 -0
- package/src/shared/lib/i18n/locales/en-US/core.json +17 -9
- package/src/shared/lib/i18n/locales/zh-CN/chat.json +7 -0
- package/src/shared/lib/i18n/locales/zh-CN/core.json +17 -9
- package/dist/assets/channels-list-page-OTCkjMOW.js +0 -8
- package/dist/assets/doc-browser-5xrHcQ2z.js +0 -1
- package/dist/assets/doc-browser-c2yO477-.js +0 -1
- package/dist/assets/index-DUCA8m2w.js +0 -109
- package/dist/assets/index-QrdUWHfm.css +0 -1
- package/dist/assets/loader-circle-CbdTrSp3.js +0 -1
- package/dist/assets/mcp-marketplace-page-DGve0muV.js +0 -9
- package/dist/assets/mcp-marketplace-page-_37VzG2q.js +0 -1
- package/dist/assets/model-config-page-DyC5H0bu.js +0 -1
- package/dist/assets/plus-DbeE4r-Q.js +0 -1
- package/dist/assets/privacy-settings-page--MOnhAZ6.js +0 -1
- package/dist/assets/providers-config-page-ChL3FUvK.js +0 -1
- package/dist/assets/react-DVMmnPWz.js +0 -8
- package/dist/assets/remote-DFoxj4ls.js +0 -1
- package/dist/assets/runtime-config-page-BQdT3nmd.js +0 -1
- package/dist/assets/security-config-CUTUm6JT.js +0 -1
- package/dist/assets/skeleton-7MYPzn8w.js +0 -1
- 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
|
-
<
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
+
});
|
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
|
|
|
@@ -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:
|
|
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
|