@nextclaw/ui 0.15.5 → 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 (169) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/dist/assets/api-V8m62-13.js +13 -0
  3. package/dist/assets/app-presenter-provider-Bzj9h_ZS.js +106 -0
  4. package/dist/assets/{appearance-settings-page-80fLa4-9.js → appearance-settings-page-DwBwu1AU.js} +1 -1
  5. package/dist/assets/{book-open-CN7Shpij.js → book-open-DtVSr5O9.js} +1 -1
  6. package/dist/assets/{channels-list-page-R7M6pzp6.js → channels-list-page-CYIqMliJ.js} +2 -2
  7. package/dist/assets/{chat-page-152YuwfU.js → chat-page-BC0icTtI.js} +1 -1
  8. package/dist/assets/{config-split-page-BlfvfmTm.js → config-split-page-B88prd8s.js} +1 -1
  9. package/dist/assets/{confirm-dialog-Dt8Kdbgp.js → confirm-dialog-BB09IC2u.js} +1 -1
  10. package/dist/assets/desktop-update-config-B-q0OhxO.js +1 -0
  11. package/dist/assets/{dist-D5ycbwCo.js → dist-Bytser-a.js} +1 -1
  12. package/dist/assets/{doc-browser-C9u7N9vH.js → doc-browser-B1FXvZm6.js} +1 -1
  13. package/dist/assets/doc-browser-Bodhf0fj2.js +1 -0
  14. package/dist/assets/{doc-browser-context-CCdW9TJf.js → doc-browser-context-D5VLNoj3.js} +1 -1
  15. package/dist/assets/{download-CyPgglKr.js → download-CdO6NoHj.js} +1 -1
  16. package/dist/assets/{external-link-BahW1sl3.js → external-link-C5NFoh5I.js} +1 -1
  17. package/dist/assets/{eye-D8gGXMkD.js → eye-B51oNRRc.js} +1 -1
  18. package/dist/assets/{folder-DGWqFDvE.js → folder-DxH7Nef-.js} +1 -1
  19. package/dist/assets/{form-actions-D2imZLnU.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-CuPetu-v.js → logo-badge-D2vORX3a.js} +1 -1
  24. package/dist/assets/mcp-marketplace-page-CGVu7mfL.js +9 -0
  25. package/dist/assets/mcp-marketplace-page-DnV0e1JS.js +1 -0
  26. package/dist/assets/{model-config-page-DiJcvBML.js → model-config-page-DYpchGVE.js} +1 -1
  27. package/dist/assets/plus-BgMBV9HJ.js +1 -0
  28. package/dist/assets/{popover-Btp7KVc3.js → popover-CONujDHf.js} +1 -1
  29. package/dist/assets/{provider-scoped-model-input-DZMtoAJG.js → provider-scoped-model-input-DA3Ua9v8.js} +1 -1
  30. package/dist/assets/providers-config-page-DXsoyMrh.js +1 -0
  31. package/dist/assets/{react-BY-U3h3t.js → react-Io6Pfq8D.js} +1 -1
  32. package/dist/assets/{refresh-cw-Cme5qFej.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-eYrrKBrA.js +1 -0
  36. package/dist/assets/{save-DNg2yJTS.js → save-CBBZVvZ-.js} +1 -1
  37. package/dist/assets/{search-3ykfrAFt.js → search-DFF842z2.js} +1 -1
  38. package/dist/assets/{search-config-page-DrVKa_Ls.js → search-config-page-DUyuwD7d.js} +1 -1
  39. package/dist/assets/{secrets-config-page-B39uQ5kF.js → secrets-config-page-DwzKQJ76.js} +2 -2
  40. package/dist/assets/security-config-D8me3pXl.js +1 -0
  41. package/dist/assets/{select-B5EecXJt.js → select-BiOpo2kV.js} +1 -1
  42. package/dist/assets/{setting-row-Qv1Ps79v.js → setting-row-C3WQXLtS.js} +1 -1
  43. package/dist/assets/{settings-2-D-3um9Az.js → settings-2-DyOvA_6K.js} +1 -1
  44. package/dist/assets/{skeleton-BgkzQd4U.js → skeleton-CQIAEbA4.js} +1 -1
  45. package/dist/assets/{tabs-BVD8gp3y.js → tabs-BTSENlI2.js} +1 -1
  46. package/dist/assets/{tag-chip-0qn-f3Bo.js → tag-chip-BG5JIGwY.js} +1 -1
  47. package/dist/assets/{trash-2-cnsMppRC.js → trash-2-CTIfwpt1.js} +1 -1
  48. package/dist/assets/use-config-CY4uF2Ou.js +1 -0
  49. package/dist/assets/{use-confirm-dialog-C_o0eQeH.js → use-confirm-dialog-Bj4u78F9.js} +1 -1
  50. package/dist/assets/{use-viewport-layout-BWav4KCL.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 +97 -3
  56. package/src/features/chat/components/conversation/chat-conversation-header-section.tsx +13 -2
  57. package/src/features/chat/components/layout/__tests__/chat-sidebar-test.utils.ts +22 -0
  58. package/src/features/chat/components/layout/__tests__/chat-sidebar.test.tsx +13 -24
  59. package/src/features/chat/components/layout/chat-sidebar-desktop-layout.tsx +16 -4
  60. package/src/features/chat/components/layout/chat-sidebar.tsx +38 -2
  61. package/src/features/chat/features/conversation/components/__tests__/session-conversation-area.test.tsx +1 -0
  62. package/src/features/chat/features/conversation/components/__tests__/session-conversation-input.streaming.test.tsx +70 -2
  63. package/src/features/chat/features/conversation/components/session-conversation-area.tsx +22 -0
  64. package/src/features/chat/features/conversation/components/session-conversation-input.tsx +8 -9
  65. package/src/features/chat/features/conversation/hooks/__tests__/use-session-conversation-input-state.test.tsx +55 -0
  66. package/src/features/chat/features/conversation/hooks/use-session-conversation-input-state.ts +12 -11
  67. package/src/features/chat/features/conversation/hooks/use-session-conversation-preference-actions.ts +94 -0
  68. package/src/features/chat/features/input/hooks/__tests__/use-chat-input-surface-state.test.tsx +82 -0
  69. package/src/features/chat/features/input/hooks/use-chat-input-surface-state.ts +187 -63
  70. package/src/features/chat/features/input/input-surface-plugins/__tests__/context-reference-plugin.test.ts +225 -0
  71. package/src/features/chat/features/input/input-surface-plugins/__tests__/slash-command-plugin.test.ts +23 -0
  72. package/src/features/chat/features/input/input-surface-plugins/chat-input-product-plugin-adapters.types.ts +6 -1
  73. package/src/features/chat/features/input/input-surface-plugins/context-reference-plugin.utils.ts +357 -0
  74. package/src/features/chat/features/input/input-surface-plugins/slash-command-plugin.utils.ts +4 -1
  75. package/src/features/chat/features/input/utils/__tests__/chat-composer-state.utils.test.ts +26 -0
  76. package/src/features/chat/features/input/utils/__tests__/chat-inline-token.utils.test.ts +67 -5
  77. package/src/features/chat/features/input/utils/chat-composer-state.utils.ts +4 -10
  78. package/src/features/chat/features/input/utils/chat-composer-token-protocol.utils.ts +29 -0
  79. package/src/features/chat/features/input/utils/chat-inline-token.utils.ts +83 -8
  80. package/src/features/chat/features/input/utils/chat-input-bar.utils.ts +1 -0
  81. package/src/features/chat/features/message/components/__tests__/chat-inline-file-preview.test.tsx +159 -0
  82. package/src/features/chat/features/message/components/__tests__/chat-message-list.container.test.tsx +92 -26
  83. package/src/features/chat/features/message/components/chat-inline-file-preview.tsx +90 -21
  84. package/src/features/chat/features/message/components/chat-message-list.container.tsx +24 -3
  85. package/src/features/chat/features/message/utils/chat-message-texts.utils.ts +1 -0
  86. package/src/features/chat/features/ncp/hooks/__tests__/use-hydrated-ncp-agent.test.tsx +96 -3
  87. package/src/features/chat/features/project/components/__tests__/chat-project-create-dialog.test.tsx +67 -0
  88. package/src/features/chat/features/project/components/chat-project-create-dialog.tsx +203 -0
  89. package/src/features/chat/features/session/components/session-header/__tests__/chat-session-header-actions.test.tsx +7 -7
  90. package/src/features/chat/features/session/components/session-header/__tests__/chat-session-project-dialog.test.tsx +47 -0
  91. package/src/features/chat/features/session/components/session-header/chat-session-header-actions.tsx +6 -3
  92. package/src/features/chat/features/session/components/session-header/chat-session-project-badge.tsx +3 -0
  93. package/src/features/chat/features/session/components/session-header/chat-session-project-dialog.tsx +3 -0
  94. package/src/features/chat/features/session/components/session-header/chat-session-switcher-groups.tsx +245 -0
  95. package/src/features/chat/features/session/components/session-header/chat-session-title-switcher.tsx +82 -128
  96. package/src/features/chat/features/session/utils/__tests__/chat-sidebar-session-groups.utils.test.ts +22 -0
  97. package/src/features/chat/features/session/utils/chat-run-metadata.utils.ts +6 -6
  98. package/src/features/chat/features/session/utils/chat-sidebar-session-groups.utils.ts +12 -1
  99. package/src/features/chat/features/welcome/components/chat-conversation-welcome.tsx +3 -0
  100. package/src/features/chat/features/welcome/components/chat-welcome.tsx +1 -0
  101. package/src/features/chat/features/welcome/utils/__tests__/chat-welcome-project-options.utils.test.ts +20 -0
  102. package/src/features/chat/features/welcome/utils/chat-welcome-project-options.utils.ts +15 -2
  103. package/src/features/chat/features/workspace/components/__tests__/chat-session-workspace-file-preview.test.tsx +5 -6
  104. package/src/features/chat/features/workspace/components/__tests__/workspace-file-content-preview.test.tsx +67 -0
  105. package/src/features/chat/features/workspace/components/chat-session-workspace-file-preview.tsx +9 -7
  106. package/src/features/chat/features/workspace/components/workspace-file-content-preview.tsx +95 -4
  107. package/src/features/chat/pages/__tests__/ncp-chat-page.test.ts +23 -0
  108. package/src/features/marketplace/components/__tests__/marketplace-page-detail.test.tsx +1 -0
  109. package/src/features/marketplace/components/__tests__/marketplace-page.test.tsx +62 -0
  110. package/src/features/marketplace/components/curated-shelves/__tests__/marketplace-curated-scene-route.test.tsx +1 -0
  111. package/src/features/marketplace/components/curated-shelves/marketplace-curated-shelves.tsx +2 -15
  112. package/src/features/marketplace/components/marketplace-page.tsx +10 -2
  113. package/src/features/marketplace/hooks/__tests__/use-marketplace.test.tsx +26 -1
  114. package/src/features/marketplace/hooks/use-marketplace-curated-scene-route.ts +11 -0
  115. package/src/features/marketplace/hooks/use-marketplace-list-model.ts +1 -1
  116. package/src/features/marketplace/hooks/use-marketplace.ts +28 -1
  117. package/src/features/settings/components/config/provider-form.tsx +0 -4
  118. package/src/features/settings/utils/__tests__/provider-form-support.utils.test.ts +7 -9
  119. package/src/features/settings/utils/provider-form-model.utils.ts +0 -4
  120. package/src/features/system-status/hooks/use-current-version-release-notes-link.ts +33 -0
  121. package/src/features/system-status/index.ts +9 -0
  122. package/src/features/system-status/utils/update-release-notes.utils.ts +44 -3
  123. package/src/index.css +10 -16
  124. package/src/shared/components/common/__tests__/brand-header.test.tsx +110 -3
  125. package/src/shared/components/common/brand-header.tsx +121 -35
  126. package/src/shared/components/path-picker/__tests__/server-path-picker-dialog.test.tsx +288 -38
  127. package/src/shared/components/path-picker/server-path-picker-dialog.tsx +290 -199
  128. package/src/shared/components/path-picker/server-path-picker-directory-list.tsx +103 -0
  129. package/src/shared/components/path-picker/server-path-picker-footer.tsx +58 -0
  130. package/src/shared/components/path-picker/server-path-picker-locations.tsx +148 -0
  131. package/src/shared/components/path-picker/server-path-picker-navigation-bar.tsx +198 -0
  132. package/src/shared/components/path-picker/server-path-picker-new-folder.tsx +59 -0
  133. package/src/shared/components/path-picker/server-path-picker-toolbar.tsx +25 -0
  134. package/src/shared/hooks/use-projects.ts +28 -0
  135. package/src/shared/hooks/use-server-path-create-directory.ts +6 -0
  136. package/src/shared/hooks/use-server-path-search.ts +40 -0
  137. package/src/shared/lib/api/index.ts +1 -0
  138. package/src/shared/lib/api/projects/project.utils.ts +14 -0
  139. package/src/shared/lib/api/types.ts +27 -0
  140. package/src/shared/lib/api/utils/server-path.utils.ts +21 -1
  141. package/src/shared/lib/i18n/locales/en-US/chat.json +40 -2
  142. package/src/shared/lib/i18n/locales/en-US/core.json +2 -3
  143. package/src/shared/lib/i18n/locales/en-US/desktop-update.json +5 -1
  144. package/src/shared/lib/i18n/locales/en-US/marketplace.json +1 -0
  145. package/src/shared/lib/i18n/locales/en-US/path-picker.json +25 -0
  146. package/src/shared/lib/i18n/locales/zh-CN/chat.json +40 -2
  147. package/src/shared/lib/i18n/locales/zh-CN/core.json +2 -3
  148. package/src/shared/lib/i18n/locales/zh-CN/desktop-update.json +5 -1
  149. package/src/shared/lib/i18n/locales/zh-CN/marketplace.json +1 -0
  150. package/src/shared/lib/i18n/locales/zh-CN/path-picker.json +25 -0
  151. package/dist/assets/api-IlX_CbwU.js +0 -13
  152. package/dist/assets/app-presenter-provider-Bbl_MVbg.js +0 -106
  153. package/dist/assets/desktop-update-config-DreMJu9z.js +0 -1
  154. package/dist/assets/doc-browser-DJQ5XPsi2.js +0 -1
  155. package/dist/assets/index-0av6WO9-.css +0 -1
  156. package/dist/assets/index-CT0iYlxV.js +0 -2
  157. package/dist/assets/loader-circle-DtKek-qG.js +0 -1
  158. package/dist/assets/mcp-marketplace-page-4qMwFDpz.js +0 -1
  159. package/dist/assets/mcp-marketplace-page-BbxN5aZF.js +0 -9
  160. package/dist/assets/plus-BwgE-Zwf.js +0 -1
  161. package/dist/assets/providers-config-page-DfiWveEF.js +0 -1
  162. package/dist/assets/remote-BaVECEZw.js +0 -1
  163. package/dist/assets/rotate-cw-CMRZxIkH.js +0 -1
  164. package/dist/assets/runtime-config-page-wygwvMB4.js +0 -1
  165. package/dist/assets/security-config-BPzet76z.js +0 -1
  166. package/dist/assets/use-config-D6yx0lb0.js +0 -1
  167. package/dist/assets/x-B3o1dWR5.js +0 -1
  168. package/src/features/chat/features/input/input-surface-plugins/__tests__/panel-app-reference-plugin.test.ts +0 -77
  169. package/src/features/chat/features/input/input-surface-plugins/panel-app-reference-plugin.utils.ts +0 -144
@@ -1,4 +1,4 @@
1
- import { render, screen } from "@testing-library/react";
1
+ import { fireEvent, render, screen } from "@testing-library/react";
2
2
  import { isValidElement } from "react";
3
3
  import type { NcpMessage } from "@nextclaw/ncp";
4
4
  import { beforeEach, expect, it, vi } from "vitest";
@@ -20,6 +20,7 @@ const captures = vi.hoisted(() => ({
20
20
  handleToolAction: vi.fn(),
21
21
  showContent: vi.fn(),
22
22
  filePreviewProps: [] as Array<{ showBreadcrumbs?: boolean }>,
23
+ selectedSession: null as null | { projectRoot: string; workingDir?: string | null },
23
24
  }));
24
25
 
25
26
  vi.mock("@nextclaw/agent-chat-ui", async (importOriginal) => {
@@ -31,6 +32,7 @@ vi.mock("@nextclaw/agent-chat-ui", async (importOriginal) => {
31
32
  onToolAction?: (action: unknown) => void;
32
33
  onFileOpen?: (action: unknown) => void;
33
34
  onAttachmentOpen?: (file: unknown) => void;
35
+ onInlineTokenClick?: (token: unknown) => void;
34
36
  renderInlineDisplay?: (display: unknown) => unknown;
35
37
  renderPanelAppCard?: (panelApp: unknown) => unknown;
36
38
  texts?: Record<string, unknown>;
@@ -53,6 +55,10 @@ vi.mock("@/features/chat/components/providers/chat-presenter.provider", () => ({
53
55
  }),
54
56
  }));
55
57
 
58
+ vi.mock("@/features/chat/features/ncp/hooks/use-ncp-chat-derived-state", () => ({
59
+ useNcpChatSelectedSession: () => captures.selectedSession,
60
+ }));
61
+
56
62
  vi.mock("@/app/components/i18n-provider", () => ({
57
63
  useI18n: () => ({ language: captures.language }),
58
64
  }));
@@ -81,6 +87,7 @@ beforeEach(() => {
81
87
  captures.handleToolAction.mockReset();
82
88
  captures.showContent.mockReset();
83
89
  captures.filePreviewProps = [];
90
+ captures.selectedSession = null;
84
91
  });
85
92
 
86
93
  it("reuses adapted message references when the source message object is unchanged", () => {
@@ -100,9 +107,7 @@ it("reuses adapted message references when the source message object is unchange
100
107
  const firstMessages =
101
108
  captures.renders[captures.renders.length - 1]?.messages ?? [];
102
109
 
103
- rerender(
104
- <ChatMessageListContainer messages={[message]} isSending={false} />,
105
- );
110
+ rerender(<ChatMessageListContainer messages={[message]} isSending={false} />);
106
111
 
107
112
  const secondMessages =
108
113
  captures.renders[captures.renders.length - 1]?.messages ?? [];
@@ -223,7 +228,7 @@ it("adds a completed assistant process summary without inventing a duration", ()
223
228
  toolCallId: "tool-1",
224
229
  toolName: "exec_command",
225
230
  state: "result",
226
- args: "{\"cmd\":\"git status\"}",
231
+ args: '{"cmd":"git status"}',
227
232
  result: "clean",
228
233
  },
229
234
  {
@@ -270,7 +275,7 @@ it("derives completed assistant process duration from message lifecycle", () =>
270
275
  toolCallId: "tool-1",
271
276
  toolName: "exec_command",
272
277
  state: "result",
273
- args: "{\"cmd\":\"git status\"}",
278
+ args: '{"cmd":"git status"}',
274
279
  result: "clean",
275
280
  },
276
281
  {
@@ -322,6 +327,53 @@ it("wires markdown file link actions to the workspace file preview manager", ()
322
327
  expect(captures.openFilePreview).toHaveBeenCalledWith(action);
323
328
  });
324
329
 
330
+ it("opens workspace file and directory tokens from the message", () => {
331
+ captures.selectedSession = {
332
+ projectRoot: "/tmp/project",
333
+ workingDir: "/tmp/project/packages/ui",
334
+ };
335
+ render(<ChatMessageListContainer messages={[]} isSending={false} />);
336
+ const onInlineTokenClick = captures.renders[captures.renders.length - 1]?.onInlineTokenClick;
337
+
338
+ onInlineTokenClick?.({
339
+ kind: "workspace_file",
340
+ key: "docs/guide.md",
341
+ label: "guide.md",
342
+ rawText: "@file:docs%2Fguide.md",
343
+ });
344
+ onInlineTokenClick?.({
345
+ kind: "workspace_directory",
346
+ key: "packages/server",
347
+ label: "server",
348
+ rawText: "@folder:packages%2Fserver",
349
+ });
350
+
351
+ expect(captures.openFilePreview).toHaveBeenNthCalledWith(1, {
352
+ path: "/tmp/project/docs/guide.md",
353
+ label: "guide.md",
354
+ viewMode: "preview",
355
+ });
356
+ expect(captures.openFilePreview).toHaveBeenNthCalledWith(2, {
357
+ path: "/tmp/project/packages/server",
358
+ label: "server",
359
+ viewMode: "preview",
360
+ });
361
+ });
362
+
363
+ it("opens panel app tokens through the existing content owner", () => {
364
+ render(<ChatMessageListContainer messages={[]} isSending={false} />);
365
+ captures.renders[captures.renders.length - 1]?.onInlineTokenClick?.({
366
+ kind: "panel_app",
367
+ key: "task-board",
368
+ label: "Task Board",
369
+ rawText: "@panel-app:task-board",
370
+ });
371
+
372
+ expect(captures.showContent).toHaveBeenCalledWith({
373
+ target: { type: "panel_app", payload: { appId: "task-board" } },
374
+ });
375
+ });
376
+
325
377
  it("opens message attachments in the workspace file preview", () => {
326
378
  const message = {
327
379
  id: "assistant-attachment",
@@ -385,12 +437,16 @@ it("renders context inheritance as a divider without repeating inherited message
385
437
  isSending={false}
386
438
  />,
387
439
  );
388
- const renderedMessages = captures.renders.flatMap((rendered) => rendered.messages);
440
+ const renderedMessages = captures.renders.flatMap(
441
+ (rendered) => rendered.messages,
442
+ );
389
443
  const renderedPayload = JSON.stringify(renderedMessages);
390
444
 
391
445
  expect(screen.getByText("chatContextInheritanceInherited")).toBeTruthy();
392
446
  expect(container.querySelector('[title*="parent-session"]')).toBeTruthy();
393
- expect(container.querySelector('[title*="chatContextInheritanceMessages: 1"]')).toBeTruthy();
447
+ expect(
448
+ container.querySelector('[title*="chatContextInheritanceMessages: 1"]'),
449
+ ).toBeTruthy();
394
450
  expect(renderedPayload).toContain("child visible request");
395
451
  expect(renderedPayload).not.toContain("parent context");
396
452
  });
@@ -416,7 +472,7 @@ it("keeps Hermes tool invocation parts as tool cards instead of flattening them
416
472
  toolCallId: "hermes-inline-tool-1",
417
473
  toolName: "search_files",
418
474
  state: "call",
419
- args: "{\"pattern\":\"*.py\"}",
475
+ args: '{"pattern":"*.py"}',
420
476
  },
421
477
  {
422
478
  type: "text",
@@ -495,10 +551,12 @@ it("delegates tool actions to the chat thread manager owner", () => {
495
551
  it("passes the inline panel app renderer to the shared chat UI", () => {
496
552
  render(<ChatMessageListContainer messages={[]} isSending={false} />);
497
553
 
498
- expect(captures.renders[captures.renders.length - 1]?.renderPanelAppCard).toEqual(expect.any(Function));
554
+ expect(
555
+ captures.renders[captures.renders.length - 1]?.renderPanelAppCard,
556
+ ).toEqual(expect.any(Function));
499
557
  });
500
558
 
501
- it("renders inline file displays through the workspace file preview", () => {
559
+ it("connects inline HTML actions to the chat thread manager", () => {
502
560
  render(<ChatMessageListContainer messages={[]} isSending={false} />);
503
561
 
504
562
  const renderInlineDisplay =
@@ -514,24 +572,30 @@ it("renders inline file displays through the workspace file preview", () => {
514
572
  description: "Rendered HTML",
515
573
  });
516
574
 
517
- if (!isValidElement<{ display?: unknown }>(rendered)) {
575
+ if (!isValidElement(rendered)) {
518
576
  throw new Error("Expected inline file renderer to return a React element");
519
577
  }
520
- expect(rendered.props.display).toEqual({
521
- target: {
522
- type: "file",
523
- payload: { path: "preview.html", viewer: "rendered" },
524
- },
525
- title: "Preview",
526
- description: "Rendered HTML",
527
- });
528
-
529
578
  render(rendered);
530
579
 
531
- expect(screen.getByText("Preview")).toBeTruthy();
532
- expect(screen.getByText("preview.html")).toBeTruthy();
533
- expect(screen.queryByText("Rendered HTML")).toBeNull();
534
- expect(captures.filePreviewProps.at(-1)?.showBreadcrumbs).toBe(false);
580
+ fireEvent.click(screen.getByLabelText("chatPanelCardExpand"));
581
+ fireEvent.click(screen.getByLabelText("chatWorkspaceOpenSource"));
582
+
583
+ expect(captures.openFilePreview).toHaveBeenNthCalledWith(1, {
584
+ path: "preview.html",
585
+ label: "Preview",
586
+ viewMode: "preview",
587
+ previewViewer: "rendered",
588
+ line: undefined,
589
+ column: undefined,
590
+ });
591
+ expect(captures.openFilePreview).toHaveBeenNthCalledWith(2, {
592
+ path: "preview.html",
593
+ label: "Preview",
594
+ viewMode: "preview",
595
+ previewViewer: "source",
596
+ line: undefined,
597
+ column: undefined,
598
+ });
535
599
  });
536
600
 
537
601
  it("keeps inline panel app displays expandable from the card header", () => {
@@ -548,7 +612,9 @@ it("keeps inline panel app displays expandable from the card header", () => {
548
612
  });
549
613
 
550
614
  if (!isValidElement<{ showExpandAction?: boolean }>(rendered)) {
551
- throw new Error("Expected inline panel app renderer to return a React element");
615
+ throw new Error(
616
+ "Expected inline panel app renderer to return a React element",
617
+ );
552
618
  }
553
619
  expect(rendered.props.showExpandAction).toBeUndefined();
554
620
  });
@@ -1,10 +1,14 @@
1
+ import { useState } from "react";
1
2
  import type {
2
3
  ChatFileOpenActionViewModel,
3
4
  ChatInlineDisplayViewModel,
4
5
  } from "@nextclaw/agent-chat-ui";
5
- import { FileText } from "lucide-react";
6
+ import { Code2, FileText, PanelRightOpen } from "lucide-react";
6
7
  import { ChatSessionWorkspaceFilePreview } from "@/features/chat/features/workspace/components/chat-session-workspace-file-preview";
7
8
  import { createWorkspaceFileTab } from "@/features/chat/features/workspace/utils/chat-workspace-file-tab.utils";
9
+ import { IconActionButton } from "@/shared/components/ui/actions/icon-action-button";
10
+ import { t } from "@/shared/lib/i18n";
11
+ import { cn } from "@/shared/lib/utils";
8
12
 
9
13
  type ChatInlineFilePreviewProps = {
10
14
  display: ChatInlineDisplayViewModel;
@@ -14,9 +18,7 @@ type ChatInlineFilePreviewProps = {
14
18
  onFileOpen: (action: ChatFileOpenActionViewModel) => void;
15
19
  };
16
20
 
17
- function readFileName(path: string): string {
18
- return path.split(/[\\/]/).filter(Boolean).pop() ?? path;
19
- }
21
+ const INLINE_HTML_MIN_HEIGHT = 240;
20
22
 
21
23
  export function ChatInlineFilePreview({
22
24
  display,
@@ -25,6 +27,9 @@ export function ChatInlineFilePreview({
25
27
  sessionWorkingDir,
26
28
  onFileOpen,
27
29
  }: ChatInlineFilePreviewProps) {
30
+ const [htmlContentHeight, setHtmlContentHeight] = useState<number | null>(
31
+ null,
32
+ );
28
33
  if (display.target.type !== "file") {
29
34
  return null;
30
35
  }
@@ -43,35 +48,99 @@ export function ChatInlineFilePreview({
43
48
  if (!file) {
44
49
  return null;
45
50
  }
46
- const title = display.title ?? readFileName(file.path);
51
+ const title =
52
+ file.label ?? file.path.split(/[\\/]/).filter(Boolean).pop() ?? file.path;
53
+ const isRenderedHtml =
54
+ file.previewViewer === "rendered" && /\.html?$/i.test(file.path);
55
+ const openInWorkspace = (previewViewer: "rendered" | "source") => {
56
+ onFileOpen({
57
+ path: file.path,
58
+ label: file.label ?? undefined,
59
+ viewMode: "preview",
60
+ previewViewer,
61
+ line: file.line ?? undefined,
62
+ column: file.column ?? undefined,
63
+ });
64
+ };
47
65
 
48
66
  return (
49
67
  <section
50
- className="my-2 w-full max-w-[48rem] overflow-hidden rounded-lg border border-border bg-card shadow-sm"
68
+ className={cn(
69
+ "my-2 w-full max-w-[48rem]",
70
+ isRenderedHtml
71
+ ? "group/inline-html relative overflow-visible"
72
+ : "overflow-hidden rounded-lg border border-border bg-card shadow-sm",
73
+ )}
51
74
  data-chat-inline-file-preview="true"
52
75
  data-chat-message-wide-content="true"
53
76
  >
54
- <header className="border-b border-border bg-muted/45 px-3 py-2">
55
- <div className="flex min-w-0 items-center gap-2 text-xs font-medium text-foreground">
56
- <FileText
57
- className="h-3.5 w-3.5 shrink-0 text-muted-foreground"
58
- aria-hidden
59
- />
60
- <span className="truncate">{title}</span>
61
- </div>
62
- <p
63
- className="mt-1 truncate font-mono text-[11px] font-normal text-muted-foreground"
64
- title={file.path}
77
+ {isRenderedHtml ? (
78
+ <div
79
+ className={cn(
80
+ "pointer-events-none absolute bottom-full left-1/2 z-10 -translate-x-1/2 pb-2 opacity-0 transition-opacity duration-150",
81
+ "group-hover/inline-html:pointer-events-auto group-hover/inline-html:opacity-100 group-focus-within/inline-html:pointer-events-auto group-focus-within/inline-html:opacity-100",
82
+ )}
83
+ data-chat-inline-file-actions="true"
65
84
  >
66
- {file.path}
67
- </p>
68
- </header>
69
- <div className="h-[420px] min-h-[240px] max-h-[min(60vh,420px)] overflow-hidden">
85
+ <div
86
+ className="flex items-center gap-0.5 rounded-lg bg-background/95 p-0.5 shadow-md ring-1 ring-border/60 backdrop-blur-sm"
87
+ data-chat-inline-file-actions-surface="true"
88
+ >
89
+ <IconActionButton
90
+ size="sm"
91
+ icon={<PanelRightOpen className="h-3.5 w-3.5" />}
92
+ label={t("chatPanelCardExpand")}
93
+ tooltipSide="top"
94
+ onClick={() => openInWorkspace("rendered")}
95
+ />
96
+ <IconActionButton
97
+ size="sm"
98
+ icon={<Code2 className="h-3.5 w-3.5" />}
99
+ label={t("chatWorkspaceOpenSource")}
100
+ tooltipSide="top"
101
+ onClick={() => openInWorkspace("source")}
102
+ />
103
+ </div>
104
+ </div>
105
+ ) : (
106
+ <header className="border-b border-border bg-muted/45 px-3 py-2">
107
+ <div className="flex min-w-0 items-center gap-2 text-xs font-medium text-foreground">
108
+ <FileText
109
+ className="h-3.5 w-3.5 shrink-0 text-muted-foreground"
110
+ aria-hidden
111
+ />
112
+ <span className="truncate">{title}</span>
113
+ </div>
114
+ <p
115
+ className="mt-1 truncate font-mono text-[11px] font-normal text-muted-foreground"
116
+ title={file.path}
117
+ >
118
+ {file.path}
119
+ </p>
120
+ </header>
121
+ )}
122
+ <div
123
+ className={cn(
124
+ "min-h-[240px] overflow-hidden",
125
+ isRenderedHtml
126
+ ? "h-[240px] max-h-[min(80vh,720px)] rounded-lg"
127
+ : "h-[420px] max-h-[min(60vh,420px)]",
128
+ )}
129
+ data-chat-inline-file-viewport="true"
130
+ style={
131
+ isRenderedHtml && htmlContentHeight
132
+ ? { height: Math.max(INLINE_HTML_MIN_HEIGHT, htmlContentHeight) }
133
+ : undefined
134
+ }
135
+ >
70
136
  <ChatSessionWorkspaceFilePreview
71
137
  file={file}
72
138
  sessionProjectRoot={sessionProjectRoot}
73
139
  sessionWorkingDir={sessionWorkingDir}
74
140
  showBreadcrumbs={false}
141
+ onHtmlContentHeightChange={
142
+ isRenderedHtml ? setHtmlContentHeight : undefined
143
+ }
75
144
  onFileOpen={onFileOpen}
76
145
  />
77
146
  </div>
@@ -14,7 +14,10 @@ import {
14
14
  buildChatMessageAdapterTexts,
15
15
  buildChatMessageTexts,
16
16
  } from "@/features/chat/features/message/utils/chat-message-texts.utils";
17
- import { readInlineTokensFromMetadata } from "@/features/chat/features/input/utils/chat-inline-token.utils";
17
+ import {
18
+ readInlineTokensFromMetadata,
19
+ resolveWorkspaceReferencePath,
20
+ } from "@/features/chat/features/input/utils/chat-inline-token.utils";
18
21
  import { adaptNcpMessageToUiMessage } from "@/features/chat/features/session/utils/ncp-session-adapter.utils";
19
22
  import {
20
23
  readContextCompactionTimeline,
@@ -362,9 +365,27 @@ export function ChatMessageListContainer({
362
365
  const sessionSkillsQuery = useChatQueryStore((state) => state.snapshot.sessionSkillsQuery);
363
366
  const handleInlineTokenClick = useCallback(
364
367
  (token: ChatInlineTokenViewModel) => {
365
- if (token.kind !== "skill") {
368
+ if (token.kind === "panel_app") {
369
+ void presenter.chatUiManager.showContent({
370
+ target: { type: "panel_app", payload: { appId: token.key } },
371
+ });
372
+ return;
373
+ }
374
+ if (token.kind === "workspace_file" || token.kind === "workspace_directory") {
375
+ const path = resolveWorkspaceReferencePath({
376
+ projectRoot: selectedSession?.projectRoot,
377
+ relativePath: token.key,
378
+ });
379
+ if (path) {
380
+ presenter.chatThreadManager.openFilePreview({
381
+ path,
382
+ label: token.label,
383
+ viewMode: "preview",
384
+ });
385
+ }
366
386
  return;
367
387
  }
388
+ if (token.kind !== "skill") return;
368
389
  const skillKey = token.key.trim();
369
390
  if (!skillKey) {
370
391
  return;
@@ -382,7 +403,7 @@ export function ChatMessageListContainer({
382
403
  previewViewer: "rendered",
383
404
  });
384
405
  },
385
- [presenter.chatThreadManager, sessionSkillsQuery],
406
+ [presenter.chatThreadManager, presenter.chatUiManager, selectedSession?.projectRoot, sessionSkillsQuery],
386
407
  );
387
408
  const handleAttachmentOpen = useCallback(
388
409
  (file: { label: string; mimeType: string; dataUrl?: string; sizeBytes?: number; isImage: boolean }) => {
@@ -107,6 +107,7 @@ export function buildChatMessageTexts(
107
107
  copyMessageLabel: t("chatMessageCopy"),
108
108
  copiedMessageLabel: t("chatMessageCopied"),
109
109
  mermaidDiagramLabel: t("chatMermaidDiagram"),
110
+ mermaidLoadingLabel: t("chatMermaidLoading"),
110
111
  mermaidRenderErrorLabel: t("chatMermaidRenderError"),
111
112
  typingLabel: t("chatTyping"),
112
113
  reasoningCharacterCountTemplates: {
@@ -1,19 +1,57 @@
1
- import { renderHook, waitFor } from "@testing-library/react";
2
- import type { NcpAgentClientEndpoint } from "@nextclaw/ncp";
3
- import { useHydratedNcpAgent } from "@nextclaw/ncp-react";
1
+ import { act, renderHook, waitFor } from "@testing-library/react";
2
+ import { NcpEventType, type NcpAgentClientEndpoint } from "@nextclaw/ncp";
3
+ import { useHydratedNcpAgent, useNcpAgentRuntime } from "@nextclaw/ncp-react";
4
+ import { DefaultNcpAgentConversationStateManager } from "@nextclaw/ncp-toolkit";
4
5
  import { beforeEach, describe, expect, it, vi } from "vitest";
5
6
 
6
7
  const mocks = vi.hoisted(() => ({
8
+ send: vi.fn(),
7
9
  stream: vi.fn(),
8
10
  stop: vi.fn(),
9
11
  }));
10
12
 
11
13
  describe("useHydratedNcpAgent", () => {
12
14
  beforeEach(() => {
15
+ mocks.send.mockReset();
13
16
  mocks.stream.mockReset();
14
17
  mocks.stop.mockReset();
15
18
  });
16
19
 
20
+ it("clears a draft conversation error when a valid retry starts", async () => {
21
+ const client = {
22
+ send: mocks.send.mockResolvedValue(null),
23
+ stop: mocks.stop.mockResolvedValue(undefined),
24
+ stream: mocks.stream.mockResolvedValue(undefined),
25
+ subscribe: vi.fn(() => () => {}),
26
+ } as unknown as NcpAgentClientEndpoint;
27
+ const manager = new DefaultNcpAgentConversationStateManager();
28
+ const { result } = renderHook(() =>
29
+ useNcpAgentRuntime({
30
+ client,
31
+ manager,
32
+ }),
33
+ );
34
+
35
+ await act(async () => {
36
+ await manager.dispatch({
37
+ occurredAt: new Date().toISOString(),
38
+ type: NcpEventType.EndpointError,
39
+ payload: {
40
+ code: "runtime-error",
41
+ message: "network error",
42
+ },
43
+ });
44
+ });
45
+ expect(result.current.snapshot.error?.message).toBe("network error");
46
+
47
+ await act(async () => {
48
+ await result.current.send("retry");
49
+ });
50
+
51
+ expect(result.current.snapshot.error).toBeNull();
52
+ expect(mocks.send).toHaveBeenCalledOnce();
53
+ });
54
+
17
55
  it("treats a newly selected session as hydrating immediately on rerender", async () => {
18
56
  const client = {
19
57
  stop: mocks.stop.mockResolvedValue(undefined),
@@ -54,4 +92,59 @@ describe("useHydratedNcpAgent", () => {
54
92
  expect(mocks.stream).toHaveBeenCalledWith({ sessionId: "session-b" });
55
93
  expect(mocks.stream).toHaveBeenCalledTimes(2);
56
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
+ });
57
150
  });
@@ -0,0 +1,67 @@
1
+ import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
2
+ import userEvent from '@testing-library/user-event';
3
+ import { describe, expect, it, vi } from 'vitest';
4
+ import { ChatProjectCreateDialog } from '@/features/chat/features/project/components/chat-project-create-dialog';
5
+ import { setLanguage } from '@/shared/lib/i18n';
6
+
7
+ const pathPickerSpy = vi.fn();
8
+
9
+ vi.mock('@/shared/components/path-picker/server-path-picker-dialog', () => ({
10
+ ServerPathPickerDialog: (props: unknown) => pathPickerSpy(props),
11
+ }));
12
+
13
+ describe('ChatProjectCreateDialog', () => {
14
+ it('selects the optional target directory with the shared path picker', async () => {
15
+ setLanguage('zh');
16
+ const user = userEvent.setup();
17
+ const onCreate = vi.fn(async () => undefined);
18
+
19
+ render(
20
+ <ChatProjectCreateDialog
21
+ open
22
+ defaultWorkspacePath="/workspace"
23
+ templates={[
24
+ { id: 'empty', name: 'Empty', description: 'Empty project' },
25
+ {
26
+ id: 'knowledge-base',
27
+ name: 'Knowledge base',
28
+ description: 'Knowledge base project',
29
+ },
30
+ ]}
31
+ isCreating={false}
32
+ onOpenChange={() => {}}
33
+ onCreate={onCreate}
34
+ />,
35
+ );
36
+
37
+ await user.type(screen.getByLabelText('项目名称'), '个人知识库');
38
+ fireEvent.change(document.querySelector('select')!, {
39
+ target: { value: 'knowledge-base' },
40
+ });
41
+ await user.click(screen.getByRole('button', { name: '浏览' }));
42
+ expect(pathPickerSpy).toHaveBeenLastCalledWith(
43
+ expect.objectContaining({
44
+ open: true,
45
+ currentPath: '',
46
+ defaultWorkspacePath: '/workspace',
47
+ title: '选择项目目标目录',
48
+ }),
49
+ );
50
+ const pickerProps = pathPickerSpy.mock.lastCall?.[0] as {
51
+ onConfirm: (path: string) => void;
52
+ };
53
+ act(() => pickerProps.onConfirm('/srv/knowledge'));
54
+ expect((screen.getByLabelText('目标目录(可选)') as HTMLInputElement).value).toBe(
55
+ '/srv/knowledge',
56
+ );
57
+ await user.click(screen.getByRole('button', { name: '创建项目' }));
58
+
59
+ await waitFor(() => {
60
+ expect(onCreate).toHaveBeenCalledWith({
61
+ name: '个人知识库',
62
+ rootPath: '/srv/knowledge',
63
+ template: 'knowledge-base',
64
+ });
65
+ });
66
+ });
67
+ });