@nextclaw/ui 0.15.6 → 0.15.7

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 (100) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/dist/assets/{api-Dyb5rgh0.js → api-V8m62-13.js} +1 -1
  3. package/dist/assets/{app-presenter-provider-7jV0nPiL.js → app-presenter-provider-Bzj9h_ZS.js} +21 -21
  4. package/dist/assets/{appearance-settings-page-DFm3BbEb.js → appearance-settings-page-DwBwu1AU.js} +1 -1
  5. package/dist/assets/{book-open-L6LCuqOr.js → book-open-DtVSr5O9.js} +1 -1
  6. package/dist/assets/{channels-list-page-DOaPKNJA.js → channels-list-page-CYIqMliJ.js} +1 -1
  7. package/dist/assets/{chat-page-Cw9uDhD_.js → chat-page-BC0icTtI.js} +1 -1
  8. package/dist/assets/{config-split-page-CqlqjnCK.js → config-split-page-B88prd8s.js} +1 -1
  9. package/dist/assets/{confirm-dialog-bAp8NU93.js → confirm-dialog-BB09IC2u.js} +1 -1
  10. package/dist/assets/{desktop-update-config-2SjMfCMl.js → desktop-update-config-B-q0OhxO.js} +1 -1
  11. package/dist/assets/{dist-CrtbGLlh.js → dist-Bytser-a.js} +1 -1
  12. package/dist/assets/{doc-browser-BmnQa30-.js → doc-browser-B1FXvZm6.js} +1 -1
  13. package/dist/assets/doc-browser-Bodhf0fj2.js +1 -0
  14. package/dist/assets/{doc-browser-context-BlpUUFrR.js → doc-browser-context-D5VLNoj3.js} +1 -1
  15. package/dist/assets/{download-BhZZNXD8.js → download-CdO6NoHj.js} +1 -1
  16. package/dist/assets/{external-link-B9Uoq5J7.js → external-link-C5NFoh5I.js} +1 -1
  17. package/dist/assets/{eye-K5knauEM.js → eye-B51oNRRc.js} +1 -1
  18. package/dist/assets/{folder-BIYZZWJv.js → folder-DxH7Nef-.js} +1 -1
  19. package/dist/assets/{form-actions-CKprWFeM.js → form-actions-C9pUwtYV.js} +1 -1
  20. package/dist/assets/index-DtWf7kk9.css +1 -0
  21. package/dist/assets/index-UnY4HtVa.js +2 -0
  22. package/dist/assets/loader-circle-BRSfaztA.js +1 -0
  23. package/dist/assets/{logo-badge-BQ-XFZod.js → logo-badge-D2vORX3a.js} +1 -1
  24. package/dist/assets/{mcp-marketplace-page-BfA7A3n4.js → mcp-marketplace-page-CGVu7mfL.js} +1 -1
  25. package/dist/assets/mcp-marketplace-page-DnV0e1JS.js +1 -0
  26. package/dist/assets/{model-config-page-BbWbW_qM.js → model-config-page-DYpchGVE.js} +1 -1
  27. package/dist/assets/plus-BgMBV9HJ.js +1 -0
  28. package/dist/assets/{popover-CX9zFsuc.js → popover-CONujDHf.js} +1 -1
  29. package/dist/assets/{provider-scoped-model-input-B-9_DtIP.js → provider-scoped-model-input-DA3Ua9v8.js} +1 -1
  30. package/dist/assets/{providers-config-page-xwIjGur2.js → providers-config-page-DXsoyMrh.js} +1 -1
  31. package/dist/assets/{react-DsgudwpT.js → react-Io6Pfq8D.js} +1 -1
  32. package/dist/assets/{refresh-cw-kXdvbhFf.js → refresh-cw-C6j2tjul.js} +1 -1
  33. package/dist/assets/remote-C1y3ARHu.js +1 -0
  34. package/dist/assets/rotate-cw-Bv2B6fAV.js +1 -0
  35. package/dist/assets/{runtime-config-page-DTsi7czJ.js → runtime-config-page-eYrrKBrA.js} +1 -1
  36. package/dist/assets/{save-C9qpz2_2.js → save-CBBZVvZ-.js} +1 -1
  37. package/dist/assets/{search-_kEi29qo.js → search-DFF842z2.js} +1 -1
  38. package/dist/assets/{search-config-page-H7ug8f4D.js → search-config-page-DUyuwD7d.js} +1 -1
  39. package/dist/assets/{secrets-config-page-C1_6rINu.js → secrets-config-page-DwzKQJ76.js} +1 -1
  40. package/dist/assets/security-config-D8me3pXl.js +1 -0
  41. package/dist/assets/{select-MJKhKAWR.js → select-BiOpo2kV.js} +1 -1
  42. package/dist/assets/{setting-row-DA3tJuew.js → setting-row-C3WQXLtS.js} +1 -1
  43. package/dist/assets/{settings-2-D_y7zXJz.js → settings-2-DyOvA_6K.js} +1 -1
  44. package/dist/assets/{skeleton-0uTLUhm9.js → skeleton-CQIAEbA4.js} +1 -1
  45. package/dist/assets/{tabs-h44XPocd.js → tabs-BTSENlI2.js} +1 -1
  46. package/dist/assets/{tag-chip-bRZ04q3C.js → tag-chip-BG5JIGwY.js} +1 -1
  47. package/dist/assets/{trash-2-BRpSE-Wt.js → trash-2-CTIfwpt1.js} +1 -1
  48. package/dist/assets/{use-config-CwDEAl6f.js → use-config-CY4uF2Ou.js} +1 -1
  49. package/dist/assets/{use-confirm-dialog-BbnWw-8c.js → use-confirm-dialog-Bj4u78F9.js} +1 -1
  50. package/dist/assets/{use-viewport-layout-CqgIMDQL.js → use-viewport-layout-ChJUcSQ6.js} +1 -1
  51. package/dist/assets/x-C6cM8wkT.js +1 -0
  52. package/dist/index.html +30 -29
  53. package/index.html +1 -0
  54. package/package.json +8 -8
  55. package/src/features/chat/components/conversation/__tests__/chat-conversation-header-section.test.tsx +13 -2
  56. package/src/features/chat/features/conversation/components/__tests__/session-conversation-input.streaming.test.tsx +18 -0
  57. package/src/features/chat/features/input/hooks/__tests__/use-chat-input-surface-state.test.tsx +47 -2
  58. package/src/features/chat/features/input/hooks/use-chat-input-surface-state.ts +63 -16
  59. package/src/features/chat/features/input/input-surface-plugins/__tests__/context-reference-plugin.test.ts +54 -4
  60. package/src/features/chat/features/input/input-surface-plugins/__tests__/slash-command-plugin.test.ts +4 -4
  61. package/src/features/chat/features/input/input-surface-plugins/chat-input-product-plugin-adapters.types.ts +1 -3
  62. package/src/features/chat/features/input/input-surface-plugins/context-reference-plugin.utils.ts +110 -27
  63. package/src/features/chat/features/input/utils/__tests__/chat-inline-token.utils.test.ts +17 -1
  64. package/src/features/chat/features/input/utils/chat-inline-token.utils.ts +17 -0
  65. package/src/features/chat/features/message/components/__tests__/chat-inline-file-preview.test.tsx +159 -0
  66. package/src/features/chat/features/message/components/__tests__/chat-message-list.container.test.tsx +92 -26
  67. package/src/features/chat/features/message/components/chat-inline-file-preview.tsx +90 -21
  68. package/src/features/chat/features/message/components/chat-message-list.container.tsx +24 -3
  69. package/src/features/chat/features/message/utils/chat-message-texts.utils.ts +1 -0
  70. package/src/features/chat/features/ncp/hooks/__tests__/use-hydrated-ncp-agent.test.tsx +55 -0
  71. package/src/features/chat/features/session/components/session-header/chat-session-title-switcher.tsx +1 -1
  72. package/src/features/chat/features/workspace/components/__tests__/chat-session-workspace-file-preview.test.tsx +5 -6
  73. package/src/features/chat/features/workspace/components/__tests__/workspace-file-content-preview.test.tsx +67 -0
  74. package/src/features/chat/features/workspace/components/chat-session-workspace-file-preview.tsx +9 -7
  75. package/src/features/chat/features/workspace/components/workspace-file-content-preview.tsx +95 -4
  76. package/src/features/marketplace/components/__tests__/marketplace-page-detail.test.tsx +1 -0
  77. package/src/features/marketplace/components/__tests__/marketplace-page.test.tsx +62 -0
  78. package/src/features/marketplace/components/curated-shelves/__tests__/marketplace-curated-scene-route.test.tsx +1 -0
  79. package/src/features/marketplace/components/curated-shelves/marketplace-curated-shelves.tsx +2 -15
  80. package/src/features/marketplace/components/marketplace-page.tsx +10 -2
  81. package/src/features/marketplace/hooks/__tests__/use-marketplace.test.tsx +26 -1
  82. package/src/features/marketplace/hooks/use-marketplace-curated-scene-route.ts +11 -0
  83. package/src/features/marketplace/hooks/use-marketplace-list-model.ts +1 -1
  84. package/src/features/marketplace/hooks/use-marketplace.ts +28 -1
  85. package/src/shared/lib/i18n/locales/en-US/chat.json +5 -0
  86. package/src/shared/lib/i18n/locales/en-US/desktop-update.json +1 -1
  87. package/src/shared/lib/i18n/locales/en-US/marketplace.json +1 -0
  88. package/src/shared/lib/i18n/locales/zh-CN/chat.json +5 -0
  89. package/src/shared/lib/i18n/locales/zh-CN/desktop-update.json +1 -1
  90. package/src/shared/lib/i18n/locales/zh-CN/marketplace.json +1 -0
  91. package/dist/assets/doc-browser-A5IRyDXQ2.js +0 -1
  92. package/dist/assets/index-BVn702m4.css +0 -1
  93. package/dist/assets/index-CFxJ1Fkg.js +0 -2
  94. package/dist/assets/loader-circle-Bj2RMrCR.js +0 -1
  95. package/dist/assets/mcp-marketplace-page-CXHYO95j.js +0 -1
  96. package/dist/assets/plus-TmFdwO3P.js +0 -1
  97. package/dist/assets/remote-B9F2uCk9.js +0 -1
  98. package/dist/assets/rotate-cw-DEc6aihr.js +0 -1
  99. package/dist/assets/security-config-Cr2ERKLu.js +0 -1
  100. package/dist/assets/x-DdEDL6ju.js +0 -1
