@nextclaw/ui 0.20.2 → 0.21.0

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 (144) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/dist/assets/{api-DvpQWjTS.js → api-DNLQPHfQ.js} +5 -5
  3. package/dist/assets/{appearance-settings-page-U0m0ktu3.js → appearance-settings-page-BlAFMOGV.js} +1 -1
  4. package/dist/assets/{book-open-DRofSTWr.js → book-open-BryfPTTa.js} +1 -1
  5. package/dist/assets/{channels-list-page-BURQarD0.js → channels-list-page-Cfbgubyv.js} +2 -2
  6. package/dist/assets/{config-split-page-BrAQYTIl.js → config-split-page-Dd8xG0Jk.js} +1 -1
  7. package/dist/assets/{confirm-dialog-DDJomLnO.js → confirm-dialog-CZIWjnJ6.js} +2 -2
  8. package/dist/assets/{createLucideIcon-DzkNHr5Z.js → createLucideIcon-BxwVe8ZD.js} +1 -1
  9. package/dist/assets/desktop-capabilities-page-C6YxsHeV.js +1 -0
  10. package/dist/assets/desktop-update-config-BVN4QPJn.js +1 -0
  11. package/dist/assets/{dist-NskPB_yW.js → dist-BRloKr3S.js} +1 -1
  12. package/dist/assets/{dist-DXbLo2b1.js → dist-Cd8Nm3BW.js} +1 -1
  13. package/dist/assets/doc-browser-Bzgqmmiu.js +1 -0
  14. package/dist/assets/doc-browser-D0qI0Z_R.js +1 -0
  15. package/dist/assets/{doc-browser-context-CnKV4gJy.js → doc-browser-context-3JGufiMA.js} +1 -1
  16. package/dist/assets/{extensions-DvlFMpAs.js → extensions-Dedd2oDI.js} +1 -1
  17. package/dist/assets/{form-actions-UnPEZBkx.js → form-actions-FWCEof3V.js} +1 -1
  18. package/dist/assets/index-CXkHcESm.css +1 -0
  19. package/dist/assets/index-D25tRg1p.js +109 -0
  20. package/dist/assets/loader-circle-BoyCoFps.js +1 -0
  21. package/dist/assets/mcp-marketplace-page-Cn-9CDh3.js +1 -0
  22. package/dist/assets/mcp-marketplace-page-e9Rmfz-d.js +10 -0
  23. package/dist/assets/model-config-page-C173Ssil.js +1 -0
  24. package/dist/assets/{navigation-link-NjUdG6Vp.js → navigation-link-DTPOvGGL.js} +1 -1
  25. package/dist/assets/plus-D1R4Vipq.js +1 -0
  26. package/dist/assets/privacy-settings-page-CRserBVF.js +1 -0
  27. package/dist/assets/providers-config-page-C8jUtnZb.js +1 -0
  28. package/dist/assets/react-BAzO6LZS.js +8 -0
  29. package/dist/assets/{refresh-cw-CkeninE0.js → refresh-cw-BCj-yjde.js} +1 -1
  30. package/dist/assets/remote-6BHWP5zi.js +1 -0
  31. package/dist/assets/{rotate-cw-DP33PuXU.js → rotate-cw-BPnW4qUl.js} +1 -1
  32. package/dist/assets/runtime-config-page-aeQ1Dryz.js +1 -0
  33. package/dist/assets/{save-Cw2Xz-pB.js → save-KT0MumfJ.js} +1 -1
  34. package/dist/assets/{search-C2CFAVvd.js → search-Cv1Hx_nx.js} +1 -1
  35. package/dist/assets/{search-config-page-dqNT0Swf.js → search-config-page-b283V-fo.js} +1 -1
  36. package/dist/assets/{secrets-config-page-De6J3wVx.js → secrets-config-page-ss7x3BOo.js} +2 -2
  37. package/dist/assets/security-config-HHz62oxH.js +1 -0
  38. package/dist/assets/{select-DSuMhwVP.js → select-BVgD4EpG.js} +1 -1
  39. package/dist/assets/skeleton-ChUmcyys.js +1 -0
  40. package/dist/assets/{tag-chip-DFBLgsGf.js → tag-chip-DZ4ircUB.js} +1 -1
  41. package/dist/assets/x-BWDRfVWL.js +1 -0
  42. package/dist/index.html +21 -21
  43. package/package.json +5 -5
  44. package/src/app/components/layout/sidebar.tsx +12 -1
  45. package/src/app/configs/app-navigation.config.ts +17 -2
  46. package/src/app/hooks/use-feature-controls.ts +11 -0
  47. package/src/app/index.tsx +10 -0
  48. package/src/features/apps/components/app-package-card.tsx +29 -7
  49. package/src/features/apps/components/app-packages-panel.tsx +4 -0
  50. package/src/features/chat/components/conversation/chat-conversation-content.tsx +18 -2
  51. package/src/features/chat/components/conversation/chat-conversation-workspace-section.tsx +6 -0
  52. package/src/features/chat/components/layout/__tests__/chat-sidebar-session-area.test.tsx +16 -2
  53. package/src/features/chat/components/layout/chat-page-shell.tsx +19 -2
  54. package/src/features/chat/components/layout/chat-sidebar-desktop-layout.tsx +16 -0
  55. package/src/features/chat/components/layout/chat-sidebar.tsx +5 -1
  56. package/src/features/chat/features/message/utils/__tests__/chat-message-timeline-compaction.utils.test.ts +2 -2
  57. package/src/features/chat/features/message/utils/chat-message-timeline.utils.ts +2 -2
  58. package/src/features/chat/features/ncp/hooks/__tests__/use-ncp-session-list-view.test.tsx +11 -3
  59. package/src/features/chat/features/ncp/hooks/use-ncp-session-list-view.ts +12 -5
  60. package/src/features/chat/features/ncp/hooks/use-ncp-session-queries.ts +15 -1
  61. package/src/features/chat/features/session/components/__tests__/chat-sidebar-session-item.test.tsx +12 -0
  62. package/src/features/chat/features/session/components/chat-sidebar-session-entry.tsx +3 -0
  63. package/src/features/chat/features/session/components/chat-sidebar-session-item.tsx +14 -1
  64. package/src/features/chat/features/session/utils/chat-session-deletion.utils.ts +25 -0
  65. package/src/features/chat/features/workspace/components/__tests__/chat-session-token-usage.test.tsx +4 -1
  66. package/src/features/chat/features/workspace/components/__tests__/chat-session-workspace-file-preview.scroll-restoration.test.tsx +182 -0
  67. package/src/features/chat/features/workspace/components/__tests__/session-cron-job-content.test.tsx +78 -0
  68. package/src/features/chat/features/workspace/components/chat-session-workspace-file-preview.tsx +47 -2
  69. package/src/features/chat/features/workspace/components/chat-session-workspace-panel-content.tsx +14 -1
  70. package/src/features/chat/features/workspace/components/chat-session-workspace-panel.tsx +9 -0
  71. package/src/features/chat/features/workspace/components/overview/chat-session-token-usage.tsx +17 -0
  72. package/src/features/chat/features/workspace/components/session-cron-job-content.tsx +269 -75
  73. package/src/features/chat/features/workspace/hooks/use-chat-conversation-workspace-state.ts +5 -0
  74. package/src/features/chat/managers/__tests__/chat-thread-deletion.manager.test.ts +125 -0
  75. package/src/features/chat/managers/__tests__/chat-thread.manager.test.ts +0 -37
  76. package/src/features/chat/managers/chat-thread.manager.ts +17 -28
  77. package/src/features/cron/components/__tests__/cron-config.test.tsx +16 -5
  78. package/src/features/cron/components/cron-config.tsx +20 -46
  79. package/src/features/cron/components/cron-job-row.tsx +1 -1
  80. package/src/features/cron/hooks/__tests__/use-cron-job-actions.test.tsx +95 -0
  81. package/src/features/cron/hooks/use-cron-job-actions.ts +109 -0
  82. package/src/features/cron/index.ts +2 -0
  83. package/src/features/desktop-capabilities/components/desktop-authorization-dialog.test.tsx +58 -0
  84. package/src/features/desktop-capabilities/components/desktop-authorization-dialog.tsx +161 -0
  85. package/src/features/desktop-capabilities/hooks/use-desktop-capabilities.test.ts +10 -0
  86. package/src/features/desktop-capabilities/hooks/use-desktop-capabilities.ts +55 -0
  87. package/src/features/desktop-capabilities/index.ts +1 -0
  88. package/src/features/desktop-capabilities/managers/desktop-capability.manager.ts +25 -0
  89. package/src/features/desktop-capabilities/pages/desktop-capabilities-page.test.tsx +44 -0
  90. package/src/features/desktop-capabilities/pages/desktop-capabilities-page.tsx +171 -0
  91. package/src/features/desktop-capabilities/stores/desktop-authorization.store.ts +21 -0
  92. package/src/features/inbox/pages/inbox-page.tsx +18 -2
  93. package/src/features/marketplace/components/marketplace-page.tsx +7 -0
  94. package/src/features/marketplace/components/mcp/__tests__/mcp-connection-dialog.test.tsx +23 -0
  95. package/src/features/marketplace/components/mcp/mcp-connection-dialog.tsx +35 -0
  96. package/src/features/marketplace/components/mcp/mcp-marketplace-page.tsx +20 -2
  97. package/src/features/panel-apps/pages/panel-app-main-page.tsx +8 -0
  98. package/src/platforms/desktop/components/desktop-app-shell.tsx +10 -1
  99. package/src/platforms/mobile/components/mobile-settings-shell.tsx +5 -1
  100. package/src/shared/components/doc-browser/doc-browser.tsx +0 -1
  101. package/src/shared/components/doc-browser/hooks/use-doc-browser-scroll-restoration.ts +9 -113
  102. package/src/shared/components/ui/__tests__/confirm-dialog.test.tsx +31 -0
  103. package/src/shared/components/ui/confirm-dialog.tsx +11 -1
  104. package/src/shared/hooks/use-panel-app-scroll-restoration.test.tsx +66 -0
  105. package/src/shared/hooks/use-panel-app-scroll-restoration.ts +107 -0
  106. package/src/shared/hooks/use-scroll-restoration.test.tsx +62 -0
  107. package/src/shared/hooks/use-scroll-restoration.ts +61 -0
  108. package/src/shared/lib/api/index.ts +1 -0
  109. package/src/shared/lib/api/ncp-session-query-cache.utils.ts +1 -0
  110. package/src/shared/lib/api/ncp-session.types.ts +3 -0
  111. package/src/shared/lib/api/types.ts +1 -0
  112. package/src/shared/lib/api/utils/mcp-connection.utils.ts +10 -0
  113. package/src/shared/lib/api/utils/ncp-session.utils.ts +3 -0
  114. package/src/shared/lib/i18n/locales/en-US/chat.json +10 -5
  115. package/src/shared/lib/i18n/locales/en-US/core.json +43 -0
  116. package/src/shared/lib/i18n/locales/en-US/cron.json +4 -2
  117. package/src/shared/lib/i18n/locales/en-US/doc-browser.json +1 -0
  118. package/src/shared/lib/i18n/locales/en-US/marketplace.json +29 -1
  119. package/src/shared/lib/i18n/locales/zh-CN/chat.json +10 -5
  120. package/src/shared/lib/i18n/locales/zh-CN/core.json +43 -0
  121. package/src/shared/lib/i18n/locales/zh-CN/cron.json +4 -2
  122. package/src/shared/lib/i18n/locales/zh-CN/doc-browser.json +1 -0
  123. package/src/shared/lib/i18n/locales/zh-CN/marketplace.json +29 -1
  124. package/src/shared/lib/navigation-history/index.ts +1 -0
  125. package/src/shared/lib/navigation-history/scroll-restoration.manager.test.ts +37 -0
  126. package/src/shared/lib/navigation-history/scroll-restoration.manager.ts +60 -0
  127. package/dist/assets/desktop-update-config-BzZFhMv9.js +0 -1
  128. package/dist/assets/doc-browser-DB22tQKU.js +0 -1
  129. package/dist/assets/doc-browser-sJjw9fl5.js +0 -1
  130. package/dist/assets/index-Dc_ZMkmo.css +0 -1
  131. package/dist/assets/index-N1HnesBp.js +0 -109
  132. package/dist/assets/loader-circle-BlPXxgDB.js +0 -1
  133. package/dist/assets/mcp-marketplace-page-BIgFTbMq.js +0 -9
  134. package/dist/assets/mcp-marketplace-page-CZN4ovdK.js +0 -1
  135. package/dist/assets/model-config-page-CnSm7kfa.js +0 -1
  136. package/dist/assets/plus-DVXol9Q5.js +0 -1
  137. package/dist/assets/privacy-settings-page-DVn2HU7W.js +0 -1
  138. package/dist/assets/providers-config-page-CzHJS3eg.js +0 -1
  139. package/dist/assets/react-DCVbGroo.js +0 -8
  140. package/dist/assets/remote-B2lsJF1c.js +0 -1
  141. package/dist/assets/runtime-config-page-D69buyGp.js +0 -1
  142. package/dist/assets/security-config-Dcu9C4OL.js +0 -1
  143. package/dist/assets/skeleton-QoWydNLp.js +0 -1
  144. package/dist/assets/x-BXvAzpjb.js +0 -1
