@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
@@ -1,126 +1,22 @@
1
- import { useCallback, useEffect, useRef, type RefObject } from 'react';
2
- import { PANEL_APP_SCROLL_RESTORATION_CONTRACT } from '@nextclaw/shared';
1
+ import type { RefObject } from 'react';
3
2
  import type { DocBrowserTab } from '@/shared/components/doc-browser/doc-browser-context';
4
-
5
- type ScrollTarget =
6
- | { kind: 'document' }
7
- | { kind: 'element'; path: Array<{ index: number; tagName: string }> };
8
-
9
- type ScrollPosition = {
10
- currentUrl: string;
11
- target: ScrollTarget;
12
- x: number;
13
- y: number;
14
- };
3
+ import { usePanelAppScrollRestoration } from '@/shared/hooks/use-panel-app-scroll-restoration';
15
4
 
16
5
  type UseDocBrowserScrollRestorationParams = {
17
6
  currentTab?: DocBrowserTab;
18
- iframeRef: RefObject<HTMLIFrameElement | null>;
7
+ iframeRef: RefObject<HTMLIFrameElement>;
19
8
  isEnabled: boolean;
20
- tabs: DocBrowserTab[];
21
9
  };
22
10
 
23
- function isScrollTarget(value: unknown): value is ScrollTarget {
24
- if (!value || typeof value !== 'object') {
25
- return false;
26
- }
27
- const candidate = value as Partial<ScrollTarget>;
28
- if (candidate.kind === 'document') {
29
- return true;
30
- }
31
- return candidate.kind === 'element'
32
- && Array.isArray(candidate.path)
33
- && candidate.path.length > 0
34
- && candidate.path.length <= 30
35
- && candidate.path.every((segment) => (
36
- typeof segment?.index === 'number'
37
- && Number.isInteger(segment.index)
38
- && segment.index >= 0
39
- && segment.index <= 1000
40
- && typeof segment.tagName === 'string'
41
- && segment.tagName.length > 0
42
- && segment.tagName.length <= 32
43
- ));
44
- }
45
-
46
- function isScrollMessage(value: unknown): value is {
47
- target: ScrollTarget;
48
- type: string;
49
- version: number;
50
- x: number;
51
- y: number;
52
- } {
53
- if (!value || typeof value !== 'object') {
54
- return false;
55
- }
56
- const candidate = value as Partial<{
57
- target: unknown;
58
- type: string;
59
- version: number;
60
- x: number;
61
- y: number;
62
- }>;
63
- return candidate.type === PANEL_APP_SCROLL_RESTORATION_CONTRACT.scrollMessageType
64
- && candidate.version === PANEL_APP_SCROLL_RESTORATION_CONTRACT.version
65
- && isScrollTarget(candidate.target)
66
- && typeof candidate.x === 'number'
67
- && Number.isFinite(candidate.x)
68
- && candidate.x >= 0
69
- && typeof candidate.y === 'number'
70
- && Number.isFinite(candidate.y)
71
- && candidate.y >= 0;
72
- }
73
-
74
11
  export function useDocBrowserScrollRestoration({
75
12
  currentTab,
76
13
  iframeRef,
77
14
  isEnabled,
78
- tabs,
79
15
  }: UseDocBrowserScrollRestorationParams) {
80
- const positionsRef = useRef(new Map<string, ScrollPosition>());
81
-
82
- useEffect(() => {
83
- const activeTabIds = new Set(tabs.map((tab) => tab.id));
84
- for (const tabId of positionsRef.current.keys()) {
85
- if (!activeTabIds.has(tabId)) {
86
- positionsRef.current.delete(tabId);
87
- }
88
- }
89
- }, [tabs]);
90
-
91
- useEffect(() => {
92
- if (!currentTab || !isEnabled) {
93
- return;
94
- }
95
- const onMessage = (event: MessageEvent) => {
96
- if (event.source !== iframeRef.current?.contentWindow || !isScrollMessage(event.data)) {
97
- return;
98
- }
99
- positionsRef.current.set(currentTab.id, {
100
- currentUrl: currentTab.currentUrl,
101
- target: event.data.target,
102
- x: event.data.x,
103
- y: event.data.y,
104
- });
105
- };
106
- window.addEventListener('message', onMessage);
107
- return () => window.removeEventListener('message', onMessage);
108
- }, [currentTab, iframeRef, isEnabled]);
109
-
110
- return useCallback(() => {
111
- if (!currentTab || !isEnabled) {
112
- return;
113
- }
114
- const position = positionsRef.current.get(currentTab.id);
115
- if (!position || position.currentUrl !== currentTab.currentUrl) {
116
- return;
117
- }
118
- iframeRef.current?.contentWindow?.postMessage({
119
- type: PANEL_APP_SCROLL_RESTORATION_CONTRACT.restoreScrollMessageType,
120
- version: PANEL_APP_SCROLL_RESTORATION_CONTRACT.version,
121
- target: position.target,
122
- x: position.x,
123
- y: position.y,
124
- }, '*');
125
- }, [currentTab, iframeRef, isEnabled]);
16
+ return usePanelAppScrollRestoration({
17
+ currentUrl: currentTab?.currentUrl ?? null,
18
+ iframeRef,
19
+ isEnabled,
20
+ restorationKey: currentTab ? `panel-app:doc-browser:${currentTab.id}` : null,
21
+ });
126
22
  }