@@ -92,4 +92,59 @@ describe("useHydratedNcpAgent", () => {
92
92
  expect(mocks.stream).toHaveBeenCalledWith({ sessionId: "session-b" });
93
93
  expect(mocks.stream).toHaveBeenCalledTimes(2);
94
94
  });
95
+
96
+ it("keeps a selected session hydrated when an earlier empty-session reset finishes late", async () => {
97
+ const stopResolvers: Array<() => void> = [];
98
+ const client = {
99
+ stop: mocks.stop.mockImplementation(
100
+ () => new Promise<void>((resolve) => stopResolvers.push(resolve)),
101
+ ),
102
+ stream: mocks.stream.mockResolvedValue(undefined),
103
+ subscribe: vi.fn(() => () => {}),
104
+ } as unknown as NcpAgentClientEndpoint;
105
+ const historyMessage = {
106
+ id: "message-1",
107
+ sessionId: "session-mobile",
108
+ role: "user",
109
+ status: "final",
110
+ parts: [{ type: "text", text: "Existing history" }],
111
+ timestamp: "2026-07-16T00:00:00.000Z",
112
+ } as const;
113
+ const loadSeed = vi.fn().mockResolvedValue({
114
+ messages: [historyMessage],
115
+ status: "idle",
116
+ });
117
+
118
+ const { result, rerender } = renderHook(
119
+ ({ sessionId }: { sessionId: string | undefined }) =>
120
+ useHydratedNcpAgent({
121
+ sessionId,
122
+ client: client as never,
123
+ loadSeed,
124
+ }),
125
+ {
126
+ initialProps: {
127
+ sessionId: undefined as string | undefined,
128
+ },
129
+ },
130
+ );
131
+
132
+ await waitFor(() => expect(stopResolvers).toHaveLength(1));
133
+ rerender({ sessionId: "session-mobile" });
134
+ await waitFor(() => expect(stopResolvers).toHaveLength(2));
135
+
136
+ await act(async () => {
137
+ stopResolvers[1]?.();
138
+ await Promise.resolve();
139
+ });
140
+ await waitFor(() => {
141
+ expect(result.current.visibleMessages).toEqual([historyMessage]);
142
+ });
143
+
144
+ await act(async () => {
145
+ stopResolvers[0]?.();
146
+ await Promise.resolve();
147
+ });
148
+ expect(result.current.visibleMessages).toEqual([historyMessage]);
149
+ });
95
150
  });
