@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
@@ -17,12 +17,8 @@ import {
17
17
  CronTaskComposer,
18
18
  CronTemplateGallery,
19
19
  } from "@/features/cron/components/cron-task-discovery";
20
- import {
21
- useCronJobs,
22
- useDeleteCronJob,
23
- useRunCronJob,
24
- useToggleCronJob,
25
- } from "@/features/cron/hooks/use-cron-jobs";
20
+ import { useCronJobs } from "@/features/cron/hooks/use-cron-jobs";
21
+ import { useCronJobActions } from "@/features/cron/hooks/use-cron-job-actions";
26
22
  import { Button } from "@/shared/components/ui/button";
27
23
  import { IconActionButton } from "@/shared/components/ui/actions/icon-action-button";
28
24
  import { Input } from "@/shared/components/ui/input";
@@ -31,7 +27,6 @@ import type {
31
27
  CronListStatus,
32
28
  CronListSummaryView,
33
29
  } from "@/shared/lib/api";
34
- import { useConfirmDialog } from "@/shared/hooks/use-confirm-dialog";
35
30
  import { t } from "@/shared/lib/i18n";
36
31
  import { cn } from "@/shared/lib/utils";
37
32
 
@@ -167,10 +162,7 @@ export function CronConfig() {
167
162
  query: deferredQuery,
168
163
  status,
169
164
  });
170
- const deleteCronJob = useDeleteCronJob();
171
- const toggleCronJob = useToggleCronJob();
172
- const runCronJob = useRunCronJob();
173
- const { confirm, ConfirmDialog } = useConfirmDialog();
165
+ const cronActions = useCronJobActions();
174
166
  const jobs = cronQuery.data?.jobs ?? [];
175
167
  const total = cronQuery.data?.total ?? 0;
176
168
  const summary = cronQuery.data?.summary;
@@ -190,46 +182,28 @@ export function CronConfig() {
190
182
  composerRef.current?.focus();
191
183
  };
192
184
 