@@ -75,14 +75,7 @@ const IconActionButton = React.forwardRef<HTMLButtonElement, IconActionButtonPro
75
75
  return (
76
76
  <TooltipProvider delayDuration={250}>
77
77
  <Tooltip disableHoverableContent>
78
- <TooltipTrigger
79
- asChild
80
- onFocus={(event) => {
81
- if (!event.currentTarget.matches(':focus-visible')) {
82
- event.preventDefault();
83
- }
84
- }}
85
- >
78
+ <TooltipTrigger asChild>
86
79
  {disabled ? <span className="inline-flex">{button}</span> : button}
87
80
  </TooltipTrigger>
88
81
  <TooltipContent side={tooltipSide} className="text-xs">{content}</TooltipContent>
@@ -7,7 +7,22 @@ const TooltipProvider = TooltipPrimitive.Provider;
7
7
 
8
8
  const Tooltip = TooltipPrimitive.Root;
9
9
 
10
- const TooltipTrigger = TooltipPrimitive.Trigger;
10
+ const TooltipTrigger = React.forwardRef<
11
+ React.ElementRef<typeof TooltipPrimitive.Trigger>,
12
+ React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Trigger>
13
+ >(({ onFocus, ...props }, ref) => (
14
+ <TooltipPrimitive.Trigger
15
+ ref={ref}
16
+ onFocus={(event) => {
17
+ onFocus?.(event);
18
+ if (!event.defaultPrevented && !event.currentTarget.matches(':focus-visible')) {
19
+ event.preventDefault();
20
+ }
21
+ }}
22
+ {...props}
23
+ />
24
+ ));
25
+ TooltipTrigger.displayName = TooltipPrimitive.Trigger.displayName;
11
26
 
12
27
  const TooltipContent = React.forwardRef<
13
28
  React.ElementRef<typeof TooltipPrimitive.Content>,
@@ -19,6 +19,7 @@ import {
19
19
  updateChannel,
20
20
  updateRuntime,
21
21
  updateProductAnalytics,
22
+ fetchProductAnalyticsStatus,
22
23
  updateSecrets,
23
24
  executeConfigAction
24
25
  } from '@/shared/lib/api';
@@ -43,6 +44,15 @@ export function useConfigMeta() {
43
44
  });
44
45
  }
45
46
 