@@ -15,6 +15,7 @@ import { ChatConversationTrack } from "@/features/chat/components/conversation/c
15
15
  import { IconActionButton } from "@/shared/components/ui/actions/icon-action-button";
16
16
  import { SCROLL_BOTTOM_EDGE_FADE_CLASS } from "@/shared/components/ui/scroll-area";
17
17
  import { t } from "@/shared/lib/i18n";
18
+ import { useScrollRestoration } from "@/shared/hooks/use-scroll-restoration";
18
19
  import type { SessionMessageToolPayloadState } from "@/features/chat/features/ncp/hooks/use-ncp-session-message-history";
19
20
 
20
21
  type ChatConversationContentProps = {
@@ -41,6 +42,13 @@ type ChatConversationContentProps = {
41
42
  welcomeSlot?: ReactNode;
42
43
  };
43
44
 
45
+ function createConversationScrollRestorationKey(
46
+ sessionKey: string | null,
47
+ showWelcome: boolean,
48
+ ): string | null {
49
+ return sessionKey && !showWelcome ? `chat-conversation:${sessionKey}` : null;
50
+ }
51
+
44
52
  export function ChatConversationContent({
45
53
  bottomSlot,
46
54
  canContinue = false,
@@ -61,9 +69,15 @@ export function ChatConversationContent({
61
69
  onEditMessage,
62
70
  welcomeSlot,
63
71
  }: ChatConversationContentProps) {
64
- const threadRef = useRef<HTMLDivElement | null>(null);
65
- const contentRef = useRef<HTMLDivElement | null>(null);
72
+ const threadRef = useRef<HTMLDivElement>(null);
73
+ const contentRef = useRef<HTMLDivElement>(null);
66
74
  const hasConversationContent = messages.length > 0 || isSending;
75
+ const scrollRestoration = useScrollRestoration({
76
+ restorationKey: createConversationScrollRestorationKey(sessionKey, showWelcome),
77
+ scrollRef: threadRef,
78
+ isEnabled: !showWelcome,
79
+ });
80
+ const { onScroll: onScrollPositionSave } = scrollRestoration;
67
81
  const { isAtBottom, onScroll, scrollToBottom } = useStickyBottomScroll({
68
82
  contentRef,
69
83
  scrollRef: threadRef,
@@ -75,6 +89,7 @@ export function ChatConversationContent({
75
89
  const hasMessages = messages.length > 0;
76
90
  const handleScroll = useCallback(
77
91
  (event: UIEvent<HTMLDivElement>) => {
92
+ onScrollPositionSave(event);
78
93
  onScroll();
79
94
  if (
80
95
  event.currentTarget.scrollTop <= 320 &&
@@ -89,6 +104,7 @@ export function ChatConversationContent({
89
104
  isLoadingPreviousMessages,
90
105
  onLoadPreviousMessages,
91
106
  onScroll,
107
+ onScrollPositionSave,
92
108
  ],
93
109
  );
94
110
  return (
@@ -29,6 +29,9 @@ export function ChatConversationWorkspaceSection({
29
29
  activeSideChatDraft,
30
30
  workspaceFileTabs,
31
31
  sessionCronJobs,
32
+ sessionCronJobsError,
33
+ sessionCronJobsLoading,
34
+ retrySessionCronJobs,
32
35
  showWorkspacePanel,
33
36
  } = useChatConversationWorkspaceState(snapshot, sessionKey);
34
37
 
@@ -49,6 +52,9 @@ export function ChatConversationWorkspaceSection({
49
52
  workspaceNavigationHistoryIndex={snapshot.workspaceNavigationHistoryIndex}
50
53
  activePanelKind={snapshot.activeWorkspacePanelKind ?? null}
51
54
  sessionCronJobs={sessionKey ? sessionCronJobs : []}
55
+ sessionCronJobsError={sessionKey ? sessionCronJobsError : false}
56
+ sessionCronJobsLoading={sessionKey ? sessionCronJobsLoading : false}
57
+ onRetrySessionCronJobs={retrySessionCronJobs}
52
58
  sessionProjectRoot={selectedSession?.projectRoot ?? draftProjectRoot}
53
59
  sessionWorkingDir={
54
60
  selectedSession?.workingDir ??
@@ -2,7 +2,7 @@ import { fireEvent, render, screen } from "@testing-library/react";
2
2
  import { describe, expect, it, vi } from "vitest";
3
3
  import { ChatSidebarSessionArea } from "@/features/chat/components/layout/chat-sidebar-desktop-layout";
4
4
 
5
- function renderSessionArea(isProjectFirstView: boolean) {
5
+ function renderSessionArea(isProjectFirstView: boolean, onScrollNearEnd = vi.fn()) {
6
6
  const onAddProject = vi.fn();
7
7
  const onSelectMode = vi.fn();
8
8
 
@@ -14,6 +14,7 @@ function renderSessionArea(isProjectFirstView: boolean) {
14
14
  isLoading={false}
15
15
  isProjectFirstView={isProjectFirstView}
16
16
  onAddProject={onAddProject}
17
+ onScrollNearEnd={onScrollNearEnd}
17
18
  onSelectMode={onSelectMode}
18
19
  projectGroups={[]}
19
20
  projectCronJobCountByRoot={new Map()}
@@ -22,7 +23,7 @@ function renderSessionArea(isProjectFirstView: boolean) {
22
23
  />,
23
24
  );
24
25
 
25
- return { onAddProject, onSelectMode };
26
+ return { onAddProject, onScrollNearEnd, onSelectMode };
26
27
  }
27
28
 
28
29
  describe("ChatSidebarSessionArea", () => {
@@ -87,4 +88,17 @@ describe("ChatSidebarSessionArea", () => {
87
88
 
88
89
  expect(onAddProject).toHaveBeenCalledOnce();
89
90
  });
91
+
92
+ it("requests the next page before scrolling reaches the end", () => {
93
+ const { onScrollNearEnd } = renderSessionArea(false);
94
+ const scroller = document.querySelector(".overflow-y-auto") as HTMLDivElement;
95
+ Object.defineProperties(scroller, {
96
+ scrollHeight: { value: 2_000 },
97
+ clientHeight: { value: 500 },
98
+ });
99
+
100
+ fireEvent.scroll(scroller, { target: { scrollTop: 1_000 } });
101
+
102
+ expect(onScrollNearEnd).toHaveBeenCalledOnce();
103
+ });
90
104
  });
@@ -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();
@@ -247,6 +247,7 @@ export function ChatSidebar({
247
247
  onTogglePinned={() =>
248
248
  presenter.chatSessionListManager.toggleSessionPinned(item.session.key)
249
249
  }
250
+ onDeleteSession={presenter.chatThreadManager.deleteSession}
250
251
  />
251
252
  );
252
253
  const createSessionAndOpenIfNeeded = (
@@ -343,6 +344,9 @@ export function ChatSidebar({
343
344
  isLoading={isLoading}
344
345
  isProjectFirstView={isProjectFirstView}
345
346
  onAddProject={openProjectAdd}
347
+ onScrollNearEnd={() => {
348
+ if (hasMore && !isLoadingMore) void loadMore();
349
+ }}
346
350
  onSelectMode={presenter.chatSessionListManager.setListMode}
347
351
  projectGroups={projectGroups}
348
352
  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;
@@ -5,6 +5,7 @@ import { ChatSidebarSessionItem } from "@/features/chat/features/session/compone
5
5
 
6
6
  const mocks = vi.hoisted(() => ({
7
7
  copyText: vi.fn(),
8
+ deleteSession: vi.fn(),
8
9
  }));
9
10
 
10
11
  vi.mock("@nextclaw/agent-chat-ui", () => ({
@@ -21,6 +22,7 @@ vi.mock("sonner", () => ({
21
22
  beforeEach(() => {
22
23
  mocks.copyText.mockReset();
23
24
  mocks.copyText.mockResolvedValue(true);
25
+ mocks.deleteSession.mockReset();
24
26
  render(
25
27
  <ChatSidebarSessionItem
26
28
  sessionKey="session:current"
@@ -50,6 +52,7 @@ beforeEach(() => {
50
52
  onSave={vi.fn()}
51
53
  onCancel={vi.fn()}
52
54
  onTogglePinned={vi.fn()}
55
+ onDelete={mocks.deleteSession}
53
56
  />,
54
57
  );
55
58
  });
@@ -87,6 +90,15 @@ it("copies the sidebar session ID from the more-actions menu", async () => {
87
90
  expect(mocks.copyText).toHaveBeenCalledWith("session:current");
88
91
  });
89
92
 
93
+ it("deletes the sidebar session from the more-actions menu", async () => {
94
+ const user = userEvent.setup();
95
+
96
+ await user.click(screen.getByRole("button", { name: "More actions" }));
97
+ await user.click(screen.getByRole("button", { name: "Delete Session" }));
98
+
99
+ expect(mocks.deleteSession).toHaveBeenCalledOnce();
100
+ });
101
+
90
102
  it("sizes the runtime icon to the session title text", () => {
91
103
  const runtimeIcon = screen.getByRole("img", { name: "Codex logo" });
92
104
 
@@ -32,6 +32,7 @@ export function ChatSidebarSessionEntry(props: {
32
32
  onSaveSessionLabel: (session: SessionEntryView) => void;
33
33
  onCancelEditingSessionLabel: () => void;
34
34
  onTogglePinned: () => void;
35
+ onDeleteSession: (sessionKey: string) => void;
35
36
  }) {
36
37
  const {
37
38
  item,
@@ -52,6 +53,7 @@ export function ChatSidebarSessionEntry(props: {
52
53
  onSaveSessionLabel,
53
54
  onCancelEditingSessionLabel,
54
55
  onTogglePinned,
56
+ onDeleteSession,
55
57
  } = props;
56
58
  const { session, runStatus } = item;
57
59
  const active = selectedSessionKey === session.key;
@@ -111,6 +113,7 @@ export function ChatSidebarSessionEntry(props: {
111
113
  onSave={() => onSaveSessionLabel(session)}
112
114
  onCancel={onCancelEditingSessionLabel}
113
115
  onTogglePinned={onTogglePinned}
116
+ onDelete={() => onDeleteSession(session.key)}
114
117
  />
115
118
  );
116
119
  }
@@ -15,9 +15,11 @@ import {
15
15
  GitBranch,
16
16
  Pencil,
17
17
  Pin,
18
+ Trash2,
18
19
  X,
19
20
  } from "lucide-react";
20
21
  import { ChatSidebarContextCard } from "@/features/chat/features/session/components/chat-sidebar-context-card";
22
+ import { ChatSessionHeaderMenuItem } from "@/features/chat/features/session/components/session-header/chat-session-header-menu-item";
21
23
 
22
24
  type ChatSidebarSessionItemProps = {
23
25
  sessionKey: string;
@@ -44,6 +46,7 @@ type ChatSidebarSessionItemProps = {
44
46
  onSave: () => void | Promise<void>;
45
47
  onCancel: () => void;
46
48
  onTogglePinned: () => void;
49
+ onDelete: () => void;
47
50
  };
48
51
 
49
52
  type ChatSidebarSessionEditingViewProps = Pick<
@@ -139,6 +142,7 @@ function ChatSidebarSessionDisplayView({
139
142
  onSelect,
140
143
  onStartEditing,
141
144
  onTogglePinned,
145
+ onDelete,
142
146
  }: ChatSidebarSessionDisplayViewProps) {
143
147
  return (
144
148
  <div className="group/session relative">
@@ -262,7 +266,14 @@ function ChatSidebarSessionDisplayView({
262
266
  sessionKey={sessionKey}
263
267
  triggerSize="sm"
264
268
  triggerTone="strong"
265
- />
269
+ >
270
+ <ChatSessionHeaderMenuItem
271
+ icon={Trash2}
272
+ label={t("chatDeleteSession")}
273
+ onClick={onDelete}
274
+ destructive
275
+ />
276
+ </ChatSessionMoreActionsMenu>
266
277
  </div>
267
278
  </div>
268
279
  );
@@ -293,6 +304,7 @@ export function ChatSidebarSessionItem({
293
304
  onSave,
294
305
  onCancel,
295
306
  onTogglePinned,
307
+ onDelete,
296
308
  }: ChatSidebarSessionItemProps) {
297
309
  return (
298
310
  <div
@@ -332,6 +344,7 @@ export function ChatSidebarSessionItem({
332
344
  projectName={projectName}
333
345
  onStartEditing={onStartEditing}
334
346
  onTogglePinned={onTogglePinned}
347
+ onDelete={onDelete}
335
348
  />
336
349
  )}
337
350
  </div>
@@ -0,0 +1,25 @@
1
+ import type { ChatThreadSnapshot } from "@/features/chat/stores/chat-thread.store";
2
+
3
+ export function createDeletedChatThreadStatePatch(): Partial<ChatThreadSnapshot> {
4
+ return {
5
+ sessionKey: null,
6
+ sessionTypeLabel: null,
7
+ agentId: null,
8
+ sessionDisplayName: undefined,
9
+ draftProjectRoot: null,
10
+ sessionProjectName: null,
11
+ canDeleteSession: false,
12
+ parentSessionKey: null,
13
+ parentSessionLabel: null,
14
+ workspacePanelParentKey: null,
15
+ activeWorkspacePanelKind: null,
16
+ childSessionTabs: [],
17
+ activeChildSessionKey: null,
18
+ activeSideChatDraft: null,
19
+ workspaceFileTabs: [],
20
+ activeWorkspaceFileKey: null,
21
+ closedWorkspaceTabEntries: [],
22
+ workspaceNavigationHistory: [],
23
+ workspaceNavigationHistoryIndex: 0,
24
+ };
25
+ }
@@ -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);