@nextclaw/ui 0.15.6 → 0.15.8

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 (106) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/dist/assets/{api-Dyb5rgh0.js → api-kGIZo6kv.js} +1 -1
  3. package/dist/assets/{app-presenter-provider-7jV0nPiL.js → app-presenter-provider-Ct0DePOC.js} +48 -48
  4. package/dist/assets/{appearance-settings-page-DFm3BbEb.js → appearance-settings-page-CcA8Kewq.js} +1 -1
  5. package/dist/assets/{book-open-L6LCuqOr.js → book-open-D4LvR1N9.js} +1 -1
  6. package/dist/assets/{channels-list-page-DOaPKNJA.js → channels-list-page-CybW_0nu.js} +1 -1
  7. package/dist/assets/{chat-page-Cw9uDhD_.js → chat-page-Bs8c1adx.js} +1 -1
  8. package/dist/assets/{config-split-page-CqlqjnCK.js → config-split-page-BvhZDJGS.js} +1 -1
  9. package/dist/assets/{confirm-dialog-bAp8NU93.js → confirm-dialog-Ucx_SQm4.js} +1 -1
  10. package/dist/assets/desktop-update-config-Ch9m1GRB.js +1 -0
  11. package/dist/assets/{dist-CrtbGLlh.js → dist-CCwmPmQ3.js} +1 -1
  12. package/dist/assets/doc-browser-Cr7330LU2.js +1 -0
  13. package/dist/assets/{doc-browser-BmnQa30-.js → doc-browser-N9UwkWvd.js} +1 -1
  14. package/dist/assets/{doc-browser-context-BlpUUFrR.js → doc-browser-context-CIIKT2sE.js} +1 -1
  15. package/dist/assets/{download-BhZZNXD8.js → download-CSAHAjF9.js} +1 -1
  16. package/dist/assets/{external-link-B9Uoq5J7.js → external-link-Dno-cJJr.js} +1 -1
  17. package/dist/assets/{eye-K5knauEM.js → eye-BZkraQUn.js} +1 -1
  18. package/dist/assets/{folder-BIYZZWJv.js → folder-Bro2sQD3.js} +1 -1
  19. package/dist/assets/{form-actions-CKprWFeM.js → form-actions-CC31CR28.js} +1 -1
  20. package/dist/assets/index-BlKbckX4.css +1 -0
  21. package/dist/assets/index-EO5Gsaff.js +2 -0
  22. package/dist/assets/loader-circle-DyToFBCM.js +1 -0
  23. package/dist/assets/{logo-badge-BQ-XFZod.js → logo-badge-B___Witw.js} +1 -1
  24. package/dist/assets/mcp-marketplace-page-3Q-VOIJ3.js +1 -0
  25. package/dist/assets/{mcp-marketplace-page-BfA7A3n4.js → mcp-marketplace-page-Ccy-UG8R.js} +1 -1
  26. package/dist/assets/{model-config-page-BbWbW_qM.js → model-config-page-BsMxhVaO.js} +1 -1
  27. package/dist/assets/plus-wynE6QCr.js +1 -0
  28. package/dist/assets/{popover-CX9zFsuc.js → popover-CAy9XSdY.js} +1 -1
  29. package/dist/assets/{provider-scoped-model-input-B-9_DtIP.js → provider-scoped-model-input-Cqo78Dx_.js} +1 -1
  30. package/dist/assets/{providers-config-page-xwIjGur2.js → providers-config-page-CZSc-ruy.js} +1 -1
  31. package/dist/assets/react-UuyIZ_Oi.js +8 -0
  32. package/dist/assets/{refresh-cw-kXdvbhFf.js → refresh-cw-w9L_vlM-.js} +1 -1
  33. package/dist/assets/remote-D4rv7kb-.js +1 -0
  34. package/dist/assets/rotate-cw-C0VFtLju.js +1 -0
  35. package/dist/assets/{runtime-config-page-DTsi7czJ.js → runtime-config-page-DM63Nkxm.js} +1 -1
  36. package/dist/assets/{save-C9qpz2_2.js → save-DdL5VkJS.js} +1 -1
  37. package/dist/assets/{search-_kEi29qo.js → search-DydusQAS.js} +1 -1
  38. package/dist/assets/{search-config-page-H7ug8f4D.js → search-config-page-CfwWop50.js} +1 -1
  39. package/dist/assets/{secrets-config-page-C1_6rINu.js → secrets-config-page-BPIZUwLW.js} +1 -1
  40. package/dist/assets/security-config-CeBjHoFL.js +1 -0
  41. package/dist/assets/{select-MJKhKAWR.js → select-BEtjsC3L.js} +1 -1
  42. package/dist/assets/{setting-row-DA3tJuew.js → setting-row-CTA16hhx.js} +1 -1
  43. package/dist/assets/{settings-2-D_y7zXJz.js → settings-2-D6BrZlD5.js} +1 -1
  44. package/dist/assets/{skeleton-0uTLUhm9.js → skeleton-DcJKUZCH.js} +1 -1
  45. package/dist/assets/{tabs-h44XPocd.js → tabs-CrPVCYHg.js} +1 -1
  46. package/dist/assets/{tag-chip-bRZ04q3C.js → tag-chip-Bvm-V7SR.js} +1 -1
  47. package/dist/assets/{trash-2-BRpSE-Wt.js → trash-2-5ABWNTB-.js} +1 -1
  48. package/dist/assets/{use-config-CwDEAl6f.js → use-config-DJaZYyZC.js} +1 -1
  49. package/dist/assets/{use-confirm-dialog-BbnWw-8c.js → use-confirm-dialog-QUWL1EuB.js} +1 -1
  50. package/dist/assets/{use-viewport-layout-CqgIMDQL.js → use-viewport-layout-BWYz8zwd.js} +1 -1
  51. package/dist/assets/x-gM1Q7cRX.js +1 -0
  52. package/dist/index.html +30 -29
  53. package/index.html +1 -0
  54. package/package.json +6 -6
  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/features/system-status/components/__tests__/desktop-update-config.test.tsx +21 -0
  86. package/src/features/system-status/components/desktop-update-config.tsx +8 -5
  87. package/src/shared/components/common/__tests__/brand-header.test.tsx +39 -17
  88. package/src/shared/components/common/brand-header.tsx +55 -51
  89. package/src/shared/lib/i18n/locales/en-US/chat.json +5 -0
  90. package/src/shared/lib/i18n/locales/en-US/desktop-update.json +5 -1
  91. package/src/shared/lib/i18n/locales/en-US/marketplace.json +1 -0
  92. package/src/shared/lib/i18n/locales/zh-CN/chat.json +5 -0
  93. package/src/shared/lib/i18n/locales/zh-CN/desktop-update.json +5 -1
  94. package/src/shared/lib/i18n/locales/zh-CN/marketplace.json +1 -0
  95. package/dist/assets/desktop-update-config-2SjMfCMl.js +0 -1
  96. package/dist/assets/doc-browser-A5IRyDXQ2.js +0 -1
  97. package/dist/assets/index-BVn702m4.css +0 -1
  98. package/dist/assets/index-CFxJ1Fkg.js +0 -2
  99. package/dist/assets/loader-circle-Bj2RMrCR.js +0 -1
  100. package/dist/assets/mcp-marketplace-page-CXHYO95j.js +0 -1
  101. package/dist/assets/plus-TmFdwO3P.js +0 -1
  102. package/dist/assets/react-DsgudwpT.js +0 -8
  103. package/dist/assets/remote-B9F2uCk9.js +0 -1
  104. package/dist/assets/rotate-cw-DEc6aihr.js +0 -1
  105. package/dist/assets/security-config-Cr2ERKLu.js +0 -1
  106. package/dist/assets/x-DdEDL6ju.js +0 -1
