@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
@@ -8,6 +8,7 @@ import { useViewportLayout } from "@/app/hooks/use-viewport-layout";
8
8
  import { ChatMobileShell } from "@/platforms/mobile";
9
9
  import { InboxPage } from "@/features/inbox";
10
10
  import { PanelAppMainPage } from "@/features/panel-apps";
11
+ import { useScrollRestoration } from "@/shared/hooks/use-scroll-restoration";
11
12
  export type MainPanelView = "chat" | "cron" | "skills" | "agents" | "inbox" | "panel-app";
12
13
  export type ChatPageProps = {
13
14
  view: MainPanelView;
@@ -36,6 +37,14 @@ type ChatPageLayoutProps = {
36
37
  };
37
38
  export function ChatPageLayout({ view, confirmDialog }: ChatPageLayoutProps) {
38
39
  const { isMobile } = useViewportLayout();
40
+ const cronScroll = useScrollRestoration<HTMLDivElement>({
41
+ restorationKey: "main-page:cron",
42
+ });
43
+ const agentsScroll = useScrollRestoration<HTMLDivElement>({
44
+ restorationKey: "main-page:agents",
45
+ });
46
+ const { onScroll: onCronScroll, scrollRef: cronScrollRef } = cronScroll;
47
+ const { onScroll: onAgentsScroll, scrollRef: agentsScrollRef } = agentsScroll;
39
48
 
40
49
  return (
41
50
  <div className="h-full flex">
@@ -52,13 +61,21 @@ export function ChatPageLayout({ view, confirmDialog }: ChatPageLayoutProps) {
52
61
  ) : view === "panel-app" ? (
53
62
  <PanelAppMainPage />
54
63
  ) : view === "cron" ? (
55
- <div className="h-full overflow-auto custom-scrollbar">
64
+ <div
65
+ ref={cronScrollRef}
66
+ onScroll={onCronScroll}
67
+ className="h-full overflow-auto custom-scrollbar"
68
+ >
56
69
  <div className={`mx-auto w-full px-4 py-4 sm:px-6 sm:py-5 ${MANAGEMENT_PAGE_CANVAS_WIDTH_CLASS}`}>
57
70
  <CronConfig />
58
71
  </div>
59
72
  </div>
60
73
  ) : view === "agents" ? (
61
- <div className="h-full overflow-auto custom-scrollbar">
74
+ <div
75
+ ref={agentsScrollRef}
76
+ onScroll={onAgentsScroll}
77
+ className="h-full overflow-auto custom-scrollbar"
78
+ >
62
79
  <div className={`mx-auto w-full px-4 py-4 sm:px-6 sm:py-5 ${MANAGEMENT_PAGE_CANVAS_WIDTH_CLASS}`}>
63
80
  <AgentsPage />
64
81
  </div>
@@ -28,6 +28,7 @@ import { ChatSidebarUtilityMenu } from "@/features/chat/components/layout/chat-s
28
28
  import { PanelAppMainSidebarNav } from "@/features/panel-apps";
29
29
  import { isWindowsDesktopHost } from "@/platforms/desktop";
30
30
  import { viewportLayoutManager } from "@/app/managers/viewport-layout.manager";
31
+ import { useScrollRestoration } from "@/shared/hooks/use-scroll-restoration";
31
32
  import {
32
33
  SIDEBAR_RAIL_CONTROL_CLASS,
33
34
  SIDEBAR_RAIL_ICON_CLASS,
@@ -154,6 +155,7 @@ export function ChatSidebarSessionArea({
154
155
  isLoading,
155
156
  isProjectFirstView,
156
157
  onAddProject,
158
+ onScrollNearEnd = () => undefined,
157
159
  onSelectMode,
158
160
  projectGroups,
159
161
  projectCronJobCountByRoot,
@@ -166,6 +168,7 @@ export function ChatSidebarSessionArea({
166
168
  isLoading: boolean;
167
169
  isProjectFirstView: boolean;
168
170
  onAddProject: () => void;
171
+ onScrollNearEnd?: () => void;
169
172
  onSelectMode: (mode: "time-first" | "project-first") => void;
170
173
  projectGroups: ReturnType<typeof groupSessionsByProject>;
171
174
  projectCronJobCountByRoot: ReadonlyMap<string, number>;
@@ -174,6 +177,11 @@ export function ChatSidebarSessionArea({
174
177
  typeof ChatSidebarSessionList
175
178
  >[0]["sessionTypeOptions"];
176
179
  }) {
180
+ const scrollRestoration = useScrollRestoration<HTMLDivElement>({
181
+ restorationKey: "chat-sidebar:sessions",
182
+ isEnabled: !isCollapsed,
183
+ });
184
+ const { onScroll: onScrollPositionSave, scrollRef } = scrollRestoration;
177
185
  if (isCollapsed) {
178
186
  return <div className="min-h-0 flex-1" aria-hidden="true" />;
179
187
  }
@@ -196,6 +204,14 @@ export function ChatSidebarSessionArea({
196
204
  </div>
197
205
 
198
206
  <div
207
+ ref={scrollRef}
208
+ onScroll={(event) => {
209
+ onScrollPositionSave(event);
210
+ const element = event.currentTarget;
211
+ if (element.scrollHeight - element.scrollTop - element.clientHeight < 600) {
212
+ onScrollNearEnd();
213
+ }
214
+ }}
199
215
  className={cn(
200
216
  "custom-scrollbar min-h-0 flex-1 overflow-y-auto px-3 pb-4 pt-1",
201
217
  SCROLL_BOTTOM_EDGE_FADE_CLASS,
@@ -120,7 +120,7 @@ export function ChatSidebar({
120
120
  const projectsQuery = useProjects();
121
121
  const projectCreateMutation = useCreateProject();
122
122
  const projectAddExistingMutation = useAddExistingProject();
123
- const { allItems, isLoading, items } = useNcpSessionListView();
123
+ const { allItems, hasMore, isLoading, isLoadingMore, items, loadMore } = useNcpSessionListView();
124
124
  const { cronJobCountByProjectRoot, cronJobCountBySessionKey } =
125
125
  useChatSidebarContextCounts(allItems);
126
126
  const { language, setLanguage } = useI18n();
@@ -343,6 +343,9 @@ export function ChatSidebar({
343
343
  isLoading={isLoading}
344
344
  isProjectFirstView={isProjectFirstView}
345
345
  onAddProject={openProjectAdd}
346
+ onScrollNearEnd={() => {
347
+ if (hasMore && !isLoadingMore) void loadMore();
348
+ }}
346
349
  onSelectMode={presenter.chatSessionListManager.setListMode}
347
350
  projectGroups={projectGroups}
348
351
  projectCronJobCountByRoot={cronJobCountByProjectRoot}
@@ -169,7 +169,7 @@ describe("mid-run compaction timeline projection", () => {
169
169
  ]);
170
170
  });
171
171
 
172
- it("does not silently clamp an invalid canonical part boundary", () => {
172
+ it("does not render a temporarily unavailable canonical part boundary as a standalone marker", () => {
173
173
  const invalidCompaction = createCompactionMessage({
174
174
  assistantId: visibleMessage.id,
175
175
  coveredPartCount: 2,
@@ -183,7 +183,7 @@ describe("mid-run compaction timeline projection", () => {
183
183
  expect(buildChatMessageTimelineItems({
184
184
  rawMessages: [visibleMessage, invalidCompaction],
185
185
  messages: [{ id: visibleMessage.id } as never],
186
- }).map((item) => item.kind)).toEqual(["message", "compaction"]);
186
+ }).map((item) => item.kind)).toEqual(["message"]);
187
187
  });
188
188
 
189
189
  it("offsets a continued assistant compaction within its canonical message", () => {
@@ -252,12 +252,12 @@ function projectVisibleChatMessageState(
252
252
  .forEach((placement) => {
253
253
  if (
254
254
  !Number.isSafeInteger(placement.boundaryIndex) ||
255
- placement.boundaryIndex < 0 ||
256
- placement.boundaryIndex > message.parts.length
255
+ placement.boundaryIndex < 0
257
256
  ) {
258
257
  inlineCompactionMessageIds.delete(placement.serviceMessageId);
259
258
  return;
260
259
  }
260
+ if (placement.boundaryIndex > message.parts.length) return;
261
261
  parts.splice(placement.boundaryIndex + insertedCount, 0, {
262
262
  type: "extension",
263
263
  extensionType: CONTEXT_COMPACTION_PART_EXTENSION_TYPE,
@@ -9,12 +9,20 @@ const mocks = vi.hoisted(() => ({
9
9
  }));
10
10
 
11
11
  vi.mock("@/features/chat/features/ncp/hooks/use-ncp-session-queries", () => ({
12
- useNcpSessions: () => ({
12
+ useInfiniteNcpSessions: () => ({
13
13
  data: {
14
- sessions: mocks.sessions,
15
- total: mocks.sessions.length,
14
+ pages: [{
15
+ sessions: mocks.sessions,
16
+ total: mocks.sessions.length,
17
+ page: 1,
18
+ pageSize: 100,
19
+ hasMore: false,
20
+ }],
16
21
  },
17
22
  isLoading: false,
23
+ hasNextPage: false,
24
+ isFetchingNextPage: false,
25
+ fetchNextPage: vi.fn(),
18
26
  }),
19
27
  }));
20
28
 
@@ -1,9 +1,9 @@
1
- import { useMemo } from "react";
1
+ import { useDeferredValue, useMemo } from "react";
2
2
  import type { SessionEntryView } from "@/shared/lib/api";
3
3
  import { sessionMatchesQuery } from "@/features/chat/features/session/utils/chat-session-display.utils";
4
4
  import { adaptNcpSessionSummaries } from "@/features/chat/features/session/utils/ncp-session-adapter.utils";
5
5
  import { useChatSessionListStore } from "@/features/chat/stores/chat-session-list.store";
6
- import { useNcpSessions } from "@/features/chat/features/ncp/hooks/use-ncp-session-queries";
6
+ import { useInfiniteNcpSessions } from "@/features/chat/features/ncp/hooks/use-ncp-session-queries";
7
7
  import type { SessionRunStatus } from "@/features/chat/types/session-run-status.types";
8
8
 
9
9
  export type NcpSessionListItemView = {
@@ -30,15 +30,19 @@ export function useNcpSessionListView(
30
30
  ) {
31
31
  const storedQuery = useChatSessionListStore((state) => state.snapshot.query);
32
32
  const query = params.query ?? storedQuery;
33
- const sessionsQuery = useNcpSessions({ limit: params.limit ?? 200 });
33
+ const deferredQuery = useDeferredValue(query);
34
+ const sessionsQuery = useInfiniteNcpSessions({
35
+ pageSize: params.limit ?? 100,
36
+ query: deferredQuery,
37
+ });
34
38
 
35
39
  const allItems = useMemo<NcpSessionListItemView[]>(() => {
36
- const summaries = sessionsQuery.data?.sessions ?? [];
40
+ const summaries = sessionsQuery.data?.pages.flatMap((page) => page.sessions) ?? [];
37
41
  return adaptNcpSessionSummaries(summaries).map((session) => ({
38
42
  session,
39
43
  runStatus: session.status === "running" ? "running" : undefined,
40
44
  }));
41
- }, [sessionsQuery.data?.sessions]);
45
+ }, [sessionsQuery.data?.pages]);
42
46
  const items = useMemo<NcpSessionListItemView[]>(() => {
43
47
  const visibleItems = allItems.filter(({ session }) =>
44
48
  shouldShowSessionInSidebar(session),
@@ -59,5 +63,8 @@ export function useNcpSessionListView(
59
63
  allItems,
60
64
  isLoading: sessionsQuery.isLoading,
61
65
  items,
66
+ hasMore: sessionsQuery.hasNextPage,
67
+ isLoadingMore: sessionsQuery.isFetchingNextPage,
68
+ loadMore: sessionsQuery.fetchNextPage,
62
69
  };
63
70
  }
@@ -1,4 +1,4 @@
1
- import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
1
+ import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
2
2
  import {
3
3
  fetchNcpSessionObservations,
4
4
  fetchNcpSessionSkills,
@@ -21,6 +21,20 @@ export function useNcpSessions(params?: { limit?: number; peerId?: string }) {
21
21
  });
22
22
  }
23
23
 
24
+ export function useInfiniteNcpSessions(params: { pageSize: number; query?: string }) {
25
+ return useInfiniteQuery({
26
+ queryKey: ['ncp-session-pages', params.pageSize, params.query?.trim() || null],
27
+ initialPageParam: 1,
28
+ queryFn: ({ pageParam }) => fetchNcpSessions({
29
+ page: pageParam,
30
+ pageSize: params.pageSize,
31
+ ...(params.query?.trim() ? { query: params.query.trim() } : {}),
32
+ }),
33
+ getNextPageParam: (lastPage) => lastPage.hasMore ? (lastPage.page ?? 1) + 1 : undefined,
34
+ ...ncpSessionQueryDefaults,
35
+ });
36
+ }
37
+
24
38
  export function useNcpSessionSkills(params: {
25
39
  sessionId: string | null;
26
40
  projectRoot?: string | null;
@@ -66,12 +66,15 @@ beforeEach(() => {
66
66
  mocks.query.isError = false;
67
67
  });
68
68
 
69
- it('shows totals, cache hit rate, and usage grouped by model', () => {
69
+ it('shows totals, model calls, cache read ratio, and usage grouped by model', () => {
70
70
  render(<ChatSessionTokenUsage sessionKey="parent-1" />);
71
71
 
72
72
  expect(screen.getByText('Token usage')).toBeTruthy();
73
73
  expect(screen.getByText('1,490')).toBeTruthy();
74
74
  expect(screen.getAllByText('40%').length).toBeGreaterThan(0);
75
+ expect(screen.getByText('Model calls')).toBeTruthy();
76
+ expect(screen.getByText('Reported calls')).toBeTruthy();
77
+ expect(screen.getByText('2 agent rounds · 3 model calls · Cache read ratio: 40%')).toBeTruthy();
75
78
  expect(screen.getByText('openai/gpt-5')).toBeTruthy();
76
79
  expect(screen.getByText('anthropic/claude-sonnet-4')).toBeTruthy();
77
80
  expect(screen.getAllByText('Partial usage')).toHaveLength(2);
@@ -0,0 +1,182 @@
1
+ import { fireEvent, render, screen } from "@testing-library/react";
2
+ import type { ReactNode } from "react";
3
+ import { beforeEach, describe, expect, it, vi } from "vitest";
4
+ import { ChatSessionWorkspaceFilePreview } from "@/features/chat/features/workspace/components/chat-session-workspace-file-preview";
5
+ import { scrollRestorationManager } from "@/shared/lib/navigation-history";
6
+
7
+ const serverPathReadMock = vi.fn();
8
+ const serverPathBrowseMock = vi.fn();
9
+
10
+ vi.mock("@/shared/hooks/use-server-path-read", () => ({
11
+ useServerPathRead: (...args: unknown[]) => serverPathReadMock(...args),
12
+ }));
13
+
14
+ vi.mock("@/shared/hooks/use-server-path-browse", () => ({
15
+ useServerPathBrowse: (...args: unknown[]) => serverPathBrowseMock(...args),
16
+ }));
17
+
18
+ vi.mock("@nextclaw/agent-chat-ui", () => ({
19
+ ChatMessageMarkdown: ({ text }: { text: string }) => <div data-testid="markdown-preview">{text}</div>,
20
+ FileOperationCodeSurface: () => null,
21
+ }));
22
+
23
+ vi.mock(
24
+ "@/features/chat/features/workspace/components/workspace-text-selection-menu",
25
+ () => ({
26
+ WorkspaceTextSelectionMenu: ({ children }: { children: ReactNode }) => children,
27
+ }),
28
+ );
29
+
30
+ function renderMarkdownPreview(file: {
31
+ key: string;
32
+ path: string;
33
+ previewViewer?: "rendered" | "source";
34
+ } = {
35
+ key: "markdown-tab",
36
+ path: "/tmp/README.md",
37
+ previewViewer: "rendered",
38
+ }) {
39
+ return render(
40
+ <ChatSessionWorkspaceFilePreview
41
+ file={{
42
+ key: file.key,
43
+ parentSessionKey: "session-1",
44
+ path: file.path,
45
+ viewMode: "preview",
46
+ previewViewer: file.previewViewer ?? "rendered",
47
+ }}
48
+ sessionProjectRoot="/tmp"
49
+ sessionWorkingDir="/tmp"
50
+ showBreadcrumbs={false}
51
+ onFileOpen={vi.fn()}
52
+ />,
53
+ );
54
+ }
55
+
56
+ describe("ChatSessionWorkspaceFilePreview Markdown scroll restoration", () => {
57
+ beforeEach(() => {
58
+ scrollRestorationManager.clear();
59
+ serverPathReadMock.mockImplementation(({ path }: { path: string }) => ({
60
+ isLoading: false,
61
+ error: null,
62
+ data: {
63
+ kind: "markdown",
64
+ resolvedPath: path,
65
+ startLine: 1,
66
+ text: `# ${path}`,
67
+ truncated: false,
68
+ },
69
+ }));
70
+ serverPathBrowseMock.mockReturnValue({
71
+ isLoading: false,
72
+ error: new Error("not a directory"),
73
+ data: null,
74
+ });
75
+ });
76
+
77
+ it("restores the rendered Markdown position when returning to the same file tab", () => {
78
+ const firstView = renderMarkdownPreview();
79
+ const firstScrollContainer = screen.getByTestId("markdown-preview").parentElement as HTMLDivElement;
80
+ firstScrollContainer.scrollTop = 144;
81
+ fireEvent.scroll(firstScrollContainer);
82
+ firstView.unmount();
83
+
84
+ renderMarkdownPreview();
85
+
86
+ expect((screen.getByTestId("markdown-preview").parentElement as HTMLDivElement).scrollTop).toBe(144);
87
+ });
88
+
89
+ it("keeps each Markdown tab's position when switching files in one preview instance", () => {
90
+ const firstFile = { key: "markdown-a", path: "/tmp/A.md" };
91
+ const secondFile = { key: "markdown-b", path: "/tmp/B.md" };
92
+ const resolvedPaths = new Map([
93
+ [firstFile.path, "/tmp/first-location/A.md"],
94
+ [secondFile.path, "/tmp/B.md"],
95
+ ]);
96
+ serverPathReadMock.mockImplementation(({ path }: { path: string }) => ({
97
+ isLoading: false,
98
+ error: null,
99
+ data: {
100
+ kind: "markdown",
101
+ resolvedPath: resolvedPaths.get(path) ?? path,
102
+ startLine: 1,
103
+ text: `# ${path}`,
104
+ truncated: false,
105
+ },
106
+ }));
107
+ const view = renderMarkdownPreview(firstFile);
108
+ const firstScrollContainer = screen.getByTestId("markdown-preview").parentElement as HTMLDivElement;
109
+ firstScrollContainer.scrollTop = 144;
110
+ fireEvent.scroll(firstScrollContainer);
111
+
112
+ view.rerender(
113
+ <ChatSessionWorkspaceFilePreview
114
+ file={{ ...secondFile, parentSessionKey: "session-1", viewMode: "preview" }}
115
+ sessionProjectRoot="/tmp"
116
+ sessionWorkingDir="/tmp"
117
+ showBreadcrumbs={false}
118
+ onFileOpen={vi.fn()}
119
+ />,
120
+ );
121
+ const secondScrollContainer = screen.getByTestId("markdown-preview").parentElement as HTMLDivElement;
122
+ secondScrollContainer.scrollTop = 72;
123
+ fireEvent.scroll(secondScrollContainer);
124
+
125
+ // Server-path data can be stale while the query swaps back to the first
126
+ // tab. Its resolved path is display data, not tab identity.
127
+ resolvedPaths.set(firstFile.path, "/tmp/returned-location/A.md");
128
+
129
+ view.rerender(
130
+ <ChatSessionWorkspaceFilePreview
131
+ file={{ ...firstFile, parentSessionKey: "session-1", viewMode: "preview" }}
132
+ sessionProjectRoot="/tmp"
133
+ sessionWorkingDir="/tmp"
134
+ showBreadcrumbs={false}
135
+ onFileOpen={vi.fn()}
136
+ />,
137
+ );
138
+
139
+ expect((screen.getByTestId("markdown-preview").parentElement as HTMLDivElement).scrollTop).toBe(144);
140
+ });
141
+
142
+ it("restores Markdown after returning from its source-view tab", () => {
143
+ const renderedFile = {
144
+ key: "markdown-rendered",
145
+ path: "/tmp/README.md",
146
+ previewViewer: "rendered" as const,
147
+ };
148
+ const sourceFile = {
149
+ key: "markdown-source",
150
+ path: "/tmp/README.md",
151
+ previewViewer: "source" as const,
152
+ };
153
+ const view = renderMarkdownPreview(renderedFile);
154
+ const scrollContainer = screen.getByTestId("markdown-preview").parentElement as HTMLDivElement;
155
+ scrollContainer.scrollTop = 144;
156
+ fireEvent.scroll(scrollContainer);
157
+
158
+ // The source-view switch replaces the rendered scroll surface before the
159
+ // rendered effect cleanup executes.
160
+ scrollContainer.scrollTop = 0;
161
+ view.rerender(
162
+ <ChatSessionWorkspaceFilePreview
163
+ file={{ ...sourceFile, parentSessionKey: "session-1", viewMode: "preview" }}
164
+ sessionProjectRoot="/tmp"
165
+ sessionWorkingDir="/tmp"
166
+ showBreadcrumbs={false}
167
+ onFileOpen={vi.fn()}
168
+ />,
169
+ );
170
+ view.rerender(
171
+ <ChatSessionWorkspaceFilePreview
172
+ file={{ ...renderedFile, parentSessionKey: "session-1", viewMode: "preview" }}
173
+ sessionProjectRoot="/tmp"
174
+ sessionWorkingDir="/tmp"
175
+ showBreadcrumbs={false}
176
+ onFileOpen={vi.fn()}
177
+ />,
178
+ );
179
+
180
+ expect((screen.getByTestId("markdown-preview").parentElement as HTMLDivElement).scrollTop).toBe(144);
181
+ });
182
+ });
@@ -0,0 +1,78 @@
1
+ import { createElement } from "react";
2
+ import { render, screen } from "@testing-library/react";
3
+ import userEvent from "@testing-library/user-event";
4
+ import { beforeEach, describe, expect, it, vi } from "vitest";
5
+
6
+ import type { CronJobView } from "@/shared/lib/api";
7
+ import { setLanguage } from "@/shared/lib/i18n";
8
+ import { SessionCronJobContent } from "@/features/chat/features/workspace/components/session-cron-job-content";
9
+
10
+ const mocks = vi.hoisted(() => ({
11
+ deleteJob: vi.fn(),
12
+ runJob: vi.fn(),
13
+ toggleJob: vi.fn(),
14
+ }));
15
+
16
+ vi.mock("@/features/cron", () => ({
17
+ CronJobDetailDialog: ({ job, open }: { job: CronJobView | null; open: boolean }) => (
18
+ open ? createElement("div", { role: "dialog" }, job?.payload.message) : null
19
+ ),
20
+ useCronJobActions: () => ({
21
+ ConfirmDialog: () => createElement("div", { "data-testid": "cron-confirm" }),
22
+ deleteJob: mocks.deleteJob,
23
+ isPending: () => false,
24
+ runJob: mocks.runJob,
25
+ toggleJob: mocks.toggleJob,
26
+ }),
27
+ }));
28
+
29
+ function createJob(): CronJobView {
30
+ return {
31
+ id: "paint-daily",
32
+ name: "每日绘画",
33
+ enabled: true,
34
+ schedule: { kind: "cron", expr: "0 9 * * *", tz: "Asia/Shanghai" },
35
+ payload: {
36
+ kind: "agent_turn",
37
+ message: "请生成一幅拥有柔和晨光、细腻建筑纹理和远处山景的城市水彩画,并将结果保存到项目图库。",
38
+ agentId: "main",
39
+ sessionId: "session:paint",
40
+ },
41
+ state: {
42
+ nextRunAt: "2026-08-27T01:00:00.000Z",
43
+ lastRunAt: "2026-08-26T01:00:00.000Z",
44
+ lastStatus: "ok",
45
+ lastError: null,
46
+ },
47
+ createdAt: "2026-08-26T01:00:00.000Z",
48
+ updatedAt: "2026-08-26T01:00:00.000Z",
49
+ deleteAfterRun: false,
50
+ };
51
+ }
52
+
53
+ describe("SessionCronJobContent", () => {
54
+ beforeEach(() => {
55
+ setLanguage("zh");
56
+ vi.clearAllMocks();
57
+ });
58
+
59
+ it("keeps the task prompt as a two-line summary and exposes task controls", async () => {
60
+ const user = userEvent.setup();
61
+ const job = createJob();
62
+ render(createElement(SessionCronJobContent, { jobs: [job] }));
63
+
64
+ const summary = screen.getByText(job.payload.message);
65
+ expect(summary.className.split(" ")).toContain("line-clamp-2");
66
+ expect(screen.getAllByTestId("cron-confirm").length).toBe(1);
67
+
68
+ await user.click(screen.getByRole("switch", { name: "暂停" }));
69
+ expect(mocks.toggleJob).toHaveBeenCalledWith(job, false);
70
+
71
+ await user.click(screen.getByRole("button", { name: "更多操作 每日绘画" }));
72
+ await user.click(screen.getByRole("button", { name: "立即执行" }));
73
+ expect(mocks.runJob).toHaveBeenCalledWith(job);
74
+
75
+ await user.click(screen.getByRole("button", { name: "查看任务详情" }));
76
+ expect(screen.getByRole("dialog").textContent).toContain(job.payload.message);
77
+ });
78
+ });
@@ -1,4 +1,4 @@
1
- import { useMemo, useRef, type ReactNode, type RefObject } from 'react';
1
+ import { useMemo, useRef, type ReactNode, type RefObject, type UIEvent } from 'react';
2
2
  import type { ChatFileOpenActionViewModel, ChatFileOperationBlockViewModel } from '@nextclaw/agent-chat-ui';
3
3
  import { NextClawClientError } from '@nextclaw/client-sdk';
4
4
  import { ChatMessageMarkdown, FileOperationCodeSurface } from '@nextclaw/agent-chat-ui';
@@ -24,6 +24,7 @@ import { buildWorkspaceFileBreadcrumb, resolveWorkspaceRelativePath } from '@/sh
24
24
  import { cn } from '@/shared/lib/utils';
25
25
  import { resolveWorkspaceFileViewer } from '@/features/chat/features/workspace/utils/chat-workspace-file-viewer.utils';
26
26
  import { WorkspaceMarkdownOutline } from './workspace-markdown-outline';
27
+ import { useScrollRestoration } from '@/shared/hooks/use-scroll-restoration';
27
28
 
28
29
  function inferPreviewKind(params: {
29
30
  path: string;
@@ -42,6 +43,31 @@ function appendPreviewRefreshVersion(url: string, refreshVersion: number): strin
42
43
  return `${url}${url.includes('?') ? '&' : '?'}refresh=${refreshVersion}`;
43
44
  }
44
45
 
46
+ function createMarkdownScrollRestorationKey(params: {
47
+ contentUrl: string | null;
48
+ fileKey: string;
49
+ previewKind: 'text' | 'markdown' | 'binary';
50
+ previewText: string | null;
51
+ previewViewer: 'source' | 'rendered' | null;
52
+ refreshVersion: number;
53
+ }): string | null {
54
+ const {
55
+ contentUrl,
56
+ fileKey,
57
+ previewKind,
58
+ previewText,
59
+ previewViewer,
60
+ refreshVersion,
61
+ } = params;
62
+ if (previewKind !== 'markdown' || previewViewer === 'source' || !previewText || contentUrl) {
63
+ return null;
64
+ }
65
+ // `fileKey` is the workspace tab's stable identity. Do not include resolvedPath:
66
+ // React Query may temporarily expose a previous result while a different file
67
+ // loads, which would turn one tab into several restoration records.
68
+ return `workspace-preview:markdown:${fileKey}:${refreshVersion}`;
69
+ }
70
+
45
71
  function buildPreviewBlock(params: {
46
72
  path: string;
47
73
  text: string;
@@ -159,6 +185,7 @@ function WorkspacePreviewBody({
159
185
  onFileOpen,
160
186
  onHtmlContentHeightChange,
161
187
  markdownScrollRef,
188
+ onMarkdownScroll,
162
189
  previewBlock,
163
190
  previewKind,
164
191
  previewViewer,
@@ -176,6 +203,7 @@ function WorkspacePreviewBody({
176
203
  onFileOpen: (action: ChatFileOpenActionViewModel) => void;
177
204
  onHtmlContentHeightChange?: (height: number) => void;
178
205
  markdownScrollRef: RefObject<HTMLDivElement>;
206
+ onMarkdownScroll: (event: UIEvent<HTMLDivElement>) => void;
179
207
  previewBlock: ChatFileOperationBlockViewModel | null;
180
208
  previewKind: 'text' | 'markdown' | 'binary';
181
209
  previewViewer: 'source' | 'rendered' | null;
@@ -224,7 +252,11 @@ function WorkspacePreviewBody({
224
252
 
225
253
  if (previewKind === 'markdown' && previewViewer !== 'source' && previewText) {
226
254
  return (
227
- <div ref={markdownScrollRef} className="h-full overflow-auto custom-scrollbar px-5 py-4">
255
+ <div
256
+ ref={markdownScrollRef}
257
+ onScroll={onMarkdownScroll}
258
+ className="h-full overflow-auto custom-scrollbar px-5 py-4"
259
+ >
228
260
  <ChatMessageMarkdown
229
261
  text={previewText}
230
262
  role="assistant"
@@ -338,6 +370,18 @@ export function ChatSessionWorkspaceFilePreview({
338
370
  previewText,
339
371
  ]);
340
372
  const isTextPreviewTruncated = !contentUrl && Boolean(previewQuery?.data?.truncated);
373
+ const markdownScrollRestoration = useScrollRestoration({
374
+ restorationKey: createMarkdownScrollRestorationKey({
375
+ contentUrl,
376
+ fileKey: file.key,
377
+ previewKind,
378
+ previewText,
379
+ previewViewer,
380
+ refreshVersion,
381
+ }),
382
+ scrollRef: markdownScrollRef,
383
+ });
384
+ const { onScroll: onMarkdownScroll } = markdownScrollRestoration;
341
385
  const breadcrumbBasePath = sessionProjectRoot ?? sessionWorkingDir;
342
386
  const excerptPath =
343
387
  resolveWorkspaceRelativePath({
@@ -365,6 +409,7 @@ export function ChatSessionWorkspaceFilePreview({
365
409
  onFileOpen={onFileOpen}
366
410
  onHtmlContentHeightChange={onHtmlContentHeightChange}
367
411
  markdownScrollRef={markdownScrollRef}
412
+ onMarkdownScroll={onMarkdownScroll}
368
413
  previewBlock={previewBlock}
369
414
  previewKind={previewKind}
370
415
  previewViewer={previewViewer}
@@ -39,8 +39,11 @@ type ChatSessionWorkspacePanelContentProps = {
39
39
  filePreviewRefreshVersion: number;
40
40
  sessionKey: string | null;
41
41
  sessionCronJobs: readonly CronJobView[];
42
+ sessionCronJobsError?: boolean;
43
+ sessionCronJobsLoading?: boolean;
42
44
  sessionProjectRoot: string | null;
43
45
  sessionWorkingDir: string | null;
46
+ onRetrySessionCronJobs?: () => void;
44
47
  };
45
48
 
46
49
  function WorkspaceOverviewEntry({
@@ -220,8 +223,11 @@ function WorkspaceSelectedContent({
220
223
  filePreviewRefreshVersion,
221
224
  sessionKey,
222
225
  sessionCronJobs,
226
+ sessionCronJobsError = false,
227
+ sessionCronJobsLoading = false,
223
228
  sessionProjectRoot,
224
229
  sessionWorkingDir,
230
+ onRetrySessionCronJobs,
225
231
  explorerControl,
226
232
  }: ChatSessionWorkspacePanelContentProps & {
227
233
  explorerControl?: { open: boolean; onToggle: () => void };
@@ -309,7 +315,14 @@ function WorkspaceSelectedContent({
309
315
  );
310
316
  }
311
317
 
312
- return <SessionCronJobContent jobs={sessionCronJobs} />;
318
+ return (
319
+ <SessionCronJobContent
320
+ jobs={sessionCronJobs}
321
+ isError={sessionCronJobsError}
322
+ isLoading={sessionCronJobsLoading}
323
+ onRetry={onRetrySessionCronJobs}
324
+ />
325
+ );
313
326
  }
314
327
 
315
328
  export function ChatSessionWorkspacePanelContent({