@nextclaw/ui 0.20.1 → 0.20.3

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 (138) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/dist/assets/{api-BBFMpRGl.js → api-iMkU_o5P.js} +3 -3
  3. package/dist/assets/{appearance-settings-page-C1Erayzl.js → appearance-settings-page-CU5k4b-M.js} +1 -1
  4. package/dist/assets/{book-open-BhTIpBKg.js → book-open-nD8gdMhA.js} +1 -1
  5. package/dist/assets/channels-list-page-Drlt62kY.js +8 -0
  6. package/dist/assets/{config-split-page-By7p-4X-.js → config-split-page-BtJZp1E5.js} +1 -1
  7. package/dist/assets/{confirm-dialog-xpl1Yl7r.js → confirm-dialog-k2-Jq_x3.js} +1 -1
  8. package/dist/assets/{createLucideIcon-B_8khHn8.js → createLucideIcon-DxlwKLr-.js} +1 -1
  9. package/dist/assets/{desktop-update-config-CGFfa7-Z.js → desktop-update-config-BiZmy7FC.js} +1 -1
  10. package/dist/assets/{dist-CXN62DSs.js → dist-Cy_e4kNb.js} +1 -1
  11. package/dist/assets/{dist-DdE-kCbi.js → dist-m08DUswB.js} +1 -1
  12. package/dist/assets/doc-browser-CPsPHh12.js +1 -0
  13. package/dist/assets/{doc-browser-context-B8NCuMv5.js → doc-browser-context-jiy-O4F8.js} +1 -1
  14. package/dist/assets/doc-browser-npOvWtgf.js +1 -0
  15. package/dist/assets/{extensions-CMctxyTf.js → extensions-CIb17__o.js} +1 -1
  16. package/dist/assets/{form-actions-BFgTdxqI.js → form-actions-DGf5zUg_.js} +1 -1
  17. package/dist/assets/index-C86hxgv5.css +1 -0
  18. package/dist/assets/index-CYBXR7zN.js +109 -0
  19. package/dist/assets/loader-circle-BlLuRFVW.js +1 -0
  20. package/dist/assets/mcp-marketplace-page-B12JNh7Q.js +1 -0
  21. package/dist/assets/mcp-marketplace-page-kLWW6kzt.js +9 -0
  22. package/dist/assets/model-config-page-DSeAjVT_.js +1 -0
  23. package/dist/assets/{navigation-link-BwWrm4k1.js → navigation-link-DAfwFCj4.js} +1 -1
  24. package/dist/assets/plus-D4LtWtTk.js +1 -0
  25. package/dist/assets/privacy-settings-page-tN0Mt15x.js +1 -0
  26. package/dist/assets/providers-config-page-CgPrXR1u.js +1 -0
  27. package/dist/assets/react-8iBFECZE.js +8 -0
  28. package/dist/assets/{refresh-cw-B7NmPJ_Z.js → refresh-cw-D_Eibznw.js} +1 -1
  29. package/dist/assets/remote-Derr_AQp.js +1 -0
  30. package/dist/assets/{rotate-cw-B0ajMaHu.js → rotate-cw-QQjmoWuq.js} +1 -1
  31. package/dist/assets/runtime-config-page-Crq96Jjq.js +1 -0
  32. package/dist/assets/{save-DWnYYpgQ.js → save-W2xthJJ2.js} +1 -1
  33. package/dist/assets/{search-D6pX7FC-.js → search-Vih1R2BD.js} +1 -1
  34. package/dist/assets/{search-config-page-Cx3o_675.js → search-config-page-CtBcL2uc.js} +1 -1
  35. package/dist/assets/{secrets-config-page-tyFl7Rvt.js → secrets-config-page-Q6XfIj0h.js} +2 -2
  36. package/dist/assets/security-config-CE9rkQvq.js +1 -0
  37. package/dist/assets/{select-qEtFTWB5.js → select-lNdozU4o.js} +1 -1
  38. package/dist/assets/skeleton-B__aTcly.js +1 -0
  39. package/dist/assets/{tag-chip-vcjAYdV_.js → tag-chip-gdaU9bE7.js} +1 -1
  40. package/dist/assets/x-CWdWBu_U.js +1 -0
  41. package/dist/index.html +21 -21
  42. package/package.json +7 -7
  43. package/src/app/components/layout/sidebar.tsx +7 -0
  44. package/src/features/apps/components/app-package-card.tsx +29 -7
  45. package/src/features/apps/components/app-packages-panel.tsx +4 -0
  46. package/src/features/chat/components/conversation/chat-conversation-content.tsx +18 -2
  47. package/src/features/chat/components/conversation/chat-conversation-workspace-section.tsx +6 -0
  48. package/src/features/chat/components/layout/__tests__/chat-sidebar-read-state.test.tsx +4 -3
  49. package/src/features/chat/components/layout/__tests__/chat-sidebar-session-area.test.tsx +27 -6
  50. package/src/features/chat/components/layout/__tests__/chat-sidebar.test.tsx +17 -17
  51. package/src/features/chat/components/layout/chat-page-shell.tsx +19 -2
  52. package/src/features/chat/components/layout/chat-sidebar-desktop-layout.tsx +33 -5
  53. package/src/features/chat/components/layout/chat-sidebar.tsx +31 -21
  54. package/src/features/chat/features/conversation/hooks/__tests__/use-session-conversation-controller.test.tsx +41 -1
  55. package/src/features/chat/features/conversation/hooks/use-session-pending-input-actions.ts +22 -4
  56. package/src/features/chat/features/message/utils/__tests__/chat-message-timeline-compaction.utils.test.ts +2 -2
  57. package/src/features/chat/features/message/utils/__tests__/chat-message-trigger-details.utils.test.ts +35 -0
  58. package/src/features/chat/features/message/utils/chat-message-timeline.utils.ts +2 -2
  59. package/src/features/chat/features/ncp/hooks/__tests__/use-ncp-agent-runtime-queue.test.tsx +50 -0
  60. package/src/features/chat/features/ncp/hooks/__tests__/use-ncp-session-list-view.test.tsx +35 -6
  61. package/src/features/chat/features/ncp/hooks/use-ncp-session-list-view.ts +43 -20
  62. package/src/features/chat/features/ncp/hooks/use-ncp-session-queries.ts +15 -1
  63. package/src/features/chat/features/session/components/__tests__/chat-sidebar-project-groups.test.tsx +26 -2
  64. package/src/features/chat/features/session/components/__tests__/chat-sidebar-session-item.test.tsx +55 -27
  65. package/src/features/chat/features/session/components/chat-sidebar-context-card.tsx +60 -0
  66. package/src/features/chat/features/session/components/chat-sidebar-project-groups.tsx +95 -54
  67. package/src/features/chat/features/session/components/chat-sidebar-session-entry.tsx +9 -8
  68. package/src/features/chat/features/session/components/chat-sidebar-session-item.tsx +141 -95
  69. package/src/features/chat/features/session/components/chat-sidebar-session-list.tsx +11 -2
  70. package/src/features/chat/features/session/hooks/use-chat-sidebar-context-counts.ts +40 -0
  71. package/src/features/chat/features/workspace/components/__tests__/chat-session-token-usage.test.tsx +4 -1
  72. package/src/features/chat/features/workspace/components/__tests__/chat-session-workspace-file-preview.scroll-restoration.test.tsx +182 -0
  73. package/src/features/chat/features/workspace/components/__tests__/chat-session-workspace-file-preview.test.tsx +16 -9
  74. package/src/features/chat/features/workspace/components/__tests__/session-cron-job-content.test.tsx +78 -0
  75. package/src/features/chat/features/workspace/components/__tests__/workspace-markdown-outline.test.tsx +128 -0
  76. package/src/features/chat/features/workspace/components/chat-session-workspace-file-breadcrumbs.tsx +3 -0
  77. package/src/features/chat/features/workspace/components/chat-session-workspace-file-preview.tsx +65 -2
  78. package/src/features/chat/features/workspace/components/chat-session-workspace-panel-content.tsx +14 -1
  79. package/src/features/chat/features/workspace/components/chat-session-workspace-panel.tsx +9 -0
  80. package/src/features/chat/features/workspace/components/overview/chat-session-token-usage.tsx +17 -0
  81. package/src/features/chat/features/workspace/components/session-cron-job-content.tsx +269 -75
  82. package/src/features/chat/features/workspace/components/workspace-markdown-outline.tsx +156 -0
  83. package/src/features/chat/features/workspace/hooks/use-chat-conversation-workspace-state.ts +5 -0
  84. package/src/features/cron/components/__tests__/cron-config.test.tsx +16 -5
  85. package/src/features/cron/components/cron-config.tsx +20 -46
  86. package/src/features/cron/components/cron-job-row.tsx +1 -1
  87. package/src/features/cron/hooks/__tests__/use-cron-job-actions.test.tsx +95 -0
  88. package/src/features/cron/hooks/use-cron-job-actions.ts +109 -0
  89. package/src/features/cron/index.ts +2 -0
  90. package/src/features/inbox/pages/inbox-page.tsx +18 -2
  91. package/src/features/marketplace/components/marketplace-page.tsx +7 -0
  92. package/src/features/panel-apps/pages/panel-app-main-page.tsx +8 -0
  93. package/src/features/settings/pages/__tests__/privacy-settings-page.test.tsx +14 -5
  94. package/src/features/settings/pages/privacy-settings-page.tsx +38 -2
  95. package/src/platforms/desktop/components/desktop-app-shell.tsx +10 -1
  96. package/src/shared/components/doc-browser/doc-browser.tsx +0 -1
  97. package/src/shared/components/doc-browser/hooks/use-doc-browser-scroll-restoration.ts +9 -113
  98. package/src/shared/components/ui/actions/icon-action-button.tsx +1 -8
  99. package/src/shared/components/ui/tooltip.tsx +16 -1
  100. package/src/shared/hooks/use-config.ts +11 -0
  101. package/src/shared/hooks/use-panel-app-scroll-restoration.test.tsx +66 -0
  102. package/src/shared/hooks/use-panel-app-scroll-restoration.ts +107 -0
  103. package/src/shared/hooks/use-scroll-restoration.test.tsx +62 -0
  104. package/src/shared/hooks/use-scroll-restoration.ts +61 -0
  105. package/src/shared/lib/api/ncp-session-query-cache.utils.ts +1 -0
  106. package/src/shared/lib/api/ncp-session.types.ts +3 -0
  107. package/src/shared/lib/api/types.ts +9 -0
  108. package/src/shared/lib/api/utils/config.utils.ts +5 -0
  109. package/src/shared/lib/api/utils/ncp-session.utils.ts +3 -0
  110. package/src/shared/lib/i18n/locales/en-US/chat.json +15 -5
  111. package/src/shared/lib/i18n/locales/en-US/core.json +17 -9
  112. package/src/shared/lib/i18n/locales/en-US/cron.json +4 -2
  113. package/src/shared/lib/i18n/locales/en-US/doc-browser.json +1 -0
  114. package/src/shared/lib/i18n/locales/zh-CN/chat.json +15 -5
  115. package/src/shared/lib/i18n/locales/zh-CN/core.json +17 -9
  116. package/src/shared/lib/i18n/locales/zh-CN/cron.json +4 -2
  117. package/src/shared/lib/i18n/locales/zh-CN/doc-browser.json +1 -0
  118. package/src/shared/lib/navigation-history/index.ts +1 -0
  119. package/src/shared/lib/navigation-history/scroll-restoration.manager.test.ts +37 -0
  120. package/src/shared/lib/navigation-history/scroll-restoration.manager.ts +60 -0
  121. package/dist/assets/channels-list-page-OTCkjMOW.js +0 -8
  122. package/dist/assets/doc-browser-5xrHcQ2z.js +0 -1
  123. package/dist/assets/doc-browser-c2yO477-.js +0 -1
  124. package/dist/assets/index-DUCA8m2w.js +0 -109
  125. package/dist/assets/index-QrdUWHfm.css +0 -1
  126. package/dist/assets/loader-circle-CbdTrSp3.js +0 -1
  127. package/dist/assets/mcp-marketplace-page-DGve0muV.js +0 -9
  128. package/dist/assets/mcp-marketplace-page-_37VzG2q.js +0 -1
  129. package/dist/assets/model-config-page-DyC5H0bu.js +0 -1
  130. package/dist/assets/plus-DbeE4r-Q.js +0 -1
  131. package/dist/assets/privacy-settings-page--MOnhAZ6.js +0 -1
  132. package/dist/assets/providers-config-page-ChL3FUvK.js +0 -1
  133. package/dist/assets/react-DVMmnPWz.js +0 -8
  134. package/dist/assets/remote-DFoxj4ls.js +0 -1
  135. package/dist/assets/runtime-config-page-BQdT3nmd.js +0 -1
  136. package/dist/assets/security-config-CUTUm6JT.js +0 -1
  137. package/dist/assets/skeleton-7MYPzn8w.js +0 -1
  138. package/dist/assets/x-DThaS5ZL.js +0 -1