@@ -4,7 +4,8 @@ import {
4
4
  buildInlineTokensFromComposer,
5
5
  CHAT_INLINE_TOKENS_METADATA_KEY,
6
6
  readInlineTokensFromMetadata,
7
- resolveInlineTokensForText
7
+ resolveInlineTokensForText,
8
+ resolveWorkspaceReferencePath,
8
9
  } from '@/features/chat/features/input/utils/chat-inline-token.utils';
9
10
 
10
11
  describe('chat-inline-token utils', () => {
@@ -143,4 +144,19 @@ describe('chat-inline-token utils', () => {
143
144
  },
144
145
  ]);
145
146
  });
147
+
148
+ it('resolves workspace token paths inside POSIX and Windows project roots', () => {
149
+ expect(resolveWorkspaceReferencePath({
150
+ projectRoot: '/tmp/project/',
151
+ relativePath: 'docs/guide.md',
152
+ })).toBe('/tmp/project/docs/guide.md');
153
+ expect(resolveWorkspaceReferencePath({
154
+ projectRoot: 'C:\\workspace\\nextclaw\\',
155
+ relativePath: 'docs/guide.md',
156
+ })).toBe('C:\\workspace\\nextclaw\\docs\\guide.md');
157
+ expect(resolveWorkspaceReferencePath({
158
+ projectRoot: '/tmp/project',
159
+ relativePath: '../secret.txt',
160
+ })).toBeNull();
161
+ });
146
162
  });
