@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.
- package/CHANGELOG.md +30 -0
- package/dist/assets/{api-BBFMpRGl.js → api-iMkU_o5P.js} +3 -3
- package/dist/assets/{appearance-settings-page-C1Erayzl.js → appearance-settings-page-CU5k4b-M.js} +1 -1
- package/dist/assets/{book-open-BhTIpBKg.js → book-open-nD8gdMhA.js} +1 -1
- package/dist/assets/channels-list-page-Drlt62kY.js +8 -0
- package/dist/assets/{config-split-page-By7p-4X-.js → config-split-page-BtJZp1E5.js} +1 -1
- package/dist/assets/{confirm-dialog-xpl1Yl7r.js → confirm-dialog-k2-Jq_x3.js} +1 -1
- package/dist/assets/{createLucideIcon-B_8khHn8.js → createLucideIcon-DxlwKLr-.js} +1 -1
- package/dist/assets/{desktop-update-config-CGFfa7-Z.js → desktop-update-config-BiZmy7FC.js} +1 -1
- package/dist/assets/{dist-CXN62DSs.js → dist-Cy_e4kNb.js} +1 -1
- package/dist/assets/{dist-DdE-kCbi.js → dist-m08DUswB.js} +1 -1
- package/dist/assets/doc-browser-CPsPHh12.js +1 -0
- package/dist/assets/{doc-browser-context-B8NCuMv5.js → doc-browser-context-jiy-O4F8.js} +1 -1
- package/dist/assets/doc-browser-npOvWtgf.js +1 -0
- package/dist/assets/{extensions-CMctxyTf.js → extensions-CIb17__o.js} +1 -1
- package/dist/assets/{form-actions-BFgTdxqI.js → form-actions-DGf5zUg_.js} +1 -1
- package/dist/assets/index-C86hxgv5.css +1 -0
- package/dist/assets/index-CYBXR7zN.js +109 -0
- package/dist/assets/loader-circle-BlLuRFVW.js +1 -0
- package/dist/assets/mcp-marketplace-page-B12JNh7Q.js +1 -0
- package/dist/assets/mcp-marketplace-page-kLWW6kzt.js +9 -0
- package/dist/assets/model-config-page-DSeAjVT_.js +1 -0
- package/dist/assets/{navigation-link-BwWrm4k1.js → navigation-link-DAfwFCj4.js} +1 -1
- package/dist/assets/plus-D4LtWtTk.js +1 -0
- package/dist/assets/privacy-settings-page-tN0Mt15x.js +1 -0
- package/dist/assets/providers-config-page-CgPrXR1u.js +1 -0
- package/dist/assets/react-8iBFECZE.js +8 -0
- package/dist/assets/{refresh-cw-B7NmPJ_Z.js → refresh-cw-D_Eibznw.js} +1 -1
- package/dist/assets/remote-Derr_AQp.js +1 -0
- package/dist/assets/{rotate-cw-B0ajMaHu.js → rotate-cw-QQjmoWuq.js} +1 -1
- package/dist/assets/runtime-config-page-Crq96Jjq.js +1 -0
- package/dist/assets/{save-DWnYYpgQ.js → save-W2xthJJ2.js} +1 -1
- package/dist/assets/{search-D6pX7FC-.js → search-Vih1R2BD.js} +1 -1
- package/dist/assets/{search-config-page-Cx3o_675.js → search-config-page-CtBcL2uc.js} +1 -1
- package/dist/assets/{secrets-config-page-tyFl7Rvt.js → secrets-config-page-Q6XfIj0h.js} +2 -2
- package/dist/assets/security-config-CE9rkQvq.js +1 -0
- package/dist/assets/{select-qEtFTWB5.js → select-lNdozU4o.js} +1 -1
- package/dist/assets/skeleton-B__aTcly.js +1 -0
- package/dist/assets/{tag-chip-vcjAYdV_.js → tag-chip-gdaU9bE7.js} +1 -1
- package/dist/assets/x-CWdWBu_U.js +1 -0
- package/dist/index.html +21 -21
- package/package.json +7 -7
- package/src/app/components/layout/sidebar.tsx +7 -0
- package/src/features/apps/components/app-package-card.tsx +29 -7
- package/src/features/apps/components/app-packages-panel.tsx +4 -0
- package/src/features/chat/components/conversation/chat-conversation-content.tsx +18 -2
- package/src/features/chat/components/conversation/chat-conversation-workspace-section.tsx +6 -0
- 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 +27 -6
- package/src/features/chat/components/layout/__tests__/chat-sidebar.test.tsx +17 -17
- package/src/features/chat/components/layout/chat-page-shell.tsx +19 -2
- package/src/features/chat/components/layout/chat-sidebar-desktop-layout.tsx +33 -5
- package/src/features/chat/components/layout/chat-sidebar.tsx +31 -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-timeline-compaction.utils.test.ts +2 -2
- package/src/features/chat/features/message/utils/__tests__/chat-message-trigger-details.utils.test.ts +35 -0
- package/src/features/chat/features/message/utils/chat-message-timeline.utils.ts +2 -2
- 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 +35 -6
- package/src/features/chat/features/ncp/hooks/use-ncp-session-list-view.ts +43 -20
- package/src/features/chat/features/ncp/hooks/use-ncp-session-queries.ts +15 -1
- 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-token-usage.test.tsx +4 -1
- package/src/features/chat/features/workspace/components/__tests__/chat-session-workspace-file-preview.scroll-restoration.test.tsx +182 -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__/session-cron-job-content.test.tsx +78 -0
- 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 +65 -2
- package/src/features/chat/features/workspace/components/chat-session-workspace-panel-content.tsx +14 -1
- package/src/features/chat/features/workspace/components/chat-session-workspace-panel.tsx +9 -0
- package/src/features/chat/features/workspace/components/overview/chat-session-token-usage.tsx +17 -0
- package/src/features/chat/features/workspace/components/session-cron-job-content.tsx +269 -75
- package/src/features/chat/features/workspace/components/workspace-markdown-outline.tsx +156 -0
- package/src/features/chat/features/workspace/hooks/use-chat-conversation-workspace-state.ts +5 -0
- package/src/features/cron/components/__tests__/cron-config.test.tsx +16 -5
- package/src/features/cron/components/cron-config.tsx +20 -46
- package/src/features/cron/components/cron-job-row.tsx +1 -1
- package/src/features/cron/hooks/__tests__/use-cron-job-actions.test.tsx +95 -0
- package/src/features/cron/hooks/use-cron-job-actions.ts +109 -0
- package/src/features/cron/index.ts +2 -0
- package/src/features/inbox/pages/inbox-page.tsx +18 -2
- package/src/features/marketplace/components/marketplace-page.tsx +7 -0
- package/src/features/panel-apps/pages/panel-app-main-page.tsx +8 -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/platforms/desktop/components/desktop-app-shell.tsx +10 -1
- package/src/shared/components/doc-browser/doc-browser.tsx +0 -1
- package/src/shared/components/doc-browser/hooks/use-doc-browser-scroll-restoration.ts +9 -113
- 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/hooks/use-panel-app-scroll-restoration.test.tsx +66 -0
- package/src/shared/hooks/use-panel-app-scroll-restoration.ts +107 -0
- package/src/shared/hooks/use-scroll-restoration.test.tsx +62 -0
- package/src/shared/hooks/use-scroll-restoration.ts +61 -0
- package/src/shared/lib/api/ncp-session-query-cache.utils.ts +1 -0
- package/src/shared/lib/api/ncp-session.types.ts +3 -0
- package/src/shared/lib/api/types.ts +9 -0
- package/src/shared/lib/api/utils/config.utils.ts +5 -0
- package/src/shared/lib/api/utils/ncp-session.utils.ts +3 -0
- package/src/shared/lib/i18n/locales/en-US/chat.json +15 -5
- package/src/shared/lib/i18n/locales/en-US/core.json +17 -9
- package/src/shared/lib/i18n/locales/en-US/cron.json +4 -2
- package/src/shared/lib/i18n/locales/en-US/doc-browser.json +1 -0
- package/src/shared/lib/i18n/locales/zh-CN/chat.json +15 -5
- package/src/shared/lib/i18n/locales/zh-CN/core.json +17 -9
- package/src/shared/lib/i18n/locales/zh-CN/cron.json +4 -2
- package/src/shared/lib/i18n/locales/zh-CN/doc-browser.json +1 -0
- package/src/shared/lib/navigation-history/index.ts +1 -0
- package/src/shared/lib/navigation-history/scroll-restoration.manager.test.ts +37 -0
- package/src/shared/lib/navigation-history/scroll-restoration.manager.ts +60 -0
- 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
|
@@ -17,12 +17,8 @@ import {
|
|
|
17
17
|
CronTaskComposer,
|
|
18
18
|
CronTemplateGallery,
|
|
19
19
|
} from "@/features/cron/components/cron-task-discovery";
|
|
20
|
-
import {
|
|
21
|
-
|
|
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
|
|
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 =
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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 =
|
|
221
|
-
|
|
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("
|
|
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
|
+
}
|
|
@@ -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
|
|
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:
|
|
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
|
+
}
|
|
@@ -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
|
|
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>
|