@@ -0,0 +1,182 @@
1
+ import { fireEvent, render, screen } from "@testing-library/react";
2
+ import type { ReactNode } from "react";
3
+ import { beforeEach, describe, expect, it, vi } from "vitest";
4
+ import { ChatSessionWorkspaceFilePreview } from "@/features/chat/features/workspace/components/chat-session-workspace-file-preview";
5
+ import { scrollRestorationManager } from "@/shared/lib/navigation-history";
6
+
7
+ const serverPathReadMock = vi.fn();
8
+ const serverPathBrowseMock = vi.fn();
9
+
10
+ vi.mock("@/shared/hooks/use-server-path-read", () => ({
11
+ useServerPathRead: (...args: unknown[]) => serverPathReadMock(...args),
12
+ }));
13
+
14
+ vi.mock("@/shared/hooks/use-server-path-browse", () => ({
15
+ useServerPathBrowse: (...args: unknown[]) => serverPathBrowseMock(...args),
16
+ }));
17
+
18
+ vi.mock("@nextclaw/agent-chat-ui", () => ({
19
+ ChatMessageMarkdown: ({ text }: { text: string }) => <div data-testid="markdown-preview">{text}</div>,
20
+ FileOperationCodeSurface: () => null,
21
+ }));
22
+
23
+ vi.mock(
24
+ "@/features/chat/features/workspace/components/workspace-text-selection-menu",
25
+ () => ({
26
+ WorkspaceTextSelectionMenu: ({ children }: { children: ReactNode }) => children,
27
+ }),
28
+ );
29
+
30
+ function renderMarkdownPreview(file: {
31
+ key: string;
32
+ path: string;
33
+ previewViewer?: "rendered" | "source";
34
+ } = {
35
+ key: "markdown-tab",
36
+ path: "/tmp/README.md",
37
+ previewViewer: "rendered",
38
+ }) {
39
+ return render(
40
+ <ChatSessionWorkspaceFilePreview
41
+ file={{
42
+ key: file.key,
43
+ parentSessionKey: "session-1",
44
+ path: file.path,
45
+ viewMode: "preview",
46
+ previewViewer: file.previewViewer ?? "rendered",
47
+ }}
48
+ sessionProjectRoot="/tmp"
49
+ sessionWorkingDir="/tmp"
50
+ showBreadcrumbs={false}
51
+ onFileOpen={vi.fn()}
52
+ />,
53
+ );
54
+ }
55
+
56
+ describe("ChatSessionWorkspaceFilePreview Markdown scroll restoration", () => {
57
+ beforeEach(() => {
58
+ scrollRestorationManager.clear();
59
+ serverPathReadMock.mockImplementation(({ path }: { path: string }) => ({
60
+ isLoading: false,
61
+ error: null,
62
+ data: {
63
+ kind: "markdown",
64
+ resolvedPath: path,
65
+ startLine: 1,
66
+ text: `# ${path}`,
67
+ truncated: false,
68
+ },
69
+ }));
70
+ serverPathBrowseMock.mockReturnValue({
71
+ isLoading: false,
72
+ error: new Error("not a directory"),
73
+ data: null,
74
+ });
75
+ });
76
+
77
+ it("restores the rendered Markdown position when returning to the same file tab", () => {
78
+ const firstView = renderMarkdownPreview();
79
+ const firstScrollContainer = screen.getByTestId("markdown-preview").parentElement as HTMLDivElement;
80
+ firstScrollContainer.scrollTop = 144;
81
+ fireEvent.scroll(firstScrollContainer);
82
+ firstView.unmount();
83
+
84
+ renderMarkdownPreview();
85
+
86
+ expect((screen.getByTestId("markdown-preview").parentElement as HTMLDivElement).scrollTop).toBe(144);
87
+ });
88
+
89
+ it("keeps each Markdown tab's position when switching files in one preview instance", () => {
90
+ const firstFile = { key: "markdown-a", path: "/tmp/A.md" };
91
+ const secondFile = { key: "markdown-b", path: "/tmp/B.md" };
92
+ const resolvedPaths = new Map([
93
+ [firstFile.path, "/tmp/first-location/A.md"],
94
+ [secondFile.path, "/tmp/B.md"],
95
+ ]);
96
+ serverPathReadMock.mockImplementation(({ path }: { path: string }) => ({
97
+ isLoading: false,
98
+ error: null,
99
+ data: {
100
+ kind: "markdown",
101
+ resolvedPath: resolvedPaths.get(path) ?? path,
102
+ startLine: 1,
103
+ text: `# ${path}`,
104
+ truncated: false,
105
+ },
106
+ }));
107
+ const view = renderMarkdownPreview(firstFile);
108
+ const firstScrollContainer = screen.getByTestId("markdown-preview").parentElement as HTMLDivElement;
109
+ firstScrollContainer.scrollTop = 144;
110
+ fireEvent.scroll(firstScrollContainer);
111
+
112
+ view.rerender(
113
+ <ChatSessionWorkspaceFilePreview
114
+ file={{ ...secondFile, parentSessionKey: "session-1", viewMode: "preview" }}
115
+ sessionProjectRoot="/tmp"
116
+ sessionWorkingDir="/tmp"
117
+ showBreadcrumbs={false}
118
+ onFileOpen={vi.fn()}
119
+ />,
120
+ );
121
+ const secondScrollContainer = screen.getByTestId("markdown-preview").parentElement as HTMLDivElement;
122
+ secondScrollContainer.scrollTop = 72;
123
+ fireEvent.scroll(secondScrollContainer);
124
+
125
+ // Server-path data can be stale while the query swaps back to the first
126
+ // tab. Its resolved path is display data, not tab identity.
127
+ resolvedPaths.set(firstFile.path, "/tmp/returned-location/A.md");
128
+
129
+ view.rerender(
130
+ <ChatSessionWorkspaceFilePreview
131
+ file={{ ...firstFile, parentSessionKey: "session-1", viewMode: "preview" }}
132
+ sessionProjectRoot="/tmp"
133
+ sessionWorkingDir="/tmp"
134
+ showBreadcrumbs={false}
135
+ onFileOpen={vi.fn()}
136
+ />,
137
+ );
138
+
139
+ expect((screen.getByTestId("markdown-preview").parentElement as HTMLDivElement).scrollTop).toBe(144);
140
+ });
141
+
142
+ it("restores Markdown after returning from its source-view tab", () => {
143
+ const renderedFile = {
144
+ key: "markdown-rendered",
145
+ path: "/tmp/README.md",
146
+ previewViewer: "rendered" as const,
147
+ };
148
+ const sourceFile = {
149
+ key: "markdown-source",
150
+ path: "/tmp/README.md",
151
+ previewViewer: "source" as const,
152
+ };
153
+ const view = renderMarkdownPreview(renderedFile);
154
+ const scrollContainer = screen.getByTestId("markdown-preview").parentElement as HTMLDivElement;
155
+ scrollContainer.scrollTop = 144;
156
+ fireEvent.scroll(scrollContainer);
157
+
158
+ // The source-view switch replaces the rendered scroll surface before the
159
+ // rendered effect cleanup executes.
160
+ scrollContainer.scrollTop = 0;
161
+ view.rerender(
162
+ <ChatSessionWorkspaceFilePreview
163
+ file={{ ...sourceFile, parentSessionKey: "session-1", viewMode: "preview" }}
164
+ sessionProjectRoot="/tmp"
165
+ sessionWorkingDir="/tmp"
166
+ showBreadcrumbs={false}
167
+ onFileOpen={vi.fn()}
168
+ />,
169
+ );
170
+ view.rerender(
171
+ <ChatSessionWorkspaceFilePreview
172
+ file={{ ...renderedFile, parentSessionKey: "session-1", viewMode: "preview" }}
173
+ sessionProjectRoot="/tmp"
174
+ sessionWorkingDir="/tmp"
175
+ showBreadcrumbs={false}
176
+ onFileOpen={vi.fn()}
177
+ />,
178
+ );
179
+
180
+ expect((screen.getByTestId("markdown-preview").parentElement as HTMLDivElement).scrollTop).toBe(144);
181
+ });
182
+ });
@@ -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,78 @@
1
+ import { createElement } from "react";
2
+ import { render, screen } from "@testing-library/react";
3
+ import userEvent from "@testing-library/user-event";
4
+ import { beforeEach, describe, expect, it, vi } from "vitest";
5
+
6
+ import type { CronJobView } from "@/shared/lib/api";
7
+ import { setLanguage } from "@/shared/lib/i18n";
8
+ import { SessionCronJobContent } from "@/features/chat/features/workspace/components/session-cron-job-content";
9
+
10
+ const mocks = vi.hoisted(() => ({
11
+ deleteJob: vi.fn(),
12
+ runJob: vi.fn(),
13
+ toggleJob: vi.fn(),
14
+ }));
15
+
16
+ vi.mock("@/features/cron", () => ({
17
+ CronJobDetailDialog: ({ job, open }: { job: CronJobView | null; open: boolean }) => (
18
+ open ? createElement("div", { role: "dialog" }, job?.payload.message) : null
19
+ ),
20
+ useCronJobActions: () => ({
21
+ ConfirmDialog: () => createElement("div", { "data-testid": "cron-confirm" }),
22
+ deleteJob: mocks.deleteJob,
23
+ isPending: () => false,
24
+ runJob: mocks.runJob,
25
+ toggleJob: mocks.toggleJob,
26
+ }),
27
+ }));
28
+
29
+ function createJob(): CronJobView {
30
+ return {
31
+ id: "paint-daily",
32
+ name: "每日绘画",
33
+ enabled: true,
34
+ schedule: { kind: "cron", expr: "0 9 * * *", tz: "Asia/Shanghai" },
35
+ payload: {
36
+ kind: "agent_turn",
37
+ message: "请生成一幅拥有柔和晨光、细腻建筑纹理和远处山景的城市水彩画,并将结果保存到项目图库。",
38
+ agentId: "main",
39
+ sessionId: "session:paint",
40
+ },
41
+ state: {
42
+ nextRunAt: "2026-08-27T01:00:00.000Z",
43
+ lastRunAt: "2026-08-26T01:00:00.000Z",
44
+ lastStatus: "ok",
45
+ lastError: null,
46
+ },
47
+ createdAt: "2026-08-26T01:00:00.000Z",
48
+ updatedAt: "2026-08-26T01:00:00.000Z",
49
+ deleteAfterRun: false,
50
+ };
51
+ }
52
+
53
+ describe("SessionCronJobContent", () => {
54
+ beforeEach(() => {
55
+ setLanguage("zh");
56
+ vi.clearAllMocks();
57
+ });
58
+
59
+ it("keeps the task prompt as a two-line summary and exposes task controls", async () => {
60
+ const user = userEvent.setup();
61
+ const job = createJob();
62
+ render(createElement(SessionCronJobContent, { jobs: [job] }));
63
+
64
+ const summary = screen.getByText(job.payload.message);
65
+ expect(summary.className.split(" ")).toContain("line-clamp-2");
66
+ expect(screen.getAllByTestId("cron-confirm").length).toBe(1);
67
+
68
+ await user.click(screen.getByRole("switch", { name: "暂停" }));
69
+ expect(mocks.toggleJob).toHaveBeenCalledWith(job, false);
70
+
71
+ await user.click(screen.getByRole("button", { name: "更多操作 每日绘画" }));
72
+ await user.click(screen.getByRole("button", { name: "立即执行" }));
73
+ expect(mocks.runJob).toHaveBeenCalledWith(job);
74
+
75
+ await user.click(screen.getByRole("button", { name: "查看任务详情" }));
76
+ expect(screen.getByRole("dialog").textContent).toContain(job.payload.message);
77
+ });
78
+ });
@@ -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, type UIEvent } 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,8 @@ 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';
27
+ import { useScrollRestoration } from '@/shared/hooks/use-scroll-restoration';
26
28
 
