@nextclaw/ui 0.15.14 → 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.
Files changed (87) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/dist/assets/{api-DGG69ZcJ.js → api-D5UCLVE_.js} +3 -3
  3. package/dist/assets/app-presenter-provider-DcpcRgLn.js +106 -0
  4. package/dist/assets/{appearance-settings-page-Byr55LGb.js → appearance-settings-page-yJR6Kt9c.js} +1 -1
  5. package/dist/assets/{book-open-BhaS5BJv.js → book-open-WsEnuW9P.js} +1 -1
  6. package/dist/assets/{card-Bcr5YM8F.js → card-xVrS1b1T.js} +1 -1
  7. package/dist/assets/{channels-list-page-C_x83ZCT.js → channels-list-page-CAWuTHSM.js} +1 -1
  8. package/dist/assets/{chat-page-CEavSS6B.js → chat-page-CXYXMIQQ.js} +1 -1
  9. package/dist/assets/{config-split-page-RXAoLeNv.js → config-split-page-xj0UtMow.js} +1 -1
  10. package/dist/assets/{confirm-dialog-o_VPKvVD.js → confirm-dialog-e2YCUzt1.js} +1 -1
  11. package/dist/assets/{desktop-update-config-DEOMOsDN.js → desktop-update-config-D5NIaimo.js} +1 -1
  12. package/dist/assets/{dist-C87P4QWA.js → dist-Ca-jiFrK.js} +1 -1
  13. package/dist/assets/doc-browser-B2tNEKrJ.js +1 -0
  14. package/dist/assets/{doc-browser-DQ75iUkA.js → doc-browser-CZX6I6CU.js} +1 -1
  15. package/dist/assets/{doc-browser-context-tE0--wSa.js → doc-browser-context-CGz0LULR.js} +1 -1
  16. package/dist/assets/{eye-DL9QkrKg.js → eye-B30J471A.js} +1 -1
  17. package/dist/assets/{form-actions-J0tIaUdK.js → form-actions-CnSzu3WD.js} +1 -1
  18. package/dist/assets/{index-BFIZUZP3.js → index-6Esxu-Pp.js} +2 -2
  19. package/dist/assets/index-BzSgA8tR.css +1 -0
  20. package/dist/assets/loader-circle-DjwSRQHW.js +1 -0
  21. package/dist/assets/{logo-badge-BYTbZbOF.js → logo-badge-CISsltAo.js} +1 -1
  22. package/dist/assets/mcp-marketplace-page-BZa7p7Qf.js +1 -0
  23. package/dist/assets/{mcp-marketplace-page-COWlzeP1.js → mcp-marketplace-page-CPcPzMUQ.js} +1 -1
  24. package/dist/assets/{model-config-page-oNqnMXNY.js → model-config-page-D6ex3_NM.js} +1 -1
  25. package/dist/assets/{navigation-link-Dk6VdDkc.js → navigation-link-qPPUiLt0.js} +1 -1
  26. package/dist/assets/plus-C45awBb1.js +1 -0
  27. package/dist/assets/{popover-Txwrum65.js → popover-DHx55dbc.js} +1 -1
  28. package/dist/assets/{provider-scoped-model-input-B7rQM4b6.js → provider-scoped-model-input-1TJxAhx6.js} +1 -1
  29. package/dist/assets/{providers-config-page-CP-OnvLK.js → providers-config-page-BwHtHIPe.js} +1 -1
  30. package/dist/assets/{react-B0LbRnAE.js → react-DerwD2EA.js} +1 -1
  31. package/dist/assets/{refresh-cw-C84mMku5.js → refresh-cw-C7bynrWX.js} +1 -1
  32. package/dist/assets/remote-CPpTKDgX.js +1 -0
  33. package/dist/assets/rotate-cw-Bhx0XkQP.js +1 -0
  34. package/dist/assets/{runtime-config-page-CIGtssWi.js → runtime-config-page-CMLVC5UV.js} +1 -1
  35. package/dist/assets/{save-R_qyrHpa.js → save-CsRsFtHS.js} +1 -1
  36. package/dist/assets/{search-UKxiouNi.js → search-D0HtJIL-.js} +1 -1
  37. package/dist/assets/{search-config-page-DzkpSGFH.js → search-config-page-n1GElgij.js} +1 -1
  38. package/dist/assets/{secrets-config-page-DhoLYm7Z.js → secrets-config-page-DbUc3Ggi.js} +1 -1
  39. package/dist/assets/security-config-n2WHhHXV.js +1 -0
  40. package/dist/assets/{select-CYHT8P5S.js → select-C1zfp9zM.js} +1 -1
  41. package/dist/assets/{settings-2-DGh4bGSi.js → settings-2-Ba3cLSN2.js} +1 -1
  42. package/dist/assets/skeleton-DL5npsVL.js +1 -0
  43. package/dist/assets/{tabs-BCihm1zK.js → tabs-DmKgMKzb.js} +1 -1
  44. package/dist/assets/{tag-chip-De0iz4o2.js → tag-chip-2vwlVUwM.js} +1 -1
  45. package/dist/assets/{trash-2-Bo3DWiju.js → trash-2-CUpWjNHu.js} +1 -1
  46. package/dist/assets/{use-config-DCzwgChy.js → use-config-BvxDWV-M.js} +1 -1
  47. package/dist/assets/{use-confirm-dialog-WZ94CehU.js → use-confirm-dialog-CeUEE4p5.js} +1 -1
  48. package/dist/assets/{use-viewport-layout-CZ-3pMne.js → use-viewport-layout-DaKJ-kap.js} +1 -1
  49. package/dist/assets/x-DGdYT7rH.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 +13 -1
  53. package/src/features/chat/components/conversation/chat-conversation-content.tsx +2 -2
  54. package/src/features/chat/features/conversation/components/__tests__/session-conversation-area.test.tsx +11 -0
  55. package/src/features/chat/features/conversation/components/__tests__/session-conversation-input.streaming.test.tsx +4 -0
  56. package/src/features/chat/features/conversation/components/session-conversation-area.tsx +3 -0
  57. package/src/features/chat/features/conversation/hooks/__tests__/use-session-conversation-controller.test.tsx +64 -72
  58. package/src/features/chat/features/conversation/hooks/__tests__/use-session-run-queue.test.tsx +85 -0
  59. package/src/features/chat/features/conversation/hooks/use-session-conversation-controller.ts +108 -257
  60. package/src/features/chat/features/conversation/hooks/use-session-run-queue.ts +52 -0
  61. package/src/features/chat/features/conversation/utils/__tests__/session-queued-input.utils.test.ts +63 -0
  62. package/src/features/chat/features/conversation/utils/session-queued-input.utils.ts +134 -0
  63. package/src/features/chat/features/input/utils/__tests__/chat-composer-state.utils.test.ts +5 -5
  64. package/src/features/chat/features/input/utils/__tests__/chat-inline-token.utils.test.ts +113 -32
  65. package/src/features/chat/features/input/utils/chat-composer-token-protocol.utils.ts +3 -2
  66. package/src/features/chat/features/input/utils/chat-inline-token.utils.ts +118 -12
  67. package/src/features/chat/features/message/components/__tests__/chat-message-list-skill-preview.container.test.tsx +170 -0
  68. package/src/features/chat/features/message/components/__tests__/chat-message-list.container.test.tsx +18 -9
  69. package/src/features/chat/features/message/components/chat-message-list.container.tsx +36 -23
  70. package/src/features/chat/features/message/hooks/__tests__/use-chat-message-virtualizer.test.tsx +90 -0
  71. package/src/features/chat/features/message/hooks/use-chat-message-virtualizer.ts +3 -5
  72. package/src/features/chat/features/ncp/hooks/__tests__/use-ncp-agent-runtime-queue.test.tsx +51 -0
  73. package/src/features/chat/features/session/utils/chat-run-metadata.utils.ts +9 -3
  74. package/src/features/chat/pages/__tests__/ncp-chat-page.test.ts +42 -8
  75. package/src/shared/lib/i18n/locales/en-US/chat.json +1 -0
  76. package/src/shared/lib/i18n/locales/zh-CN/chat.json +1 -0
  77. package/dist/assets/app-presenter-provider-Dp90n1Va.js +0 -106
  78. package/dist/assets/doc-browser-BuGh646I.js +0 -1
  79. package/dist/assets/index-B2YF-7Yw.css +0 -1
  80. package/dist/assets/loader-circle-CAnHHSrY.js +0 -1
  81. package/dist/assets/mcp-marketplace-page-CaUFv37E.js +0 -1
  82. package/dist/assets/plus-DjdTWPjQ.js +0 -1
  83. package/dist/assets/remote-_L4pTEaW.js +0 -1
  84. package/dist/assets/rotate-cw-DQJhDnjg.js +0 -1
  85. package/dist/assets/security-config-yraYoy9s.js +0 -1
  86. package/dist/assets/skeleton-BQuylYFO.js +0 -1
  87. 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
+ });
@@ -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
- kind: "skill",
230
- key: "weather",
231
- label: "Weather",
232
- rawText: "$weather",
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
- key: "weather",
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
- token.kind === "workspace_file" ||
347
- token.kind === "workspace_directory"
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 skillKey = token.key.trim();
364
- if (!skillKey) {
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
- const records = sessionSkillsQuery?.data?.records ?? [];
368
- const matched = records.find(
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
- presenter.chatThreadManager.openFilePreview({
376
- path: skillPath,
377
- label: token.label || matched?.name || skillKey,
378
- viewMode: "preview",
379
- previewViewer: "rendered",
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
- sessionSkillsQuery,
399
+ sessionKey,
387
400
  ],
388
401
  );
389
402
  const handleAttachmentOpen = useCallback(
@@ -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 ChatMessageVirtualRow[];
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 { buildInlineTokensFromComposer, CHAT_INLINE_TOKENS_METADATA_KEY } from "@/features/chat/features/input/utils/chat-inline-token.utils";
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
- kind: 'workspace_file',
233
- key: 'src/file name.ts',
234
- label: 'file name.ts',
235
- rawText: '@file:src%2Ffile%20name.ts',
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。",