47
+ export function useProductAnalyticsStatus() {
48
+ return useQuery({
49
+ queryKey: ['product-analytics-status'],
50
+ queryFn: fetchProductAnalyticsStatus,
51
+ staleTime: 15_000,
52
+ refetchOnWindowFocus: true
53
+ });
54
+ }
55
+
46
56
  export function useProviders() {
47
57
  return useQuery({
48
58
  queryKey: ['providers'],
@@ -108,6 +118,7 @@ export function useUpdateProductAnalytics() {
108
118
  queryClient.setQueryData<ConfigView>(['config'], (current) => current
109
119
  ? { ...current, productAnalytics }
110
120
  : current);
121
+ queryClient.invalidateQueries({ queryKey: ['product-analytics-status'] });
111
122
  toast.success(t('configSavedApplied'));
112
123
  },
113
124
  onError: (error: Error) => {
@@ -0,0 +1,66 @@
1
+ import { fireEvent, render } from "@testing-library/react";
2
+ import { useRef } from "react";
3
+ import { beforeEach, describe, expect, it, vi } from "vitest";
4
+ import { PANEL_APP_SCROLL_RESTORATION_CONTRACT } from "@nextclaw/shared";
5
+ import { usePanelAppScrollRestoration } from "@/shared/hooks/use-panel-app-scroll-restoration";
6
+ import { scrollRestorationManager } from "@/shared/lib/navigation-history";
7
+
8
+ function PanelAppFrame({ currentUrl = "/app", restorationKey = "panel-app:test" }: {
9
+ currentUrl?: string;
10
+ restorationKey?: string;
11
+ }) {
12
+ const iframeRef = useRef<HTMLIFrameElement>(null);
13
+ const restoreScroll = usePanelAppScrollRestoration({
14
+ currentUrl,
15
+ iframeRef,
16
+ isEnabled: true,
17
+ restorationKey,
18
+ });
19
+ return <iframe ref={iframeRef} data-testid="panel-app-frame" onLoad={restoreScroll} />;
20
+ }
21
+
22
+ describe("usePanelAppScrollRestoration", () => {
23
+ beforeEach(() => scrollRestorationManager.clear());
24
+
25
+ it("records only scroll messages from its iframe and restores them after load", () => {
26
+ const { getByTestId } = render(<PanelAppFrame />);
27
+ const iframe = getByTestId("panel-app-frame") as HTMLIFrameElement;
28
+ const postMessage = vi.spyOn(iframe.contentWindow!, "postMessage");
29
+ const target = { kind: "document" } as const;
30
+
31
+ window.dispatchEvent(new MessageEvent("message", {
32
+ data: {
33
+ type: PANEL_APP_SCROLL_RESTORATION_CONTRACT.scrollMessageType,
34
+ version: PANEL_APP_SCROLL_RESTORATION_CONTRACT.version,
35
+ target,
36
+ x: 8,
37
+ y: 64,
38
+ },
39
+ source: iframe.contentWindow,
40
+ }));
41
+ fireEvent.load(iframe);
42
+
43
+ expect(postMessage).toHaveBeenCalledWith({
44
+ type: PANEL_APP_SCROLL_RESTORATION_CONTRACT.restoreScrollMessageType,
45
+ version: PANEL_APP_SCROLL_RESTORATION_CONTRACT.version,
46
+ target,
47
+ x: 8,
48
+ y: 64,
49
+ }, "*");
50
+ });
51
+
52
+ it("does not restore a position from another resource URL", () => {
53
+ scrollRestorationManager.save("panel-app:test", {
54
+ x: 0,
55
+ y: 64,
56
+ payload: { currentUrl: "/old-app", target: { kind: "document" } },
57
+ });
58
+ const { getByTestId } = render(<PanelAppFrame currentUrl="/new-app" />);
59
+ const iframe = getByTestId("panel-app-frame") as HTMLIFrameElement;
60
+ const postMessage = vi.spyOn(iframe.contentWindow!, "postMessage");
61
+
62
+ fireEvent.load(iframe);
63
+
64
+ expect(postMessage).not.toHaveBeenCalled();
65
+ });
66
+ });
@@ -0,0 +1,107 @@
1
+ import { useCallback, useEffect, type RefObject } from "react";
2
+ import { PANEL_APP_SCROLL_RESTORATION_CONTRACT } from "@nextclaw/shared";
3
+ import { scrollRestorationManager } from "@/shared/lib/navigation-history";
4
+
5
+ type ScrollTarget =
6
+ | { kind: "document" }
7
+ | { kind: "element"; path: Array<{ index: number; tagName: string }> };
8
+
9
+ type PanelAppScrollSnapshot = {
10
+ currentUrl: string;
11
+ target: ScrollTarget;
12
+ };
13
+
14
+ type UsePanelAppScrollRestorationParams = {
15
+ currentUrl: string | null;
16
+ iframeRef: RefObject<HTMLIFrameElement>;
17
+ isEnabled: boolean;
18
+ restorationKey: string | null;
19
+ };
20
+
21
+ function isScrollTarget(value: unknown): value is ScrollTarget {
22
+ if (!value || typeof value !== "object") return false;
23
+ const candidate = value as Partial<ScrollTarget>;
24
+ if (candidate.kind === "document") return true;
25
+ return candidate.kind === "element"
26
+ && Array.isArray(candidate.path)
27
+ && candidate.path.length > 0
28
+ && candidate.path.length <= 30
29
+ && candidate.path.every((segment) => (
30
+ typeof segment?.index === "number"
31
+ && Number.isInteger(segment.index)
32
+ && segment.index >= 0
33
+ && segment.index <= 1000
34
+ && typeof segment.tagName === "string"
35
+ && segment.tagName.length > 0
36
+ && segment.tagName.length <= 32
37
+ ));
38
+ }
39
+
40
+ function isPanelAppScrollSnapshot(value: unknown): value is PanelAppScrollSnapshot {
41
+ if (!value || typeof value !== "object") return false;
42
+ const candidate = value as Partial<PanelAppScrollSnapshot>;
43
+ return typeof candidate.currentUrl === "string" && isScrollTarget(candidate.target);
44
+ }
45
+
46
+ function isScrollMessage(value: unknown): value is {
47
+ target: ScrollTarget;
48
+ x: number;
49
+ y: number;
50
+ } {
51
+ if (!value || typeof value !== "object") return false;
52
+ const candidate = value as Partial<{
53
+ target: unknown;
54
+ type: string;
55
+ version: number;
56
+ x: number;
57
+ y: number;
58
+ }>;
59
+ return candidate.type === PANEL_APP_SCROLL_RESTORATION_CONTRACT.scrollMessageType
60
+ && candidate.version === PANEL_APP_SCROLL_RESTORATION_CONTRACT.version
61
+ && isScrollTarget(candidate.target)
62
+ && typeof candidate.x === "number"
63
+ && Number.isFinite(candidate.x)
64
+ && candidate.x >= 0
65
+ && typeof candidate.y === "number"
66
+ && Number.isFinite(candidate.y)
67
+ && candidate.y >= 0;
68
+ }
69
+
70
+ /** Bridges the Panel App iframe scroll protocol to the shared navigation history. */
71
+ export function usePanelAppScrollRestoration({
72
+ currentUrl,
73
+ iframeRef,
74
+ isEnabled,
75
+ restorationKey,
76
+ }: UsePanelAppScrollRestorationParams) {
77
+ useEffect(() => {
78
+ if (!currentUrl || !isEnabled || !restorationKey) return;
79
+ const onMessage = (event: MessageEvent) => {
80
+ if (event.source !== iframeRef.current?.contentWindow || !isScrollMessage(event.data)) {
81
+ return;
82
+ }
83
+ scrollRestorationManager.save(restorationKey, {
84
+ x: event.data.x,
85
+ y: event.data.y,
86
+ payload: { currentUrl, target: event.data.target } satisfies PanelAppScrollSnapshot,
87
+ });
88
+ };
89
+ window.addEventListener("message", onMessage);
90
+ return () => window.removeEventListener("message", onMessage);
91
+ }, [currentUrl, iframeRef, isEnabled, restorationKey]);
92
+
93
+ return useCallback(() => {
94
+ if (!currentUrl || !isEnabled || !restorationKey) return;
95
+ const position = scrollRestorationManager.read(restorationKey);
96
+ if (!position || !isPanelAppScrollSnapshot(position.payload) || position.payload.currentUrl !== currentUrl) {
97
+ return;
98
+ }
99
+ iframeRef.current?.contentWindow?.postMessage({
100
+ type: PANEL_APP_SCROLL_RESTORATION_CONTRACT.restoreScrollMessageType,
101
+ version: PANEL_APP_SCROLL_RESTORATION_CONTRACT.version,
102
+ target: position.payload.target,
103
+ x: position.x,
104
+ y: position.y,
105
+ }, "*");
106
+ }, [currentUrl, iframeRef, isEnabled, restorationKey]);
107
+ }
@@ -0,0 +1,62 @@
1
+ import { fireEvent, render } from "@testing-library/react";
2
+ import { beforeEach, describe, expect, it } from "vitest";
3
+ import { useScrollRestoration } from "@/shared/hooks/use-scroll-restoration";
4
+ import { scrollRestorationManager } from "@/shared/lib/navigation-history";
5
+
6
+ function ScrollSurface({ restorationKey }: { restorationKey: string }) {
7
+ const scrollRestoration = useScrollRestoration<HTMLDivElement>({ restorationKey });
8
+ const { onScroll, scrollRef } = scrollRestoration;
9
+ return (
10
+ <div
11
+ ref={scrollRef}
12
+ data-testid="scroll-surface"
13
+ onScroll={onScroll}
14
+ />
15
+ );
16
+ }
17
+
18
+ describe("useScrollRestoration", () => {
19
+ beforeEach(() => scrollRestorationManager.clear());
20
+
21
+ it("restores an existing position during layout and saves scroll events", () => {
22
+ scrollRestorationManager.save("page:first", { x: 16, y: 48 });
23
+ const { getByTestId } = render(<ScrollSurface restorationKey="page:first" />);
24
+ const surface = getByTestId("scroll-surface") as HTMLDivElement;
25
+
26
+ expect(surface.scrollLeft).toBe(16);
27
+ expect(surface.scrollTop).toBe(48);
28
+
29
+ surface.scrollLeft = 24;
30
+ surface.scrollTop = 72;
31
+ fireEvent.scroll(surface);
32
+
33
+ expect(scrollRestorationManager.read("page:first")).toEqual({ x: 24, y: 72 });
34
+ });
35
+
36
+ it("saves the previous key and restores the next key when navigation changes", () => {
37
+ scrollRestorationManager.save("page:second", { x: 0, y: 96 });
38
+ const view = render(<ScrollSurface restorationKey="page:first" />);
39
+ const surface = view.getByTestId("scroll-surface") as HTMLDivElement;
40
+ surface.scrollTop = 32;
41
+ fireEvent.scroll(surface);
42
+
43
+ view.rerender(<ScrollSurface restorationKey="page:second" />);
44
+
45
+ expect(scrollRestorationManager.read("page:first")).toEqual({ x: 0, y: 32 });
46
+ expect(surface.scrollTop).toBe(96);
47
+ });
48
+
49
+ it("keeps the last scroll event when React resets the element during navigation", () => {
50
+ const view = render(<ScrollSurface restorationKey="page:first" />);
51
+ const surface = view.getByTestId("scroll-surface") as HTMLDivElement;
52
+ surface.scrollTop = 144;
53
+ fireEvent.scroll(surface);
54
+
55
+ // Switching the content can reset the DOM scroll position before the
56
+ // previous layout effect's cleanup runs.
57
+ surface.scrollTop = 0;
58
+ view.rerender(<ScrollSurface restorationKey="page:second" />);
59
+
60
+ expect(scrollRestorationManager.read("page:first")).toEqual({ x: 0, y: 144 });
61
+ });
62
+ });
@@ -0,0 +1,61 @@
1
+ import { useCallback, useLayoutEffect, useRef, type RefObject, type UIEvent } from "react";
2
+ import {
3
+ scrollRestorationManager,
4
+ type ScrollRestorationPosition,
5
+ } from "@/shared/lib/navigation-history";
6
+
7
+ type UseScrollRestorationParams<T extends HTMLElement> = {
8
+ restorationKey: string | null;
9
+ scrollRef?: RefObject<T>;
10
+ isEnabled?: boolean;
11
+ };
12
+
13
+ /** Connects one DOM scroll surface to the shared in-memory navigation history. */
14
+ export function useScrollRestoration<T extends HTMLElement>({
15
+ restorationKey,
16
+ scrollRef,
17
+ isEnabled = true,
18
+ }: UseScrollRestorationParams<T>) {
19
+ const internalScrollRef = useRef<T>(null);
20
+ const lastKnownPositionRef = useRef<ScrollRestorationPosition | null>(null);
21
+ const resolvedScrollRef = scrollRef ?? internalScrollRef;
22
+
23
+ useLayoutEffect(() => {
24
+ if (!isEnabled || !restorationKey) return;
25
+ const element = resolvedScrollRef.current;
26
+ const position = scrollRestorationManager.read(restorationKey);
27
+ if (element && position) {
28
+ element.scrollLeft = position.x;
29
+ element.scrollTop = position.y;
30
+ }
31
+ if (element) {
32
+ lastKnownPositionRef.current = {
33
+ x: element.scrollLeft,
34
+ y: element.scrollTop,
35
+ };
36
+ }
37
+
38
+ return () => {
39
+ if (!element) return;
40
+ scrollRestorationManager.save(
41
+ restorationKey,
42
+ lastKnownPositionRef.current ?? {
43
+ x: element.scrollLeft,
44
+ y: element.scrollTop,
45
+ },
46
+ );
47
+ };
48
+ }, [isEnabled, restorationKey, resolvedScrollRef]);
49
+
50
+ const onScroll = useCallback((event: UIEvent<T>) => {
51
+ if (!isEnabled || !restorationKey) return;
52
+ const position = {
53
+ x: event.currentTarget.scrollLeft,
54
+ y: event.currentTarget.scrollTop,
55
+ };
56
+ lastKnownPositionRef.current = position;
57
+ scrollRestorationManager.save(restorationKey, position);
58
+ }, [isEnabled, restorationKey]);
59
+
60
+ return { onScroll, scrollRef: resolvedScrollRef };
61
+ }
@@ -149,6 +149,7 @@ export function applyNcpSessionRealtimeEvent(
149
149
  queryClient: QueryClient | undefined,
150
150
  event: Extract<WsEvent, { type: 'session.run-status' | 'session.summary.upsert' | 'session.summary.delete' }>
151
151
  ): void {
152
+ void queryClient?.invalidateQueries({ queryKey: ['ncp-session-pages'] });
152
153
  if (event.type === 'session.run-status') {
153
154
  updateNcpSessionRunStatusInQueryClient(queryClient, event.payload);
154
155
  return;
@@ -103,6 +103,9 @@ export type NcpSessionSummaryView = NcpSessionSummary;
103
103
  export type NcpSessionsListView = {
104
104
  sessions: NcpSessionSummaryView[];
105
105
  total: number;
106
+ page?: number;
107
+ pageSize?: number;
108
+ hasMore?: boolean;
106
109
  };
107
110
 
108
111
  export type NcpMessageView = NcpMessage;
@@ -488,6 +488,7 @@ export type PanelAppListView = {
488
488
  workspacePath: string;
489
489
  panelsPath: string;
490
490
  entries: PanelAppEntryView[];
491
+ unavailablePackages?: Array<{ appId: string; message: string }>;
491
492
  };
492
493
 
493
494
  export type PanelAppPreferencesUpdateView = {
@@ -638,6 +639,7 @@ export type SecretsConfigUpdate = {
638
639
  export type ProductAnalyticsAudience = 'external' | 'internal' | 'qa';
639
640
 
640
641
  export type ProductAnalyticsView = {
642
+ schemaVersion: 2;
641
643
  enabled: boolean;
642
644
  audience: ProductAnalyticsAudience;
643
645
  };
@@ -647,6 +649,13 @@ export type ProductAnalyticsConfigUpdate = {
647
649
  audience?: ProductAnalyticsAudience;
648
650
  };
649
651
 
652
+ export type ProductAnalyticsStatusView = {
653
+ lastAttemptAt: string | null;
654
+ lastSuccessAt: string | null;
655
+ lastError: string | null;
656
+ pendingReceiptCount: number;
657
+ };
658
+
650
659
  export type ChannelConfigUpdate = Record<string, unknown>;
651
660
 
652
661
  export type ConfigView = {
@@ -31,6 +31,7 @@ import type {
31
31
  ProviderCreateResult,
32
32
  ProviderDeleteResult,
33
33
  ProductAnalyticsConfigUpdate,
34
+ ProductAnalyticsStatusView,
34
35
  ProductAnalyticsView,
35
36
  RuntimeConfigUpdate,
36
37
  SecretsConfigUpdate,
@@ -178,6 +179,10 @@ export async function updateProductAnalytics(
178
179
  return await nextclawClient.config.updateProductAnalytics(data);
179
180
  }
180
181
 
182
+ export async function fetchProductAnalyticsStatus(): Promise<ProductAnalyticsStatusView> {
183
+ return await nextclawClient.config.fetchProductAnalyticsStatus();
184
+ }
185
+
181
186
  export async function executeConfigAction(
182
187
  actionId: string,
183
188
  data: ConfigActionExecuteRequest
@@ -14,6 +14,9 @@ import type {
14
14
  // GET /api/ncp/sessions
15
15
  export async function fetchNcpSessions(params?: {
16
16
  limit?: number;
17
+ page?: number;
18
+ pageSize?: number;
19
+ query?: string;
17
20
  peerId?: string;
18
21
  }): Promise<NcpSessionsListView> {
19
22
  return (await nextclawClient.sessions.list(params)) as NcpSessionsListView;
@@ -44,6 +44,7 @@
44
44
  "chatWorkspaceBack": "Back in workspace",
45
45
  "chatWorkspaceForward": "Forward in workspace",
46
46
  "chatWorkspaceRefreshFile": "Refresh preview",
47
+ "chatWorkspaceMarkdownOutline": "Document outline",
47
48
  "chatWorkspaceMaximizePanel": "Maximize workspace panel",
48
49
  "chatWorkspaceRestorePanel": "Restore workspace panel",
49
50
  "chatWorkspaceClosePanel": "Close workspace panel",
@@ -79,7 +80,7 @@
79
80
  "chatWorkspaceContinuousAttentionEmpty": "No continuous attention",
80
81
  "chatWorkspaceContinuousAttentionEmptyDescription": "The Agent has not established this type of ongoing relationship for the session.",
81
82
  "chatWorkspaceTokenUsage": "Token usage",
82
- "chatWorkspaceTokenUsageDescription": "Counts this session's own AI runs. Cached input is included in input tokens; the hit rate is cached reads divided by all input.",
83
+ "chatWorkspaceTokenUsageDescription": "Summarizes this session's recorded Agent rounds. Cached input is included in input tokens; cache read ratio is cached reads divided by all input tokens.",
83
84
  "chatWorkspaceTokenUsageLoading": "Calculating token usage…",
84
85
  "chatWorkspaceTokenUsageLoadFailed": "Couldn’t load token usage.",
85
86
  "chatWorkspaceTokenUsageRetry": "Retry",
@@ -88,11 +89,14 @@
88
89
  "chatWorkspaceTokenUsageInput": "Input",
89
90
  "chatWorkspaceTokenUsageOutput": "Output",
90
91
  "chatWorkspaceTokenUsageCachedInput": "Cached input",
91
- "chatWorkspaceTokenUsageCacheHitRate": "Cache hit rate",
92
- "chatWorkspaceTokenUsageRuns": "AI runs",
92
+ "chatWorkspaceTokenUsageCacheHitRate": "Cache read ratio",
93
+ "chatWorkspaceTokenUsageRuns": "Agent rounds",
94
+ "chatWorkspaceTokenUsageModelCalls": "Model calls",
95
+ "chatWorkspaceTokenUsageReportedModelCalls": "Reported calls",
93
96
  "chatWorkspaceTokenUsageByModel": "By model",
94
- "chatWorkspaceTokenUsageModelRunCount": "Runs: {count}",
95
- "chatWorkspaceTokenUsageModelCacheHitRate": "Cache hit rate: {rate}",
97
+ "chatWorkspaceTokenUsageModelRunCount": "{count} agent rounds",
98
+ "chatWorkspaceTokenUsageModelCallCount": "{count} model calls",
99
+ "chatWorkspaceTokenUsageModelCacheHitRate": "Cache read ratio: {rate}",
96
100
  "chatWorkspaceTokenUsageReported": "Usage reported",
97
101
  "chatWorkspaceTokenUsagePartial": "Partial usage",
98
102
  "chatWorkspaceTokenUsageUnavailable": "Usage unavailable",
@@ -594,6 +598,12 @@
594
598
  "chatSidebarUnpinProject": "Unpin project",
595
599
  "chatSidebarExpandProject": "Expand project",
596
600
  "chatSidebarCollapseProject": "Collapse project",
601
+ "chatSidebarContextSessions": "Sessions",
602
+ "chatSidebarContextProject": "Project",
603
+ "chatSidebarContextPath": "Path",
604
+ "chatSidebarContextChildSessions": "Child sessions",
605
+ "chatSidebarContextScheduledTasks": "Scheduled tasks",
606
+ "chatSidebarContextNoProject": "No project",
597
607
  "chatSidebarProjectViewEmpty": "No projects yet",
598
608
  "chatBackgroundReplyFallbackTitle": "New reply",
599
609
  "chatBackgroundReplyFallbackPreview": "A reply is ready. Open the session to view it.",