@nextclaw/ui 0.20.2 → 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 +12 -0
- package/dist/assets/{api-DvpQWjTS.js → api-iMkU_o5P.js} +3 -3
- package/dist/assets/{appearance-settings-page-U0m0ktu3.js → appearance-settings-page-CU5k4b-M.js} +1 -1
- package/dist/assets/{book-open-DRofSTWr.js → book-open-nD8gdMhA.js} +1 -1
- package/dist/assets/{channels-list-page-BURQarD0.js → channels-list-page-Drlt62kY.js} +1 -1
- package/dist/assets/{config-split-page-BrAQYTIl.js → config-split-page-BtJZp1E5.js} +1 -1
- package/dist/assets/{confirm-dialog-DDJomLnO.js → confirm-dialog-k2-Jq_x3.js} +1 -1
- package/dist/assets/{createLucideIcon-DzkNHr5Z.js → createLucideIcon-DxlwKLr-.js} +1 -1
- package/dist/assets/{desktop-update-config-BzZFhMv9.js → desktop-update-config-BiZmy7FC.js} +1 -1
- package/dist/assets/{dist-DXbLo2b1.js → dist-Cy_e4kNb.js} +1 -1
- package/dist/assets/{dist-NskPB_yW.js → dist-m08DUswB.js} +1 -1
- package/dist/assets/doc-browser-CPsPHh12.js +1 -0
- package/dist/assets/{doc-browser-context-CnKV4gJy.js → doc-browser-context-jiy-O4F8.js} +1 -1
- package/dist/assets/doc-browser-npOvWtgf.js +1 -0
- package/dist/assets/{extensions-DvlFMpAs.js → extensions-CIb17__o.js} +1 -1
- package/dist/assets/{form-actions-UnPEZBkx.js → form-actions-DGf5zUg_.js} +1 -1
- package/dist/assets/{index-Dc_ZMkmo.css → index-C86hxgv5.css} +1 -1
- 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-BIgFTbMq.js → mcp-marketplace-page-kLWW6kzt.js} +1 -1
- package/dist/assets/{model-config-page-CnSm7kfa.js → model-config-page-DSeAjVT_.js} +1 -1
- package/dist/assets/{navigation-link-NjUdG6Vp.js → navigation-link-DAfwFCj4.js} +1 -1
- package/dist/assets/plus-D4LtWtTk.js +1 -0
- package/dist/assets/{privacy-settings-page-DVn2HU7W.js → privacy-settings-page-tN0Mt15x.js} +1 -1
- package/dist/assets/{providers-config-page-CzHJS3eg.js → providers-config-page-CgPrXR1u.js} +1 -1
- package/dist/assets/react-8iBFECZE.js +8 -0
- package/dist/assets/{refresh-cw-CkeninE0.js → refresh-cw-D_Eibznw.js} +1 -1
- package/dist/assets/remote-Derr_AQp.js +1 -0
- package/dist/assets/{rotate-cw-DP33PuXU.js → rotate-cw-QQjmoWuq.js} +1 -1
- package/dist/assets/{runtime-config-page-D69buyGp.js → runtime-config-page-Crq96Jjq.js} +1 -1
- package/dist/assets/{save-Cw2Xz-pB.js → save-W2xthJJ2.js} +1 -1
- package/dist/assets/{search-C2CFAVvd.js → search-Vih1R2BD.js} +1 -1
- package/dist/assets/{search-config-page-dqNT0Swf.js → search-config-page-CtBcL2uc.js} +1 -1
- package/dist/assets/{secrets-config-page-De6J3wVx.js → secrets-config-page-Q6XfIj0h.js} +1 -1
- package/dist/assets/security-config-CE9rkQvq.js +1 -0
- package/dist/assets/{select-DSuMhwVP.js → select-lNdozU4o.js} +1 -1
- package/dist/assets/skeleton-B__aTcly.js +1 -0
- package/dist/assets/{tag-chip-DFBLgsGf.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 +8 -8
- 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-session-area.test.tsx +16 -2
- package/src/features/chat/components/layout/chat-page-shell.tsx +19 -2
- package/src/features/chat/components/layout/chat-sidebar-desktop-layout.tsx +16 -0
- package/src/features/chat/components/layout/chat-sidebar.tsx +4 -1
- package/src/features/chat/features/message/utils/__tests__/chat-message-timeline-compaction.utils.test.ts +2 -2
- package/src/features/chat/features/message/utils/chat-message-timeline.utils.ts +2 -2
- package/src/features/chat/features/ncp/hooks/__tests__/use-ncp-session-list-view.test.tsx +11 -3
- package/src/features/chat/features/ncp/hooks/use-ncp-session-list-view.ts +12 -5
- package/src/features/chat/features/ncp/hooks/use-ncp-session-queries.ts +15 -1
- 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__/session-cron-job-content.test.tsx +78 -0
- package/src/features/chat/features/workspace/components/chat-session-workspace-file-preview.tsx +47 -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/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/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/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 +1 -0
- package/src/shared/lib/api/utils/ncp-session.utils.ts +3 -0
- package/src/shared/lib/i18n/locales/en-US/chat.json +8 -5
- 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 +8 -5
- 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/doc-browser-DB22tQKU.js +0 -1
- package/dist/assets/doc-browser-sJjw9fl5.js +0 -1
- package/dist/assets/index-N1HnesBp.js +0 -109
- package/dist/assets/loader-circle-BlPXxgDB.js +0 -1
- package/dist/assets/mcp-marketplace-page-CZN4ovdK.js +0 -1
- package/dist/assets/plus-DVXol9Q5.js +0 -1
- package/dist/assets/react-DCVbGroo.js +0 -8
- package/dist/assets/remote-B2lsJF1c.js +0 -1
- package/dist/assets/security-config-Dcu9C4OL.js +0 -1
- package/dist/assets/skeleton-QoWydNLp.js +0 -1
- package/dist/assets/x-BXvAzpjb.js +0 -1
|
@@ -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>
|
|
@@ -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>
|
|
@@ -1,126 +1,22 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { PANEL_APP_SCROLL_RESTORATION_CONTRACT } from '@nextclaw/shared';
|
|
1
|
+
import type { RefObject } from 'react';
|
|
3
2
|
import type { DocBrowserTab } from '@/shared/components/doc-browser/doc-browser-context';
|
|
4
|
-
|
|
5
|
-
type ScrollTarget =
|
|
6
|
-
| { kind: 'document' }
|
|
7
|
-
| { kind: 'element'; path: Array<{ index: number; tagName: string }> };
|
|
8
|
-
|
|
9
|
-
type ScrollPosition = {
|
|
10
|
-
currentUrl: string;
|
|
11
|
-
target: ScrollTarget;
|
|
12
|
-
x: number;
|
|
13
|
-
y: number;
|
|
14
|
-
};
|
|
3
|
+
import { usePanelAppScrollRestoration } from '@/shared/hooks/use-panel-app-scroll-restoration';
|
|
15
4
|
|
|
16
5
|
type UseDocBrowserScrollRestorationParams = {
|
|
17
6
|
currentTab?: DocBrowserTab;
|
|
18
|
-
iframeRef: RefObject<HTMLIFrameElement
|
|
7
|
+
iframeRef: RefObject<HTMLIFrameElement>;
|
|
19
8
|
isEnabled: boolean;
|
|
20
|
-
tabs: DocBrowserTab[];
|
|
21
9
|
};
|
|
22
10
|
|
|
23
|
-
function isScrollTarget(value: unknown): value is ScrollTarget {
|
|
24
|
-
if (!value || typeof value !== 'object') {
|
|
25
|
-
return false;
|
|
26
|
-
}
|
|
27
|
-
const candidate = value as Partial<ScrollTarget>;
|
|
28
|
-
if (candidate.kind === 'document') {
|
|
29
|
-
return true;
|
|
30
|
-
}
|
|
31
|
-
return candidate.kind === 'element'
|
|
32
|
-
&& Array.isArray(candidate.path)
|
|
33
|
-
&& candidate.path.length > 0
|
|
34
|
-
&& candidate.path.length <= 30
|
|
35
|
-
&& candidate.path.every((segment) => (
|
|
36
|
-
typeof segment?.index === 'number'
|
|
37
|
-
&& Number.isInteger(segment.index)
|
|
38
|
-
&& segment.index >= 0
|
|
39
|
-
&& segment.index <= 1000
|
|
40
|
-
&& typeof segment.tagName === 'string'
|
|
41
|
-
&& segment.tagName.length > 0
|
|
42
|
-
&& segment.tagName.length <= 32
|
|
43
|
-
));
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
function isScrollMessage(value: unknown): value is {
|
|
47
|
-
target: ScrollTarget;
|
|
48
|
-
type: string;
|
|
49
|
-
version: number;
|
|
50
|
-
x: number;
|
|
51
|
-
y: number;
|
|
52
|
-
} {
|
|
53
|
-
if (!value || typeof value !== 'object') {
|
|
54
|
-
return false;
|
|
55
|
-
}
|
|
56
|
-
const candidate = value as Partial<{
|
|
57
|
-
target: unknown;
|
|
58
|
-
type: string;
|
|
59
|
-
version: number;
|
|
60
|
-
x: number;
|
|
61
|
-
y: number;
|
|
62
|
-
}>;
|
|
63
|
-
return candidate.type === PANEL_APP_SCROLL_RESTORATION_CONTRACT.scrollMessageType
|
|
64
|
-
&& candidate.version === PANEL_APP_SCROLL_RESTORATION_CONTRACT.version
|
|
65
|
-
&& isScrollTarget(candidate.target)
|
|
66
|
-
&& typeof candidate.x === 'number'
|
|
67
|
-
&& Number.isFinite(candidate.x)
|
|
68
|
-
&& candidate.x >= 0
|
|
69
|
-
&& typeof candidate.y === 'number'
|
|
70
|
-
&& Number.isFinite(candidate.y)
|
|
71
|
-
&& candidate.y >= 0;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
11
|
export function useDocBrowserScrollRestoration({
|
|
75
12
|
currentTab,
|
|
76
13
|
iframeRef,
|
|
77
14
|
isEnabled,
|
|
78
|
-
tabs,
|
|
79
15
|
}: UseDocBrowserScrollRestorationParams) {
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
positionsRef.current.delete(tabId);
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
}, [tabs]);
|
|
90
|
-
|
|
91
|
-
useEffect(() => {
|
|
92
|
-
if (!currentTab || !isEnabled) {
|
|
93
|
-
return;
|
|
94
|
-
}
|
|
95
|
-
const onMessage = (event: MessageEvent) => {
|
|
96
|
-
if (event.source !== iframeRef.current?.contentWindow || !isScrollMessage(event.data)) {
|
|
97
|
-
return;
|
|
98
|
-
}
|
|
99
|
-
positionsRef.current.set(currentTab.id, {
|
|
100
|
-
currentUrl: currentTab.currentUrl,
|
|
101
|
-
target: event.data.target,
|
|
102
|
-
x: event.data.x,
|
|
103
|
-
y: event.data.y,
|
|
104
|
-
});
|
|
105
|
-
};
|
|
106
|
-
window.addEventListener('message', onMessage);
|
|
107
|
-
return () => window.removeEventListener('message', onMessage);
|
|
108
|
-
}, [currentTab, iframeRef, isEnabled]);
|
|
109
|
-
|
|
110
|
-
return useCallback(() => {
|
|
111
|
-
if (!currentTab || !isEnabled) {
|
|
112
|
-
return;
|
|
113
|
-
}
|
|
114
|
-
const position = positionsRef.current.get(currentTab.id);
|
|
115
|
-
if (!position || position.currentUrl !== currentTab.currentUrl) {
|
|
116
|
-
return;
|
|
117
|
-
}
|
|
118
|
-
iframeRef.current?.contentWindow?.postMessage({
|
|
119
|
-
type: PANEL_APP_SCROLL_RESTORATION_CONTRACT.restoreScrollMessageType,
|
|
120
|
-
version: PANEL_APP_SCROLL_RESTORATION_CONTRACT.version,
|
|
121
|
-
target: position.target,
|
|
122
|
-
x: position.x,
|
|
123
|
-
y: position.y,
|
|
124
|
-
}, '*');
|
|
125
|
-
}, [currentTab, iframeRef, isEnabled]);
|
|
16
|
+
return usePanelAppScrollRestoration({
|
|
17
|
+
currentUrl: currentTab?.currentUrl ?? null,
|
|
18
|
+
iframeRef,
|
|
19
|
+
isEnabled,
|
|
20
|
+
restorationKey: currentTab ? `panel-app:doc-browser:${currentTab.id}` : null,
|
|
21
|
+
});
|
|
126
22
|
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { fireEvent, render } from "@testing-library/react";
|
|
2
|
+
import { useRef } from "react";
|
|
3
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
import { PANEL_APP_SCROLL_RESTORATION_CONTRACT } from "@nextclaw/shared";
|
|
5
|
+
import { usePanelAppScrollRestoration } from "@/shared/hooks/use-panel-app-scroll-restoration";
|
|
6
|
+
import { scrollRestorationManager } from "@/shared/lib/navigation-history";
|
|
7
|
+
|
|
8
|
+
function PanelAppFrame({ currentUrl = "/app", restorationKey = "panel-app:test" }: {
|
|
9
|
+
currentUrl?: string;
|
|
10
|
+
restorationKey?: string;
|
|
11
|
+
}) {
|
|
12
|
+
const iframeRef = useRef<HTMLIFrameElement>(null);
|
|
13
|
+
const restoreScroll = usePanelAppScrollRestoration({
|
|
14
|
+
currentUrl,
|
|
15
|
+
iframeRef,
|
|
16
|
+
isEnabled: true,
|
|
17
|
+
restorationKey,
|
|
18
|
+
});
|
|
19
|
+
return <iframe ref={iframeRef} data-testid="panel-app-frame" onLoad={restoreScroll} />;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe("usePanelAppScrollRestoration", () => {
|
|
23
|
+
beforeEach(() => scrollRestorationManager.clear());
|
|
24
|
+
|
|
25
|
+
it("records only scroll messages from its iframe and restores them after load", () => {
|
|
26
|
+
const { getByTestId } = render(<PanelAppFrame />);
|
|
27
|
+
const iframe = getByTestId("panel-app-frame") as HTMLIFrameElement;
|
|
28
|
+
const postMessage = vi.spyOn(iframe.contentWindow!, "postMessage");
|
|
29
|
+
const target = { kind: "document" } as const;
|
|
30
|
+
|
|
31
|
+
window.dispatchEvent(new MessageEvent("message", {
|
|
32
|
+
data: {
|
|
33
|
+
type: PANEL_APP_SCROLL_RESTORATION_CONTRACT.scrollMessageType,
|
|
34
|
+
version: PANEL_APP_SCROLL_RESTORATION_CONTRACT.version,
|
|
35
|
+
target,
|
|
36
|
+
x: 8,
|
|
37
|
+
y: 64,
|
|
38
|
+
},
|
|
39
|
+
source: iframe.contentWindow,
|
|
40
|
+
}));
|
|
41
|
+
fireEvent.load(iframe);
|
|
42
|
+
|
|
43
|
+
expect(postMessage).toHaveBeenCalledWith({
|
|
44
|
+
type: PANEL_APP_SCROLL_RESTORATION_CONTRACT.restoreScrollMessageType,
|
|
45
|
+
version: PANEL_APP_SCROLL_RESTORATION_CONTRACT.version,
|
|
46
|
+
target,
|
|
47
|
+
x: 8,
|
|
48
|
+
y: 64,
|
|
49
|
+
}, "*");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("does not restore a position from another resource URL", () => {
|
|
53
|
+
scrollRestorationManager.save("panel-app:test", {
|
|
54
|
+
x: 0,
|
|
55
|
+
y: 64,
|
|
56
|
+
payload: { currentUrl: "/old-app", target: { kind: "document" } },
|
|
57
|
+
});
|
|
58
|
+
const { getByTestId } = render(<PanelAppFrame currentUrl="/new-app" />);
|
|
59
|
+
const iframe = getByTestId("panel-app-frame") as HTMLIFrameElement;
|
|
60
|
+
const postMessage = vi.spyOn(iframe.contentWindow!, "postMessage");
|
|
61
|
+
|
|
62
|
+
fireEvent.load(iframe);
|
|
63
|
+
|
|
64
|
+
expect(postMessage).not.toHaveBeenCalled();
|
|
65
|
+
});
|
|
66
|
+
});
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { useCallback, useEffect, type RefObject } from "react";
|
|
2
|
+
import { PANEL_APP_SCROLL_RESTORATION_CONTRACT } from "@nextclaw/shared";
|
|
3
|
+
import { scrollRestorationManager } from "@/shared/lib/navigation-history";
|
|
4
|
+
|
|
5
|
+
type ScrollTarget =
|
|
6
|
+
| { kind: "document" }
|
|
7
|
+
| { kind: "element"; path: Array<{ index: number; tagName: string }> };
|
|
8
|
+
|
|
9
|
+
type PanelAppScrollSnapshot = {
|
|
10
|
+
currentUrl: string;
|
|
11
|
+
target: ScrollTarget;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
type UsePanelAppScrollRestorationParams = {
|
|
15
|
+
currentUrl: string | null;
|
|
16
|
+
iframeRef: RefObject<HTMLIFrameElement>;
|
|
17
|
+
isEnabled: boolean;
|
|
18
|
+
restorationKey: string | null;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
function isScrollTarget(value: unknown): value is ScrollTarget {
|
|
22
|
+
if (!value || typeof value !== "object") return false;
|
|
23
|
+
const candidate = value as Partial<ScrollTarget>;
|
|
24
|
+
if (candidate.kind === "document") return true;
|
|
25
|
+
return candidate.kind === "element"
|
|
26
|
+
&& Array.isArray(candidate.path)
|
|
27
|
+
&& candidate.path.length > 0
|
|
28
|
+
&& candidate.path.length <= 30
|
|
29
|
+
&& candidate.path.every((segment) => (
|
|
30
|
+
typeof segment?.index === "number"
|
|
31
|
+
&& Number.isInteger(segment.index)
|
|
32
|
+
&& segment.index >= 0
|
|
33
|
+
&& segment.index <= 1000
|
|
34
|
+
&& typeof segment.tagName === "string"
|
|
35
|
+
&& segment.tagName.length > 0
|
|
36
|
+
&& segment.tagName.length <= 32
|
|
37
|
+
));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function isPanelAppScrollSnapshot(value: unknown): value is PanelAppScrollSnapshot {
|
|
41
|
+
if (!value || typeof value !== "object") return false;
|
|
42
|
+
const candidate = value as Partial<PanelAppScrollSnapshot>;
|
|
43
|
+
return typeof candidate.currentUrl === "string" && isScrollTarget(candidate.target);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function isScrollMessage(value: unknown): value is {
|
|
47
|
+
target: ScrollTarget;
|
|
48
|
+
x: number;
|
|
49
|
+
y: number;
|
|
50
|
+
} {
|
|
51
|
+
if (!value || typeof value !== "object") return false;
|
|
52
|
+
const candidate = value as Partial<{
|
|
53
|
+
target: unknown;
|
|
54
|
+
type: string;
|
|
55
|
+
version: number;
|
|
56
|
+
x: number;
|
|
57
|
+
y: number;
|
|
58
|
+
}>;
|
|
59
|
+
return candidate.type === PANEL_APP_SCROLL_RESTORATION_CONTRACT.scrollMessageType
|
|
60
|
+
&& candidate.version === PANEL_APP_SCROLL_RESTORATION_CONTRACT.version
|
|
61
|
+
&& isScrollTarget(candidate.target)
|
|
62
|
+
&& typeof candidate.x === "number"
|
|
63
|
+
&& Number.isFinite(candidate.x)
|
|
64
|
+
&& candidate.x >= 0
|
|
65
|
+
&& typeof candidate.y === "number"
|
|
66
|
+
&& Number.isFinite(candidate.y)
|
|
67
|
+
&& candidate.y >= 0;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Bridges the Panel App iframe scroll protocol to the shared navigation history. */
|
|
71
|
+
export function usePanelAppScrollRestoration({
|
|
72
|
+
currentUrl,
|
|
73
|
+
iframeRef,
|
|
74
|
+
isEnabled,
|
|
75
|
+
restorationKey,
|
|
76
|
+
}: UsePanelAppScrollRestorationParams) {
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
if (!currentUrl || !isEnabled || !restorationKey) return;
|
|
79
|
+
const onMessage = (event: MessageEvent) => {
|
|
80
|
+
if (event.source !== iframeRef.current?.contentWindow || !isScrollMessage(event.data)) {
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
scrollRestorationManager.save(restorationKey, {
|
|
84
|
+
x: event.data.x,
|
|
85
|
+
y: event.data.y,
|
|
86
|
+
payload: { currentUrl, target: event.data.target } satisfies PanelAppScrollSnapshot,
|
|
87
|
+
});
|
|
88
|
+
};
|
|
89
|
+
window.addEventListener("message", onMessage);
|
|
90
|
+
return () => window.removeEventListener("message", onMessage);
|
|
91
|
+
}, [currentUrl, iframeRef, isEnabled, restorationKey]);
|
|
92
|
+
|
|
93
|
+
return useCallback(() => {
|
|
94
|
+
if (!currentUrl || !isEnabled || !restorationKey) return;
|
|
95
|
+
const position = scrollRestorationManager.read(restorationKey);
|
|
96
|
+
if (!position || !isPanelAppScrollSnapshot(position.payload) || position.payload.currentUrl !== currentUrl) {
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
iframeRef.current?.contentWindow?.postMessage({
|
|
100
|
+
type: PANEL_APP_SCROLL_RESTORATION_CONTRACT.restoreScrollMessageType,
|
|
101
|
+
version: PANEL_APP_SCROLL_RESTORATION_CONTRACT.version,
|
|
102
|
+
target: position.payload.target,
|
|
103
|
+
x: position.x,
|
|
104
|
+
y: position.y,
|
|
105
|
+
}, "*");
|
|
106
|
+
}, [currentUrl, iframeRef, isEnabled, restorationKey]);
|
|
107
|
+
}
|