@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.
- package/CHANGELOG.md +31 -0
- package/dist/assets/api-O5yEgzBt.js +13 -0
- package/dist/assets/app-presenter-provider-DXTYtH78.js +106 -0
- package/dist/assets/appearance-settings-page-BuGxSvlJ.js +1 -0
- package/dist/assets/{book-open-WsEnuW9P.js → book-open-HtUSPwoS.js} +1 -1
- package/dist/assets/card-GkBc-UMw.js +1 -0
- package/dist/assets/{channels-list-page-CAWuTHSM.js → channels-list-page-Bspr1Jp4.js} +2 -2
- package/dist/assets/chat-page-BKQ89f4s.js +1 -0
- package/dist/assets/config-split-page-CvLXSFSC.js +1 -0
- package/dist/assets/confirm-dialog-BVH7haN0.js +5 -0
- package/dist/assets/desktop-update-config-C9ZUI8UW.js +1 -0
- package/dist/assets/{dist-Ca-jiFrK.js → dist-CZx9Q6jc.js} +1 -1
- package/dist/assets/doc-browser-B4l0Xu7u.js +1 -0
- package/dist/assets/doc-browser-DsVF1DrS.js +1 -0
- package/dist/assets/doc-browser-context-Dy7EhLI8.js +1 -0
- package/dist/assets/{eye-B30J471A.js → eye-DU7p1-7X.js} +1 -1
- package/dist/assets/form-actions-aM9_jSJC.js +1 -0
- package/dist/assets/index-6pHX0DZH.js +2 -0
- package/dist/assets/index-DN6KM7WD.css +1 -0
- package/dist/assets/loader-circle-Dd6jw700.js +1 -0
- package/dist/assets/logo-badge-2-iV1pkr.js +1 -0
- package/dist/assets/mcp-marketplace-page-C-d0RMfg.js +1 -0
- package/dist/assets/mcp-marketplace-page-Dg03SZX1.js +9 -0
- package/dist/assets/model-config-page-DOBqLZoQ.js +1 -0
- package/dist/assets/{navigation-link-qPPUiLt0.js → navigation-link-Ct6xtnVM.js} +1 -1
- package/dist/assets/plus-CsgeRId8.js +1 -0
- package/dist/assets/{popover-DHx55dbc.js → popover-C2oIpGBF.js} +1 -1
- package/dist/assets/provider-scoped-model-input-DDqCdnTU.js +1 -0
- package/dist/assets/{providers-config-page-BwHtHIPe.js → providers-config-page-BAVPJKpx.js} +1 -1
- package/dist/assets/react-CeMr9V52.js +8 -0
- package/dist/assets/{refresh-cw-C7bynrWX.js → refresh-cw-BZ8giusN.js} +1 -1
- package/dist/assets/remote-C-r0SHcz.js +1 -0
- package/dist/assets/rotate-cw-DVdRfMSO.js +1 -0
- package/dist/assets/{runtime-config-page-CbSrKeJt.js → runtime-config-page-D0244uxJ.js} +1 -1
- package/dist/assets/{save-CsRsFtHS.js → save-CSzFGRse.js} +1 -1
- package/dist/assets/{search-D0HtJIL-.js → search-BmSWsiG2.js} +1 -1
- package/dist/assets/{search-config-page-n1GElgij.js → search-config-page-CrL1pUzy.js} +1 -1
- package/dist/assets/{secrets-config-page-DbUc3Ggi.js → secrets-config-page-BlgsD0-L.js} +2 -2
- package/dist/assets/security-config-cDrdpMJW.js +1 -0
- package/dist/assets/{select-C1zfp9zM.js → select-CklWb6Rs.js} +1 -1
- package/dist/assets/{settings-2-Ba3cLSN2.js → settings-2-BSZf8327.js} +1 -1
- package/dist/assets/skeleton-DG9u1BmA.js +1 -0
- package/dist/assets/{tabs-DmKgMKzb.js → tabs-Cah5klko.js} +1 -1
- package/dist/assets/{tag-chip-2vwlVUwM.js → tag-chip-DcrirDgN.js} +1 -1
- package/dist/assets/{trash-2-CUpWjNHu.js → trash-2-BLQOULWD.js} +1 -1
- package/dist/assets/use-config-CuFIsUVG.js +1 -0
- package/dist/assets/use-confirm-dialog-CcySmkWp.js +1 -0
- package/dist/assets/{use-viewport-layout-DaKJ-kap.js → use-viewport-layout-DU29bUJS.js} +1 -1
- package/dist/assets/x-BVoeXny1.js +1 -0
- package/dist/index.html +28 -28
- package/package.json +9 -9
- package/src/features/chat/components/conversation/__tests__/chat-conversation-content.test.tsx +11 -0
- package/src/features/chat/components/conversation/chat-conversation-content.tsx +7 -1
- package/src/features/chat/features/conversation/components/__tests__/session-conversation-area.test.tsx +50 -4
- package/src/features/chat/features/conversation/components/session-conversation-area.tsx +25 -1
- package/src/features/chat/features/conversation/components/session-conversation-input.tsx +3 -0
- package/src/features/chat/features/conversation/hooks/__tests__/use-session-conversation-input-state.test.tsx +41 -0
- package/src/features/chat/features/conversation/hooks/__tests__/use-session-conversation-slash-commands.test.tsx +28 -0
- package/src/features/chat/features/conversation/hooks/use-session-conversation-input-query.ts +1 -1
- package/src/features/chat/features/conversation/hooks/use-session-conversation-input-state.ts +0 -1
- package/src/features/chat/features/conversation/hooks/use-session-conversation-preference-actions.ts +10 -11
- package/src/features/chat/features/conversation/hooks/use-session-conversation-slash-commands.ts +5 -2
- package/src/features/chat/features/message/components/__tests__/chat-inline-panel-app-card.test.tsx +35 -0
- package/src/features/chat/features/message/components/chat-inline-file-preview.tsx +3 -1
- package/src/features/chat/features/message/components/chat-inline-panel-app-card.tsx +13 -5
- package/src/features/chat/features/message/components/chat-message-list.container.tsx +13 -9
- package/src/features/chat/features/message/utils/__tests__/chat-message-show-content-tool-card.utils.test.ts +9 -0
- package/src/features/chat/features/message/utils/chat-inline-panel-app-card.utils.ts +4 -1
- package/src/features/chat/features/message/utils/chat-message-show-content-tool-card.utils.ts +29 -0
- package/src/features/chat/features/message/utils/chat-message-texts.utils.ts +1 -0
- package/src/features/chat/features/ncp/hooks/__tests__/use-hydrated-ncp-agent.test.tsx +122 -1
- package/src/features/chat/features/ncp/hooks/__tests__/use-ncp-agent-runtime-queue.test.tsx +31 -6
- package/src/features/chat/features/ncp/hooks/__tests__/use-ncp-agent-runtime.test.tsx +107 -1
- package/src/features/chat/features/workspace/components/__tests__/workspace-file-content-preview.test.tsx +41 -0
- package/src/features/chat/features/workspace/components/chat-session-workspace-file-preview.tsx +4 -0
- package/src/features/chat/features/workspace/components/workspace-file-content-preview.tsx +15 -2
- package/src/features/chat/features/workspace/utils/chat-workspace-file-tab-persistence.utils.ts +11 -0
- package/src/features/chat/features/workspace/utils/chat-workspace-file-tab.utils.ts +1 -0
- package/src/features/chat/managers/__tests__/chat-thread.manager.test.ts +11 -8
- package/src/features/chat/managers/__tests__/chat-ui.manager.test.ts +6 -0
- package/src/features/chat/managers/chat-thread.manager.ts +2 -0
- package/src/features/chat/managers/chat-ui.manager.ts +10 -4
- package/src/features/chat/stores/__tests__/chat-thread.store.test.ts +4 -0
- package/src/features/chat/stores/chat-thread.store.ts +6 -1
- package/src/shared/components/doc-browser/__tests__/doc-browser-context.test.tsx +32 -0
- package/src/shared/components/doc-browser/doc-browser-panel-parts.tsx +2 -0
- package/src/shared/components/doc-browser/managers/doc-browser.manager.ts +20 -38
- package/src/shared/components/doc-browser/types/doc-browser.types.ts +5 -0
- package/src/shared/components/doc-browser/utils/doc-browser-state.utils.ts +63 -0
- package/src/shared/lib/i18n/locales/en-US/chat.json +1 -0
- package/src/shared/lib/i18n/locales/zh-CN/chat.json +1 -0
- package/dist/assets/api-D5UCLVE_.js +0 -13
- package/dist/assets/app-presenter-provider-C9AcZKKs.js +0 -106
- package/dist/assets/appearance-settings-page-CiD9Ey8f.js +0 -1
- package/dist/assets/card-xVrS1b1T.js +0 -1
- package/dist/assets/chat-page-B0cC3jsB.js +0 -1
- package/dist/assets/config-split-page-xj0UtMow.js +0 -1
- package/dist/assets/confirm-dialog-e2YCUzt1.js +0 -5
- package/dist/assets/desktop-update-config-D5NIaimo.js +0 -1
- package/dist/assets/doc-browser-B2tNEKrJ.js +0 -1
- package/dist/assets/doc-browser-CZX6I6CU.js +0 -1
- package/dist/assets/doc-browser-context-CGz0LULR.js +0 -1
- package/dist/assets/form-actions-CnSzu3WD.js +0 -1
- package/dist/assets/index-QveCjxCj.css +0 -1
- package/dist/assets/index-bOqpao-w.js +0 -2
- package/dist/assets/loader-circle-DjwSRQHW.js +0 -1
- package/dist/assets/logo-badge-CISsltAo.js +0 -1
- package/dist/assets/mcp-marketplace-page-BZa7p7Qf.js +0 -1
- package/dist/assets/mcp-marketplace-page-CPcPzMUQ.js +0 -9
- package/dist/assets/model-config-page-D6ex3_NM.js +0 -1
- package/dist/assets/plus-C45awBb1.js +0 -1
- package/dist/assets/provider-scoped-model-input-1TJxAhx6.js +0 -1
- package/dist/assets/react-DerwD2EA.js +0 -8
- package/dist/assets/remote-B0nlynVp.js +0 -1
- package/dist/assets/rotate-cw-Bhx0XkQP.js +0 -1
- package/dist/assets/security-config-n2WHhHXV.js +0 -1
- package/dist/assets/skeleton-DL5npsVL.js +0 -1
- package/dist/assets/use-config-BvxDWV-M.js +0 -1
- package/dist/assets/use-confirm-dialog-CeUEE4p5.js +0 -1
- 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],
|
package/src/features/chat/features/message/utils/chat-message-show-content-tool-card.utils.ts
CHANGED
|
@@ -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 {
|
|
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 {
|
|
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('
|
|
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:
|
|
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((
|
|
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
|
+
});
|
package/src/features/chat/features/workspace/components/chat-session-workspace-file-preview.tsx
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
/>
|
package/src/features/chat/features/workspace/utils/chat-workspace-file-tab-persistence.utils.ts
CHANGED
|
@@ -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: '
|
|
305
|
-
label: '
|
|
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: '
|
|
310
|
-
label: '
|
|
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::
|
|
320
|
-
path: '
|
|
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::
|
|
325
|
-
path: '
|
|
327
|
+
key: 'parent-session-1::diff::chart.html',
|
|
328
|
+
path: 'chart.html',
|
|
326
329
|
viewMode: 'diff',
|
|
327
330
|
}),
|
|
328
331
|
]),
|