@nextclaw/ui 0.15.13 → 0.15.15
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 +35 -0
- package/dist/assets/{api-DGG69ZcJ.js → api-D5UCLVE_.js} +3 -3
- package/dist/assets/app-presenter-provider-DcpcRgLn.js +106 -0
- package/dist/assets/{appearance-settings-page-BZSd1SrH.js → appearance-settings-page-yJR6Kt9c.js} +1 -1
- package/dist/assets/{book-open-BhaS5BJv.js → book-open-WsEnuW9P.js} +1 -1
- package/dist/assets/{card-Bcr5YM8F.js → card-xVrS1b1T.js} +1 -1
- package/dist/assets/{channels-list-page-C_x83ZCT.js → channels-list-page-CAWuTHSM.js} +1 -1
- package/dist/assets/{chat-page-D2xfR6TK.js → chat-page-CXYXMIQQ.js} +1 -1
- package/dist/assets/{config-split-page-RXAoLeNv.js → config-split-page-xj0UtMow.js} +1 -1
- package/dist/assets/{confirm-dialog-o_VPKvVD.js → confirm-dialog-e2YCUzt1.js} +1 -1
- package/dist/assets/{desktop-update-config-DEOMOsDN.js → desktop-update-config-D5NIaimo.js} +1 -1
- package/dist/assets/{dist-C87P4QWA.js → dist-Ca-jiFrK.js} +1 -1
- package/dist/assets/doc-browser-B2tNEKrJ.js +1 -0
- package/dist/assets/{doc-browser-DQ75iUkA.js → doc-browser-CZX6I6CU.js} +1 -1
- package/dist/assets/{doc-browser-context-tE0--wSa.js → doc-browser-context-CGz0LULR.js} +1 -1
- package/dist/assets/{eye-DL9QkrKg.js → eye-B30J471A.js} +1 -1
- package/dist/assets/{form-actions-J0tIaUdK.js → form-actions-CnSzu3WD.js} +1 -1
- package/dist/assets/{index-B9QEjnoY.js → index-6Esxu-Pp.js} +2 -2
- package/dist/assets/index-BzSgA8tR.css +1 -0
- package/dist/assets/loader-circle-DjwSRQHW.js +1 -0
- package/dist/assets/{logo-badge-BYTbZbOF.js → logo-badge-CISsltAo.js} +1 -1
- package/dist/assets/mcp-marketplace-page-BZa7p7Qf.js +1 -0
- package/dist/assets/{mcp-marketplace-page-COWlzeP1.js → mcp-marketplace-page-CPcPzMUQ.js} +1 -1
- package/dist/assets/{model-config-page-oNqnMXNY.js → model-config-page-D6ex3_NM.js} +1 -1
- package/dist/assets/{navigation-link-Dk6VdDkc.js → navigation-link-qPPUiLt0.js} +1 -1
- package/dist/assets/plus-C45awBb1.js +1 -0
- package/dist/assets/{popover-Txwrum65.js → popover-DHx55dbc.js} +1 -1
- package/dist/assets/{provider-scoped-model-input-B7rQM4b6.js → provider-scoped-model-input-1TJxAhx6.js} +1 -1
- package/dist/assets/{providers-config-page-CP-OnvLK.js → providers-config-page-BwHtHIPe.js} +1 -1
- package/dist/assets/{react-B0LbRnAE.js → react-DerwD2EA.js} +1 -1
- package/dist/assets/{refresh-cw-C84mMku5.js → refresh-cw-C7bynrWX.js} +1 -1
- package/dist/assets/remote-CPpTKDgX.js +1 -0
- package/dist/assets/rotate-cw-Bhx0XkQP.js +1 -0
- package/dist/assets/{runtime-config-page-u_fuHxc8.js → runtime-config-page-CMLVC5UV.js} +1 -1
- package/dist/assets/{save-R_qyrHpa.js → save-CsRsFtHS.js} +1 -1
- package/dist/assets/{search-UKxiouNi.js → search-D0HtJIL-.js} +1 -1
- package/dist/assets/{search-config-page-DzkpSGFH.js → search-config-page-n1GElgij.js} +1 -1
- package/dist/assets/{secrets-config-page-DhoLYm7Z.js → secrets-config-page-DbUc3Ggi.js} +1 -1
- package/dist/assets/security-config-n2WHhHXV.js +1 -0
- package/dist/assets/{select-CYHT8P5S.js → select-C1zfp9zM.js} +1 -1
- package/dist/assets/{settings-2-DGh4bGSi.js → settings-2-Ba3cLSN2.js} +1 -1
- package/dist/assets/skeleton-DL5npsVL.js +1 -0
- package/dist/assets/{tabs-BCihm1zK.js → tabs-DmKgMKzb.js} +1 -1
- package/dist/assets/{tag-chip-De0iz4o2.js → tag-chip-2vwlVUwM.js} +1 -1
- package/dist/assets/{trash-2-Bo3DWiju.js → trash-2-CUpWjNHu.js} +1 -1
- package/dist/assets/{use-config-DCzwgChy.js → use-config-BvxDWV-M.js} +1 -1
- package/dist/assets/{use-confirm-dialog-WZ94CehU.js → use-confirm-dialog-CeUEE4p5.js} +1 -1
- package/dist/assets/{use-viewport-layout-CZ-3pMne.js → use-viewport-layout-DaKJ-kap.js} +1 -1
- package/dist/assets/x-DGdYT7rH.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 +13 -1
- package/src/features/chat/components/conversation/chat-conversation-content.tsx +2 -2
- package/src/features/chat/features/conversation/components/__tests__/session-conversation-area.test.tsx +11 -0
- package/src/features/chat/features/conversation/components/__tests__/session-conversation-input.streaming.test.tsx +4 -0
- package/src/features/chat/features/conversation/components/session-conversation-area.tsx +3 -0
- package/src/features/chat/features/conversation/hooks/__tests__/use-session-conversation-controller.test.tsx +64 -72
- package/src/features/chat/features/conversation/hooks/__tests__/use-session-run-queue.test.tsx +85 -0
- package/src/features/chat/features/conversation/hooks/use-session-conversation-controller.ts +108 -257
- package/src/features/chat/features/conversation/hooks/use-session-run-queue.ts +52 -0
- package/src/features/chat/features/conversation/utils/__tests__/session-queued-input.utils.test.ts +63 -0
- package/src/features/chat/features/conversation/utils/session-queued-input.utils.ts +134 -0
- package/src/features/chat/features/input/utils/__tests__/chat-composer-state.utils.test.ts +5 -5
- package/src/features/chat/features/input/utils/__tests__/chat-inline-token.utils.test.ts +113 -32
- package/src/features/chat/features/input/utils/chat-composer-token-protocol.utils.ts +3 -2
- package/src/features/chat/features/input/utils/chat-inline-token.utils.ts +118 -12
- package/src/features/chat/features/message/components/__tests__/chat-message-list-skill-preview.container.test.tsx +170 -0
- package/src/features/chat/features/message/components/__tests__/chat-message-list.container.test.tsx +18 -9
- package/src/features/chat/features/message/components/chat-message-list.container.tsx +36 -23
- package/src/features/chat/features/message/hooks/__tests__/use-chat-message-virtualizer.test.tsx +90 -0
- package/src/features/chat/features/message/hooks/use-chat-message-virtualizer.ts +3 -5
- package/src/features/chat/features/ncp/hooks/__tests__/use-ncp-agent-runtime-queue.test.tsx +51 -0
- package/src/features/chat/features/session/utils/chat-run-metadata.utils.ts +9 -3
- package/src/features/chat/pages/__tests__/ncp-chat-page.test.ts +42 -8
- 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/app-presenter-provider-B1RgR5ug.js +0 -106
- package/dist/assets/doc-browser-BuGh646I.js +0 -1
- package/dist/assets/index-B2YF-7Yw.css +0 -1
- package/dist/assets/loader-circle-CAnHHSrY.js +0 -1
- package/dist/assets/mcp-marketplace-page-CaUFv37E.js +0 -1
- package/dist/assets/plus-DjdTWPjQ.js +0 -1
- package/dist/assets/remote-2gZkJSX4.js +0 -1
- package/dist/assets/rotate-cw-DQJhDnjg.js +0 -1
- package/dist/assets/security-config-yraYoy9s.js +0 -1
- package/dist/assets/skeleton-BQuylYFO.js +0 -1
- package/dist/assets/x-NZxXyqW1.js +0 -1
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { render, waitFor } from "@testing-library/react";
|
|
2
|
+
import { type ComponentProps, useRef } from "react";
|
|
3
|
+
import { beforeEach, expect, it, vi } from "vitest";
|
|
4
|
+
import { ChatMessageListContainer as RuntimeChatMessageListContainer } from "@/features/chat/features/message/components/chat-message-list.container";
|
|
5
|
+
|
|
6
|
+
function ChatMessageListContainer({
|
|
7
|
+
sessionKey = "session-1",
|
|
8
|
+
...props
|
|
9
|
+
}: Omit<
|
|
10
|
+
ComponentProps<typeof RuntimeChatMessageListContainer>,
|
|
11
|
+
"sessionKey" | "scrollRef"
|
|
12
|
+
> & {
|
|
13
|
+
sessionKey?: string | null;
|
|
14
|
+
}) {
|
|
15
|
+
const scrollRef = useRef<HTMLDivElement>(null);
|
|
16
|
+
return (
|
|
17
|
+
<RuntimeChatMessageListContainer
|
|
18
|
+
{...props}
|
|
19
|
+
scrollRef={scrollRef}
|
|
20
|
+
sessionKey={sessionKey}
|
|
21
|
+
/>
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const captures = vi.hoisted(() => ({
|
|
26
|
+
renders: [] as Array<{
|
|
27
|
+
onInlineTokenClick?: (token: unknown) => void;
|
|
28
|
+
}>,
|
|
29
|
+
openFilePreview: vi.fn(),
|
|
30
|
+
fetchSessionSkills: vi.fn(),
|
|
31
|
+
toastError: vi.fn(),
|
|
32
|
+
selectedSession: null as null | {
|
|
33
|
+
projectRoot: string;
|
|
34
|
+
workingDir?: string | null;
|
|
35
|
+
},
|
|
36
|
+
}));
|
|
37
|
+
|
|
38
|
+
vi.mock("@/shared/lib/api", async (importOriginal) => ({
|
|
39
|
+
...(await importOriginal<object>()),
|
|
40
|
+
fetchNcpSessionSkills: captures.fetchSessionSkills,
|
|
41
|
+
}));
|
|
42
|
+
|
|
43
|
+
vi.mock("sonner", () => ({
|
|
44
|
+
toast: { error: captures.toastError },
|
|
45
|
+
}));
|
|
46
|
+
|
|
47
|
+
vi.mock("@nextclaw/agent-chat-ui", async (importOriginal) => ({
|
|
48
|
+
...((await importOriginal()) as object),
|
|
49
|
+
ChatMessageList: (props: {
|
|
50
|
+
onInlineTokenClick?: (token: unknown) => void;
|
|
51
|
+
}) => {
|
|
52
|
+
captures.renders.push(props);
|
|
53
|
+
return <div data-testid="chat-message-list" />;
|
|
54
|
+
},
|
|
55
|
+
}));
|
|
56
|
+
|
|
57
|
+
vi.mock(
|
|
58
|
+
"@/features/chat/features/message/hooks/use-chat-message-virtualizer",
|
|
59
|
+
() => ({
|
|
60
|
+
useChatMessageVirtualizer: ({
|
|
61
|
+
rows,
|
|
62
|
+
}: {
|
|
63
|
+
rows: Array<{ key: string }>;
|
|
64
|
+
}) => ({
|
|
65
|
+
containerRef: vi.fn(),
|
|
66
|
+
virtualizer: {
|
|
67
|
+
getVirtualItems: () => rows.map((_, index) => ({ index, start: 0 })),
|
|
68
|
+
measureElement: vi.fn(),
|
|
69
|
+
},
|
|
70
|
+
}),
|
|
71
|
+
}),
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
vi.mock("@/features/chat/components/providers/chat-presenter.provider", () => ({
|
|
75
|
+
usePresenter: () => ({
|
|
76
|
+
chatThreadManager: {
|
|
77
|
+
openFilePreview: captures.openFilePreview,
|
|
78
|
+
handleToolAction: vi.fn(),
|
|
79
|
+
},
|
|
80
|
+
chatUiManager: { showContent: vi.fn() },
|
|
81
|
+
}),
|
|
82
|
+
}));
|
|
83
|
+
|
|
84
|
+
vi.mock(
|
|
85
|
+
"@/features/chat/features/ncp/hooks/use-ncp-chat-derived-state",
|
|
86
|
+
() => ({
|
|
87
|
+
useNcpChatSelectedSession: () => captures.selectedSession,
|
|
88
|
+
}),
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
vi.mock("@/app/components/i18n-provider", () => ({
|
|
92
|
+
useI18n: () => ({ language: "en" }),
|
|
93
|
+
}));
|
|
94
|
+
|
|
95
|
+
vi.mock("@/shared/lib/i18n", () => ({
|
|
96
|
+
formatDateTime: (value: string) => value,
|
|
97
|
+
t: (key: string) => key,
|
|
98
|
+
}));
|
|
99
|
+
|
|
100
|
+
beforeEach(() => {
|
|
101
|
+
captures.renders = [];
|
|
102
|
+
captures.openFilePreview.mockReset();
|
|
103
|
+
captures.fetchSessionSkills.mockReset();
|
|
104
|
+
captures.toastError.mockReset();
|
|
105
|
+
captures.selectedSession = null;
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
function clickSkill(token: Record<string, unknown>, sessionKey?: string): void {
|
|
109
|
+
render(
|
|
110
|
+
<ChatMessageListContainer
|
|
111
|
+
messages={[]}
|
|
112
|
+
isSending
|
|
113
|
+
sessionKey={sessionKey}
|
|
114
|
+
/>,
|
|
115
|
+
);
|
|
116
|
+
captures.renders[captures.renders.length - 1]?.onInlineTokenClick?.(token);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
it("opens a persisted skill path without consulting the session catalog", () => {
|
|
120
|
+
clickSkill({
|
|
121
|
+
kind: "skill", ref: "global:/skills/review", name: "review", source: "global",
|
|
122
|
+
path: "/home/.agents/skills/review/SKILL.md", label: "review", rawText: "$review",
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
expect(captures.openFilePreview).toHaveBeenCalledWith({
|
|
126
|
+
path: "/home/.agents/skills/review/SKILL.md",
|
|
127
|
+
label: "review",
|
|
128
|
+
viewMode: "preview",
|
|
129
|
+
previewViewer: "rendered",
|
|
130
|
+
});
|
|
131
|
+
expect(captures.fetchSessionSkills).not.toHaveBeenCalled();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("resolves a persisted v1 skill against its owning session", async () => {
|
|
135
|
+
captures.selectedSession = { projectRoot: "/tmp/project" };
|
|
136
|
+
captures.fetchSessionSkills.mockResolvedValue({
|
|
137
|
+
records: [{
|
|
138
|
+
ref: "project:/tmp/project/.agents/skills/review",
|
|
139
|
+
name: "review",
|
|
140
|
+
path: "/tmp/project/.agents/skills/review/SKILL.md",
|
|
141
|
+
}],
|
|
142
|
+
});
|
|
143
|
+
clickSkill({
|
|
144
|
+
kind: "skill", ref: "project:/tmp/project/.agents/skills/review", name: "review",
|
|
145
|
+
source: null, path: null, label: "review", rawText: "$project:/skills/review",
|
|
146
|
+
}, "session-legacy");
|
|
147
|
+
|
|
148
|
+
await waitFor(() => expect(captures.openFilePreview).toHaveBeenCalledWith({
|
|
149
|
+
path: "/tmp/project/.agents/skills/review/SKILL.md",
|
|
150
|
+
label: "review",
|
|
151
|
+
viewMode: "preview",
|
|
152
|
+
previewViewer: "rendered",
|
|
153
|
+
}));
|
|
154
|
+
expect(captures.fetchSessionSkills).toHaveBeenCalledWith("session-legacy", {
|
|
155
|
+
projectRoot: "/tmp/project",
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("shows an error when a persisted v1 skill is no longer resolvable", async () => {
|
|
160
|
+
captures.fetchSessionSkills.mockResolvedValue({ records: [] });
|
|
161
|
+
clickSkill({
|
|
162
|
+
kind: "skill", ref: "workspace:/missing/review", name: "review",
|
|
163
|
+
source: null, path: null, label: "review", rawText: "$workspace:/missing/review",
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
await waitFor(() => expect(captures.toastError).toHaveBeenCalledWith(
|
|
167
|
+
"chatSkillPreviewUnavailable",
|
|
168
|
+
));
|
|
169
|
+
expect(captures.openFilePreview).not.toHaveBeenCalled();
|
|
170
|
+
});
|
package/src/features/chat/features/message/components/__tests__/chat-message-list.container.test.tsx
CHANGED
|
@@ -224,14 +224,20 @@ it("adapts persisted inline token metadata into markdown token data", () => {
|
|
|
224
224
|
status: "final",
|
|
225
225
|
timestamp: "2026-03-31T10:00:00.000Z",
|
|
226
226
|
metadata: {
|
|
227
|
-
ui_inline_tokens:
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
227
|
+
ui_inline_tokens: {
|
|
228
|
+
schemaVersion: 2,
|
|
229
|
+
items: [
|
|
230
|
+
{
|
|
231
|
+
kind: "skill",
|
|
232
|
+
ref: "workspace:/skills/weather",
|
|
233
|
+
name: "weather",
|
|
234
|
+
source: "workspace",
|
|
235
|
+
path: "/skills/weather/SKILL.md",
|
|
236
|
+
label: "Weather",
|
|
237
|
+
rawText: "$weather",
|
|
238
|
+
},
|
|
239
|
+
],
|
|
240
|
+
},
|
|
235
241
|
},
|
|
236
242
|
parts: [{ type: "text", text: "please use $weather now" }],
|
|
237
243
|
} satisfies NcpMessage;
|
|
@@ -249,7 +255,10 @@ it("adapts persisted inline token metadata into markdown token data", () => {
|
|
|
249
255
|
inlineTokens: [
|
|
250
256
|
{
|
|
251
257
|
kind: "skill",
|
|
252
|
-
|
|
258
|
+
ref: "workspace:/skills/weather",
|
|
259
|
+
name: "weather",
|
|
260
|
+
source: "workspace",
|
|
261
|
+
path: "/skills/weather/SKILL.md",
|
|
253
262
|
label: "Weather",
|
|
254
263
|
rawText: "$weather",
|
|
255
264
|
},
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
type RefObject,
|
|
7
7
|
} from "react";
|
|
8
8
|
import type { NcpMessage } from "@nextclaw/ncp";
|
|
9
|
+
import { toast } from "sonner";
|
|
9
10
|
import {
|
|
10
11
|
type ChatInlineDisplayViewModel,
|
|
11
12
|
type ChatInlineTokenViewModel,
|
|
@@ -51,7 +52,7 @@ import {
|
|
|
51
52
|
type ChatTimelineItem,
|
|
52
53
|
type ContextInheritanceTimelineView,
|
|
53
54
|
} from "@/features/chat/features/message/utils/chat-message-timeline.utils";
|
|
54
|
-
import { buildServerPathContentUrl } from "@/shared/lib/api";
|
|
55
|
+
import { buildServerPathContentUrl, fetchNcpSessionSkills } from "@/shared/lib/api";
|
|
55
56
|
import { formatDateTime, t } from "@/shared/lib/i18n";
|
|
56
57
|
import { cn } from "@/shared/lib/utils";
|
|
57
58
|
|
|
@@ -331,20 +332,18 @@ export function ChatMessageListContainer({
|
|
|
331
332
|
activeRowKey,
|
|
332
333
|
focusedRowKey,
|
|
333
334
|
});
|
|
334
|
-
const sessionSkillsQuery = useChatQueryStore(
|
|
335
|
-
(state) => state.snapshot.sessionSkillsQuery,
|
|
336
|
-
);
|
|
337
335
|
const handleInlineTokenClick = useCallback(
|
|
338
336
|
(token: ChatInlineTokenViewModel) => {
|
|
339
|
-
if (token.kind === "panel_app") {
|
|
337
|
+
if (token.kind === "panel_app" && "key" in token) {
|
|
340
338
|
void presenter.chatUiManager.showContent({
|
|
341
339
|
target: { type: "panel_app", payload: { appId: token.key } },
|
|
342
340
|
});
|
|
343
341
|
return;
|
|
344
342
|
}
|
|
345
343
|
if (
|
|
346
|
-
|
|
347
|
-
token.kind === "
|
|
344
|
+
"key" in token &&
|
|
345
|
+
(token.kind === "workspace_file" ||
|
|
346
|
+
token.kind === "workspace_directory")
|
|
348
347
|
) {
|
|
349
348
|
const path = resolveWorkspaceReferencePath({
|
|
350
349
|
projectRoot: selectedSession?.projectRoot,
|
|
@@ -359,31 +358,45 @@ export function ChatMessageListContainer({
|
|
|
359
358
|
}
|
|
360
359
|
return;
|
|
361
360
|
}
|
|
362
|
-
if (token.kind !== "skill") return;
|
|
363
|
-
const
|
|
364
|
-
if (
|
|
361
|
+
if (token.kind !== "skill" || !("ref" in token)) return;
|
|
362
|
+
const skillPath = token.path?.trim();
|
|
363
|
+
if (skillPath) {
|
|
364
|
+
presenter.chatThreadManager.openFilePreview({
|
|
365
|
+
path: skillPath,
|
|
366
|
+
label: token.label || token.name,
|
|
367
|
+
viewMode: "preview",
|
|
368
|
+
previewViewer: "rendered",
|
|
369
|
+
});
|
|
365
370
|
return;
|
|
366
371
|
}
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
(record) => record.ref === skillKey || record.name === skillKey,
|
|
370
|
-
);
|
|
371
|
-
const skillPath = matched?.path?.trim();
|
|
372
|
-
if (!skillPath) {
|
|
372
|
+
if (!sessionKey) {
|
|
373
|
+
toast.error(t("chatSkillPreviewUnavailable"));
|
|
373
374
|
return;
|
|
374
375
|
}
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
376
|
+
void fetchNcpSessionSkills(sessionKey, {
|
|
377
|
+
projectRoot: selectedSession?.projectRoot ?? null,
|
|
378
|
+
}).then(({ records }) => {
|
|
379
|
+
const exact = records.find((record) => record.ref === token.ref);
|
|
380
|
+
const named = records.filter((record) => record.name === token.name);
|
|
381
|
+
const matched = exact ?? (named.length === 1 ? named[0] : null);
|
|
382
|
+
const legacyPath = matched?.path.trim();
|
|
383
|
+
if (!matched || !legacyPath) {
|
|
384
|
+
toast.error(t("chatSkillPreviewUnavailable"));
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
presenter.chatThreadManager.openFilePreview({
|
|
388
|
+
path: legacyPath,
|
|
389
|
+
label: token.label || matched.name,
|
|
390
|
+
viewMode: "preview",
|
|
391
|
+
previewViewer: "rendered",
|
|
392
|
+
});
|
|
393
|
+
}).catch(() => toast.error(t("chatSkillPreviewUnavailable")));
|
|
381
394
|
},
|
|
382
395
|
[
|
|
383
396
|
presenter.chatThreadManager,
|
|
384
397
|
presenter.chatUiManager,
|
|
385
398
|
selectedSession?.projectRoot,
|
|
386
|
-
|
|
399
|
+
sessionKey,
|
|
387
400
|
],
|
|
388
401
|
);
|
|
389
402
|
const handleAttachmentOpen = useCallback(
|
package/src/features/chat/features/message/hooks/__tests__/use-chat-message-virtualizer.test.tsx
CHANGED
|
@@ -160,4 +160,94 @@ describe("useChatMessageVirtualizer", () => {
|
|
|
160
160
|
expect(result.current.virtualizer.getTotalSize()).toBe(420);
|
|
161
161
|
row.remove();
|
|
162
162
|
});
|
|
163
|
+
|
|
164
|
+
it("keeps the visible anchor when an offscreen earlier row changes height", async () => {
|
|
165
|
+
const scrollElement = document.createElement("div");
|
|
166
|
+
Object.defineProperties(scrollElement, {
|
|
167
|
+
clientHeight: { configurable: true, value: 800 },
|
|
168
|
+
offsetHeight: { configurable: true, value: 800 },
|
|
169
|
+
offsetWidth: { configurable: true, value: 800 },
|
|
170
|
+
scrollTop: { configurable: true, writable: true, value: 0 },
|
|
171
|
+
});
|
|
172
|
+
scrollElement.scrollTo = vi.fn((options) => {
|
|
173
|
+
if (typeof options === "object" && options.top !== undefined) {
|
|
174
|
+
scrollElement.scrollTop = options.top;
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
const rows = Array.from({ length: 10 }, (_, index) => ({
|
|
178
|
+
key: `message:${index}`,
|
|
179
|
+
}));
|
|
180
|
+
const { result } = renderHook(() =>
|
|
181
|
+
useChatMessageVirtualizer({
|
|
182
|
+
rows,
|
|
183
|
+
scrollRef: { current: scrollElement },
|
|
184
|
+
}),
|
|
185
|
+
);
|
|
186
|
+
await waitFor(() =>
|
|
187
|
+
expect(
|
|
188
|
+
result.current.virtualizer.getVirtualItems().length,
|
|
189
|
+
).toBeGreaterThan(0),
|
|
190
|
+
);
|
|
191
|
+
act(() => result.current.virtualizer.resizeItem(0, 220));
|
|
192
|
+
scrollElement.scrollTop = 900;
|
|
193
|
+
act(() => scrollElement.dispatchEvent(new Event("scroll")));
|
|
194
|
+
await waitFor(() =>
|
|
195
|
+
expect(result.current.virtualizer.scrollOffset).toBe(900),
|
|
196
|
+
);
|
|
197
|
+
const anchor = result.current.virtualizer.getVirtualItemForOffset(900);
|
|
198
|
+
const anchorOffset = 900 - (anchor?.start ?? 0);
|
|
199
|
+
|
|
200
|
+
act(() => result.current.virtualizer.resizeItem(0, 480));
|
|
201
|
+
|
|
202
|
+
const anchored = result.current.virtualizer.getVirtualItemForOffset(
|
|
203
|
+
scrollElement.scrollTop,
|
|
204
|
+
);
|
|
205
|
+
expect(scrollElement.scrollTop).toBe(1_160);
|
|
206
|
+
expect(anchored?.key).toBe(anchor?.key);
|
|
207
|
+
expect(scrollElement.scrollTop - (anchored?.start ?? 0)).toBe(anchorOffset);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("keeps later rows anchored when the first visible row changes height", async () => {
|
|
211
|
+
const scrollElement = document.createElement("div");
|
|
212
|
+
Object.defineProperties(scrollElement, {
|
|
213
|
+
clientHeight: { configurable: true, value: 800 },
|
|
214
|
+
offsetHeight: { configurable: true, value: 800 },
|
|
215
|
+
offsetWidth: { configurable: true, value: 800 },
|
|
216
|
+
scrollTop: { configurable: true, writable: true, value: 0 },
|
|
217
|
+
});
|
|
218
|
+
scrollElement.scrollTo = vi.fn((options) => {
|
|
219
|
+
if (typeof options === "object" && options.top !== undefined) {
|
|
220
|
+
scrollElement.scrollTop = options.top;
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
const rows = Array.from({ length: 10 }, (_, index) => ({
|
|
224
|
+
key: `message:${index}`,
|
|
225
|
+
}));
|
|
226
|
+
const { result } = renderHook(() =>
|
|
227
|
+
useChatMessageVirtualizer({
|
|
228
|
+
rows,
|
|
229
|
+
scrollRef: { current: scrollElement },
|
|
230
|
+
}),
|
|
231
|
+
);
|
|
232
|
+
await waitFor(() =>
|
|
233
|
+
expect(
|
|
234
|
+
result.current.virtualizer.getVirtualItems().length,
|
|
235
|
+
).toBeGreaterThan(0),
|
|
236
|
+
);
|
|
237
|
+
act(() => result.current.virtualizer.resizeItem(4, 220));
|
|
238
|
+
scrollElement.scrollTop = 900;
|
|
239
|
+
act(() => scrollElement.dispatchEvent(new Event("scroll")));
|
|
240
|
+
await waitFor(() =>
|
|
241
|
+
expect(result.current.virtualizer.scrollOffset).toBe(900),
|
|
242
|
+
);
|
|
243
|
+
const anchor = result.current.virtualizer.getVirtualItemForOffset(980);
|
|
244
|
+
const anchorTop = (anchor?.start ?? 0) - scrollElement.scrollTop;
|
|
245
|
+
|
|
246
|
+
act(() => result.current.virtualizer.resizeItem(4, 480));
|
|
247
|
+
|
|
248
|
+
const anchored = result.current.virtualizer.getVirtualItemForOffset(1_240);
|
|
249
|
+
expect(scrollElement.scrollTop).toBe(1_160);
|
|
250
|
+
expect(anchored?.key).toBe(anchor?.key);
|
|
251
|
+
expect((anchored?.start ?? 0) - scrollElement.scrollTop).toBe(anchorTop);
|
|
252
|
+
});
|
|
163
253
|
});
|
|
@@ -5,14 +5,10 @@ import {
|
|
|
5
5
|
useVirtualizer,
|
|
6
6
|
} from "@tanstack/react-virtual";
|
|
7
7
|
|
|
8
|
-
type ChatMessageVirtualRow = {
|
|
9
|
-
key: string;
|
|
10
|
-
};
|
|
11
|
-
|
|
12
8
|
const ESTIMATED_CHAT_MESSAGE_ROW_HEIGHT = 180;
|
|
13
9
|
|
|
14
10
|
export function useChatMessageVirtualizer(params: {
|
|
15
|
-
rows: readonly
|
|
11
|
+
rows: readonly { key: string }[];
|
|
16
12
|
scrollRef: RefObject<HTMLDivElement | null>;
|
|
17
13
|
activeRowKey?: string | null;
|
|
18
14
|
focusedRowKey?: string | null;
|
|
@@ -56,6 +52,8 @@ export function useChatMessageVirtualizer(params: {
|
|
|
56
52
|
elementScroll(offset, options, instance);
|
|
57
53
|
},
|
|
58
54
|
});
|
|
55
|
+
virtualizer.shouldAdjustScrollPositionOnItemSizeChange = (item) =>
|
|
56
|
+
item.start < (virtualizer.scrollOffset ?? 0);
|
|
59
57
|
const containerRef = useCallback(
|
|
60
58
|
(node: HTMLDivElement | null) => {
|
|
61
59
|
sizeContainerRef.current = node;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { act, renderHook } from '@testing-library/react';
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { NcpEventType, type NcpAgentClientEndpoint } from '@nextclaw/ncp';
|
|
4
|
+
import { useNcpAgentRuntime } from '@nextclaw/ncp-react';
|
|
5
|
+
import { DefaultNcpAgentConversationStateManager } from '@nextclaw/ncp-toolkit';
|
|
6
|
+
|
|
7
|
+
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 () => {
|
|
9
|
+
const manager = new DefaultNcpAgentConversationStateManager();
|
|
10
|
+
manager.hydrate({ sessionId: 'session-1', messages: [] });
|
|
11
|
+
await manager.dispatch({
|
|
12
|
+
type: NcpEventType.RunStarted,
|
|
13
|
+
payload: {
|
|
14
|
+
sessionId: 'session-1',
|
|
15
|
+
runId: 'run-1',
|
|
16
|
+
messageId: 'assistant-1',
|
|
17
|
+
},
|
|
18
|
+
});
|
|
19
|
+
const send = vi.fn(async () => ({
|
|
20
|
+
assistantMessageId: null,
|
|
21
|
+
runId: null,
|
|
22
|
+
sessionId: 'session-1',
|
|
23
|
+
userMessageId: 'user-queued',
|
|
24
|
+
}));
|
|
25
|
+
const client = {
|
|
26
|
+
abort: vi.fn(async () => undefined),
|
|
27
|
+
send,
|
|
28
|
+
stop: vi.fn(async () => undefined),
|
|
29
|
+
stream: vi.fn(async () => undefined),
|
|
30
|
+
subscribe: vi.fn(() => () => undefined),
|
|
31
|
+
} as unknown as NcpAgentClientEndpoint;
|
|
32
|
+
const { result } = renderHook(() => useNcpAgentRuntime({
|
|
33
|
+
client,
|
|
34
|
+
manager,
|
|
35
|
+
sessionId: 'session-1',
|
|
36
|
+
}));
|
|
37
|
+
|
|
38
|
+
expect(result.current.isRunning).toBe(true);
|
|
39
|
+
await act(async () => {
|
|
40
|
+
await result.current.send('queue this');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
expect(send).toHaveBeenCalledWith(expect.objectContaining({
|
|
44
|
+
sessionId: 'session-1',
|
|
45
|
+
message: expect.objectContaining({
|
|
46
|
+
parts: [{ type: 'text', text: 'queue this' }],
|
|
47
|
+
}),
|
|
48
|
+
}));
|
|
49
|
+
expect(result.current.visibleMessages).toEqual([]);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
@@ -2,7 +2,12 @@ import {
|
|
|
2
2
|
CHAT_SESSION_MATERIALIZATION_METADATA_KEY,
|
|
3
3
|
type AgentRunSessionMaterializationMetadata,
|
|
4
4
|
} from "@nextclaw/shared";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
buildInlineTokensFromComposer,
|
|
7
|
+
CHAT_INLINE_TOKENS_METADATA_KEY,
|
|
8
|
+
createInlineTokensMetadata,
|
|
9
|
+
type ChatSkillReferenceSnapshot,
|
|
10
|
+
} from "@/features/chat/features/input/utils/chat-inline-token.utils";
|
|
6
11
|
import { normalizeRequestedSkills } from "@/features/chat/features/runtime/utils/chat-runtime.utils";
|
|
7
12
|
import { normalizeSessionProjectRootValue } from "@/shared/lib/session-project";
|
|
8
13
|
|
|
@@ -20,6 +25,7 @@ export function buildChatRunMetadata(payload: {
|
|
|
20
25
|
sessionType?: string;
|
|
21
26
|
projectRoot?: string | null;
|
|
22
27
|
requestedSkills?: string[];
|
|
28
|
+
skillRecords?: readonly ChatSkillReferenceSnapshot[];
|
|
23
29
|
composerNodes?: Parameters<typeof buildInlineTokensFromComposer>[0];
|
|
24
30
|
sessionMaterialization?: AgentRunSessionMaterializationMetadata | null;
|
|
25
31
|
}): Record<string, unknown> {
|
|
@@ -36,10 +42,10 @@ export function buildChatRunMetadata(payload: {
|
|
|
36
42
|
metadata.requested_skill_refs = requestedSkills;
|
|
37
43
|
}
|
|
38
44
|
const inlineTokens = payload.composerNodes
|
|
39
|
-
? buildInlineTokensFromComposer(payload.composerNodes)
|
|
45
|
+
? buildInlineTokensFromComposer(payload.composerNodes, payload.skillRecords)
|
|
40
46
|
: [];
|
|
41
47
|
if (inlineTokens.length > 0) {
|
|
42
|
-
metadata[CHAT_INLINE_TOKENS_METADATA_KEY] = inlineTokens;
|
|
48
|
+
metadata[CHAT_INLINE_TOKENS_METADATA_KEY] = createInlineTokensMetadata(inlineTokens);
|
|
43
49
|
}
|
|
44
50
|
if (payload.sessionMaterialization) {
|
|
45
51
|
metadata[CHAT_SESSION_MATERIALIZATION_METADATA_KEY] = payload.sessionMaterialization;
|
|
@@ -190,6 +190,37 @@ describe('buildChatRunMetadata', () => {
|
|
|
190
190
|
});
|
|
191
191
|
});
|
|
192
192
|
|
|
193
|
+
it('snapshots explicit skill references while keeping internal refs out of message text', () => {
|
|
194
|
+
expect(buildChatRunMetadata({
|
|
195
|
+
requestedSkills: ['workspace:/tmp/workspace/skills/review'],
|
|
196
|
+
skillRecords: [{
|
|
197
|
+
ref: 'workspace:/tmp/workspace/skills/review',
|
|
198
|
+
name: 'review',
|
|
199
|
+
source: 'workspace',
|
|
200
|
+
path: '/tmp/workspace/skills/review/SKILL.md',
|
|
201
|
+
}],
|
|
202
|
+
composerNodes: [createChatComposerTokenNode({
|
|
203
|
+
tokenKind: 'skill',
|
|
204
|
+
tokenKey: 'workspace:/tmp/workspace/skills/review',
|
|
205
|
+
label: 'review',
|
|
206
|
+
})],
|
|
207
|
+
})).toMatchObject({
|
|
208
|
+
requested_skill_refs: ['workspace:/tmp/workspace/skills/review'],
|
|
209
|
+
ui_inline_tokens: {
|
|
210
|
+
schemaVersion: 2,
|
|
211
|
+
items: [{
|
|
212
|
+
kind: 'skill',
|
|
213
|
+
ref: 'workspace:/tmp/workspace/skills/review',
|
|
214
|
+
name: 'review',
|
|
215
|
+
source: 'workspace',
|
|
216
|
+
path: '/tmp/workspace/skills/review/SKILL.md',
|
|
217
|
+
label: 'review',
|
|
218
|
+
rawText: '$review',
|
|
219
|
+
}],
|
|
220
|
+
},
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
|
|
193
224
|
it('serializes panel app references as text without run metadata', () => {
|
|
194
225
|
expect(
|
|
195
226
|
buildChatRunMetadata({
|
|
@@ -227,14 +258,17 @@ describe('buildChatRunMetadata', () => {
|
|
|
227
258
|
],
|
|
228
259
|
}),
|
|
229
260
|
).toMatchObject({
|
|
230
|
-
ui_inline_tokens:
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
261
|
+
ui_inline_tokens: {
|
|
262
|
+
schemaVersion: 2,
|
|
263
|
+
items: [
|
|
264
|
+
{
|
|
265
|
+
kind: 'workspace_file',
|
|
266
|
+
key: 'src/file name.ts',
|
|
267
|
+
label: 'file name.ts',
|
|
268
|
+
rawText: '@file:src%2Ffile%20name.ts',
|
|
269
|
+
},
|
|
270
|
+
],
|
|
271
|
+
},
|
|
238
272
|
});
|
|
239
273
|
});
|
|
240
274
|
|
|
@@ -433,6 +433,7 @@
|
|
|
433
433
|
"chatSkillsPickerNoDescription": "No description",
|
|
434
434
|
"chatSkillsPickerOfficial": "Official",
|
|
435
435
|
"chatSkillsPickerManage": "Manage Skills",
|
|
436
|
+
"chatSkillPreviewUnavailable": "This skill file is unavailable. It may have been moved or removed.",
|
|
436
437
|
"chatInputAttach": "Attach file",
|
|
437
438
|
"chatInputAttachmentUnsupported": "This file type is not supported in the current upload flow.",
|
|
438
439
|
"chatInputAttachmentTooLarge": "Files must be {maxMb} MB or smaller.",
|
|
@@ -433,6 +433,7 @@
|
|
|
433
433
|
"chatSkillsPickerNoDescription": "暂无描述",
|
|
434
434
|
"chatSkillsPickerOfficial": "官方",
|
|
435
435
|
"chatSkillsPickerManage": "管理技能",
|
|
436
|
+
"chatSkillPreviewUnavailable": "无法打开这个技能文件,它可能已被移动或删除。",
|
|
436
437
|
"chatInputAttach": "添加附件",
|
|
437
438
|
"chatInputAttachmentUnsupported": "当前上传流程不支持该文件类型。",
|
|
438
439
|
"chatInputAttachmentTooLarge": "文件不能超过 {maxMb} MB。",
|