27
29
  function inferPreviewKind(params: {
28
30
  path: string;
@@ -41,6 +43,31 @@ function appendPreviewRefreshVersion(url: string, refreshVersion: number): strin
41
43
  return `${url}${url.includes('?') ? '&' : '?'}refresh=${refreshVersion}`;
42
44
  }
43
45
 
46
+ function createMarkdownScrollRestorationKey(params: {
47
+ contentUrl: string | null;
48
+ fileKey: string;
49
+ previewKind: 'text' | 'markdown' | 'binary';
50
+ previewText: string | null;
51
+ previewViewer: 'source' | 'rendered' | null;
52
+ refreshVersion: number;
53
+ }): string | null {
54
+ const {
55
+ contentUrl,
56
+ fileKey,
57
+ previewKind,
58
+ previewText,
59
+ previewViewer,
60
+ refreshVersion,
61
+ } = params;
62
+ if (previewKind !== 'markdown' || previewViewer === 'source' || !previewText || contentUrl) {
63
+ return null;
64
+ }
65
+ // `fileKey` is the workspace tab's stable identity. Do not include resolvedPath:
66
+ // React Query may temporarily expose a previous result while a different file
67
+ // loads, which would turn one tab into several restoration records.
68
+ return `workspace-preview:markdown:${fileKey}:${refreshVersion}`;
69
+ }
70
+
44
71
  function buildPreviewBlock(params: {
45
72
  path: string;
46
73
  text: string;
@@ -157,6 +184,8 @@ function WorkspacePreviewBody({
157
184
  fileBasePath,
158
185
  onFileOpen,
159
186
  onHtmlContentHeightChange,
187
+ markdownScrollRef,
188
+ onMarkdownScroll,
160
189
  previewBlock,
161
190
  previewKind,
162
191
  previewViewer,
@@ -173,6 +202,8 @@ function WorkspacePreviewBody({
173
202
  fileBasePath: string | null;
174
203
  onFileOpen: (action: ChatFileOpenActionViewModel) => void;
175
204
  onHtmlContentHeightChange?: (height: number) => void;
205
+ markdownScrollRef: RefObject<HTMLDivElement>;
206
+ onMarkdownScroll: (event: UIEvent<HTMLDivElement>) => void;
176
207
  previewBlock: ChatFileOperationBlockViewModel | null;
177
208
  previewKind: 'text' | 'markdown' | 'binary';
178
209
  previewViewer: 'source' | 'rendered' | null;
@@ -221,7 +252,11 @@ function WorkspacePreviewBody({
221
252
 
222
253
  if (previewKind === 'markdown' && previewViewer !== 'source' && previewText) {
223
254
  return (
224
- <div className="h-full overflow-auto custom-scrollbar px-5 py-4">
255
+ <div
256
+ ref={markdownScrollRef}
257
+ onScroll={onMarkdownScroll}
258
+ className="h-full overflow-auto custom-scrollbar px-5 py-4"
259
+ >
225
260
  <ChatMessageMarkdown
226
261
  text={previewText}
227
262
  role="assistant"
@@ -266,6 +301,7 @@ export function ChatSessionWorkspaceFilePreview({
266
301
  onFileOpen,
267
302
  onTextExcerptAdd,
268
303
  }: ChatSessionWorkspaceFilePreviewProps) {
304
+ const markdownScrollRef = useRef<HTMLDivElement>(null);
269
305
  const isPreviewMode = file.viewMode === 'preview';
270
306
  const suppliedContentUrl = file.contentUrl?.trim() || null;
271
307
  const usesServerPath = isPreviewMode && !suppliedContentUrl;
@@ -334,6 +370,18 @@ export function ChatSessionWorkspaceFilePreview({
334
370
  previewText,
335
371
  ]);
336
372
  const isTextPreviewTruncated = !contentUrl && Boolean(previewQuery?.data?.truncated);
373
+ const markdownScrollRestoration = useScrollRestoration({
374
+ restorationKey: createMarkdownScrollRestorationKey({
375
+ contentUrl,
376
+ fileKey: file.key,
377
+ previewKind,
378
+ previewText,
379
+ previewViewer,
380
+ refreshVersion,
381
+ }),
382
+ scrollRef: markdownScrollRef,
383
+ });
384
+ const { onScroll: onMarkdownScroll } = markdownScrollRestoration;
337
385
  const breadcrumbBasePath = sessionProjectRoot ?? sessionWorkingDir;
338
386
  const excerptPath =
339
387
  resolveWorkspaceRelativePath({
@@ -360,6 +408,8 @@ export function ChatSessionWorkspaceFilePreview({
360
408
  fileBasePath={sessionWorkingDir}
361
409
  onFileOpen={onFileOpen}
362
410
  onHtmlContentHeightChange={onHtmlContentHeightChange}
411
+ markdownScrollRef={markdownScrollRef}
412
+ onMarkdownScroll={onMarkdownScroll}
363
413
  previewBlock={previewBlock}
364
414
  previewKind={previewKind}
365
415
  previewViewer={previewViewer}
@@ -377,6 +427,19 @@ export function ChatSessionWorkspaceFilePreview({
377
427
  breadcrumb={breadcrumb}
378
428
  leading={breadcrumbLeading}
379
429
  onFileOpen={onFileOpen}
430
+ trailing={
431
+ isPreviewMode &&
432
+ previewKind === 'markdown' &&
433
+ previewViewer !== 'source' &&
434
+ previewText &&
435
+ !contentUrl ? (
436
+ <WorkspaceMarkdownOutline
437
+ contentKey={previewText}
438
+ documentKey={resolvedPath}
439
+ scrollContainerRef={markdownScrollRef}
440
+ />
441
+ ) : undefined
442
+ }
380
443
  />
381
444
  ) : null}
382
445
 
@@ -39,8 +39,11 @@ type ChatSessionWorkspacePanelContentProps = {
39
39
  filePreviewRefreshVersion: number;
40
40
  sessionKey: string | null;
41
41
  sessionCronJobs: readonly CronJobView[];
42
+ sessionCronJobsError?: boolean;
43
+ sessionCronJobsLoading?: boolean;
42
44
  sessionProjectRoot: string | null;
43
45
  sessionWorkingDir: string | null;
46
+ onRetrySessionCronJobs?: () => void;
44
47
  };
45
48
 
46
49
  function WorkspaceOverviewEntry({
@@ -220,8 +223,11 @@ function WorkspaceSelectedContent({
220
223
  filePreviewRefreshVersion,
221
224
  sessionKey,
222
225
  sessionCronJobs,
226
+ sessionCronJobsError = false,
227
+ sessionCronJobsLoading = false,
223
228
  sessionProjectRoot,
224
229
  sessionWorkingDir,
230
+ onRetrySessionCronJobs,
225
231
  explorerControl,
226
232
  }: ChatSessionWorkspacePanelContentProps & {
227
233
  explorerControl?: { open: boolean; onToggle: () => void };
@@ -309,7 +315,14 @@ function WorkspaceSelectedContent({
309
315
  );
310
316
  }
311
317
 
312
- return <SessionCronJobContent jobs={sessionCronJobs} />;
318
+ return (
319
+ <SessionCronJobContent
320
+ jobs={sessionCronJobs}
321
+ isError={sessionCronJobsError}
322
+ isLoading={sessionCronJobsLoading}
323
+ onRetry={onRetrySessionCronJobs}
324
+ />
325
+ );
313
326
  }
314
327
 
315
328
  export function ChatSessionWorkspacePanelContent({
@@ -44,6 +44,9 @@ type ChatSessionWorkspacePanelProps = {
44
44
  workspaceNavigationHistoryIndex?: number;
45
45
  activePanelKind?: ChatWorkspacePanelKind | null;
46
46
  sessionCronJobs?: readonly CronJobView[];
47
+ sessionCronJobsError?: boolean;
48
+ sessionCronJobsLoading?: boolean;
49
+ onRetrySessionCronJobs?: () => void;
47
50
  sessionProjectRoot: string | null;
48
51
  sessionWorkingDir: string | null;
49
52
  workspacePanelWidth?: number;
@@ -62,6 +65,9 @@ export function ChatSessionWorkspacePanel({
62
65
  workspaceNavigationHistoryIndex = 0,
63
66
  activePanelKind,
64
67
  sessionCronJobs = [],
68
+ sessionCronJobsError = false,
69
+ sessionCronJobsLoading = false,
70
+ onRetrySessionCronJobs,
65
71
  sessionProjectRoot,
66
72
  sessionWorkingDir,
67
73
  workspacePanelWidth = CHAT_WORKSPACE_PANEL_DEFAULT_WIDTH,
@@ -218,6 +224,9 @@ export function ChatSessionWorkspacePanel({
218
224
  filePreviewRefreshVersion={filePreviewRefreshVersion}
219
225
  sessionKey={sessionKey}
220
226
  sessionCronJobs={sessionCronJobs}
227
+ sessionCronJobsError={sessionCronJobsError}
228
+ sessionCronJobsLoading={sessionCronJobsLoading}
229
+ onRetrySessionCronJobs={onRetrySessionCronJobs}
221
230
  sessionProjectRoot={sessionProjectRoot}
222
231
  sessionWorkingDir={sessionWorkingDir}
223
232
  />