@@ -15,6 +15,23 @@ const CHAT_WORKSPACE_DIRECTORY_TOKEN_PATTERN = /@folder:([^\s]+)/g;
15
15
 
16
16
  export type ChatInlineTokenSource = ChatInlineTokenMetadata;
17
17
 
18
+ export function resolveWorkspaceReferencePath(params: {
19
+ projectRoot: string | null | undefined;
20
+ relativePath: string;
21
+ }): string | null {
22
+ const projectRoot = params.projectRoot?.trim().replace(/[\\/]+$/, '') ?? '';
23
+ const relativeSegments = params.relativePath.trim().replace(/\\/g, '/').split('/');
24
+ if (
25
+ !projectRoot ||
26
+ relativeSegments.length === 0 ||
27
+ relativeSegments.some((segment) => !segment || segment === '.' || segment === '..')
28
+ ) {
29
+ return null;
30
+ }
31
+ const separator = projectRoot.includes('\\') ? '\\' : '/';
32
+ return `${projectRoot}${separator}${relativeSegments.join(separator)}`;
33
+ }
34
+
18
35
  function isRecord(value: unknown): value is Record<string, unknown> {
19
36
  return typeof value === 'object' && value !== null && !Array.isArray(value);
20
37
  }
@@ -0,0 +1,159 @@
1
+ import { act, fireEvent, render, screen } from "@testing-library/react";
2
+ import { beforeEach, expect, it, vi } from "vitest";
3
+ import { ChatInlineFilePreview } from "@/features/chat/features/message/components/chat-inline-file-preview";
4
+
5
+ const captures = vi.hoisted(() => ({
6
+ filePreviewProps: [] as Array<{
7
+ showBreadcrumbs?: boolean;
8
+ onHtmlContentHeightChange?: (height: number) => void;
9
+ }>,
10
+ }));
11
+
12
+ vi.mock(
13
+ "@/features/chat/features/workspace/components/chat-session-workspace-file-preview",
14
+ () => ({
15
+ ChatSessionWorkspaceFilePreview: (props: {
16
+ showBreadcrumbs?: boolean;
17
+ onHtmlContentHeightChange?: (height: number) => void;
18
+ }) => {
19
+ captures.filePreviewProps.push(props);
20
+ return <div data-testid="inline-workspace-file-preview" />;
21
+ },
22
+ }),
23
+ );
24
+
25
+ vi.mock("@/shared/lib/i18n", () => ({
26
+ t: (key: string) => key,
27
+ }));
28
+
29
+ beforeEach(() => {
30
+ captures.filePreviewProps = [];
31
+ });
32
+
33
+ it("renders adaptive inline HTML without persistent file chrome", () => {
34
+ const onFileOpen = vi.fn();
35
+ const { container } = render(
36
+ <ChatInlineFilePreview
37
+ display={{
38
+ target: {
39
+ type: "file",
40
+ payload: { path: "preview.html", viewer: "rendered" },
41
+ },
42
+ title: "Preview",
43
+ description: "Rendered HTML",
44
+ }}
45
+ parentSessionKey={null}
46
+ sessionProjectRoot={null}
47
+ sessionWorkingDir={null}
48
+ onFileOpen={onFileOpen}
49
+ />,
50
+ );
51
+
52
+ expect(screen.queryByText("Preview")).toBeNull();
53
+ expect(screen.queryByText("preview.html")).toBeNull();
54
+ expect(screen.queryByText("Rendered HTML")).toBeNull();
55
+ const preview = container.querySelector(
56
+ '[data-chat-inline-file-preview="true"]',
57
+ );
58
+ const actions = container.querySelector(
59
+ '[data-chat-inline-file-actions="true"]',
60
+ );
61
+ const viewport = container.querySelector<HTMLElement>(
62
+ '[data-chat-inline-file-viewport="true"]',
63
+ );
64
+ const iframeSurface = screen.getByTestId("inline-workspace-file-preview");
65
+ expect(preview?.classList.contains("border")).toBe(false);
66
+ expect(preview?.classList.contains("pt-9")).toBe(false);
67
+ expect(actions?.classList.contains("left-1/2")).toBe(true);
68
+ expect(actions?.classList.contains("-translate-x-1/2")).toBe(true);
69
+ expect(actions?.classList.contains("bottom-full")).toBe(true);
70
+ expect(actions?.classList.contains("pb-2")).toBe(true);
71
+ expect(actions?.classList.contains("right-2")).toBe(false);
72
+ expect(actions?.classList.contains("opacity-0")).toBe(true);
73
+ expect(actions?.classList.contains("pointer-events-none")).toBe(true);
74
+ expect(
75
+ actions?.classList.contains("group-hover/inline-html:opacity-100"),
76
+ ).toBe(true);
77
+ expect(
78
+ actions?.classList.contains("group-hover/inline-html:pointer-events-auto"),
79
+ ).toBe(true);
80
+ expect(
81
+ actions?.classList.contains("group-focus-within/inline-html:opacity-100"),
82
+ ).toBe(true);
83
+ expect(
84
+ actions?.classList.contains(
85
+ "group-focus-within/inline-html:pointer-events-auto",
86
+ ),
87
+ ).toBe(true);
88
+ expect(
89
+ container.querySelector('[data-chat-inline-file-actions-surface="true"]'),
90
+ ).toBeTruthy();
91
+ expect(viewport?.classList.contains("rounded-lg")).toBe(true);
92
+ expect(viewport?.classList.contains("h-[240px]")).toBe(true);
93
+ expect(viewport?.classList.contains("max-h-[min(80vh,720px)]")).toBe(true);
94
+ expect(captures.filePreviewProps.at(-1)?.showBreadcrumbs).toBe(false);
95
+ expect(captures.filePreviewProps.at(-1)?.onHtmlContentHeightChange).toEqual(
96
+ expect.any(Function),
97
+ );
98
+
99
+ act(() => {
100
+ captures.filePreviewProps.at(-1)?.onHtmlContentHeightChange?.(560);
101
+ });
102
+
103
+ expect(viewport?.style.height).toBe("560px");
104
+ expect(screen.getByTestId("inline-workspace-file-preview")).toBe(
105
+ iframeSurface,
106
+ );
107
+
108
+ fireEvent.click(screen.getByLabelText("chatPanelCardExpand"));
109
+ fireEvent.click(screen.getByLabelText("chatWorkspaceOpenSource"));
110
+
111
+ expect(onFileOpen).toHaveBeenNthCalledWith(1, {
112
+ path: "preview.html",
113
+ label: "Preview",
114
+ viewMode: "preview",
115
+ previewViewer: "rendered",
116
+ line: undefined,
117
+ column: undefined,
118
+ });
119
+ expect(onFileOpen).toHaveBeenNthCalledWith(2, {
120
+ path: "preview.html",
121
+ label: "Preview",
122
+ viewMode: "preview",
123
+ previewViewer: "source",
124
+ line: undefined,
125
+ column: undefined,
126
+ });
127
+ });
128
+
129
+ it("keeps file chrome for non-HTML inline previews", () => {
130
+ const { container } = render(
131
+ <ChatInlineFilePreview
132
+ display={{
133
+ target: {
134
+ type: "file",
135
+ payload: { path: "notes.md", viewer: "rendered" },
136
+ },
137
+ title: "Notes",
138
+ }}
139
+ parentSessionKey={null}
140
+ sessionProjectRoot={null}
141
+ sessionWorkingDir={null}
142
+ onFileOpen={vi.fn()}
143
+ />,
144
+ );
145
+
146
+ expect(screen.getByText("Notes")).toBeTruthy();
147
+ expect(screen.getByText("notes.md")).toBeTruthy();
148
+ expect(
149
+ container
150
+ .querySelector('[data-chat-inline-file-preview="true"]')
151
+ ?.classList.contains("border"),
152
+ ).toBe(true);
153
+ expect(
154
+ container.querySelector('[data-chat-inline-file-actions="true"]'),
155
+ ).toBeNull();
156
+ expect(
157
+ captures.filePreviewProps.at(-1)?.onHtmlContentHeightChange,
158
+ ).toBeUndefined();
159
+ });
@@ -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: {