@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.
Files changed (105) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/assets/{api-DvpQWjTS.js → api-iMkU_o5P.js} +3 -3
  3. package/dist/assets/{appearance-settings-page-U0m0ktu3.js → appearance-settings-page-CU5k4b-M.js} +1 -1
  4. package/dist/assets/{book-open-DRofSTWr.js → book-open-nD8gdMhA.js} +1 -1
  5. package/dist/assets/{channels-list-page-BURQarD0.js → channels-list-page-Drlt62kY.js} +1 -1
  6. package/dist/assets/{config-split-page-BrAQYTIl.js → config-split-page-BtJZp1E5.js} +1 -1
  7. package/dist/assets/{confirm-dialog-DDJomLnO.js → confirm-dialog-k2-Jq_x3.js} +1 -1
  8. package/dist/assets/{createLucideIcon-DzkNHr5Z.js → createLucideIcon-DxlwKLr-.js} +1 -1
  9. package/dist/assets/{desktop-update-config-BzZFhMv9.js → desktop-update-config-BiZmy7FC.js} +1 -1
  10. package/dist/assets/{dist-DXbLo2b1.js → dist-Cy_e4kNb.js} +1 -1
  11. package/dist/assets/{dist-NskPB_yW.js → dist-m08DUswB.js} +1 -1
  12. package/dist/assets/doc-browser-CPsPHh12.js +1 -0
  13. package/dist/assets/{doc-browser-context-CnKV4gJy.js → doc-browser-context-jiy-O4F8.js} +1 -1
  14. package/dist/assets/doc-browser-npOvWtgf.js +1 -0
  15. package/dist/assets/{extensions-DvlFMpAs.js → extensions-CIb17__o.js} +1 -1
  16. package/dist/assets/{form-actions-UnPEZBkx.js → form-actions-DGf5zUg_.js} +1 -1
  17. package/dist/assets/{index-Dc_ZMkmo.css → index-C86hxgv5.css} +1 -1
  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-BIgFTbMq.js → mcp-marketplace-page-kLWW6kzt.js} +1 -1
  22. package/dist/assets/{model-config-page-CnSm7kfa.js → model-config-page-DSeAjVT_.js} +1 -1
  23. package/dist/assets/{navigation-link-NjUdG6Vp.js → navigation-link-DAfwFCj4.js} +1 -1
  24. package/dist/assets/plus-D4LtWtTk.js +1 -0
  25. package/dist/assets/{privacy-settings-page-DVn2HU7W.js → privacy-settings-page-tN0Mt15x.js} +1 -1
  26. package/dist/assets/{providers-config-page-CzHJS3eg.js → providers-config-page-CgPrXR1u.js} +1 -1
  27. package/dist/assets/react-8iBFECZE.js +8 -0
  28. package/dist/assets/{refresh-cw-CkeninE0.js → refresh-cw-D_Eibznw.js} +1 -1
  29. package/dist/assets/remote-Derr_AQp.js +1 -0
  30. package/dist/assets/{rotate-cw-DP33PuXU.js → rotate-cw-QQjmoWuq.js} +1 -1
  31. package/dist/assets/{runtime-config-page-D69buyGp.js → runtime-config-page-Crq96Jjq.js} +1 -1
  32. package/dist/assets/{save-Cw2Xz-pB.js → save-W2xthJJ2.js} +1 -1
  33. package/dist/assets/{search-C2CFAVvd.js → search-Vih1R2BD.js} +1 -1
  34. package/dist/assets/{search-config-page-dqNT0Swf.js → search-config-page-CtBcL2uc.js} +1 -1
  35. package/dist/assets/{secrets-config-page-De6J3wVx.js → secrets-config-page-Q6XfIj0h.js} +1 -1
  36. package/dist/assets/security-config-CE9rkQvq.js +1 -0
  37. package/dist/assets/{select-DSuMhwVP.js → select-lNdozU4o.js} +1 -1
  38. package/dist/assets/skeleton-B__aTcly.js +1 -0
  39. package/dist/assets/{tag-chip-DFBLgsGf.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 +8 -8
  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-session-area.test.tsx +16 -2
  49. package/src/features/chat/components/layout/chat-page-shell.tsx +19 -2
  50. package/src/features/chat/components/layout/chat-sidebar-desktop-layout.tsx +16 -0
  51. package/src/features/chat/components/layout/chat-sidebar.tsx +4 -1
  52. package/src/features/chat/features/message/utils/__tests__/chat-message-timeline-compaction.utils.test.ts +2 -2
  53. package/src/features/chat/features/message/utils/chat-message-timeline.utils.ts +2 -2
  54. package/src/features/chat/features/ncp/hooks/__tests__/use-ncp-session-list-view.test.tsx +11 -3
  55. package/src/features/chat/features/ncp/hooks/use-ncp-session-list-view.ts +12 -5
  56. package/src/features/chat/features/ncp/hooks/use-ncp-session-queries.ts +15 -1
  57. package/src/features/chat/features/workspace/components/__tests__/chat-session-token-usage.test.tsx +4 -1
  58. package/src/features/chat/features/workspace/components/__tests__/chat-session-workspace-file-preview.scroll-restoration.test.tsx +182 -0
  59. package/src/features/chat/features/workspace/components/__tests__/session-cron-job-content.test.tsx +78 -0
  60. package/src/features/chat/features/workspace/components/chat-session-workspace-file-preview.tsx +47 -2
  61. package/src/features/chat/features/workspace/components/chat-session-workspace-panel-content.tsx +14 -1
  62. package/src/features/chat/features/workspace/components/chat-session-workspace-panel.tsx +9 -0
  63. package/src/features/chat/features/workspace/components/overview/chat-session-token-usage.tsx +17 -0
  64. package/src/features/chat/features/workspace/components/session-cron-job-content.tsx +269 -75
  65. package/src/features/chat/features/workspace/hooks/use-chat-conversation-workspace-state.ts +5 -0
  66. package/src/features/cron/components/__tests__/cron-config.test.tsx +16 -5
  67. package/src/features/cron/components/cron-config.tsx +20 -46
  68. package/src/features/cron/components/cron-job-row.tsx +1 -1
  69. package/src/features/cron/hooks/__tests__/use-cron-job-actions.test.tsx +95 -0
  70. package/src/features/cron/hooks/use-cron-job-actions.ts +109 -0
  71. package/src/features/cron/index.ts +2 -0
  72. package/src/features/inbox/pages/inbox-page.tsx +18 -2
  73. package/src/features/marketplace/components/marketplace-page.tsx +7 -0
  74. package/src/features/panel-apps/pages/panel-app-main-page.tsx +8 -0
  75. package/src/platforms/desktop/components/desktop-app-shell.tsx +10 -1
  76. package/src/shared/components/doc-browser/doc-browser.tsx +0 -1
  77. package/src/shared/components/doc-browser/hooks/use-doc-browser-scroll-restoration.ts +9 -113
  78. package/src/shared/hooks/use-panel-app-scroll-restoration.test.tsx +66 -0
  79. package/src/shared/hooks/use-panel-app-scroll-restoration.ts +107 -0
  80. package/src/shared/hooks/use-scroll-restoration.test.tsx +62 -0
  81. package/src/shared/hooks/use-scroll-restoration.ts +61 -0
  82. package/src/shared/lib/api/ncp-session-query-cache.utils.ts +1 -0
  83. package/src/shared/lib/api/ncp-session.types.ts +3 -0
  84. package/src/shared/lib/api/types.ts +1 -0
  85. package/src/shared/lib/api/utils/ncp-session.utils.ts +3 -0
  86. package/src/shared/lib/i18n/locales/en-US/chat.json +8 -5
  87. package/src/shared/lib/i18n/locales/en-US/cron.json +4 -2
  88. package/src/shared/lib/i18n/locales/en-US/doc-browser.json +1 -0
  89. package/src/shared/lib/i18n/locales/zh-CN/chat.json +8 -5
  90. package/src/shared/lib/i18n/locales/zh-CN/cron.json +4 -2
  91. package/src/shared/lib/i18n/locales/zh-CN/doc-browser.json +1 -0
  92. package/src/shared/lib/navigation-history/index.ts +1 -0
  93. package/src/shared/lib/navigation-history/scroll-restoration.manager.test.ts +37 -0
  94. package/src/shared/lib/navigation-history/scroll-restoration.manager.ts +60 -0
  95. package/dist/assets/doc-browser-DB22tQKU.js +0 -1
  96. package/dist/assets/doc-browser-sJjw9fl5.js +0 -1
  97. package/dist/assets/index-N1HnesBp.js +0 -109
  98. package/dist/assets/loader-circle-BlPXxgDB.js +0 -1
  99. package/dist/assets/mcp-marketplace-page-CZN4ovdK.js +0 -1
  100. package/dist/assets/plus-DVXol9Q5.js +0 -1
  101. package/dist/assets/react-DCVbGroo.js +0 -8
  102. package/dist/assets/remote-B2lsJF1c.js +0 -1
  103. package/dist/assets/security-config-Dcu9C4OL.js +0 -1
  104. package/dist/assets/skeleton-QoWydNLp.js +0 -1
  105. 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
+ }
@@ -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>
@@ -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,
@@ -1,126 +1,22 @@
1
- import { useCallback, useEffect, useRef, type RefObject } from 'react';
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 | null>;
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
- const positionsRef = useRef(new Map<string, ScrollPosition>());
81
-
82
- useEffect(() => {
83
- const activeTabIds = new Set(tabs.map((tab) => tab.id));
84
- for (const tabId of positionsRef.current.keys()) {
85
- if (!activeTabIds.has(tabId)) {
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
+ }