@nextclaw/ui 0.20.1 → 0.20.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (138) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/dist/assets/{api-BBFMpRGl.js → api-iMkU_o5P.js} +3 -3
  3. package/dist/assets/{appearance-settings-page-C1Erayzl.js → appearance-settings-page-CU5k4b-M.js} +1 -1
  4. package/dist/assets/{book-open-BhTIpBKg.js → book-open-nD8gdMhA.js} +1 -1
  5. package/dist/assets/channels-list-page-Drlt62kY.js +8 -0
  6. package/dist/assets/{config-split-page-By7p-4X-.js → config-split-page-BtJZp1E5.js} +1 -1
  7. package/dist/assets/{confirm-dialog-xpl1Yl7r.js → confirm-dialog-k2-Jq_x3.js} +1 -1
  8. package/dist/assets/{createLucideIcon-B_8khHn8.js → createLucideIcon-DxlwKLr-.js} +1 -1
  9. package/dist/assets/{desktop-update-config-CGFfa7-Z.js → desktop-update-config-BiZmy7FC.js} +1 -1
  10. package/dist/assets/{dist-CXN62DSs.js → dist-Cy_e4kNb.js} +1 -1
  11. package/dist/assets/{dist-DdE-kCbi.js → dist-m08DUswB.js} +1 -1
  12. package/dist/assets/doc-browser-CPsPHh12.js +1 -0
  13. package/dist/assets/{doc-browser-context-B8NCuMv5.js → doc-browser-context-jiy-O4F8.js} +1 -1
  14. package/dist/assets/doc-browser-npOvWtgf.js +1 -0
  15. package/dist/assets/{extensions-CMctxyTf.js → extensions-CIb17__o.js} +1 -1
  16. package/dist/assets/{form-actions-BFgTdxqI.js → form-actions-DGf5zUg_.js} +1 -1
  17. package/dist/assets/index-C86hxgv5.css +1 -0
  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-kLWW6kzt.js +9 -0
  22. package/dist/assets/model-config-page-DSeAjVT_.js +1 -0
  23. package/dist/assets/{navigation-link-BwWrm4k1.js → navigation-link-DAfwFCj4.js} +1 -1
  24. package/dist/assets/plus-D4LtWtTk.js +1 -0
  25. package/dist/assets/privacy-settings-page-tN0Mt15x.js +1 -0
  26. package/dist/assets/providers-config-page-CgPrXR1u.js +1 -0
  27. package/dist/assets/react-8iBFECZE.js +8 -0
  28. package/dist/assets/{refresh-cw-B7NmPJ_Z.js → refresh-cw-D_Eibznw.js} +1 -1
  29. package/dist/assets/remote-Derr_AQp.js +1 -0
  30. package/dist/assets/{rotate-cw-B0ajMaHu.js → rotate-cw-QQjmoWuq.js} +1 -1
  31. package/dist/assets/runtime-config-page-Crq96Jjq.js +1 -0
  32. package/dist/assets/{save-DWnYYpgQ.js → save-W2xthJJ2.js} +1 -1
  33. package/dist/assets/{search-D6pX7FC-.js → search-Vih1R2BD.js} +1 -1
  34. package/dist/assets/{search-config-page-Cx3o_675.js → search-config-page-CtBcL2uc.js} +1 -1
  35. package/dist/assets/{secrets-config-page-tyFl7Rvt.js → secrets-config-page-Q6XfIj0h.js} +2 -2
  36. package/dist/assets/security-config-CE9rkQvq.js +1 -0
  37. package/dist/assets/{select-qEtFTWB5.js → select-lNdozU4o.js} +1 -1
  38. package/dist/assets/skeleton-B__aTcly.js +1 -0
  39. package/dist/assets/{tag-chip-vcjAYdV_.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 +7 -7
  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-read-state.test.tsx +4 -3
  49. package/src/features/chat/components/layout/__tests__/chat-sidebar-session-area.test.tsx +27 -6
  50. package/src/features/chat/components/layout/__tests__/chat-sidebar.test.tsx +17 -17
  51. package/src/features/chat/components/layout/chat-page-shell.tsx +19 -2
  52. package/src/features/chat/components/layout/chat-sidebar-desktop-layout.tsx +33 -5
  53. package/src/features/chat/components/layout/chat-sidebar.tsx +31 -21
  54. package/src/features/chat/features/conversation/hooks/__tests__/use-session-conversation-controller.test.tsx +41 -1
  55. package/src/features/chat/features/conversation/hooks/use-session-pending-input-actions.ts +22 -4
  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/__tests__/chat-message-trigger-details.utils.test.ts +35 -0
  58. package/src/features/chat/features/message/utils/chat-message-timeline.utils.ts +2 -2
  59. package/src/features/chat/features/ncp/hooks/__tests__/use-ncp-agent-runtime-queue.test.tsx +50 -0
  60. package/src/features/chat/features/ncp/hooks/__tests__/use-ncp-session-list-view.test.tsx +35 -6
  61. package/src/features/chat/features/ncp/hooks/use-ncp-session-list-view.ts +43 -20
  62. package/src/features/chat/features/ncp/hooks/use-ncp-session-queries.ts +15 -1
  63. package/src/features/chat/features/session/components/__tests__/chat-sidebar-project-groups.test.tsx +26 -2
  64. package/src/features/chat/features/session/components/__tests__/chat-sidebar-session-item.test.tsx +55 -27
  65. package/src/features/chat/features/session/components/chat-sidebar-context-card.tsx +60 -0
  66. package/src/features/chat/features/session/components/chat-sidebar-project-groups.tsx +95 -54
  67. package/src/features/chat/features/session/components/chat-sidebar-session-entry.tsx +9 -8
  68. package/src/features/chat/features/session/components/chat-sidebar-session-item.tsx +141 -95
  69. package/src/features/chat/features/session/components/chat-sidebar-session-list.tsx +11 -2
  70. package/src/features/chat/features/session/hooks/use-chat-sidebar-context-counts.ts +40 -0
  71. package/src/features/chat/features/workspace/components/__tests__/chat-session-token-usage.test.tsx +4 -1
  72. package/src/features/chat/features/workspace/components/__tests__/chat-session-workspace-file-preview.scroll-restoration.test.tsx +182 -0
  73. package/src/features/chat/features/workspace/components/__tests__/chat-session-workspace-file-preview.test.tsx +16 -9
  74. package/src/features/chat/features/workspace/components/__tests__/session-cron-job-content.test.tsx +78 -0
  75. package/src/features/chat/features/workspace/components/__tests__/workspace-markdown-outline.test.tsx +128 -0
  76. package/src/features/chat/features/workspace/components/chat-session-workspace-file-breadcrumbs.tsx +3 -0
  77. package/src/features/chat/features/workspace/components/chat-session-workspace-file-preview.tsx +65 -2
  78. package/src/features/chat/features/workspace/components/chat-session-workspace-panel-content.tsx +14 -1
  79. package/src/features/chat/features/workspace/components/chat-session-workspace-panel.tsx +9 -0
  80. package/src/features/chat/features/workspace/components/overview/chat-session-token-usage.tsx +17 -0
  81. package/src/features/chat/features/workspace/components/session-cron-job-content.tsx +269 -75
  82. package/src/features/chat/features/workspace/components/workspace-markdown-outline.tsx +156 -0
  83. package/src/features/chat/features/workspace/hooks/use-chat-conversation-workspace-state.ts +5 -0
  84. package/src/features/cron/components/__tests__/cron-config.test.tsx +16 -5
  85. package/src/features/cron/components/cron-config.tsx +20 -46
  86. package/src/features/cron/components/cron-job-row.tsx +1 -1
  87. package/src/features/cron/hooks/__tests__/use-cron-job-actions.test.tsx +95 -0
  88. package/src/features/cron/hooks/use-cron-job-actions.ts +109 -0
  89. package/src/features/cron/index.ts +2 -0
  90. package/src/features/inbox/pages/inbox-page.tsx +18 -2
  91. package/src/features/marketplace/components/marketplace-page.tsx +7 -0
  92. package/src/features/panel-apps/pages/panel-app-main-page.tsx +8 -0
  93. package/src/features/settings/pages/__tests__/privacy-settings-page.test.tsx +14 -5
  94. package/src/features/settings/pages/privacy-settings-page.tsx +38 -2
  95. package/src/platforms/desktop/components/desktop-app-shell.tsx +10 -1
  96. package/src/shared/components/doc-browser/doc-browser.tsx +0 -1
  97. package/src/shared/components/doc-browser/hooks/use-doc-browser-scroll-restoration.ts +9 -113
  98. package/src/shared/components/ui/actions/icon-action-button.tsx +1 -8
  99. package/src/shared/components/ui/tooltip.tsx +16 -1
  100. package/src/shared/hooks/use-config.ts +11 -0
  101. package/src/shared/hooks/use-panel-app-scroll-restoration.test.tsx +66 -0
  102. package/src/shared/hooks/use-panel-app-scroll-restoration.ts +107 -0
  103. package/src/shared/hooks/use-scroll-restoration.test.tsx +62 -0
  104. package/src/shared/hooks/use-scroll-restoration.ts +61 -0
  105. package/src/shared/lib/api/ncp-session-query-cache.utils.ts +1 -0
  106. package/src/shared/lib/api/ncp-session.types.ts +3 -0
  107. package/src/shared/lib/api/types.ts +9 -0
  108. package/src/shared/lib/api/utils/config.utils.ts +5 -0
  109. package/src/shared/lib/api/utils/ncp-session.utils.ts +3 -0
  110. package/src/shared/lib/i18n/locales/en-US/chat.json +15 -5
  111. package/src/shared/lib/i18n/locales/en-US/core.json +17 -9
  112. package/src/shared/lib/i18n/locales/en-US/cron.json +4 -2
  113. package/src/shared/lib/i18n/locales/en-US/doc-browser.json +1 -0
  114. package/src/shared/lib/i18n/locales/zh-CN/chat.json +15 -5
  115. package/src/shared/lib/i18n/locales/zh-CN/core.json +17 -9
  116. package/src/shared/lib/i18n/locales/zh-CN/cron.json +4 -2
  117. package/src/shared/lib/i18n/locales/zh-CN/doc-browser.json +1 -0
  118. package/src/shared/lib/navigation-history/index.ts +1 -0
  119. package/src/shared/lib/navigation-history/scroll-restoration.manager.test.ts +37 -0
  120. package/src/shared/lib/navigation-history/scroll-restoration.manager.ts +60 -0
  121. package/dist/assets/channels-list-page-OTCkjMOW.js +0 -8
  122. package/dist/assets/doc-browser-5xrHcQ2z.js +0 -1
  123. package/dist/assets/doc-browser-c2yO477-.js +0 -1
  124. package/dist/assets/index-DUCA8m2w.js +0 -109
  125. package/dist/assets/index-QrdUWHfm.css +0 -1
  126. package/dist/assets/loader-circle-CbdTrSp3.js +0 -1
  127. package/dist/assets/mcp-marketplace-page-DGve0muV.js +0 -9
  128. package/dist/assets/mcp-marketplace-page-_37VzG2q.js +0 -1
  129. package/dist/assets/model-config-page-DyC5H0bu.js +0 -1
  130. package/dist/assets/plus-DbeE4r-Q.js +0 -1
  131. package/dist/assets/privacy-settings-page--MOnhAZ6.js +0 -1
  132. package/dist/assets/providers-config-page-ChL3FUvK.js +0 -1
  133. package/dist/assets/react-DVMmnPWz.js +0 -8
  134. package/dist/assets/remote-DFoxj4ls.js +0 -1
  135. package/dist/assets/runtime-config-page-BQdT3nmd.js +0 -1
  136. package/dist/assets/security-config-CUTUm6JT.js +0 -1
  137. package/dist/assets/skeleton-7MYPzn8w.js +0 -1
  138. package/dist/assets/x-DThaS5ZL.js +0 -1
@@ -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,14 +14,16 @@ 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={[]}
20
+ projectCronJobCountByRoot={new Map()}
19
21
  renderSessionItem={() => <></>}
20
22
  sessionTypeOptions={[]}
21
23
  />,
22
24
  );
