@nextclaw/ui 0.15.17 → 0.15.19

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 (120) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/dist/assets/api-O5yEgzBt.js +13 -0
  3. package/dist/assets/app-presenter-provider-DXTYtH78.js +106 -0
  4. package/dist/assets/appearance-settings-page-BuGxSvlJ.js +1 -0
  5. package/dist/assets/{book-open-WsEnuW9P.js → book-open-HtUSPwoS.js} +1 -1
  6. package/dist/assets/card-GkBc-UMw.js +1 -0
  7. package/dist/assets/{channels-list-page-CAWuTHSM.js → channels-list-page-Bspr1Jp4.js} +2 -2
  8. package/dist/assets/chat-page-BKQ89f4s.js +1 -0
  9. package/dist/assets/config-split-page-CvLXSFSC.js +1 -0
  10. package/dist/assets/confirm-dialog-BVH7haN0.js +5 -0
  11. package/dist/assets/desktop-update-config-C9ZUI8UW.js +1 -0
  12. package/dist/assets/{dist-Ca-jiFrK.js → dist-CZx9Q6jc.js} +1 -1
  13. package/dist/assets/doc-browser-B4l0Xu7u.js +1 -0
  14. package/dist/assets/doc-browser-DsVF1DrS.js +1 -0
  15. package/dist/assets/doc-browser-context-Dy7EhLI8.js +1 -0
  16. package/dist/assets/{eye-B30J471A.js → eye-DU7p1-7X.js} +1 -1
  17. package/dist/assets/form-actions-aM9_jSJC.js +1 -0
  18. package/dist/assets/index-6pHX0DZH.js +2 -0
  19. package/dist/assets/index-DN6KM7WD.css +1 -0
  20. package/dist/assets/loader-circle-Dd6jw700.js +1 -0
  21. package/dist/assets/logo-badge-2-iV1pkr.js +1 -0
  22. package/dist/assets/mcp-marketplace-page-C-d0RMfg.js +1 -0
  23. package/dist/assets/mcp-marketplace-page-Dg03SZX1.js +9 -0
  24. package/dist/assets/model-config-page-DOBqLZoQ.js +1 -0
  25. package/dist/assets/{navigation-link-qPPUiLt0.js → navigation-link-Ct6xtnVM.js} +1 -1
  26. package/dist/assets/plus-CsgeRId8.js +1 -0
  27. package/dist/assets/{popover-DHx55dbc.js → popover-C2oIpGBF.js} +1 -1
  28. package/dist/assets/provider-scoped-model-input-DDqCdnTU.js +1 -0
  29. package/dist/assets/{providers-config-page-BwHtHIPe.js → providers-config-page-BAVPJKpx.js} +1 -1
  30. package/dist/assets/react-CeMr9V52.js +8 -0
  31. package/dist/assets/{refresh-cw-C7bynrWX.js → refresh-cw-BZ8giusN.js} +1 -1
  32. package/dist/assets/remote-C-r0SHcz.js +1 -0
  33. package/dist/assets/rotate-cw-DVdRfMSO.js +1 -0
  34. package/dist/assets/{runtime-config-page-CbSrKeJt.js → runtime-config-page-D0244uxJ.js} +1 -1
  35. package/dist/assets/{save-CsRsFtHS.js → save-CSzFGRse.js} +1 -1
  36. package/dist/assets/{search-D0HtJIL-.js → search-BmSWsiG2.js} +1 -1
  37. package/dist/assets/{search-config-page-n1GElgij.js → search-config-page-CrL1pUzy.js} +1 -1
  38. package/dist/assets/{secrets-config-page-DbUc3Ggi.js → secrets-config-page-BlgsD0-L.js} +2 -2
  39. package/dist/assets/security-config-cDrdpMJW.js +1 -0
  40. package/dist/assets/{select-C1zfp9zM.js → select-CklWb6Rs.js} +1 -1
  41. package/dist/assets/{settings-2-Ba3cLSN2.js → settings-2-BSZf8327.js} +1 -1
  42. package/dist/assets/skeleton-DG9u1BmA.js +1 -0
  43. package/dist/assets/{tabs-DmKgMKzb.js → tabs-Cah5klko.js} +1 -1
  44. package/dist/assets/{tag-chip-2vwlVUwM.js → tag-chip-DcrirDgN.js} +1 -1
  45. package/dist/assets/{trash-2-CUpWjNHu.js → trash-2-BLQOULWD.js} +1 -1
  46. package/dist/assets/use-config-CuFIsUVG.js +1 -0
  47. package/dist/assets/use-confirm-dialog-CcySmkWp.js +1 -0
  48. package/dist/assets/{use-viewport-layout-DaKJ-kap.js → use-viewport-layout-DU29bUJS.js} +1 -1
  49. package/dist/assets/x-BVoeXny1.js +1 -0
  50. package/dist/index.html +28 -28
  51. package/package.json +9 -9
  52. package/src/features/chat/components/conversation/__tests__/chat-conversation-content.test.tsx +11 -0
  53. package/src/features/chat/components/conversation/chat-conversation-content.tsx +7 -1
  54. package/src/features/chat/features/conversation/components/__tests__/session-conversation-area.test.tsx +50 -4
  55. package/src/features/chat/features/conversation/components/session-conversation-area.tsx +25 -1
  56. package/src/features/chat/features/conversation/components/session-conversation-input.tsx +3 -0
  57. package/src/features/chat/features/conversation/hooks/__tests__/use-session-conversation-input-state.test.tsx +41 -0
  58. package/src/features/chat/features/conversation/hooks/__tests__/use-session-conversation-slash-commands.test.tsx +28 -0
  59. package/src/features/chat/features/conversation/hooks/use-session-conversation-input-query.ts +1 -1
  60. package/src/features/chat/features/conversation/hooks/use-session-conversation-input-state.ts +0 -1
  61. package/src/features/chat/features/conversation/hooks/use-session-conversation-preference-actions.ts +10 -11
  62. package/src/features/chat/features/conversation/hooks/use-session-conversation-slash-commands.ts +5 -2
  63. package/src/features/chat/features/message/components/__tests__/chat-inline-panel-app-card.test.tsx +35 -0
  64. package/src/features/chat/features/message/components/chat-inline-file-preview.tsx +3 -1
  65. package/src/features/chat/features/message/components/chat-inline-panel-app-card.tsx +13 -5
  66. package/src/features/chat/features/message/components/chat-message-list.container.tsx +13 -9
  67. package/src/features/chat/features/message/utils/__tests__/chat-message-show-content-tool-card.utils.test.ts +9 -0
  68. package/src/features/chat/features/message/utils/chat-inline-panel-app-card.utils.ts +4 -1
  69. package/src/features/chat/features/message/utils/chat-message-show-content-tool-card.utils.ts +29 -0
  70. package/src/features/chat/features/message/utils/chat-message-texts.utils.ts +1 -0
  71. package/src/features/chat/features/ncp/hooks/__tests__/use-hydrated-ncp-agent.test.tsx +122 -1
  72. package/src/features/chat/features/ncp/hooks/__tests__/use-ncp-agent-runtime-queue.test.tsx +31 -6
  73. package/src/features/chat/features/ncp/hooks/__tests__/use-ncp-agent-runtime.test.tsx +107 -1
  74. package/src/features/chat/features/workspace/components/__tests__/workspace-file-content-preview.test.tsx +41 -0
  75. package/src/features/chat/features/workspace/components/chat-session-workspace-file-preview.tsx +4 -0
  76. package/src/features/chat/features/workspace/components/workspace-file-content-preview.tsx +15 -2
  77. package/src/features/chat/features/workspace/utils/chat-workspace-file-tab-persistence.utils.ts +11 -0
  78. package/src/features/chat/features/workspace/utils/chat-workspace-file-tab.utils.ts +1 -0
  79. package/src/features/chat/managers/__tests__/chat-thread.manager.test.ts +11 -8
  80. package/src/features/chat/managers/__tests__/chat-ui.manager.test.ts +6 -0
  81. package/src/features/chat/managers/chat-thread.manager.ts +2 -0
  82. package/src/features/chat/managers/chat-ui.manager.ts +10 -4
  83. package/src/features/chat/stores/__tests__/chat-thread.store.test.ts +4 -0
  84. package/src/features/chat/stores/chat-thread.store.ts +6 -1
  85. package/src/shared/components/doc-browser/__tests__/doc-browser-context.test.tsx +32 -0
  86. package/src/shared/components/doc-browser/doc-browser-panel-parts.tsx +2 -0
  87. package/src/shared/components/doc-browser/managers/doc-browser.manager.ts +20 -38
  88. package/src/shared/components/doc-browser/types/doc-browser.types.ts +5 -0
  89. package/src/shared/components/doc-browser/utils/doc-browser-state.utils.ts +63 -0
  90. package/src/shared/lib/i18n/locales/en-US/chat.json +1 -0
  91. package/src/shared/lib/i18n/locales/zh-CN/chat.json +1 -0
  92. package/dist/assets/api-D5UCLVE_.js +0 -13
  93. package/dist/assets/app-presenter-provider-C9AcZKKs.js +0 -106
  94. package/dist/assets/appearance-settings-page-CiD9Ey8f.js +0 -1
  95. package/dist/assets/card-xVrS1b1T.js +0 -1
  96. package/dist/assets/chat-page-B0cC3jsB.js +0 -1
  97. package/dist/assets/config-split-page-xj0UtMow.js +0 -1
  98. package/dist/assets/confirm-dialog-e2YCUzt1.js +0 -5
  99. package/dist/assets/desktop-update-config-D5NIaimo.js +0 -1
  100. package/dist/assets/doc-browser-B2tNEKrJ.js +0 -1
  101. package/dist/assets/doc-browser-CZX6I6CU.js +0 -1
  102. package/dist/assets/doc-browser-context-CGz0LULR.js +0 -1
  103. package/dist/assets/form-actions-CnSzu3WD.js +0 -1
  104. package/dist/assets/index-QveCjxCj.css +0 -1
  105. package/dist/assets/index-bOqpao-w.js +0 -2
  106. package/dist/assets/loader-circle-DjwSRQHW.js +0 -1
  107. package/dist/assets/logo-badge-CISsltAo.js +0 -1
  108. package/dist/assets/mcp-marketplace-page-BZa7p7Qf.js +0 -1
  109. package/dist/assets/mcp-marketplace-page-CPcPzMUQ.js +0 -9
  110. package/dist/assets/model-config-page-D6ex3_NM.js +0 -1
  111. package/dist/assets/plus-C45awBb1.js +0 -1
  112. package/dist/assets/provider-scoped-model-input-1TJxAhx6.js +0 -1
  113. package/dist/assets/react-DerwD2EA.js +0 -8
  114. package/dist/assets/remote-B0nlynVp.js +0 -1
  115. package/dist/assets/rotate-cw-Bhx0XkQP.js +0 -1
  116. package/dist/assets/security-config-n2WHhHXV.js +0 -1
  117. package/dist/assets/skeleton-DL5npsVL.js +0 -1
  118. package/dist/assets/use-config-BvxDWV-M.js +0 -1
  119. package/dist/assets/use-confirm-dialog-CeUEE4p5.js +0 -1
  120. package/dist/assets/x-DGdYT7rH.js +0 -1