@@ -235,7 +235,7 @@ export function ChatSessionTitleSwitcher({
235
235
  (state) => state.isSidebarCollapsed,
236
236
  );
237
237
 
238
- if (layoutMode !== "desktop" || !isSidebarCollapsed) {
238
+ if (layoutMode === "desktop" && !isSidebarCollapsed) {
239
239
  return <ChatSessionTitle title={title} />;
240
240
  }
241
241
 
@@ -20,6 +20,7 @@ const destroyPresentationMock = vi.fn();
20
20
  type RenderWorkspaceFilePreviewOptions = {
21
21
  file?: Partial<ChatWorkspaceFileTab>;
22
22
  onFileOpen?: (action: ChatFileOpenActionViewModel) => void;
23
+ onHtmlContentHeightChange?: (height: number) => void;
23
24
  refreshVersion?: number;
24
25
  sessionProjectRoot?: string | null;
25
26
  sessionWorkingDir?: string | null;
@@ -104,6 +105,7 @@ function mockTextRead(overrides: TextReadDataOverrides = {}) {
104
105
  function renderWorkspaceFilePreview({
105
106
  file,
106
107
  onFileOpen = vi.fn(),
108
+ onHtmlContentHeightChange,
107
109
  refreshVersion,
108
110
  sessionProjectRoot = "/tmp",
109
111
  sessionWorkingDir = "/tmp",
@@ -116,6 +118,7 @@ function renderWorkspaceFilePreview({
116
118
  sessionWorkingDir={sessionWorkingDir}
117
119
  refreshVersion={refreshVersion}
118
120
  showBreadcrumbs={showBreadcrumbs}
121
+ onHtmlContentHeightChange={onHtmlContentHeightChange}
119
122
  onFileOpen={onFileOpen}
120
123
  />,
121
124
  );
@@ -316,9 +319,7 @@ describe("ChatSessionWorkspaceFilePreview rendering", () => {
316
319
  });
317
320
 
318
321
  expect(screen.getByTestId(testId)).toBeTruthy();
319
- expect(
320
- screen.queryByText(t("chatWorkspacePreviewTruncated")),
321
- ).toBeNull();
322
+ expect(screen.queryByText(t("chatWorkspacePreviewTruncated"))).toBeNull();
322
323
  },
323
324
  );
324
325
  });
@@ -751,9 +752,7 @@ describe("ChatSessionWorkspaceFilePreview text rendering", () => {
751
752
  });
752
753
  const indexButton = screen.getByRole("button", { name: "index.ts" });
753
754
  fireEvent.click(componentsTreeItem.querySelector("button")!);
754
- expect(
755
- componentsTreeItem.getAttribute("aria-expanded"),
756
- ).toBe("true");
755
+ expect(componentsTreeItem.getAttribute("aria-expanded")).toBe("true");
757
756
  expect(onFileOpen).not.toHaveBeenCalled();
758
757
 
759
758
  fireEvent.click(indexButton);
@@ -0,0 +1,67 @@
1
+ import { fireEvent, render, screen } from "@testing-library/react";
2
+ import { afterEach, expect, it, vi } from "vitest";
3
+ import { WorkspaceFileContentPreview } from "@/features/chat/features/workspace/components/workspace-file-content-preview";
4
+
5
+ const defaultResizeObserver = globalThis.ResizeObserver;
6
+
7
+ afterEach(() => {
8
+ vi.stubGlobal("ResizeObserver", defaultResizeObserver);
9
+ });
10
+
11
+ it("reports same-origin HTML height changes without replacing the iframe", () => {
12
+ const observe = vi.fn();
13
+ const disconnect = vi.fn();
14
+ let notifyResize: ResizeObserverCallback = () => undefined;
15
+ class HeightResizeObserver {
16
+ constructor(callback: ResizeObserverCallback) {
17
+ notifyResize = callback;
18
+ }
19
+
20
+ observe = observe;
21
+
22
+ unobserve = vi.fn();
23
+
24
+ disconnect = disconnect;
25
+ }
26
+ vi.stubGlobal("ResizeObserver", HeightResizeObserver);
27
+ const onHtmlContentHeightChange = vi.fn();
28
+ const { unmount } = render(
29
+ <WorkspaceFileContentPreview
30
+ contentUrl="/api/server-paths/content?path=%2Ftmp%2Fexample.html"
31
+ kind="html"
32
+ label="example.html"
33
+ onHtmlContentHeightChange={onHtmlContentHeightChange}
34
+ />,
35
+ );
36
+ const frame = screen.getByTestId(
37
+ "workspace-html-preview",
38
+ ) as HTMLIFrameElement;
39
+ const iframeDocument = document.implementation.createHTMLDocument();
40
+ Object.defineProperty(frame, "contentDocument", {
41
+ configurable: true,
42
+ value: iframeDocument,
43
+ });
44
+ const { documentElement } = iframeDocument;
45
+ Object.defineProperty(documentElement, "scrollHeight", {
46
+ configurable: true,
47
+ value: 640,
48
+ });
49
+
50
+ fireEvent.load(frame);
51
+
52
+ expect(onHtmlContentHeightChange).toHaveBeenLastCalledWith(640);
53
+ expect(observe).toHaveBeenCalledWith(documentElement);
54
+ expect(screen.getByTestId("workspace-html-preview")).toBe(frame);
55
+
56
+ Object.defineProperty(documentElement, "scrollHeight", {
57
+ configurable: true,
58
+ value: 900,
59
+ });
60
+ notifyResize([], {} as ResizeObserver);
61
+
62
+ expect(onHtmlContentHeightChange).toHaveBeenLastCalledWith(900);
63
+ expect(screen.getByTestId("workspace-html-preview")).toBe(frame);
64
+
65
+ unmount();
66
+ expect(disconnect).toHaveBeenCalledTimes(1);
67
+ });
@@ -180,6 +180,7 @@ function WorkspacePreviewBody({
180
180
  directoryQuery,
181
181
  fileBasePath,
182
182
  onFileOpen,
183
+ onHtmlContentHeightChange,
183
184
  previewBlock,
184
185
  previewKind,
185
186
  previewViewer,
@@ -194,6 +195,7 @@ function WorkspacePreviewBody({
194
195
  directoryQuery: ReturnType<typeof useServerPathBrowse> | null | undefined;
195
196
  fileBasePath: string | null;
196
197
  onFileOpen: (action: ChatFileOpenActionViewModel) => void;
198
+ onHtmlContentHeightChange?: (height: number) => void;
197
199
  previewBlock: ChatFileOperationBlockViewModel | null;
198
200
  previewKind: "text" | "markdown" | "binary";
199
201
  previewViewer: "source" | "rendered" | null;
@@ -208,6 +210,7 @@ function WorkspacePreviewBody({
208
210
  contentUrl={contentUrl}
209
211
  kind={contentUrlKind}
210
212
  label={contentLabel}
213
+ onHtmlContentHeightChange={onHtmlContentHeightChange}
211
214
  />
212
215
  );
213
216
  }
@@ -291,6 +294,7 @@ type ChatSessionWorkspaceFilePreviewProps = {
291
294
  sessionProjectRoot: string | null;
292
295
  sessionWorkingDir: string | null;
293
296
  showBreadcrumbs?: boolean;
297
+ onHtmlContentHeightChange?: (height: number) => void;
294
298
  onFileOpen: (action: ChatFileOpenActionViewModel) => void;
295
299
  };
296
300
 
@@ -300,6 +304,7 @@ export function ChatSessionWorkspaceFilePreview({
300
304
  sessionProjectRoot,
301
305
  sessionWorkingDir,
302
306
  showBreadcrumbs = true,
307
+ onHtmlContentHeightChange,
303
308
  onFileOpen,
304
309
  }: ChatSessionWorkspaceFilePreviewProps) {
305
310
  const isPreviewMode = file.viewMode === "preview";
@@ -384,7 +389,8 @@ export function ChatSessionWorkspaceFilePreview({
384
389
  previewQuery?.data?.startLine,
385
390
  previewText,
386
391
  ]);
387
- const isTextPreviewTruncated = !contentUrl && Boolean(previewQuery?.data?.truncated);
392
+ const isTextPreviewTruncated =
393
+ !contentUrl && Boolean(previewQuery?.data?.truncated);
388
394
  const breadcrumbBasePath = sessionProjectRoot ?? sessionWorkingDir;
389
395
  const breadcrumb = useMemo(
390
396
  () =>
@@ -394,12 +400,7 @@ export function ChatSessionWorkspaceFilePreview({
394
400
  sessionProjectRoot: breadcrumbBasePath,
395
401
  truncated: isTextPreviewTruncated,
396
402
  }),
397
- [
398
- breadcrumbBasePath,
399
- isTextPreviewTruncated,
400
- previewPathKind,
401
- resolvedPath,
402
- ],
403
+ [breadcrumbBasePath, isTextPreviewTruncated, previewPathKind, resolvedPath],
403
404
  );
404
405
 
405
406
  return (
@@ -422,6 +423,7 @@ export function ChatSessionWorkspaceFilePreview({
422
423
  directoryQuery={directoryQuery}
423
424
  fileBasePath={sessionWorkingDir}
424
425
  onFileOpen={onFileOpen}
426
+ onHtmlContentHeightChange={onHtmlContentHeightChange}
425
427
  previewBlock={previewBlock}
426
428
  previewKind={previewKind}
427
429
  previewViewer={previewViewer}
@@ -1,3 +1,4 @@
1
+ import { useEffect, useRef } from "react";
1
2
  import { WorkspaceDocxPreview } from "./file-content-preview/workspace-docx-preview";
2
3
  import { WorkspacePresentationPreview } from "./file-content-preview/workspace-presentation-preview";
3
4
  import { WorkspaceSpreadsheetPreview } from "./file-content-preview/workspace-spreadsheet-preview";
@@ -115,14 +116,97 @@ function WorkspaceUnsupportedContent({
115
116
  );
116
117
  }
117
118
 
119
+ function readHtmlDocumentHeight(document: Document): number {
120
+ const { body, documentElement } = document;
121
+ return Math.ceil(
122
+ Math.max(
123
+ body?.clientHeight ?? 0,
124
+ body?.offsetHeight ?? 0,
125
+ body?.scrollHeight ?? 0,
126
+ documentElement.clientHeight,
127
+ documentElement.offsetHeight,
128
+ documentElement.scrollHeight,
129
+ ),
130
+ );
131
+ }
132
+
133
+ function WorkspaceHtmlPreview({
134
+ contentUrl,
135
+ label,
136
+ onContentHeightChange,
137
+ }: {
138
+ contentUrl: string;
139
+ label: string;
140
+ onContentHeightChange?: (height: number) => void;
141
+ }) {
142
+ const iframeRef = useRef<HTMLIFrameElement>(null);
143
+ const observerCleanupRef = useRef<(() => void) | null>(null);
144
+
145
+ useEffect(
146
+ () => () => {
147
+ observerCleanupRef.current?.();
148
+ },
149
+ [],
150
+ );
151
+
152
+ const observeContentHeight = () => {
153
+ observerCleanupRef.current?.();
154
+ observerCleanupRef.current = null;
155
+ const iframe = iframeRef.current;
156
+ if (
157
+ !iframe ||
158
+ !onContentHeightChange ||
159
+ typeof ResizeObserver === "undefined"
160
+ ) {
161
+ return;
162
+ }
163
+
164
+ try {
165
+ const document = iframe.contentDocument;
166
+ if (!document) {
167
+ return;
168
+ }
169
+ const reportHeight = () => {
170
+ const height = readHtmlDocumentHeight(document);
171
+ if (height > 0) {
172
+ onContentHeightChange(height);
173
+ }
174
+ };
175
+ const observer = new ResizeObserver(reportHeight);
176
+ observer.observe(document.documentElement);
177
+ if (document.body) {
178
+ observer.observe(document.body);
179
+ }
180
+ reportHeight();
181
+ observerCleanupRef.current = () => observer.disconnect();
182
+ } catch {
183
+ // Cross-origin documents keep the caller's bounded fallback height.
184
+ }
185
+ };
186
+
187
+ return (
188
+ <iframe
189
+ ref={iframeRef}
190
+ allowFullScreen
191
+ className="h-full w-full border-0 bg-white"
192
+ data-testid="workspace-html-preview"
193
+ src={contentUrl}
194
+ title={label}
195
+ onLoad={observeContentHeight}
196
+ />
197
+ );
198
+ }
199
+
118
200
  export function WorkspaceFileContentPreview({
119
201
  contentUrl,
120
202
  kind,
121
203
  label,
204
+ onHtmlContentHeightChange,
122
205
  }: {
123
206
  contentUrl: string;
124
207
  kind: WorkspaceFileContentKind;
125
208
  label: string;
209
+ onHtmlContentHeightChange?: (height: number) => void;
126
210
  }) {
127
211
  if (kind === "image") {
128
212
  return (
@@ -165,14 +249,21 @@ export function WorkspaceFileContentPreview({
165
249
  </div>
166
250
  );
167
251
  }
168
- if (kind === "pdf" || kind === "html") {
252
+ if (kind === "html") {
253
+ return (
254
+ <WorkspaceHtmlPreview
255
+ contentUrl={contentUrl}
256
+ label={label}
257
+ onContentHeightChange={onHtmlContentHeightChange}
258
+ />
259
+ );
260
+ }
261
+ if (kind === "pdf") {
169
262
  return (
170
263
  <iframe
171
264
  allowFullScreen
172
265
  className="h-full w-full border-0 bg-white"
173
- data-testid={
174
- kind === "pdf" ? "workspace-content-pdf" : "workspace-html-preview"
175
- }
266
+ data-testid="workspace-content-pdf"
176
267
  src={contentUrl}
177
268
  title={label}
178
269
  />
@@ -62,6 +62,7 @@ vi.mock("@/shared/hooks/use-confirm-dialog", () => ({
62
62
 
63
63
  vi.mock("@/features/marketplace/hooks/use-marketplace", () => ({
64
64
  useMarketplaceItems: () => mocks.itemsQuery,
65
+ useMarketplaceRecentItems: () => mocks.itemsQuery,
65
66
  useMarketplaceInstalled: () => mocks.installedQuery,
66
67
  useMarketplaceSkillScenes: () => ({
67
68
  data: { scenes: [] },
@@ -31,6 +31,7 @@ const mocks = vi.hoisted(() => ({
31
31
  openExternalUrl: vi.fn(),
32
32
  routeParams: {} as { scene?: string },
33
33
  itemsQuery: null as unknown as ItemsQueryState,
34
+ recentItemsQuery: null as unknown as ItemsQueryState,
34
35
  installedQuery: null as unknown as InstalledQueryState,
35
36
  installMutation: {
36
37
  mutateAsync: vi.fn(),
@@ -82,6 +83,7 @@ vi.mock("@/shared/lib/host-capabilities", () => ({
82
83
 
83
84
  vi.mock("@/features/marketplace/hooks/use-marketplace", () => ({
84
85
  useMarketplaceItems: () => mocks.itemsQuery,
86
+ useMarketplaceRecentItems: () => mocks.recentItemsQuery,
85
87
  useMarketplaceSkillScenes: () => ({
86
88
  data: {
87
89
  scenes: [
@@ -135,6 +137,7 @@ function createItemsQuery(overrides: Partial<Record<string, unknown>> = {}) {
135
137
  isError: false,
136
138
  error: null,
137
139
  hasNextPage: false,
140
+ fetchNextPage: vi.fn(),
138
141
  ...overrides,
139
142
  };
140
143
  }
@@ -172,6 +175,7 @@ describe("MarketplacePage", () => {
172
175
  mocks.manageMutation.isPending = false;
173
176
  mocks.manageMutation.variables = undefined;
174
177
  mocks.itemsQuery = createItemsQuery();
178
+ mocks.recentItemsQuery = createItemsQuery();
175
179
  mocks.installedQuery = createInstalledQuery();
176
180
  });
177
181
 
@@ -216,6 +220,64 @@ describe("MarketplacePage", () => {
216
220
  expect(screen.getByText("Web Search")).toBeTruthy();
217
221
  });
218
222
 
223
+ it("shows the stable catalog total instead of a loaded-page fraction", () => {
224
+ mocks.itemsQuery = createItemsQuery({
225
+ data: {
226
+ total: 36,
227
+ page: 1,
228
+ pageSize: 20,
229
+ totalPages: 2,
230
+ sort: "relevance",
231
+ items: [createMarketplaceItem()],
232
+ } satisfies MarketplaceListView,
233
+ hasNextPage: true,
234
+ });
235
+
236
+ render(<MarketplacePage forcedType="skills" />);
237
+
238
+ expect(screen.getByText("36 skills")).toBeTruthy();
239
+ expect(screen.queryByText("1 / 36")).toBeNull();
240
+ });
241
+
242
+ it("renders recently updated skills from the dedicated updated query", () => {
243
+ mocks.itemsQuery = createItemsQuery({
244
+ data: {
245
+ total: 4,
246
+ page: 1,
247
+ pageSize: 20,
248
+ totalPages: 1,
249
+ sort: "relevance",
250
+ items: [
251
+ createMarketplaceItem({ name: "Relevant One" }),
252
+ createMarketplaceItem({ id: "2", slug: "2", name: "Relevant Two" }),
253
+ createMarketplaceItem({ id: "3", slug: "3", name: "Relevant Three" }),
254
+ createMarketplaceItem({ id: "4", slug: "4", name: "Relevant Four" }),
255
+ ],
256
+ } satisfies MarketplaceListView,
257
+ });
258
+ mocks.recentItemsQuery = createItemsQuery({
259
+ data: {
260
+ total: 1,
261
+ page: 1,
262
+ pageSize: 6,
263
+ totalPages: 1,
264
+ sort: "updated",
265
+ items: [
266
+ createMarketplaceItem({
267
+ id: "recent",
268
+ slug: "recent",
269
+ name: "Actually Recent",
270
+ updatedAt: "2026-07-17T00:00:00.000Z",
271
+ }),
272
+ ],
273
+ } satisfies MarketplaceListView,
274
+ });
275
+
276
+ render(<MarketplacePage forcedType="skills" />);
277
+
278
+ expect(screen.getByText("Actually Recent")).toBeTruthy();
279
+ });
280
+
219
281
  it("does not render a redundant type label in skill cards", () => {
220
282
  mocks.itemsQuery = createItemsQuery({
221
283
  data: {
@@ -72,6 +72,7 @@ vi.mock("@/shared/hooks/use-confirm-dialog", () => ({
72
72
 
73
73
  vi.mock("@/features/marketplace/hooks/use-marketplace", () => ({
74
74
  useMarketplaceItems: () => mocks.itemsQuery,
75
+ useMarketplaceRecentItems: () => mocks.itemsQuery,
75
76
  useMarketplaceSkillScenes: () => mocks.scenesQuery,
76
77
  useMarketplaceSkillSceneCounts: () => new Map([["development-debugging", 2]]),
77
78
  useMarketplaceInstalled: () => ({
@@ -33,7 +33,7 @@ export type MarketplaceShelfEntry = {
33
33
  };
34
34
 
35
35
  export function MarketplaceCuratedShelves(props: {
36
- entries: MarketplaceShelfEntry[];
36
+ recentEntries: MarketplaceShelfEntry[];
37
37
  scenes: MarketplaceSceneView[];
38
38
  isScenesLoading: boolean;
39
39
  isItemsLoading: boolean;
@@ -44,7 +44,7 @@ export function MarketplaceCuratedShelves(props: {
44
44
  onOpenScene: (scene: string) => void;
45
45
  }) {
46
46
  const {
47
- entries,
47
+ recentEntries,
48
48
  scenes,
49
49
  isScenesLoading,
50
50
  isItemsLoading,
@@ -55,10 +55,6 @@ export function MarketplaceCuratedShelves(props: {
55
55
  onOpenScene,
56
56
  } = props;
57
57
  const localeFallbacks = buildLocaleFallbacks(language);
58
- const recentEntries = [...entries]
59
- .sort((left, right) => compareUpdatedAt(left.item, right.item))
60
- .slice(0, 6);
61
-
62
58
  return (
63
59
  <div className="mb-4 space-y-5">
64
60
  <section className="space-y-2.5">
@@ -345,15 +341,6 @@ function ShelfHeader({
345
341
  );
346
342
  }
347
343
 
348
- function compareUpdatedAt(left: MarketplaceItemSummary, right: MarketplaceItemSummary) {
349
- const leftTs = Date.parse(left.updatedAt);
350
- const rightTs = Date.parse(right.updatedAt);
351
- if (Number.isNaN(leftTs) || Number.isNaN(rightTs)) {
352
- return right.updatedAt.localeCompare(left.updatedAt);
353
- }
354
- return rightTs - leftTs;
355
- }
356
-
357
344
  function readLocalized(text: MarketplaceShelfLocalizedText, language: string) {
358
345
  return language.startsWith("zh") ? text.zh : text.en;
359
346
  }
@@ -6,6 +6,7 @@ import { useI18n } from "@/app/components/i18n-provider";
6
6
  import {
7
7
  useMarketplaceInstalled,
8
8
  useMarketplaceItems,
9
+ useMarketplaceRecentItems,
9
10
  } from "@/features/marketplace/hooks/use-marketplace";
10
11
  import {
11
12
  FilterPanel,
@@ -77,6 +78,10 @@ export function MarketplacePage({
77
78
  sort,
78
79
  pageSize: PAGE_SIZE,
79
80
  });
81
+ const recentItemsQuery = useMarketplaceRecentItems(
82
+ typeFilter,
83
+ scope === "all" && !searchText.trim() && !query && !sceneParam,
84
+ );
80
85
 
81
86
  const {
82
87
  containerRef: listContainerRef,
@@ -115,6 +120,7 @@ export function MarketplacePage({
115
120
 
116
121
  const curatedSceneRoute = useMarketplaceCuratedSceneRoute({
117
122
  items: listModel.allItems,
123
+ recentItems: recentItemsQuery.data?.items ?? [],
118
124
  installedRecordLookup: listModel.installedRecordLookup,
119
125
  scene: sceneParam,
120
126
  forcedType,
@@ -201,10 +207,12 @@ export function MarketplacePage({
201
207
 
202
208
  {curatedSceneRoute.showShelves && (
203
209
  <MarketplaceCuratedShelves
204
- entries={curatedSceneRoute.entries}
210
+ recentEntries={curatedSceneRoute.recentEntries}
205
211
  scenes={curatedSceneRoute.scenes}
206
212
  isScenesLoading={curatedSceneRoute.isScenesLoading}
207
- isItemsLoading={listModel.showListSkeleton}
213
+ isItemsLoading={
214
+ listModel.showListSkeleton || recentItemsQuery.isLoading
215
+ }
208
216
  language={language}
209
217
  installState={installState}
210
218
  onOpen={(entry) => void openItemDetail(entry.item, entry.record)}
@@ -1,7 +1,10 @@
1
1
  import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
2
2
  import { renderHook, waitFor } from "@testing-library/react";
3
3
  import type { ReactNode } from "react";
4
- import { useMarketplaceItems } from "@/features/marketplace/hooks/use-marketplace";
4
+ import {
5
+ useMarketplaceItems,
6
+ useMarketplaceRecentItems,
7
+ } from "@/features/marketplace/hooks/use-marketplace";
5
8
  import type { MarketplaceListView } from "@/shared/lib/api";
6
9
 
7
10
  const mocks = vi.hoisted(() => ({
@@ -106,4 +109,26 @@ describe("useMarketplaceItems", () => {
106
109
  expect(result.current.data?.items[0]?.name).toBe("Search Skill");
107
110
  });
108
111
  });
112
+
113
+ it("loads the recently updated shelf from its own updated query", async () => {
114
+ mocks.fetchMarketplaceItems.mockResolvedValueOnce(
115
+ createMarketplaceList("Recently Updated Skill"),
116
+ );
117
+
118
+ const { result } = renderHook(() => useMarketplaceRecentItems("skill"), {
119
+ wrapper: createWrapper(),
120
+ });
121
+
122
+ await waitFor(() => {
123
+ expect(result.current.data?.items[0]?.name).toBe(
124
+ "Recently Updated Skill",
125
+ );
126
+ });
127
+ expect(mocks.fetchMarketplaceItems).toHaveBeenCalledWith({
128
+ type: "skill",
129
+ sort: "updated",
130
+ page: 1,
131
+ pageSize: 6,
132
+ });
133
+ });
109
134
  });
@@ -12,6 +12,7 @@ import { useMemo } from "react";
12
12
 
13
13
  type MarketplaceCuratedSceneRouteParams = {
14
14
  items: MarketplaceItemSummary[];
15
+ recentItems: MarketplaceItemSummary[];
15
16
  installedRecordLookup: Map<string, MarketplaceInstalledRecord>;
16
17
  scene?: string;
17
18
  forcedType?: "skills";
@@ -28,6 +29,7 @@ export function useMarketplaceCuratedSceneRoute(
28
29
  ) {
29
30
  const {
30
31
  items,
32
+ recentItems,
31
33
  installedRecordLookup,
32
34
  scene,
33
35
  forcedType,
@@ -62,6 +64,14 @@ export function useMarketplaceCuratedSceneRoute(
62
64
  })),
63
65
  [items, installedRecordLookup],
64
66
  );
67
+ const recentEntries = useMemo(
68
+ () =>
69
+ recentItems.map((item) => ({
70
+ item,
71
+ record: findInstalledRecordForItem(item, installedRecordLookup),
72
+ })),
73
+ [installedRecordLookup, recentItems],
74
+ );
65
75
  const selectedScene = useMemo(() => {
66
76
  const normalizedScene = scene?.trim();
67
77
  if (!normalizedScene) {
@@ -94,6 +104,7 @@ export function useMarketplaceCuratedSceneRoute(
94
104
 
95
105
  return {
96
106
  entries,
107
+ recentEntries,
97
108
  scenes,
98
109
  selectedScene,
99
110
  sceneEntries: entries,
@@ -103,7 +103,7 @@ export function useMarketplaceListModel(params: MarketplaceListModelParams) {
103
103
  ? t("loading")
104
104
  : `${installedEntries.length} ${t("marketplaceInstalledSkillsCountSuffix")}`
105
105
  : catalogView
106
- ? `${allItems.length} / ${catalogView.total}`
106
+ ? `${catalogView.total} ${t("marketplaceSkillsCountSuffix")}`
107
107
  : t("loading");
108
108
  const error =
109
109
  scope === "all" && catalogError