193
- const handleDelete = async (job: CronJobView) => {
194
- const confirmed = await confirm({
195
- title: `${t("cronDeleteConfirm")}?`,
196
- description: job.name ? `${job.name} (${job.id})` : job.id,
197
- variant: "destructive",
198
- confirmLabel: t("delete"),
185
+ const handleDelete = (job: CronJobView) => {
186
+ void cronActions.deleteJob(job, () => {
187
+ setExpandedJobId(null);
188
+ setSelectedJobId(null);
189
+ if (jobs.length === 1 && page > 0) {
190
+ setPage(page - 1);
191
+ }
199
192
  });
200
- if (!confirmed) {
201
- return;
202
- }
203
- setExpandedJobId(null);
204
- setSelectedJobId(null);
205
- if (jobs.length === 1 && page > 0) {
206
- setPage(page - 1);
207
- }
208
- deleteCronJob.mutate({ id: job.id });
209
193
  };
210
194
 
211
195
  const handleToggle = (job: CronJobView, enabled: boolean) => {
212
- const leavesCurrentFilter =
213
- (status === "enabled" && !enabled) || (status === "disabled" && enabled);
214
- if (leavesCurrentFilter && jobs.length === 1 && page > 0) {
215
- setPage(page - 1);
216
- }
217
- toggleCronJob.mutate({ id: job.id, enabled });
196
+ void cronActions.toggleJob(job, enabled, () => {
197
+ const leavesCurrentFilter =
198
+ (status === "enabled" && !enabled) || (status === "disabled" && enabled);
199
+ if (leavesCurrentFilter && jobs.length === 1 && page > 0) {
200
+ setPage(page - 1);
201
+ }
202
+ });
218
203
  };
219
204
 
220
- const handleRun = async (job: CronJobView) => {
221
- const force = !job.enabled;
222
- if (force) {
223
- const confirmed = await confirm({
224
- title: `${t("cronRunForceConfirm")}?`,
225
- description: job.name ? `${job.name} (${job.id})` : job.id,
226
- confirmLabel: t("cronRunNow"),
227
- });
228
- if (!confirmed) {
229
- return;
230
- }
231
- }
232
- runCronJob.mutate({ id: job.id, force });
205
+ const handleRun = (job: CronJobView) => {
206
+ void cronActions.runJob(job);
233
207
  };
234
208
 
235
209
  const handlePageChange = (nextPage: number) => {
@@ -446,7 +420,7 @@ export function CronConfig() {
446
420
  onRun={handleRun}
447
421
  onToggle={handleToggle}
448
422
  />
449
- <ConfirmDialog />
423
+ <cronActions.ConfirmDialog />
450
424
  </PageLayout>
451
425
  );
452
426
  }
@@ -166,7 +166,7 @@ export function CronJobRow({
166
166
  size="icon"
167
167
  className="h-8 w-8"
168
168
  onClick={() => onEdit(job)}
169
- aria-label={`${t("edit")} ${jobLabel}`}
169
+ aria-label={`${t("cronEditDetails")} ${jobLabel}`}
170
170
  >
171
171
  <Pencil className="h-3.5 w-3.5" />
172
172
  </Button>
@@ -0,0 +1,95 @@
1
+ import { act, renderHook } from "@testing-library/react";
2
+ import { beforeEach, describe, expect, it, vi } from "vitest";
3
+
4
+ import type { CronJobView } from "@/shared/lib/api";
5
+ import { setLanguage } from "@/shared/lib/i18n";
6
+ import { useCronJobActions } from "@/features/cron/hooks/use-cron-job-actions";
7
+
8
+ const mocks = vi.hoisted(() => ({
9
+ confirm: vi.fn(),
10
+ deleteJob: vi.fn(),
11
+ runJob: vi.fn(),
12
+ toggleJob: vi.fn(),
13
+ }));
14
+
15
+ vi.mock("@/features/cron/hooks/use-cron-jobs", () => ({
16
+ useDeleteCronJob: () => ({ mutateAsync: mocks.deleteJob }),
17
+ useRunCronJob: () => ({ mutateAsync: mocks.runJob }),
18
+ useToggleCronJob: () => ({ mutateAsync: mocks.toggleJob }),
19
+ }));
20
+
21
+ vi.mock("@/shared/hooks/use-confirm-dialog", () => ({
22
+ useConfirmDialog: () => ({
23
+ confirm: mocks.confirm,
24
+ ConfirmDialog: () => null,
25
+ }),
26
+ }));
27
+
28
+ function createJob(overrides: Partial<CronJobView> = {}): CronJobView {
29
+ return {
30
+ id: "paint-daily",
31
+ name: "每日绘画",
32
+ enabled: true,
33
+ schedule: { kind: "cron", expr: "0 9 * * *", tz: "Asia/Shanghai" },
34
+ payload: {
35
+ kind: "agent_turn",
36
+ message: "生成一张今日绘画。",
37
+ agentId: "main",
38
+ sessionId: "session:paint",
39
+ },
40
+ state: {
41
+ nextRunAt: "2026-08-27T01:00:00.000Z",
42
+ lastRunAt: null,
43
+ lastStatus: null,
44
+ lastError: null,
45
+ },
46
+ createdAt: "2026-08-26T01:00:00.000Z",
47
+ updatedAt: "2026-08-26T01:00:00.000Z",
48
+ deleteAfterRun: false,
49
+ ...overrides,
50
+ };
51
+ }
52
+
53
+ describe("useCronJobActions", () => {
54
+ beforeEach(() => {
55
+ setLanguage("zh");
56
+ vi.clearAllMocks();
57
+ mocks.confirm.mockResolvedValue(true);
58
+ mocks.deleteJob.mockResolvedValue({ deleted: true });
59
+ mocks.runJob.mockResolvedValue({ job: createJob(), executed: true });
60
+ mocks.toggleJob.mockResolvedValue({ job: createJob() });
61
+ });
62
+
63
+ it("requires confirmation and forces a paused task to run without enabling it", async () => {
64
+ const { result } = renderHook(() => useCronJobActions());
65
+ const job = createJob({ enabled: false });
66
+
67
+ await act(async () => {
68
+ await result.current.runJob(job);
69
+ });
70
+
71
+ expect(mocks.confirm).toHaveBeenCalledWith({
72
+ title: "任务已暂停,仍要立即执行?",
73
+ description: "每日绘画 (paint-daily)",
74
+ confirmLabel: "立即执行",
75
+ });
76
+ expect(mocks.runJob).toHaveBeenCalledWith({ id: "paint-daily", force: true });
77
+ expect(mocks.toggleJob).not.toHaveBeenCalled();
78
+ });
79
+
80
+ it("uses a destructive confirmation before deleting", async () => {
81
+ const { result } = renderHook(() => useCronJobActions());
82
+
83
+ await act(async () => {
84
+ await result.current.deleteJob(createJob());
85
+ });
86
+
87
+ expect(mocks.confirm).toHaveBeenCalledWith({
88
+ title: "确认删除定时任务?",
89
+ description: "每日绘画 (paint-daily)",
90
+ confirmLabel: "删除",
91
+ variant: "destructive",
92
+ });
93
+ expect(mocks.deleteJob).toHaveBeenCalledWith({ id: "paint-daily" });
94
+ });
95
+ });
@@ -0,0 +1,109 @@
1
+ import { useState } from "react";
2
+
3
+ import type { CronJobView } from "@/shared/lib/api";
4
+ import { useConfirmDialog } from "@/shared/hooks/use-confirm-dialog";
5
+ import { t } from "@/shared/lib/i18n";
6
+ import {
7
+ useDeleteCronJob,
8
+ useRunCronJob,
9
+ useToggleCronJob,
10
+ } from "./use-cron-jobs";
11
+
12
+ type AfterSuccess = () => void;
13
+
14
+ function describeJob(job: CronJobView): string {
15
+ return job.name ? `${job.name} (${job.id})` : job.id;
16
+ }
17
+
18
+ export function useCronJobActions() {
19
+ const deleteCronJob = useDeleteCronJob();
20
+ const runCronJob = useRunCronJob();
21
+ const toggleCronJob = useToggleCronJob();
22
+ const { confirm, ConfirmDialog } = useConfirmDialog();
23
+ const [pendingJobIds, setPendingJobIds] = useState<ReadonlySet<string>>(
24
+ new Set(),
25
+ );
26
+
27
+ const execute = async (
28
+ job: CronJobView,
29
+ action: () => Promise<unknown>,
30
+ afterSuccess?: AfterSuccess,
31
+ ): Promise<boolean> => {
32
+ setPendingJobIds((ids) => new Set(ids).add(job.id));
33
+ try {
34
+ await action();
35
+ afterSuccess?.();
36
+ return true;
37
+ } catch {
38
+ return false;
39
+ } finally {
40
+ setPendingJobIds((ids) => {
41
+ const nextIds = new Set(ids);
42
+ nextIds.delete(job.id);
43
+ return nextIds;
44
+ });
45
+ }
46
+ };
47
+
48
+ const deleteJob = async (
49
+ job: CronJobView,
50
+ afterSuccess?: AfterSuccess,
51
+ ): Promise<boolean> => {
52
+ const confirmed = await confirm({
53
+ title: `${t("cronDeleteConfirm")}?`,
54
+ description: describeJob(job),
55
+ confirmLabel: t("delete"),
56
+ variant: "destructive",
57
+ });
58
+ if (!confirmed) {
59
+ return false;
60
+ }
61
+ return await execute(
62
+ job,
63
+ () => deleteCronJob.mutateAsync({ id: job.id }),
64
+ afterSuccess,
65
+ );
66
+ };
67
+
68
+ const runJob = async (
69
+ job: CronJobView,
70
+ afterSuccess?: AfterSuccess,
71
+ ): Promise<boolean> => {
72
+ const force = !job.enabled;
73
+ if (force) {
74
+ const confirmed = await confirm({
75
+ title: `${t("cronRunForceConfirm")}?`,
76
+ description: describeJob(job),
77
+ confirmLabel: t("cronRunNow"),
78
+ });
79
+ if (!confirmed) {
80
+ return false;
81
+ }
82
+ }
83
+ return await execute(
84
+ job,
85
+ () => runCronJob.mutateAsync({ id: job.id, force }),
86
+ afterSuccess,
87
+ );
88
+ };
89
+
90
+ const toggleJob = async (
91
+ job: CronJobView,
92
+ enabled: boolean,
93
+ afterSuccess?: AfterSuccess,
94
+ ): Promise<boolean> => {
95
+ return await execute(
96
+ job,
97
+ () => toggleCronJob.mutateAsync({ id: job.id, enabled }),
98
+ afterSuccess,
99
+ );
100
+ };
101
+
102
+ return {
103
+ ConfirmDialog,
104
+ deleteJob,
105
+ isPending: (job: CronJobView) => pendingJobIds.has(job.id),
106
+ runJob,
107
+ toggleJob,
108
+ };
109
+ }
@@ -1,4 +1,6 @@
1
1
  export { CronConfig } from "./components/cron-config";
2
+ export { CronJobDetailDialog } from "./components/cron-job-detail-dialog";
3
+ export { useCronJobActions } from "./hooks/use-cron-job-actions";
2
4
  export {
3
5
  useCronJobs,
4
6
  useDeleteCronJob,
@@ -12,6 +12,7 @@ import { Button } from "@/shared/components/ui/button";
12
12
  import { useConfirmDialog } from "@/shared/hooks/use-confirm-dialog";
13
13
  import { formatDateShort, formatDateTime, t } from "@/shared/lib/i18n";
14
14
  import { cn } from "@/shared/lib/utils";
15
+ import { useScrollRestoration } from "@/shared/hooks/use-scroll-restoration";
15
16
 
16
17
  type InboxFilter = "unread" | "all" | "archived";
17
18
 
@@ -64,6 +65,10 @@ function InboxListPane({
64
65
  filter: InboxFilter;
65
66
  onFilterChange: (filter: InboxFilter) => void;
66
67
  }) {
68
+ const scrollRestoration = useScrollRestoration<HTMLDivElement>({
69
+ restorationKey: "inbox:list",
70
+ });
71
+ const { onScroll, scrollRef } = scrollRestoration;
67
72
  const filterItems: Array<{ id: InboxFilter; label: string; count: number }> = [
68
73
  {
69
74
  id: "unread",
@@ -100,7 +105,11 @@ function InboxListPane({
100
105
  </button>
101
106
  ))}
102
107
  </div>
103
- <div className="custom-scrollbar min-h-0 flex-1 overflow-y-auto p-2">
108
+ <div
109
+ ref={scrollRef}
110
+ onScroll={onScroll}
111
+ className="custom-scrollbar min-h-0 flex-1 overflow-y-auto p-2"
112
+ >
104
113
  {filteredDeliveries.length > 0 ? (
105
114
  <ul className="space-y-0.5">
106
115
  {filteredDeliveries.map((delivery) => (
@@ -166,6 +175,10 @@ function InboxDetailPane({
166
175
  onDelete: () => void;
167
176
  onReadToggle: () => void;
168
177
  }) {
178
+ const scrollRestoration = useScrollRestoration<HTMLDivElement>({
179
+ restorationKey: delivery ? `inbox:delivery:${delivery.id}` : null,
180
+ });
181
+ const { onScroll, scrollRef } = scrollRestoration;
169
182
  if (!delivery) {
170
183
  return <main className="flex min-h-0 flex-col"><InboxEmptyState selection /></main>;
171
184
  }
@@ -204,7 +217,10 @@ function InboxDetailPane({
204
217
  isHtml
205
218
  ? "p-3 sm:p-4"
206
219
  : "custom-scrollbar overflow-y-auto px-5 py-5 sm:px-6 sm:py-6",
207
- )}>
220
+ )}
221
+ ref={scrollRef}
222
+ onScroll={onScroll}
223
+ >
208
224
  <div className={cn("mx-auto max-w-5xl", isHtml && "h-full")}>
209
225
  <InboxDeliveryContent
210
226
  className={isHtml ? "h-full" : undefined}
@@ -30,6 +30,7 @@ import { PageLayout } from "@/app/components/layout/page-layout";
30
30
  import { useEffect, useMemo, useState } from "react";
31
31
  import { useNavigate, useParams } from "react-router-dom";
32
32
  import { useInfiniteScrollLoader } from "@/shared/hooks/use-infinite-scroll-loader";
33
+ import { useScrollRestoration } from "@/shared/hooks/use-scroll-restoration";
33
34
  import { Tabs, TabsList, TabsTrigger } from "@/shared/components/ui/tabs";
34
35
 
35
36
  const PAGE_SIZE = 20;
@@ -95,6 +96,11 @@ export function MarketplacePage({
95
96
  onLoadMore: () => itemsQuery.fetchNextPage(),
96
97
  watchValue: `${typeFilter}:${scope}:${query}:${sceneParam ?? ""}:${sort}:${itemsQuery.data?.loadedItems ?? 0}:${itemsQuery.data?.loadedPages ?? 0}`,
97
98
  });
99
+ const scrollRestoration = useScrollRestoration({
100
+ restorationKey: `marketplace:${forcedType ?? "all"}`,
101
+ scrollRef: listContainerRef,
102
+ });
103
+ const { onScroll: onScrollPositionSave } = scrollRestoration;
98
104
 
99
105
  useEffect(() => {
100
106
  const container = listContainerRef.current;
@@ -177,6 +183,7 @@ export function MarketplacePage({
177
183
  <section className="flex min-h-0 flex-1 flex-col">
178
184
  <div
179
185
  ref={listContainerRef}
186
+ onScroll={onScrollPositionSave}
180
187
  className="min-h-0 flex-1 overflow-y-auto custom-scrollbar pr-1"
181
188
  aria-busy={listModel.showListSkeleton || itemsQuery.isFetchingNextPage}
182
189
  >
@@ -12,6 +12,7 @@ import {
12
12
  } from "@/features/panel-apps/utils/panel-app-iframe.utils";
13
13
  import { openApps } from "@/features/panel-apps/utils/panel-app-doc-browser.utils";
14
14
  import { useDocBrowser } from "@/shared/components/doc-browser";
15
+ import { usePanelAppScrollRestoration } from "@/shared/hooks/use-panel-app-scroll-restoration";
15
16
  import { t } from "@/shared/lib/i18n";
16
17
 
17
18
  export function PanelAppMainPage() {
@@ -25,6 +26,12 @@ export function PanelAppMainPage() {
25
26
  [appId, panelApps.data?.entries],
26
27
  );
27
28
  const runtime = usePanelAppMainRuntime(entry);
29
+ const restoreScroll = usePanelAppScrollRestoration({
30
+ currentUrl: entry?.contentPath ?? null,
31
+ iframeRef,
32
+ isEnabled: Boolean(entry),
33
+ restorationKey: entry ? `panel-app:main:${entry.appId}` : null,
34
+ });
28
35
 
29
36
  useEffect(() => {
30
37
  const handleMessage = (event: MessageEvent) => {
@@ -88,6 +95,7 @@ export function PanelAppMainPage() {
88
95
  title={entry.title}
89
96
  sandbox={PANEL_APP_IFRAME_SANDBOX}
90
97
  className="block h-full w-full border-0 bg-background"
98
+ onLoad={restoreScroll}
91
99
  onPointerOver={(event) => focusPanelAppIframe(event.currentTarget)}
92
100
  />
93
101
  </div>
@@ -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
+ }
@@ -14,6 +14,7 @@ import type { DocBrowserTabMenuGroupsResolver } from "@/shared/components/doc-br
14
14
  import { cn } from "@/shared/lib/utils";
15
15
  import { useViewportLayoutStore } from "@/app/stores/viewport-layout.store";
16
16
  import { SIDEBAR_RAIL_WIDTH_PX } from "@/app/components/layout/sidebar-rail.styles";
17
+ import { useScrollRestoration } from "@/shared/hooks/use-scroll-restoration";
17
18
 
18
19
  const DocBrowser = lazy(async () => ({
19
20
  default: (await import("@/shared/components/doc-browser/doc-browser"))
@@ -55,6 +56,10 @@ export function DesktopAppShell({
55
56
  : isMainRoute
56
57
  ? "280px"
57
58
  : "240px";
59
+ const settingsScroll = useScrollRestoration<HTMLElement>({
60
+ restorationKey: isMainRoute ? null : `settings-page:${pathname}`,
61
+ });
62
+ const { onScroll: onSettingsScroll, scrollRef: settingsScrollRef } = settingsScroll;
58
63
 
59
64
  return (
60
65
  <div
@@ -82,7 +87,11 @@ export function DesktopAppShell({
82
87
  {isMainRoute ? (
83
88
  <div className="flex-1 h-full overflow-hidden">{children}</div>
84
89
  ) : (
85
- <main className="flex-1 overflow-auto p-8 pb-16 custom-scrollbar">
90
+ <main
91
+ ref={settingsScrollRef}
92
+ onScroll={onSettingsScroll}
93
+ className="flex-1 overflow-auto p-8 pb-16 custom-scrollbar"
94
+ >
86
95
  <div className="mx-auto h-full max-w-6xl animate-fade-in">
87
96
  {children}
88
97
  </div>
@@ -215,7 +215,6 @@ export function DocBrowser({
215
215
  currentTab,
216
216
  iframeRef,
217
217
  isEnabled: supportsScrollRestoration,
218
- tabs,
219
218
  });
220
219
  const { handleUrlSubmit, setUrlInput, urlInput } = useDocBrowserAddressBar({
221
220
  activeTabId,