23
25
 
24
- return { onAddProject, onSelectMode };
26
+ return { onAddProject, onScrollNearEnd, onSelectMode };
25
27
  }
26
28
 
27
29
  describe("ChatSidebarSessionArea", () => {
@@ -59,7 +61,9 @@ describe("ChatSidebarSessionArea", () => {
59
61
 
60
62
  it("uses a folder-plus icon for the add-project action", () => {
61
63
  const { onAddProject } = renderSessionArea(true);
62
- const addProjectButton = screen.getByRole("button", { name: "Add Project" });
64
+ const addProjectButton = screen.getByRole("button", {
65
+ name: "Add Project",
66
+ });
63
67
  const modeIndicator = screen
64
68
  .getByRole("group", { name: "Session list view" })
65
69
  .querySelector("span[aria-hidden='true']");
@@ -72,12 +76,29 @@ describe("ChatSidebarSessionArea", () => {
72
76
  expect(addProjectButton.className).toContain("hover:bg-gray-200/60");
73
77
  expect(modeIndicator?.className).toContain("translate-x-full");
74
78
  expect(modeIndicator?.className).toContain("motion-reduce:transition-none");
75
- expect(screen.getByRole("button", { name: "Project" }).getAttribute("aria-pressed")).toBe(
76
- "true",
79
+ expect(
80
+ screen
81
+ .getByRole("button", { name: "Project" })
82
+ .getAttribute("aria-pressed"),
83
+ ).toBe("true");
84
+ expect(modeIndicator?.parentElement?.previousElementSibling).toBe(
85
+ addProjectButton,
77
86
  );
78
- expect(modeIndicator?.parentElement?.previousElementSibling).toBe(addProjectButton);
79
87
  fireEvent.click(addProjectButton);
80
88
 
81
89
  expect(onAddProject).toHaveBeenCalledOnce();
82
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
+ });
83
104
  });
@@ -18,7 +18,6 @@ const mocks = vi.hoisted(() => ({
18
18
  setQuery: vi.fn(),
19
19
  setListMode: vi.fn(),
20
20
  selectSession: vi.fn(),
21
- openChildSessionPanel: vi.fn(),
22
21
  docOpen: vi.fn(),
23
22
  setLanguage: vi.fn(),
24
23
  setTheme: vi.fn(),
@@ -49,7 +48,12 @@ vi.mock("@/shared/hooks/use-projects", () => ({
49
48
  isPending: false,
50
49
  error: null,
51
50
  }),
52
- useAddExistingProject: () => ({ mutateAsync: vi.fn(), reset: vi.fn(), isPending: false, error: null }),
51
+ useAddExistingProject: () => ({
52
+ mutateAsync: vi.fn(),
53
+ reset: vi.fn(),
54
+ isPending: false,
55
+ error: null,
56
+ }),
53
57
  }));
54
58
  function sidebarElement(variant?: "desktop" | "mobile") {
55
59
  const queryClient = new QueryClient({
@@ -82,7 +86,10 @@ function expectCodexSelectedInSessionTypeMenu() {
82
86
  ).toBe("true");
83
87
  }
84
88
  function expectSessionCreated(sessionType: string, projectRoot?: string) {
85
- expect(mocks.createSession).toHaveBeenCalledWith({ projectRoot, sessionType });
89
+ expect(mocks.createSession).toHaveBeenCalledWith({
90
+ projectRoot,
91
+ sessionType,
92
+ });
86
93
  }
87
94
 
88
95
  vi.mock("@/features/chat/components/providers/chat-presenter.provider", () => ({
@@ -106,9 +113,7 @@ vi.mock("@/features/chat/components/providers/chat-presenter.provider", () => ({
106
113
  .markSessionRead(sessionKey, readAt)
107
114
  : undefined,
108
115
  },
109
- chatThreadManager: {
110
- openChildSessionPanel: mocks.openChildSessionPanel,
111
- },
116
+ chatThreadManager: {},
112
117
  }),
113
118
  }));
114
119
 
@@ -145,6 +150,7 @@ vi.mock(
145
150
 
146
151
  vi.mock("@/features/chat/features/ncp/hooks/use-ncp-session-list-view", () => ({
147
152
  useNcpSessionListView: () => ({
153
+ allItems: mocks.sessionItems,
148
154
  isLoading: mocks.isLoading,
149
155
  items: mocks.sessionItems,
150
156
  }),
@@ -196,7 +202,6 @@ function resetSidebarTestState() {
196
202
  mocks.setQuery.mockReset();
197
203
  mocks.setListMode.mockReset();
198
204
  mocks.selectSession.mockReset();
199
- mocks.openChildSessionPanel.mockReset();
200
205
  mocks.docOpen.mockReset();
201
206
  mocks.setLanguage.mockReset();
202
207
  mocks.setTheme.mockReset();
@@ -284,9 +289,9 @@ describe("ChatSidebar create and list basics", () => {
284
289
  it("keeps the desktop brand row compact with symmetric vertical padding", () => {
285
290
  renderSidebar();
286
291
 
287
- expect(
288
- screen.getByTestId("brand-header").parentElement?.className,
289
- ).toMatch(/(?:^|\s)py-2(?:\s|$)/);
292
+ expect(screen.getByTestId("brand-header").parentElement?.className).toMatch(
293
+ /(?:^|\s)py-2(?:\s|$)/,
294
+ );
290
295
  });
291
296
 
292
297
  it("shows setup required status for runtime session types that are not ready yet", () => {
@@ -857,7 +862,7 @@ describe("ChatSidebar session item interactions", () => {
857
862
  expect(screen.queryByLabelText("Session has unread updates")).toBeNull();
858
863
  });
859
864
 
860
- it("opens the child-session browser from a parent session row", () => {
865
+ it("keeps child-session counts out of the parent session row", () => {
861
866
  mocks.sessionItems = [
862
867
  createSessionItem({
863
868
  key: "session:parent-1",
@@ -882,11 +887,6 @@ describe("ChatSidebar session item interactions", () => {
882
887
 
883
888
  renderSidebar();
884
889
 
885
- fireEvent.click(screen.getByLabelText("View child sessions"));
886
-
887
- expect(mocks.openChildSessionPanel).toHaveBeenCalledWith({
888
- parentSessionKey: "session:parent-1",
889
- activeChildSessionKey: "session:child-1",
890
- });
890
+ expect(screen.queryByLabelText("View child sessions")).toBeNull();
891
891
  });
892
892
  });
@@ -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,
@@ -63,7 +64,10 @@ function ChatSidebarCollapseButton({ isCollapsed }: { isCollapsed: boolean }) {
63
64
  <IconActionButton
64
65
  icon={<Icon className={SIDEBAR_RAIL_ICON_CLASS} />}
65
66
  label={label}
66
- className={cn(SIDEBAR_RAIL_SURFACE_CLASS, isCollapsed && SIDEBAR_RAIL_CONTROL_CLASS)}
67
+ className={cn(
68
+ SIDEBAR_RAIL_SURFACE_CLASS,
69
+ isCollapsed && SIDEBAR_RAIL_CONTROL_CLASS,
70
+ )}
67
71
  onClick={viewportLayoutManager.toggleSidebarCollapsed}
68
72
  />
69
73
  );
@@ -77,7 +81,9 @@ export function ChatSidebarDesktopHeader({
77
81
  isCollapsed: boolean;
78
82
  }) {
79
83
  const isWindowsHost = isWindowsDesktopHost();
80
- const shouldReserveMacWindowControls = typeof window !== "undefined" && window.nextclawDesktop?.platform === "darwin";
84
+ const shouldReserveMacWindowControls =
85
+ typeof window !== "undefined" &&
86
+ window.nextclawDesktop?.platform === "darwin";
81
87
 
82
88
  return (
83
89
  <div
@@ -124,9 +130,13 @@ export function ChatSidebarDesktopNav({
124
130
  density="compact"
125
131
  collapsed={isCollapsed}
126
132
  indicator={item.target === "/inbox" && unreadCount > 0}
127
- trailing={item.target === "/inbox" && unreadCount > 0
128
- ? unreadCount > 99 ? "99+" : unreadCount
129
- : undefined}
133
+ trailing={
134
+ item.target === "/inbox" && unreadCount > 0
135
+ ? unreadCount > 99
136
+ ? "99+"
137
+ : unreadCount
138
+ : undefined
139
+ }
130
140
  className={isCollapsed ? undefined : "rounded-lg px-2.5 py-1.5"}
131
141
  />
132
142
  </li>
@@ -145,8 +155,10 @@ export function ChatSidebarSessionArea({
145
155
  isLoading,
146
156
  isProjectFirstView,
147
157
  onAddProject,
158
+ onScrollNearEnd = () => undefined,
148
159
  onSelectMode,
149
160
  projectGroups,
161
+ projectCronJobCountByRoot,
150
162
  renderSessionItem,
151
163
  sessionTypeOptions,
152
164
  }: {
@@ -156,13 +168,20 @@ export function ChatSidebarSessionArea({
156
168
  isLoading: boolean;
157
169
  isProjectFirstView: boolean;
158
170
  onAddProject: () => void;
171
+ onScrollNearEnd?: () => void;
159
172
  onSelectMode: (mode: "time-first" | "project-first") => void;
160
173
  projectGroups: ReturnType<typeof groupSessionsByProject>;
174
+ projectCronJobCountByRoot: ReadonlyMap<string, number>;
161
175
  renderSessionItem: (item: NcpSessionListItemView) => JSX.Element;
162
176
  sessionTypeOptions: Parameters<
163
177
  typeof ChatSidebarSessionList
164
178
  >[0]["sessionTypeOptions"];
165
179
  }) {
180
+ const scrollRestoration = useScrollRestoration<HTMLDivElement>({
181
+ restorationKey: "chat-sidebar:sessions",
182
+ isEnabled: !isCollapsed,
183
+ });
184
+ const { onScroll: onScrollPositionSave, scrollRef } = scrollRestoration;
166
185
  if (isCollapsed) {
167
186
  return <div className="min-h-0 flex-1" aria-hidden="true" />;
168
187
  }
@@ -185,6 +204,14 @@ export function ChatSidebarSessionArea({
185
204
  </div>
186
205
 
187
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
+ }}
188
215
  className={cn(
189
216
  "custom-scrollbar min-h-0 flex-1 overflow-y-auto px-3 pb-4 pt-1",
190
217
  SCROLL_BOTTOM_EDGE_FADE_CLASS,
@@ -195,6 +222,7 @@ export function ChatSidebarSessionArea({
195
222
  isProjectFirstView={isProjectFirstView}
196
223
  groups={groups}
197
224
  projectGroups={projectGroups}
225
+ projectCronJobCountByRoot={projectCronJobCountByRoot}
198
226
  defaultSessionType={defaultSessionType}
199
227
  sessionTypeOptions={sessionTypeOptions}
200
228
  renderSessionItem={renderSessionItem}
@@ -21,7 +21,10 @@ import {
21
21
  useCreateProject,
22
22
  useProjects,
23
23
  } from "@/shared/hooks/use-projects";
24
- import type { ProjectAddExistingRequest, ProjectCreateRequest } from "@/shared/lib/api";
24
+ import type {
25
+ ProjectAddExistingRequest,
26
+ ProjectCreateRequest,
27
+ } from "@/shared/lib/api";
25
28
  import { normalizeSessionProjectRootValue } from "@/shared/lib/session-project";
26
29
  import { cn } from "@/shared/lib/utils";
27
30
  import { LANGUAGE_OPTIONS, t, type I18nLanguage } from "@/shared/lib/i18n";
@@ -50,6 +53,7 @@ import { useChatNewSessionTypePreference } from "@/features/chat/features/sessio
50
53
  import { useViewportLayoutStore } from "@/app/stores/viewport-layout.store";
51
54
  import { SIDEBAR_RAIL_WIDTH_CLASS } from "@/app/components/layout/sidebar-rail.styles";
52
55
  import { ChatProjectAddDialog } from "@/features/chat/features/project/components/chat-project-add-dialog";
56
+ import { useChatSidebarContextCounts } from "@/features/chat/features/session/hooks/use-chat-sidebar-context-counts";
53
57
 
54
58
  type ChatSidebarVariant = "desktop" | "mobile";
55
59
 
@@ -116,7 +120,9 @@ export function ChatSidebar({
116
120
  const projectsQuery = useProjects();
117
121
  const projectCreateMutation = useCreateProject();
118
122
  const projectAddExistingMutation = useAddExistingProject();
119
- const { isLoading, items } = useNcpSessionListView();
123
+ const { allItems, hasMore, isLoading, isLoadingMore, items, loadMore } = useNcpSessionListView();
124
+ const { cronJobCountByProjectRoot, cronJobCountBySessionKey } =
125
+ useChatSidebarContextCounts(allItems);
120
126
  const { language, setLanguage } = useI18n();
121
127
  const { theme, setTheme } = useTheme();
122
128
  const currentThemeLabel = t(
@@ -160,21 +166,27 @@ export function ChatSidebar({
160
166
  [items],
161
167
  );
162
168
  const childSessionsByParentKey = useMemo(
163
- () => groupChildSessionsByParentKey(items),
164
- [items],
169
+ () => groupChildSessionsByParentKey(allItems),
170
+ [allItems],
165
171
  );
166
172
  const groups = useMemo(
167
173
  () => groupSessionsByDate(sortedItems, pinnedSessionKeys),
168
174
  [pinnedSessionKeys, sortedItems],
169
175
  );
170
176
  const projectGroups = useMemo(
171
- () => groupSessionsByProject(
172
- sortedItems,
173
- pinnedSessionKeys,
177
+ () =>
178
+ groupSessionsByProject(
179
+ sortedItems,
180
+ pinnedSessionKeys,
181
+ pinnedProjectRoots,
182
+ projectsQuery.data?.projects ?? [],
183
+ ),
184
+ [
174
185
  pinnedProjectRoots,
175
- projectsQuery.data?.projects ?? [],
176
- ),
177
- [pinnedProjectRoots, pinnedSessionKeys, projectsQuery.data?.projects, sortedItems],
186
+ pinnedSessionKeys,
187
+ projectsQuery.data?.projects,
188
+ sortedItems,
189
+ ],
178
190
  );
179
191
  const sessionTypeOptions = useMemo(
180
192
  () => buildSessionTypeOptions(sessionTypesData?.options ?? []),
@@ -220,6 +232,7 @@ export function ChatSidebar({
220
232
  optimisticReadAtBySessionKey={optimisticReadAtBySessionKey}
221
233
  agentsById={agentsById}
222
234
  childSessionsByParentKey={childSessionsByParentKey}
235
+ cronJobCount={cronJobCountBySessionKey.get(item.session.key) ?? 0}
223
236
  editingSessionKey={editingSessionKey}
224
237
  draftLabel={draftLabel}
225
238
  savingSessionKey={savingSessionKey}
@@ -227,12 +240,6 @@ export function ChatSidebar({
227
240
  isPinned={pinnedSessionKeys.has(item.session.key)}
228
241
  sessionTitle={getSessionTitle}
229
242
  onSelectSession={presenter.chatSessionListManager.selectSession}
230
- onOpenChildSessions={(parentSessionKey, activeChildSessionKey) =>
231
- presenter.chatThreadManager.openChildSessionPanel({
232
- parentSessionKey,
233
- activeChildSessionKey,
234
- })
235
- }
236
243
  onStartEditingSessionLabel={startEditingSessionLabel}
237
244
  onDraftLabelChange={setDraftLabel}
238
245
  onSaveSessionLabel={saveSessionLabel}
@@ -256,7 +263,9 @@ export function ChatSidebar({
256
263
  projectAddExistingMutation.reset();
257
264
  setIsProjectAddOpen(true);
258
265
  };
259
- const createProjectFromSidebar = async (input: ProjectCreateRequest): Promise<void> => {
266
+ const createProjectFromSidebar = async (
267
+ input: ProjectCreateRequest,
268
+ ): Promise<void> => {
260
269
  await projectCreateMutation.mutateAsync(input);
261
270
  setIsProjectAddOpen(false);
262
271
  };
@@ -274,10 +283,7 @@ export function ChatSidebar({
274
283
  isMobileVariant
275
284
  ? "flex-1 overflow-hidden"
276
285
  : shouldCollapse
277
- ? cn(
278
- SIDEBAR_RAIL_WIDTH_CLASS,
279
- "shrink-0",
280
- )
286
+ ? cn(SIDEBAR_RAIL_WIDTH_CLASS, "shrink-0")
281
287
  : "w-[280px] shrink-0",
282
288
  )}
283
289
  data-sidebar-collapsed={shouldCollapse ? "true" : "false"}
@@ -337,8 +343,12 @@ export function ChatSidebar({
337
343
  isLoading={isLoading}
338
344
  isProjectFirstView={isProjectFirstView}
339
345
  onAddProject={openProjectAdd}
346
+ onScrollNearEnd={() => {
347
+ if (hasMore && !isLoadingMore) void loadMore();
348
+ }}
340
349
  onSelectMode={presenter.chatSessionListManager.setListMode}
341
350
  projectGroups={projectGroups}
351
+ projectCronJobCountByRoot={cronJobCountByProjectRoot}
342
352
  renderSessionItem={renderSessionItem}
343
353
  sessionTypeOptions={sessionTypeOptions}
344
354
  />
@@ -1,6 +1,6 @@
1
1
  import { act, renderHook, waitFor } from '@testing-library/react';
2
2
  import { describe, expect, it, vi } from 'vitest';
3
- import type { UiNcpSessionQueuedInputView } from '@nextclaw/client-sdk';
3
+ import { NextClawClientError, type UiNcpSessionQueuedInputView } from '@nextclaw/client-sdk';
4
4
  import type { NcpAgentSendEnvelope, NcpMessage, NcpRunHandle } from '@nextclaw/ncp';
5
5
 
6
6
  import { useSessionConversationController } from '@/features/chat/features/conversation/hooks/use-session-conversation-controller';
@@ -448,3 +448,43 @@ describe('useSessionConversationController backend run queue', () => {
448
448
  expect(params.onSessionMaterialized).not.toHaveBeenCalled();
449
449
  });
450
450
  });
451
+
452
+ describe('useSessionConversationController stale queue reconciliation', () => {
453
+ it('reconciles repeated stale queued actions without exposing their not-found errors', async () => {
454
+ const queuedInput = createQueuedInput();
455
+ const params = createControllerParams({ isRunning: true, queuedInputs: [queuedInput] });
456
+ params.runQueue.steerQueuedInput.mockRejectedValue(new NextClawClientError({
457
+ code: 'NOT_FOUND',
458
+ message: `queued input not found in session session-1: ${queuedInput.id}`,
459
+ status: 404,
460
+ }));
461
+ const { result } = renderHook(() => useSessionConversationController(params));
462
+
463
+ act(() => result.current.steerQueuedInput(queuedInput.id));
464
+ await waitFor(() => expect(params.runQueue.refreshPendingInputs).toHaveBeenCalledTimes(1));
465
+
466
+ act(() => result.current.steerQueuedInput(queuedInput.id));
467
+ await waitFor(() => expect(params.runQueue.refreshPendingInputs).toHaveBeenCalledTimes(2));
468
+
469
+ expect(params.setSendError).toHaveBeenLastCalledWith(null);
470
+ expect(params.setSendError).not.toHaveBeenCalledWith(expect.stringContaining('queued input not found'));
471
+ });
472
+
473
+ it('keeps a real steering failure visible', async () => {
474
+ const queuedInput = createQueuedInput();
475
+ const params = createControllerParams({ isRunning: true, queuedInputs: [queuedInput] });
476
+ params.runQueue.steerQueuedInput.mockRejectedValue(new NextClawClientError({
477
+ code: 'STEER_UNAVAILABLE',
478
+ message: 'The active runtime cannot accept this input at the next safe step.',
479
+ status: 409,
480
+ }));
481
+ const { result } = renderHook(() => useSessionConversationController(params));
482
+
483
+ act(() => result.current.steerQueuedInput(queuedInput.id));
484
+
485
+ await waitFor(() => expect(params.setSendError).toHaveBeenCalledWith(
486
+ 'The active runtime cannot accept this input at the next safe step.',
487
+ ));
488
+ expect(params.runQueue.refreshPendingInputs).not.toHaveBeenCalled();
489
+ });
490
+ });
@@ -1,7 +1,8 @@
1
1
  import { useCallback, useMemo, useState } from 'react';
2
- import type {
3
- UiNcpSessionPendingInputView,
4
- UiNcpSessionQueuedInputView,
2
+ import {
3
+ NextClawClientError,
4
+ type UiNcpSessionPendingInputView,
5
+ type UiNcpSessionQueuedInputView,
5
6
  } from '@nextclaw/client-sdk';
6
7
  import type { NcpAgentSendEnvelope } from '@nextclaw/ncp';
7
8
 
@@ -20,10 +21,17 @@ type ComposerDraftSnapshot = Pick<
20
21
 
21
22
  type SessionRunQueue = {
22
23
  readonly inputs: readonly UiNcpSessionQueuedInputView[];
24
+ readonly refreshPendingInputs: () => Promise<readonly UiNcpSessionPendingInputView[]>;
23
25
  readonly removeQueuedInput: (id: string) => Promise<UiNcpSessionQueuedInputView | null>;
24
26
  readonly steerQueuedInput: (id: string) => Promise<UiNcpSessionPendingInputView | null>;
25
27
  };
26
28
 
29
+ function isStaleQueuedInputError(error: unknown): boolean {
30
+ return error instanceof NextClawClientError
31
+ && error.status === 404
32
+ && error.code === 'NOT_FOUND';
33
+ }
34
+
27
35
  export type SubmittingQueuedInput = {
28
36
  readonly attachments: readonly SessionQueuedInputAttachmentPreview[];
29
37
  readonly id: string;
@@ -127,7 +135,17 @@ export function useQueuedInputActions(params: {
127
135
  const steerQueuedInput = useCallback((id: string) => {
128
136
  void runQueue.steerQueuedInput(id).then((steered) => {
129
137
  if (steered) setSendError(null);
130
- }).catch((error) => {
138
+ }).catch(async (error) => {
139
+ if (isStaleQueuedInputError(error)) {
140
+ try {
141
+ await runQueue.refreshPendingInputs();
142
+ setSendError(null);
143
+ return;
144
+ } catch (refreshError) {
145
+ setSendError(refreshError instanceof Error ? refreshError.message : String(refreshError));
146
+ return;
147
+ }
148
+ }
131
149
  setSendError(error instanceof Error ? error.message : String(error));
132
150
  });
133
151
  }, [runQueue, setSendError]);
@@ -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", () => {
@@ -109,4 +109,39 @@ describe("buildChatMessageTriggerDetails", () => {
109
109
  "run-trigger-metadata",
110
110
  ]);
111
111
  });
112
+
113
+ it("keeps a completed steering user message actionable from persisted run metadata", () => {
114
+ const details = buildChatMessageTriggerDetails({
115
+ labels,
116
+ message: {
117
+ metadata: {
118
+ run_spec: {
119
+ runId: "run-1",
120
+ model: "openai/gpt-5.6",
121
+ },
122
+ run_trigger: {
123
+ version: 1,
124
+ actor: "human",
125
+ source: "internal",
126
+ sourceMessageId: "user-steering",
127
+ targetRunId: "run-1",
128
+ triggeredAt: "2026-08-25T13:39:04.000Z",
129
+ },
130
+ },
131
+ },
132
+ });
133
+
134
+ expect(details).toMatchObject({
135
+ triggerLabel: "More actions",
136
+ items: [{
137
+ key: "run-trigger-metadata",
138
+ dialog: {
139
+ rows: expect.arrayContaining([
140
+ { label: "Target run", value: "run-1" },
141
+ { label: "Run model", value: "openai/gpt-5.6" },
142
+ ]),
143
+ },
144
+ }],
145
+ });
146
+ });
112
147
  });
@@ -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,
@@ -73,4 +73,54 @@ describe('useNcpAgentRuntime backend queue submission', () => {
73
73
  expect(result.current.visibleMessages).toEqual([queuedMessage]);
74
74
  });
75
75
  });
76
+
77
+ it('keeps a steered message in the pending projection instead of accepting it as a new run', async () => {
78
+ const manager = new DefaultNcpAgentConversationStateManager();
79
+ manager.hydrate({ sessionId: 'session-1', messages: [] });
80
+ await manager.dispatch({
81
+ type: NcpEventType.RunStarted,
82
+ payload: {
83
+ sessionId: 'session-1',
84
+ runId: 'run-1',
85
+ messageId: 'assistant-1',
86
+ },
87
+ });
88
+ const send = vi.fn(async (envelope: NcpAgentSendEnvelope) => ({
89
+ assistantMessageId: null,
90
+ delivery: 'steered' as const,
91
+ runId: 'run-1',
92
+ sessionId: 'session-1',
93
+ userMessageId: envelope.message.id,
94
+ }));
95
+ const client = {
96
+ abort: vi.fn(async () => undefined),
97
+ send,
98
+ stop: vi.fn(async () => undefined),
99
+ stream: vi.fn(async () => undefined),
100
+ subscribe: vi.fn(() => () => undefined),
101
+ } as unknown as NcpAgentClientEndpoint;
102
+ const { result } = renderHook(() => useNcpAgentRuntime({
103
+ client,
104
+ manager,
105
+ sessionId: 'session-1',
106
+ }));
107
+
108
+ await act(async () => {
109
+ await result.current.send({
110
+ delivery: 'prefer-steer',
111
+ message: {
112
+ id: 'user-steering',
113
+ sessionId: 'session-1',
114
+ role: 'user',
115
+ status: 'final',
116
+ parts: [{ type: 'text', text: 'change direction' }],
117
+ timestamp: '2026-08-25T00:00:00.000Z',
118
+ },
119
+ sessionId: 'session-1',
120
+ });
121
+ });
122
+
123
+ expect(result.current.visibleMessages).toEqual([]);
124
+ expect(result.current.snapshot.activeRun?.runId).toBe('run-1');
125
+ });
76
126
  });