@@ -150,6 +150,9 @@ describe("buildShowContentToolCard", () => {
150
150
  payload: {
151
151
  appId: "reader",
152
152
  path: "/tmp/reader.panel",
153
+ params: {
154
+ file: { path: "/tmp/photo.png" },
155
+ },
153
156
  },
154
157
  },
155
158
  title: "Reader",
@@ -166,6 +169,9 @@ describe("buildShowContentToolCard", () => {
166
169
  panelApp: {
167
170
  appId: "reader",
168
171
  path: "/tmp/reader.panel",
172
+ params: {
173
+ file: { path: "/tmp/photo.png" },
174
+ },
169
175
  title: "Reader",
170
176
  action: {
171
177
  kind: "show-content",
@@ -176,6 +182,9 @@ describe("buildShowContentToolCard", () => {
176
182
  payload: {
177
183
  appId: "reader",
178
184
  path: "/tmp/reader.panel",
185
+ params: {
186
+ file: { path: "/tmp/photo.png" },
187
+ },
179
188
  },
180
189
  },
181
190
  title: "Reader",
@@ -5,6 +5,7 @@ import {
5
5
  } from "@/features/right-panel-resources";
6
6
  import { PANEL_APP_INLINE_HOST_CONTRACT } from "@nextclaw/shared";
7
7
  import type { DocBrowserTab } from "@/shared/components/doc-browser/doc-browser-context";
8
+ import type { ChatContentParams } from "@nextclaw/agent-chat-ui";
8
9
 
9
10
  export function createFallbackPanelAppContentPath(appId: string, sourcePath?: string): string {
10
11
  return createPanelAppContentPath(appId, sourcePath);
@@ -37,11 +38,13 @@ export function readInlinePanelAppContentHeight(value: unknown): number | null {
37
38
  export function createInlinePanelAppTab(params: {
38
39
  appId: string;
39
40
  path?: string;
41
+ params?: ChatContentParams;
40
42
  title: string;
41
43
  url: string;
42
44
  }): DocBrowserTab {
43
- const { appId, path, title, url } = params;
45
+ const { appId, params: contentParams, path, title, url } = params;
44
46
  return {
47
+ contentParams,
45
48
  currentUrl: url,
46
49
  dedupeKey: `panel-app:${path ?? appId}`,
47
50
  history: [url],
@@ -7,6 +7,10 @@ import type {
7
7
  import type {
8
8
  ToolCardViewSource,
9
9
  } from "./chat-message-tool-card.utils";
10
+ import {
11
+ readUiContentParams,
12
+ type UiContentParams,
13
+ } from "@nextclaw/shared";
10
14
 
11
15
  const SHOW_CONTENT_TOOL_NAMES = ["show_content", "show_file", "show_url", "show_panel_app"];
12
16
 
@@ -56,6 +60,14 @@ function readFileViewer(value: unknown): NonNullable<
56
60
  : undefined;
57
61
  }
58
62
 
63
+ function readContentParams(value: unknown): UiContentParams | undefined | null {
64
+ try {
65
+ return readUiContentParams(value);
66
+ } catch {
67
+ return null;
68
+ }
69
+ }
70
+
59
71
  function readShowContentRequest(value: unknown): ChatUiShowContentRequest | null {
60
72
  if (!isRecord(value) || !isRecord(value.target)) {
61
73
  return null;
@@ -78,6 +90,16 @@ function readShowContentRequest(value: unknown): ChatUiShowContentRequest | null
78
90
  if (!path) {
79
91
  return null;
80
92
  }
93
+ const params = readContentParams(payload.params);
94
+ if (
95
+ params === null ||
96
+ (params && (
97
+ readFileViewer(payload.viewer) === "source" ||
98
+ !/\.html?$/i.test(path)
99
+ ))
100
+ ) {
101
+ return null;
102
+ }
81
103
  return {
82
104
  target: {
83
105
  type: "file",
@@ -86,6 +108,7 @@ function readShowContentRequest(value: unknown): ChatUiShowContentRequest | null
86
108
  line: readOptionalPositiveInteger(payload.line),
87
109
  column: readOptionalPositiveInteger(payload.column),
88
110
  viewer: readFileViewer(payload.viewer),
111
+ params: params ?? undefined,
89
112
  },
90
113
  },
91
114
  title,
@@ -116,12 +139,17 @@ function readShowContentRequest(value: unknown): ChatUiShowContentRequest | null
116
139
  if (!appId) {
117
140
  return null;
118
141
  }
142
+ const params = readContentParams(payload.params);
143
+ if (params === null) {
144
+ return null;
145
+ }
119
146
  return {
120
147
  target: {
121
148
  type: "panel_app",
122
149
  payload: {
123
150
  appId,
124
151
  path: readOptionalString(payload.path),
152
+ params: params ?? undefined,
125
153
  },
126
154
  },
127
155
  title,
@@ -180,6 +208,7 @@ export function buildShowContentToolCard(params: {
180
208
  ? {
181
209
  appId: request.target.payload.appId,
182
210
  path: request.target.payload.path,
211
+ params: request.target.payload.params,
183
212
  title: request.title,
184
213
  action: showContentAction,
185
214
  }
@@ -150,6 +150,7 @@ export function buildChatMessageTexts(
150
150
  copyMessageLabel: t("chatMessageCopy"),
151
151
  copiedMessageLabel: t("chatMessageCopied"),
152
152
  mermaidDiagramLabel: t("chatMermaidDiagram"),
153
+ mermaidExpandLabel: t("chatMermaidExpand"),
153
154
  mermaidLoadingLabel: t("chatMermaidLoading"),
154
155
  mermaidRenderErrorLabel: t("chatMermaidRenderError"),
155
156
  typingLabel: t("chatTyping"),
@@ -1,5 +1,10 @@
1
1
  import { act, renderHook, waitFor } from "@testing-library/react";
2
- import { NcpEventType, type NcpAgentClientEndpoint } from "@nextclaw/ncp";
2
+ import {
3
+ NcpEventType,
4
+ type NcpAgentClientEndpoint,
5
+ type NcpEndpointSubscriber,
6
+ type NcpMessage,
7
+ } from "@nextclaw/ncp";
3
8
  import { useHydratedNcpAgent, useNcpAgentRuntime } from "@nextclaw/ncp-react";
4
9
  import { DefaultNcpAgentConversationStateManager } from "@nextclaw/ncp-toolkit";
5
10
  import { beforeEach, describe, expect, it, vi } from "vitest";
@@ -147,4 +152,120 @@ describe("useHydratedNcpAgent", () => {
147
152
  });
148
153
  expect(result.current.visibleMessages).toEqual([historyMessage]);
149
154
  });
155
+
156
+ it("starts the live stream when a draft manager already contains the materialized session", async () => {
157
+ let subscriber: NcpEndpointSubscriber | null = null;
158
+ const client = {
159
+ stop: mocks.stop.mockResolvedValue(undefined),
160
+ stream: mocks.stream.mockImplementation(() => new Promise(() => {})),
161
+ subscribe: vi.fn((nextSubscriber: NcpEndpointSubscriber) => {
162
+ subscriber = nextSubscriber;
163
+ return () => {};
164
+ }),
165
+ } as unknown as NcpAgentClientEndpoint;
166
+ const loadSeed = vi.fn().mockResolvedValue({
167
+ messages: [],
168
+ status: "idle",
169
+ });
170
+ const materializedMessage: NcpMessage = {
171
+ id: "message-materialized",
172
+ sessionId: "session-materialized",
173
+ role: "user",
174
+ status: "final",
175
+ parts: [{ type: "text", text: "Visible immediately" }],
176
+ timestamp: "2026-07-24T00:00:00.000Z",
177
+ };
178
+ const { result, rerender } = renderHook(
179
+ ({ sessionId }: { sessionId?: string }) =>
180
+ useHydratedNcpAgent({
181
+ sessionId,
182
+ client: client as never,
183
+ loadSeed,
184
+ }),
185
+ { initialProps: { sessionId: undefined as string | undefined } },
186
+ );
187
+
188
+ await waitFor(() => {
189
+ expect(subscriber).not.toBeNull();
190
+ });
191
+ act(() => {
192
+ subscriber?.({
193
+ type: NcpEventType.MessageSent,
194
+ payload: {
195
+ sessionId: "session-materialized",
196
+ message: materializedMessage,
197
+ },
198
+ });
199
+ });
200
+ await waitFor(() => {
201
+ expect(result.current.visibleMessages).toEqual([materializedMessage]);
202
+ });
203
+
204
+ rerender({ sessionId: "session-materialized" });
205
+
206
+ await waitFor(() => {
207
+ expect(result.current.isHydrating).toBe(false);
208
+ });
209
+ expect(loadSeed).not.toHaveBeenCalled();
210
+ expect(mocks.stream).toHaveBeenCalledWith({
211
+ sessionId: "session-materialized",
212
+ });
213
+ });
214
+
215
+ it("reloads missed state and reconnects after the live stream disconnects", async () => {
216
+ vi.useFakeTimers();
217
+ try {
218
+ const connected = new Promise<void>(() => {});
219
+ const client = {
220
+ stop: mocks.stop.mockResolvedValue(undefined),
221
+ stream: mocks.stream
222
+ .mockRejectedValueOnce(new Error("stream disconnected"))
223
+ .mockImplementationOnce(() => connected),
224
+ subscribe: vi.fn(() => () => {}),
225
+ send: mocks.send,
226
+ } as unknown as NcpAgentClientEndpoint;
227
+ const recoveredMessage = {
228
+ id: "message-recovered",
229
+ sessionId: "session-recovery",
230
+ role: "assistant",
231
+ status: "final",
232
+ parts: [{ type: "text", text: "Recovered" }],
233
+ timestamp: "2026-07-24T00:01:00.000Z",
234
+ } as const;
235
+ const loadSeed = vi
236
+ .fn()
237
+ .mockResolvedValueOnce({ messages: [], status: "running" })
238
+ .mockResolvedValueOnce({
239
+ messages: [recoveredMessage],
240
+ status: "idle",
241
+ });
242
+ const { result } = renderHook(() =>
243
+ useHydratedNcpAgent({
244
+ sessionId: "session-recovery",
245
+ client: client as never,
246
+ loadSeed,
247
+ }),
248
+ );
249
+
250
+ await vi.waitFor(() => {
251
+ expect(result.current.hydrateError?.message).toBe(
252
+ "stream disconnected",
253
+ );
254
+ });
255
+ await act(async () => {
256
+ await vi.runOnlyPendingTimersAsync();
257
+ });
258
+ await vi.waitFor(() => {
259
+ expect(result.current.visibleMessages).toEqual([recoveredMessage]);
260
+ });
261
+
262
+ expect(loadSeed).toHaveBeenCalledTimes(2);
263
+ expect(mocks.stream).toHaveBeenCalledTimes(2);
264
+ expect(mocks.send).not.toHaveBeenCalled();
265
+ expect(result.current.hydrateError).toBeNull();
266
+ expect(result.current.isRunning).toBe(false);
267
+ } finally {
268
+ vi.useRealTimers();
269
+ }
270
+ });
150
271
  });
@@ -1,11 +1,17 @@
1
- import { act, renderHook } from '@testing-library/react';
1
+ import { act, renderHook, waitFor } from '@testing-library/react';
2
2
  import { describe, expect, it, vi } from 'vitest';
3
- import { NcpEventType, type NcpAgentClientEndpoint } from '@nextclaw/ncp';
3
+ import {
4
+ NcpEventType,
5
+ type NcpAgentClientEndpoint,
6
+ type NcpAgentSendEnvelope,
7
+ type NcpEndpointSubscriber,
8
+ type NcpMessage,
9
+ } from '@nextclaw/ncp';
4
10
  import { useNcpAgentRuntime } from '@nextclaw/ncp-react';
5
11
  import { DefaultNcpAgentConversationStateManager } from '@nextclaw/ncp-toolkit';
6
12
 
7
13
  describe('useNcpAgentRuntime backend queue submission', () => {
8
- it('forwards a new message while a run is active and waits for backend events to add it', async () => {
14
+ it('keeps a queued message out of the conversation until the backend starts it', async () => {
9
15
  const manager = new DefaultNcpAgentConversationStateManager();
10
16
  manager.hydrate({ sessionId: 'session-1', messages: [] });
11
17
  await manager.dispatch({
@@ -16,18 +22,22 @@ describe('useNcpAgentRuntime backend queue submission', () => {
16
22
  messageId: 'assistant-1',
17
23
  },
18
24
  });
19
- const send = vi.fn(async () => ({
25
+ const send = vi.fn(async (envelope: NcpAgentSendEnvelope) => ({
20
26
  assistantMessageId: null,
21
27
  runId: null,
22
28
  sessionId: 'session-1',
23
- userMessageId: 'user-queued',
29
+ userMessageId: envelope.message.id,
24
30
  }));
31
+ let subscriber: NcpEndpointSubscriber | null = null;
25
32
  const client = {
26
33
  abort: vi.fn(async () => undefined),
27
34
  send,
28
35
  stop: vi.fn(async () => undefined),
29
36
  stream: vi.fn(async () => undefined),
30
- subscribe: vi.fn(() => () => undefined),
37
+ subscribe: vi.fn((listener: NcpEndpointSubscriber) => {
38
+ subscriber = listener;
39
+ return () => undefined;
40
+ }),
31
41
  } as unknown as NcpAgentClientEndpoint;
32
42
  const { result } = renderHook(() => useNcpAgentRuntime({
33
43
  client,
@@ -47,5 +57,20 @@ describe('useNcpAgentRuntime backend queue submission', () => {
47
57
  }),
48
58
  }));
49
59
  expect(result.current.visibleMessages).toEqual([]);
60
+
61
+ const queuedMessage = send.mock.calls[0][0].message as NcpMessage;
62
+ await act(async () => {
63
+ subscriber?.({
64
+ type: NcpEventType.MessageSent,
65
+ payload: {
66
+ sessionId: 'session-1',
67
+ message: queuedMessage,
68
+ },
69
+ });
70
+ });
71
+
72
+ await waitFor(() => {
73
+ expect(result.current.visibleMessages).toEqual([queuedMessage]);
74
+ });
50
75
  });
51
76
  });
@@ -200,12 +200,118 @@ describe("useNcpAgentRuntime", () => {
200
200
  assistantMessageId: "assistant-1",
201
201
  runId: "run-1",
202
202
  });
203
- expect(result.current.visibleMessages).toEqual([]);
203
+ expect(result.current.visibleMessages).toEqual([
204
+ expect.objectContaining({
205
+ ...envelope.message,
206
+ sessionId: "session-created",
207
+ }),
208
+ ]);
204
209
  expect(client.stop).not.toHaveBeenCalled();
205
210
 
206
211
  rerender({ sessionId: "session-created" });
207
212
  });
208
213
 
214
+ it("shows an existing-session user message before send returns and deduplicates the server event", async () => {
215
+ const client = new DeferredSendClient();
216
+ const manager = new DefaultNcpAgentConversationStateManager();
217
+ const envelope: NcpAgentSendEnvelope = {
218
+ sessionId: "session-existing",
219
+ message: {
220
+ id: "user-optimistic",
221
+ sessionId: "session-existing",
222
+ role: "user",
223
+ status: "final",
224
+ parts: [{ type: "text", text: "hello now" }],
225
+ timestamp: now,
226
+ },
227
+ };
228
+ let releaseSend = () => {};
229
+ client.send.mockImplementationOnce(
230
+ (pendingEnvelope) =>
231
+ new Promise((resolve) => {
232
+ releaseSend = () =>
233
+ resolve({
234
+ sessionId: "session-existing",
235
+ userMessageId: pendingEnvelope.message.id,
236
+ assistantMessageId: "assistant-1",
237
+ runId: "run-1",
238
+ });
239
+ }),
240
+ );
241
+ const { result } = renderHook(() =>
242
+ useNcpAgentRuntime({
243
+ sessionId: "session-existing",
244
+ client,
245
+ manager: manager as never,
246
+ }),
247
+ );
248
+
249
+ let sendPromise!: ReturnType<typeof result.current.send>;
250
+ act(() => {
251
+ sendPromise = result.current.send(envelope);
252
+ });
253
+
254
+ await waitFor(() => {
255
+ expect(result.current.visibleMessages).toEqual([envelope.message]);
256
+ });
257
+
258
+ await act(async () => {
259
+ await client.emit({
260
+ type: NcpEventType.MessageSent,
261
+ payload: {
262
+ sessionId: "session-existing",
263
+ message: envelope.message as NcpMessage,
264
+ },
265
+ });
266
+ });
267
+ await waitFor(() => {
268
+ expect(result.current.visibleMessages).toHaveLength(1);
269
+ });
270
+
271
+ await act(async () => {
272
+ releaseSend();
273
+ await sendPromise;
274
+ });
275
+ expect(result.current.visibleMessages).toEqual([envelope.message]);
276
+ });
277
+
278
+ it("marks an optimistic user message as failed when send fails", async () => {
279
+ const client = new DeferredSendClient();
280
+ const manager = new DefaultNcpAgentConversationStateManager();
281
+ const envelope: NcpAgentSendEnvelope = {
282
+ sessionId: "session-existing",
283
+ message: {
284
+ id: "user-failed",
285
+ sessionId: "session-existing",
286
+ role: "user",
287
+ status: "final",
288
+ parts: [{ type: "text", text: "please retry" }],
289
+ timestamp: now,
290
+ },
291
+ };
292
+ client.send.mockRejectedValueOnce(new Error("network unavailable"));
293
+ const { result } = renderHook(() =>
294
+ useNcpAgentRuntime({
295
+ sessionId: "session-existing",
296
+ client,
297
+ manager: manager as never,
298
+ }),
299
+ );
300
+
301
+ await act(async () => {
302
+ await expect(result.current.send(envelope)).rejects.toThrow(
303
+ "network unavailable",
304
+ );
305
+ });
306
+
307
+ expect(result.current.visibleMessages).toEqual([
308
+ expect.objectContaining({
309
+ ...envelope.message,
310
+ status: "error",
311
+ }),
312
+ ]);
313
+ });
314
+
209
315
  it("aborts by session id even before a hydrated active run reaches local state", async () => {
210
316
  const client = new DeferredSendClient();
211
317
  const manager = new DefaultNcpAgentConversationStateManager();
@@ -87,3 +87,44 @@ it("reports same-origin HTML height changes without replacing the iframe", () =>
87
87
  unmount();
88
88
  expect(disconnect).toHaveBeenCalledTimes(1);
89
89
  });
90
+
91
+ it("passes params through window.name without exposing values in the content URL", () => {
92
+ const { rerender } = render(
93
+ <WorkspaceFileContentPreview
94
+ contentUrl="/api/server-paths/content?path=%2Ftmp%2Fchart.html"
95
+ contentParams={{
96
+ chart: {
97
+ series: [3, 5, 8],
98
+ },
99
+ }}
100
+ kind="html"
101
+ label="chart.html"
102
+ />,
103
+ );
104
+
105
+ const frame = screen.getByTestId(
106
+ "workspace-html-preview",
107
+ ) as HTMLIFrameElement;
108
+ expect(frame.name).toContain('"series":[3,5,8]');
109
+ expect(frame.src).toContain("nextclawContentParams=1");
110
+ expect(frame.src).not.toContain("series");
111
+
112
+ rerender(
113
+ <WorkspaceFileContentPreview
114
+ contentUrl="/api/server-paths/content?path=%2Ftmp%2Fchart.html"
115
+ contentParams={{
116
+ chart: {
117
+ series: [13, 21, 34],
118
+ },
119
+ }}
120
+ kind="html"
121
+ label="chart.html"
122
+ />,
123
+ );
124
+
125
+ const nextFrame = screen.getByTestId(
126
+ "workspace-html-preview",
127
+ ) as HTMLIFrameElement;
128
+ expect(nextFrame).not.toBe(frame);
129
+ expect(nextFrame.name).toContain('"series":[13,21,34]');
130
+ });
@@ -185,6 +185,7 @@ function WorkspacePreviewBody({
185
185
  contentUrl,
186
186
  contentUrlKind,
187
187
  contentLabel,
188
+ contentParams,
188
189
  directoryQuery,
189
190
  fileBasePath,
190
191
  onFileOpen,
@@ -200,6 +201,7 @@ function WorkspacePreviewBody({
200
201
  contentUrl: string | null;
201
202
  contentUrlKind: WorkspaceFileContentKind | null;
202
203
  contentLabel: string;
204
+ contentParams: ChatWorkspaceFileTab["params"];
203
205
  directoryQuery: ReturnType<typeof useServerPathBrowse> | null | undefined;
204
206
  fileBasePath: string | null;
205
207
  onFileOpen: (action: ChatFileOpenActionViewModel) => void;
@@ -216,6 +218,7 @@ function WorkspacePreviewBody({
216
218
  return (
217
219
  <WorkspaceFileContentPreview
218
220
  contentUrl={contentUrl}
221
+ contentParams={contentParams ?? undefined}
219
222
  kind={contentUrlKind}
220
223
  label={contentLabel}
221
224
  onHtmlContentHeightChange={onHtmlContentHeightChange}
@@ -424,6 +427,7 @@ export function ChatSessionWorkspaceFilePreview({
424
427
  contentUrl={contentUrl}
425
428
  contentUrlKind={contentUrlKind}
426
429
  contentLabel={file.label?.trim() || resolvedPath}
430
+ contentParams={file.params}
427
431
  directoryQuery={directoryQuery}
428
432
  fileBasePath={sessionWorkingDir}
429
433
  onFileOpen={onFileOpen}
@@ -1,5 +1,10 @@
1
1
  import { useEffect, useRef } from "react";
2
- import { readInlineContentHeight } from "@nextclaw/shared";
2
+ import {
3
+ appendUiContentParamsBootstrapQuery,
4
+ createUiContentParamsWindowName,
5
+ readInlineContentHeight,
6
+ type UiContentParams,
7
+ } from "@nextclaw/shared";
3
8
  import { WorkspaceDocxPreview } from "./file-content-preview/workspace-docx-preview";
4
9
  import { WorkspacePresentationPreview } from "./file-content-preview/workspace-presentation-preview";
5
10
  import { WorkspaceSpreadsheetPreview } from "./file-content-preview/workspace-spreadsheet-preview";
@@ -119,15 +124,18 @@ function WorkspaceUnsupportedContent({
119
124
 
120
125
  function WorkspaceHtmlPreview({
121
126
  contentUrl,
127
+ contentParams,
122
128
  label,
123
129
  onContentHeightChange,
124
130
  }: {
125
131
  contentUrl: string;
132
+ contentParams?: UiContentParams;
126
133
  label: string;
127
134
  onContentHeightChange?: (height: number) => void;
128
135
  }) {
129
136
  const iframeRef = useRef<HTMLIFrameElement>(null);
130
137
  const observerCleanupRef = useRef<(() => void) | null>(null);
138
+ const contentParamsWindowName = createUiContentParamsWindowName(contentParams);
131
139
 
132
140
  useEffect(
133
141
  () => () => {
@@ -177,10 +185,12 @@ function WorkspaceHtmlPreview({
177
185
  return (
178
186
  <iframe
179
187
  ref={iframeRef}
188
+ key={contentParamsWindowName ?? "no-content-params"}
180
189
  allowFullScreen
181
190
  className="h-full w-full border-0 bg-white"
182
191
  data-testid="workspace-html-preview"
183
- src={contentUrl}
192
+ name={contentParamsWindowName}
193
+ src={appendUiContentParamsBootstrapQuery(contentUrl, contentParams)}
184
194
  title={label}
185
195
  onLoad={observeContentHeight}
186
196
  />
@@ -189,11 +199,13 @@ function WorkspaceHtmlPreview({
189
199
 
190
200
  export function WorkspaceFileContentPreview({
191
201
  contentUrl,
202
+ contentParams,
192
203
  kind,
193
204
  label,
194
205
  onHtmlContentHeightChange,
195
206
  }: {
196
207
  contentUrl: string;
208
+ contentParams?: UiContentParams;
197
209
  kind: WorkspaceFileContentKind;
198
210
  label: string;
199
211
  onHtmlContentHeightChange?: (height: number) => void;
@@ -243,6 +255,7 @@ export function WorkspaceFileContentPreview({
243
255
  return (
244
256
  <WorkspaceHtmlPreview
245
257
  contentUrl={contentUrl}
258
+ contentParams={contentParams}
246
259
  label={label}
247
260
  onContentHeightChange={onHtmlContentHeightChange}
248
261
  />
@@ -1,5 +1,6 @@
1
1
  import type { ChatFilePreviewViewer } from '@nextclaw/agent-chat-ui';
2
2
  import type { ChatWorkspaceFileTab } from '@/features/chat/stores/chat-thread.store';
3
+ import { readUiContentParams } from '@nextclaw/shared';
3
4
 
4
5
  function isRecord(value: unknown): value is Record<string, unknown> {
5
6
  return !!value && typeof value === 'object';
@@ -27,6 +28,14 @@ function normalizeFilePreviewViewer(value: unknown): ChatFilePreviewViewer | nul
27
28
  : null;
28
29
  }
29
30
 
31
+ function normalizeContentParams(value: unknown): ChatWorkspaceFileTab['params'] {
32
+ try {
33
+ return readUiContentParams(value) ?? null;
34
+ } catch {
35
+ return null;
36
+ }
37
+ }
38
+
30
39
  export function normalizePersistedWorkspaceFileTab(
31
40
  value: unknown,
32
41
  ): ChatWorkspaceFileTab | null {
@@ -52,6 +61,7 @@ export function normalizePersistedWorkspaceFileTab(
52
61
  previewViewer: normalizeFilePreviewViewer(value.previewViewer),
53
62
  line: normalizeOptionalNumber(value.line),
54
63
  column: normalizeOptionalNumber(value.column),
64
+ params: normalizeContentParams(value.params),
55
65
  rawText: normalizeOptionalText(value.rawText),
56
66
  contentUrl: normalizeOptionalString(value.contentUrl),
57
67
  mimeType: normalizeOptionalString(value.mimeType),
@@ -75,6 +85,7 @@ export function toPersistedWorkspaceFileTab(
75
85
  previewViewer: tab.previewViewer,
76
86
  line: tab.line,
77
87
  column: tab.column,
88
+ params: tab.params,
78
89
  rawText: tab.rawText,
79
90
  contentUrl: tab.contentUrl,
80
91
  mimeType: tab.mimeType,
@@ -29,6 +29,7 @@ export function createWorkspaceFileTab(
29
29
  previewViewer,
30
30
  line: action.line ?? null,
31
31
  column: action.column ?? null,
32
+ params: action.params ?? null,
32
33
  rawText: action.rawText ?? null,
33
34
  contentUrl: action.contentUrl?.trim() || null,
34
35
  mimeType: action.mimeType?.trim() || null,
@@ -301,13 +301,15 @@ describe('ChatThreadManager', () => {
301
301
  );
302
302
 
303
303
  manager.openFilePreview({
304
- path: 'README.md',
305
- label: 'README.md',
304
+ path: 'chart.html',
305
+ label: 'Chart',
306
306
  viewMode: 'preview',
307
+ previewViewer: 'rendered',
308
+ params: { chart: { series: [3, 5, 8] } },
307
309
  });
308
310
  manager.openFilePreview({
309
- path: 'README.md',
310
- label: 'README.md',
311
+ path: 'chart.html',
312
+ label: 'Chart',
311
313
  viewMode: 'diff',
312
314
  beforeText: 'old\n',
313
315
  afterText: 'new\n',
@@ -316,13 +318,14 @@ describe('ChatThreadManager', () => {
316
318
  expect(useChatThreadStore.getState().snapshot.workspaceFileTabs).toEqual(
317
319
  expect.arrayContaining([
318
320
  expect.objectContaining({
319
- key: 'parent-session-1::preview:rendered::README.md',
320
- path: 'README.md',
321
+ key: 'parent-session-1::preview:rendered::chart.html',
322
+ path: 'chart.html',
321
323
  viewMode: 'preview',
324
+ params: { chart: { series: [3, 5, 8] } },
322
325
  }),
323
326
  expect.objectContaining({
324
- key: 'parent-session-1::diff::README.md',
325
- path: 'README.md',
327
+ key: 'parent-session-1::diff::chart.html',
328
+ path: 'chart.html',
326
329
  viewMode: 'diff',
327
330
  }),
328
331
  ]),