@nextclaw/ui 0.15.19 → 0.15.21
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 +40 -0
- package/dist/assets/api-CHo2gekJ.js +13 -0
- package/dist/assets/app-presenter-provider-2_DmWYT9.js +108 -0
- package/dist/assets/appearance-settings-page-BEy2UE7R.js +1 -0
- package/dist/assets/{book-open-HtUSPwoS.js → book-open-BrZL4o9L.js} +1 -1
- package/dist/assets/card-B5rRLu_6.js +1 -0
- package/dist/assets/{channels-list-page-Bspr1Jp4.js → channels-list-page-C1dnm68I.js} +2 -2
- package/dist/assets/chat-page-Dz6tvZ91.js +1 -0
- package/dist/assets/config-split-page-6y6W6dz4.js +1 -0
- package/dist/assets/confirm-dialog-q1CpEmR8.js +5 -0
- package/dist/assets/desktop-update-config-DrdlwP4j.js +1 -0
- package/dist/assets/{dist-CZx9Q6jc.js → dist-C9EX46wM.js} +1 -1
- package/dist/assets/doc-browser-BfttDSti.js +1 -0
- package/dist/assets/{doc-browser-B4l0Xu7u.js → doc-browser-DH-3hDnI.js} +1 -1
- package/dist/assets/{doc-browser-context-Dy7EhLI8.js → doc-browser-context-TU2heon0.js} +1 -1
- package/dist/assets/{eye-DU7p1-7X.js → eye-B-3dyZNA.js} +1 -1
- package/dist/assets/form-actions-DvvxCpL4.js +1 -0
- package/dist/assets/index-CXDPLDFD.css +1 -0
- package/dist/assets/index-k_eWScvN.js +2 -0
- package/dist/assets/loader-circle-B35NlHTI.js +1 -0
- package/dist/assets/logo-badge-DB51c0cS.js +1 -0
- package/dist/assets/mcp-marketplace-page-CkPtwPBk.js +1 -0
- package/dist/assets/{mcp-marketplace-page-Dg03SZX1.js → mcp-marketplace-page-DWtsIqml.js} +3 -3
- package/dist/assets/{model-config-page-DOBqLZoQ.js → model-config-page-D6cGNnMM.js} +1 -1
- package/dist/assets/{navigation-link-Ct6xtnVM.js → navigation-link-BSJEUiwD.js} +1 -1
- package/dist/assets/plus-BRVe5jSJ.js +1 -0
- package/dist/assets/{popover-C2oIpGBF.js → popover-Bp9IpE_N.js} +1 -1
- package/dist/assets/provider-scoped-model-input-dk7fz9NL.js +1 -0
- package/dist/assets/{providers-config-page-BAVPJKpx.js → providers-config-page-DTP2YfxA.js} +1 -1
- package/dist/assets/react-D6ShNq95.js +8 -0
- package/dist/assets/{refresh-cw-BZ8giusN.js → refresh-cw-C2e94pmp.js} +1 -1
- package/dist/assets/remote-FllhXW3o.js +1 -0
- package/dist/assets/rotate-cw-CTC-Qszi.js +1 -0
- package/dist/assets/runtime-config-page-C57OLdPm.js +1 -0
- package/dist/assets/{save-CSzFGRse.js → save-CkwDOgvt.js} +1 -1
- package/dist/assets/{search-BmSWsiG2.js → search-CLXM2reO.js} +1 -1
- package/dist/assets/{search-config-page-CrL1pUzy.js → search-config-page-tnJXaaSr.js} +1 -1
- package/dist/assets/{secrets-config-page-BlgsD0-L.js → secrets-config-page-DQ_Curaj.js} +1 -1
- package/dist/assets/security-config-S00DjFfO.js +1 -0
- package/dist/assets/{select-CklWb6Rs.js → select-CNFBf00l.js} +1 -1
- package/dist/assets/{settings-2-BSZf8327.js → settings-2-B9IT2HGd.js} +1 -1
- package/dist/assets/skeleton-Dc06gatK.js +1 -0
- package/dist/assets/{tabs-Cah5klko.js → tabs-BuNr8LSO.js} +1 -1
- package/dist/assets/{tag-chip-DcrirDgN.js → tag-chip-CDkB5smU.js} +1 -1
- package/dist/assets/{trash-2-BLQOULWD.js → trash-2-GchaJuEM.js} +1 -1
- package/dist/assets/{use-config-CuFIsUVG.js → use-config-C69rR15i.js} +1 -1
- package/dist/assets/use-confirm-dialog-DF_EgPYx.js +1 -0
- package/dist/assets/{use-viewport-layout-DU29bUJS.js → use-viewport-layout-BACNZyMY.js} +1 -1
- package/dist/assets/x-BXFquY9e.js +1 -0
- package/dist/index.html +28 -28
- package/package.json +9 -9
- package/src/app/components/__tests__/app-notification-runtime.test.tsx +32 -0
- package/src/app/components/app-notification-runtime.tsx +15 -0
- package/src/app/components/layout/sidebar-items.tsx +23 -9
- package/src/app/configs/app-navigation.config.ts +21 -0
- package/src/app/index.tsx +22 -2
- package/src/app/presenters/app.presenter.ts +8 -0
- package/src/features/chat/components/layout/chat-page-shell.tsx +7 -2
- package/src/features/chat/components/layout/chat-sidebar-desktop-layout.tsx +12 -0
- package/src/features/chat/features/conversation/hooks/__tests__/use-session-run-queue.test.tsx +28 -0
- package/src/features/chat/features/conversation/hooks/use-session-run-queue.ts +4 -5
- package/src/features/chat/features/input/hooks/__tests__/use-chat-input-surface-state.test.tsx +13 -0
- package/src/features/chat/features/input/hooks/use-chat-input-surface-state.ts +31 -8
- package/src/features/chat/features/input/input-surface-plugins/__tests__/context-reference-plugin.test.ts +79 -9
- package/src/features/chat/features/input/input-surface-plugins/__tests__/slash-command-plugin.test.ts +16 -4
- package/src/features/chat/features/input/input-surface-plugins/chat-input-product-plugin-adapters.types.ts +10 -2
- package/src/features/chat/features/input/input-surface-plugins/context-reference-items.utils.ts +240 -0
- package/src/features/chat/features/input/input-surface-plugins/context-reference-plugin.utils.ts +62 -173
- package/src/features/chat/features/input/utils/__tests__/chat-inline-token.utils.test.ts +29 -0
- package/src/features/chat/features/input/utils/chat-composer-token-protocol.utils.ts +5 -0
- package/src/features/chat/features/input/utils/chat-inline-token.utils.ts +26 -5
- package/src/features/chat/features/message/components/__tests__/chat-message-list.container.test.tsx +12 -1
- package/src/features/chat/features/message/components/chat-message-list.container.tsx +9 -5
- package/src/features/chat/index.ts +1 -0
- package/src/features/chat/managers/__tests__/chat-completion-notification.manager.test.ts +177 -0
- package/src/features/chat/managers/chat-completion-notification.manager.ts +146 -0
- package/src/features/chat/pages/__tests__/ncp-chat-page.test.ts +26 -0
- package/src/features/chat/pages/__tests__/ncp-chat-page.test.tsx +29 -2
- package/src/features/chat/pages/ncp-chat-page.tsx +9 -0
- package/src/features/inbox/components/inbox-delivery-content.test.tsx +57 -0
- package/src/features/inbox/components/inbox-delivery-content.tsx +118 -0
- package/src/features/inbox/components/inbox-reader-dialog.test.tsx +77 -0
- package/src/features/inbox/components/inbox-reader-dialog.tsx +224 -0
- package/src/features/inbox/components/inbox-runtime.tsx +14 -0
- package/src/features/inbox/hooks/use-inbox-deliveries.ts +14 -0
- package/src/features/inbox/index.ts +7 -0
- package/src/features/inbox/managers/inbox.manager.test.ts +86 -0
- package/src/features/inbox/managers/inbox.manager.ts +178 -0
- package/src/features/inbox/pages/inbox-page.test.ts +28 -0
- package/src/features/inbox/pages/inbox-page.tsx +385 -0
- package/src/features/inbox/stores/inbox.store.ts +23 -0
- package/src/features/notifications/components/__tests__/app-notification-toast.test.tsx +48 -0
- package/src/features/notifications/components/app-notification-toast.tsx +80 -0
- package/src/features/notifications/index.ts +8 -0
- package/src/features/notifications/managers/__tests__/app-notification.manager.test.ts +75 -0
- package/src/features/notifications/managers/app-notification.manager.ts +41 -0
- package/src/platforms/mobile/components/mobile-bottom-nav.tsx +14 -7
- package/src/shared/hooks/use-projects.ts +2 -1
- package/src/shared/lib/i18n/index.ts +4 -0
- package/src/shared/lib/i18n/locales/en-US/chat.json +16 -4
- package/src/shared/lib/i18n/locales/en-US/inbox.json +30 -0
- package/src/shared/lib/i18n/locales/zh-CN/chat.json +16 -4
- package/src/shared/lib/i18n/locales/zh-CN/inbox.json +30 -0
- package/dist/assets/api-O5yEgzBt.js +0 -13
- package/dist/assets/app-presenter-provider-DXTYtH78.js +0 -106
- package/dist/assets/appearance-settings-page-BuGxSvlJ.js +0 -1
- package/dist/assets/card-GkBc-UMw.js +0 -1
- package/dist/assets/chat-page-BKQ89f4s.js +0 -1
- package/dist/assets/config-split-page-CvLXSFSC.js +0 -1
- package/dist/assets/confirm-dialog-BVH7haN0.js +0 -5
- package/dist/assets/desktop-update-config-C9ZUI8UW.js +0 -1
- package/dist/assets/doc-browser-DsVF1DrS.js +0 -1
- package/dist/assets/form-actions-aM9_jSJC.js +0 -1
- package/dist/assets/index-6pHX0DZH.js +0 -2
- package/dist/assets/index-DN6KM7WD.css +0 -1
- package/dist/assets/loader-circle-Dd6jw700.js +0 -1
- package/dist/assets/logo-badge-2-iV1pkr.js +0 -1
- package/dist/assets/mcp-marketplace-page-C-d0RMfg.js +0 -1
- package/dist/assets/plus-CsgeRId8.js +0 -1
- package/dist/assets/provider-scoped-model-input-DDqCdnTU.js +0 -1
- package/dist/assets/react-CeMr9V52.js +0 -8
- package/dist/assets/remote-C-r0SHcz.js +0 -1
- package/dist/assets/rotate-cw-DVdRfMSO.js +0 -1
- package/dist/assets/runtime-config-page-D0244uxJ.js +0 -1
- package/dist/assets/security-config-cDrdpMJW.js +0 -1
- package/dist/assets/skeleton-DG9u1BmA.js +0 -1
- package/dist/assets/use-confirm-dialog-CcySmkWp.js +0 -1
- package/dist/assets/x-BVoeXny1.js +0 -1
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import {
|
|
2
|
+
CHAT_PROJECT_TOKEN_KIND,
|
|
2
3
|
CHAT_WORKSPACE_DIRECTORY_TOKEN_KIND,
|
|
3
4
|
CHAT_WORKSPACE_FILE_TOKEN_KIND,
|
|
4
5
|
} from '@nextclaw/shared';
|
|
5
6
|
|
|
6
7
|
const CHAT_SKILL_TOKEN_PREFIX = '$';
|
|
7
8
|
const CHAT_PANEL_APP_TOKEN_PREFIX = '@panel-app:';
|
|
9
|
+
const CHAT_PROJECT_TOKEN_PREFIX = '@project:';
|
|
8
10
|
const CHAT_WORKSPACE_FILE_TOKEN_PREFIX = '@file:';
|
|
9
11
|
const CHAT_WORKSPACE_DIRECTORY_TOKEN_PREFIX = '@folder:';
|
|
10
12
|
|
|
@@ -20,6 +22,9 @@ export function serializeChatComposerTokenText(params: {
|
|
|
20
22
|
if (tokenKind === 'panel_app') {
|
|
21
23
|
return `${CHAT_PANEL_APP_TOKEN_PREFIX}${tokenKey}`;
|
|
22
24
|
}
|
|
25
|
+
if (tokenKind === CHAT_PROJECT_TOKEN_KIND) {
|
|
26
|
+
return `${CHAT_PROJECT_TOKEN_PREFIX}${encodeURIComponent(tokenKey)}`;
|
|
27
|
+
}
|
|
23
28
|
if (tokenKind === CHAT_WORKSPACE_FILE_TOKEN_KIND) {
|
|
24
29
|
return `${CHAT_WORKSPACE_FILE_TOKEN_PREFIX}${encodeURIComponent(tokenKey)}`;
|
|
25
30
|
}
|
|
@@ -2,6 +2,7 @@ import type { ChatComposerNode } from '@nextclaw/agent-chat-ui';
|
|
|
2
2
|
import {
|
|
3
3
|
CHAT_INLINE_TOKENS_METADATA_KEY,
|
|
4
4
|
CHAT_INLINE_TOKENS_SCHEMA_VERSION,
|
|
5
|
+
CHAT_PROJECT_TOKEN_KIND,
|
|
5
6
|
CHAT_WORKSPACE_DIRECTORY_TOKEN_KIND,
|
|
6
7
|
CHAT_WORKSPACE_FILE_TOKEN_KIND,
|
|
7
8
|
type ChatInlineTokenMetadata,
|
|
@@ -13,6 +14,7 @@ import { serializeChatComposerTokenText } from './chat-composer-token-protocol.u
|
|
|
13
14
|
export { CHAT_INLINE_TOKENS_METADATA_KEY };
|
|
14
15
|
const CHAT_PANEL_APP_TOKEN_PREFIX = '@panel-app:';
|
|
15
16
|
const CHAT_PANEL_APP_TOKEN_PATTERN = /@panel-app:([A-Za-z0-9_-]+)/g;
|
|
17
|
+
const CHAT_PROJECT_TOKEN_PATTERN = /@project:([^\s]+)/g;
|
|
16
18
|
const CHAT_WORKSPACE_FILE_TOKEN_PATTERN = /@file:([^\s]+)/g;
|
|
17
19
|
const CHAT_WORKSPACE_DIRECTORY_TOKEN_PATTERN = /@folder:([^\s]+)/g;
|
|
18
20
|
|
|
@@ -96,6 +98,7 @@ export function buildInlineTokensFromComposer(
|
|
|
96
98
|
}
|
|
97
99
|
if (
|
|
98
100
|
node.tokenKind !== 'skill' &&
|
|
101
|
+
node.tokenKind !== CHAT_PROJECT_TOKEN_KIND &&
|
|
99
102
|
node.tokenKind !== CHAT_WORKSPACE_FILE_TOKEN_KIND &&
|
|
100
103
|
node.tokenKind !== CHAT_WORKSPACE_DIRECTORY_TOKEN_KIND
|
|
101
104
|
) {
|
|
@@ -121,6 +124,15 @@ export function buildInlineTokensFromComposer(
|
|
|
121
124
|
});
|
|
122
125
|
continue;
|
|
123
126
|
}
|
|
127
|
+
if (node.tokenKind === CHAT_PROJECT_TOKEN_KIND) {
|
|
128
|
+
tokens.push({
|
|
129
|
+
kind: CHAT_PROJECT_TOKEN_KIND,
|
|
130
|
+
key: node.tokenKey,
|
|
131
|
+
label: node.label,
|
|
132
|
+
rawText,
|
|
133
|
+
});
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
124
136
|
const workspaceKind = node.tokenKind === CHAT_WORKSPACE_FILE_TOKEN_KIND
|
|
125
137
|
? CHAT_WORKSPACE_FILE_TOKEN_KIND
|
|
126
138
|
: CHAT_WORKSPACE_DIRECTORY_TOKEN_KIND;
|
|
@@ -134,8 +146,11 @@ export function buildInlineTokensFromComposer(
|
|
|
134
146
|
return dedupeInlineTokens(tokens);
|
|
135
147
|
}
|
|
136
148
|
|
|
137
|
-
function
|
|
138
|
-
kind:
|
|
149
|
+
function appendEncodedKeyTokens(params: {
|
|
150
|
+
kind:
|
|
151
|
+
| typeof CHAT_PROJECT_TOKEN_KIND
|
|
152
|
+
| typeof CHAT_WORKSPACE_FILE_TOKEN_KIND
|
|
153
|
+
| typeof CHAT_WORKSPACE_DIRECTORY_TOKEN_KIND;
|
|
139
154
|
pattern: RegExp;
|
|
140
155
|
text: string;
|
|
141
156
|
tokens: ChatInlineTokenSource[];
|
|
@@ -155,7 +170,7 @@ function appendWorkspaceTokens(params: {
|
|
|
155
170
|
tokens.push({
|
|
156
171
|
kind,
|
|
157
172
|
key,
|
|
158
|
-
label: key.split(
|
|
173
|
+
label: key.split(/[\\/]+/).filter(Boolean).at(-1) ?? key,
|
|
159
174
|
rawText: match[0],
|
|
160
175
|
});
|
|
161
176
|
}
|
|
@@ -175,13 +190,19 @@ export function buildInlineTokensFromTextProtocol(text: string): ChatInlineToken
|
|
|
175
190
|
rawText: `${CHAT_PANEL_APP_TOKEN_PREFIX}${key}`
|
|
176
191
|
});
|
|
177
192
|
}
|
|
178
|
-
|
|
193
|
+
appendEncodedKeyTokens({
|
|
194
|
+
kind: CHAT_PROJECT_TOKEN_KIND,
|
|
195
|
+
pattern: CHAT_PROJECT_TOKEN_PATTERN,
|
|
196
|
+
text,
|
|
197
|
+
tokens,
|
|
198
|
+
});
|
|
199
|
+
appendEncodedKeyTokens({
|
|
179
200
|
kind: CHAT_WORKSPACE_FILE_TOKEN_KIND,
|
|
180
201
|
pattern: CHAT_WORKSPACE_FILE_TOKEN_PATTERN,
|
|
181
202
|
text,
|
|
182
203
|
tokens,
|
|
183
204
|
});
|
|
184
|
-
|
|
205
|
+
appendEncodedKeyTokens({
|
|
185
206
|
kind: CHAT_WORKSPACE_DIRECTORY_TOKEN_KIND,
|
|
186
207
|
pattern: CHAT_WORKSPACE_DIRECTORY_TOKEN_PATTERN,
|
|
187
208
|
text,
|
package/src/features/chat/features/message/components/__tests__/chat-message-list.container.test.tsx
CHANGED
|
@@ -453,7 +453,7 @@ it("wires markdown file link actions to the workspace file preview manager", ()
|
|
|
453
453
|
expect(captures.openFilePreview).toHaveBeenCalledWith(action);
|
|
454
454
|
});
|
|
455
455
|
|
|
456
|
-
it("opens workspace
|
|
456
|
+
it("opens workspace and project reference tokens from the message", () => {
|
|
457
457
|
captures.selectedSession = {
|
|
458
458
|
projectRoot: "/tmp/project",
|
|
459
459
|
workingDir: "/tmp/project/packages/ui",
|
|
@@ -474,6 +474,12 @@ it("opens workspace file and directory tokens from the message", () => {
|
|
|
474
474
|
label: "server",
|
|
475
475
|
rawText: "@folder:packages%2Fserver",
|
|
476
476
|
});
|
|
477
|
+
onInlineTokenClick?.({
|
|
478
|
+
kind: "project",
|
|
479
|
+
key: "/tmp/nextclaw",
|
|
480
|
+
label: "NextClaw",
|
|
481
|
+
rawText: "@project:%2Ftmp%2Fnextclaw",
|
|
482
|
+
});
|
|
477
483
|
|
|
478
484
|
expect(captures.openFilePreview).toHaveBeenNthCalledWith(1, {
|
|
479
485
|
path: "/tmp/project/docs/guide.md",
|
|
@@ -485,6 +491,11 @@ it("opens workspace file and directory tokens from the message", () => {
|
|
|
485
491
|
label: "server",
|
|
486
492
|
viewMode: "preview",
|
|
487
493
|
});
|
|
494
|
+
expect(captures.openFilePreview).toHaveBeenNthCalledWith(3, {
|
|
495
|
+
path: "/tmp/nextclaw",
|
|
496
|
+
label: "NextClaw",
|
|
497
|
+
viewMode: "preview",
|
|
498
|
+
});
|
|
488
499
|
});
|
|
489
500
|
|
|
490
501
|
it("opens panel app tokens through the existing content owner", () => {
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
type RefObject,
|
|
7
7
|
} from "react";
|
|
8
8
|
import type { NcpMessage } from "@nextclaw/ncp";
|
|
9
|
+
import { CHAT_PROJECT_TOKEN_KIND } from "@nextclaw/shared";
|
|
9
10
|
import { toast } from "sonner";
|
|
10
11
|
import {
|
|
11
12
|
type ChatInlineDisplayViewModel,
|
|
@@ -346,13 +347,16 @@ export function ChatMessageListContainer({
|
|
|
346
347
|
}
|
|
347
348
|
if (
|
|
348
349
|
"key" in token &&
|
|
349
|
-
(token.kind ===
|
|
350
|
+
(token.kind === CHAT_PROJECT_TOKEN_KIND ||
|
|
351
|
+
token.kind === "workspace_file" ||
|
|
350
352
|
token.kind === "workspace_directory")
|
|
351
353
|
) {
|
|
352
|
-
const path =
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
354
|
+
const path = token.kind === CHAT_PROJECT_TOKEN_KIND
|
|
355
|
+
? token.key
|
|
356
|
+
: resolveWorkspaceReferencePath({
|
|
357
|
+
projectRoot: selectedSession?.projectRoot,
|
|
358
|
+
relativePath: token.key,
|
|
359
|
+
});
|
|
356
360
|
if (path) {
|
|
357
361
|
presenter.chatThreadManager.openFilePreview({
|
|
358
362
|
path,
|
|
@@ -4,6 +4,7 @@ export { ChatPresenterProvider } from "./components/providers/chat-presenter.pro
|
|
|
4
4
|
export { usePresenter } from "./components/providers/chat-presenter.provider";
|
|
5
5
|
export { ChatPresenter } from "./presenters/chat.presenter";
|
|
6
6
|
export { ChatDraftIntentManager } from "./managers/chat-draft-intent.manager";
|
|
7
|
+
export { ChatCompletionNotificationManager } from "./managers/chat-completion-notification.manager";
|
|
7
8
|
export { useChatSessionListStore } from "./stores/chat-session-list.store";
|
|
8
9
|
export { useChatThreadStore } from "./stores/chat-thread.store";
|
|
9
10
|
export { useChatMessageLayoutStore } from "./stores/chat-message-layout.store";
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import {
|
|
2
|
+
NCP_INTERNAL_VISIBILITY_METADATA_KEY,
|
|
3
|
+
NcpEventType,
|
|
4
|
+
type NcpEndpointEvent,
|
|
5
|
+
type NcpMessage,
|
|
6
|
+
} from "@nextclaw/ncp";
|
|
7
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
8
|
+
import { ChatCompletionNotificationManager } from "@/features/chat/managers/chat-completion-notification.manager";
|
|
9
|
+
import { buildSessionPath } from "@/features/chat/features/session/utils/chat-session-route.utils";
|
|
10
|
+
import { useChatQueryStore } from "@/features/chat/stores/ncp-chat-query.store";
|
|
11
|
+
|
|
12
|
+
const mocks = vi.hoisted(() => ({
|
|
13
|
+
eventHandler: null as ((event: NcpEndpointEvent) => void) | null,
|
|
14
|
+
on: vi.fn(),
|
|
15
|
+
unsubscribe: vi.fn(),
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
vi.mock("@/shared/lib/api", () => ({
|
|
19
|
+
nextclawClient: {
|
|
20
|
+
eventBus: {
|
|
21
|
+
on: mocks.on,
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
function createMessage(overrides: Partial<NcpMessage> = {}): NcpMessage {
|
|
27
|
+
return {
|
|
28
|
+
id: "assistant-message-1",
|
|
29
|
+
sessionId: "session-background",
|
|
30
|
+
role: "assistant",
|
|
31
|
+
status: "final",
|
|
32
|
+
timestamp: "2026-08-05T10:00:00.000Z",
|
|
33
|
+
parts: [{ type: "text", text: " The requested research is complete. " }],
|
|
34
|
+
...overrides,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function createCompletedEvent(message = createMessage()): NcpEndpointEvent {
|
|
39
|
+
return {
|
|
40
|
+
type: NcpEventType.MessageCompleted,
|
|
41
|
+
payload: {
|
|
42
|
+
sessionId: message.sessionId,
|
|
43
|
+
message,
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function emit(event: NcpEndpointEvent): void {
|
|
49
|
+
if (!mocks.eventHandler) {
|
|
50
|
+
throw new Error("Notification manager has not subscribed");
|
|
51
|
+
}
|
|
52
|
+
mocks.eventHandler(event);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
describe("ChatCompletionNotificationManager", () => {
|
|
56
|
+
const show = vi.fn();
|
|
57
|
+
let manager: ChatCompletionNotificationManager;
|
|
58
|
+
|
|
59
|
+
beforeEach(() => {
|
|
60
|
+
mocks.eventHandler = null;
|
|
61
|
+
mocks.on.mockReset();
|
|
62
|
+
mocks.unsubscribe.mockReset();
|
|
63
|
+
mocks.on.mockImplementation((_key, handler: (event: NcpEndpointEvent) => void) => {
|
|
64
|
+
mocks.eventHandler = handler;
|
|
65
|
+
return mocks.unsubscribe;
|
|
66
|
+
});
|
|
67
|
+
show.mockReset();
|
|
68
|
+
useChatQueryStore.setState({ snapshot: {} });
|
|
69
|
+
manager = new ChatCompletionNotificationManager({ show });
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
afterEach(() => {
|
|
73
|
+
manager.stop();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("maps a background assistant final reply to a navigable global notification", () => {
|
|
77
|
+
useChatQueryStore.setState({
|
|
78
|
+
snapshot: {
|
|
79
|
+
sessionsQuery: {
|
|
80
|
+
data: {
|
|
81
|
+
sessions: [{
|
|
82
|
+
sessionId: "session-background",
|
|
83
|
+
messageCount: 2,
|
|
84
|
+
updatedAt: "2026-08-05T10:00:00.000Z",
|
|
85
|
+
metadata: { label: "Quarterly research" },
|
|
86
|
+
}],
|
|
87
|
+
total: 1,
|
|
88
|
+
},
|
|
89
|
+
} as never,
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
manager.start();
|
|
93
|
+
emit(createCompletedEvent());
|
|
94
|
+
|
|
95
|
+
expect(show).toHaveBeenCalledWith({
|
|
96
|
+
id: "chat-reply:assistant-message-1",
|
|
97
|
+
title: "Quarterly research",
|
|
98
|
+
description: "The requested research is complete.",
|
|
99
|
+
href: buildSessionPath("session-background"),
|
|
100
|
+
ariaLabel: "Open the new reply in Quarterly research",
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("suppresses messages completed in the active session, including later replays", () => {
|
|
105
|
+
manager.start();
|
|
106
|
+
manager.syncActiveSession("session-background");
|
|
107
|
+
emit(createCompletedEvent());
|
|
108
|
+
expect(show).not.toHaveBeenCalled();
|
|
109
|
+
|
|
110
|
+
manager.syncActiveSession(null);
|
|
111
|
+
emit(createCompletedEvent());
|
|
112
|
+
expect(show).not.toHaveBeenCalled();
|
|
113
|
+
|
|
114
|
+
const nextMessage = createMessage({ id: "assistant-message-2" });
|
|
115
|
+
emit(createCompletedEvent(nextMessage));
|
|
116
|
+
emit(createCompletedEvent(nextMessage));
|
|
117
|
+
expect(show).toHaveBeenCalledOnce();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("ignores non-final, non-assistant, hidden, and unrelated events", () => {
|
|
121
|
+
manager.start();
|
|
122
|
+
emit(createCompletedEvent(createMessage({ status: "streaming" })));
|
|
123
|
+
emit(createCompletedEvent(createMessage({ role: "service" })));
|
|
124
|
+
emit(createCompletedEvent(createMessage({
|
|
125
|
+
metadata: { [NCP_INTERNAL_VISIBILITY_METADATA_KEY]: "hidden" },
|
|
126
|
+
})));
|
|
127
|
+
emit({
|
|
128
|
+
type: NcpEventType.RunFinished,
|
|
129
|
+
payload: { sessionId: "session-background" },
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
expect(show).not.toHaveBeenCalled();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("uses localized fallbacks when the session and reply have no display text", () => {
|
|
136
|
+
manager.start();
|
|
137
|
+
emit(createCompletedEvent(createMessage({ parts: [] })));
|
|
138
|
+
|
|
139
|
+
expect(show).toHaveBeenCalledWith(expect.objectContaining({
|
|
140
|
+
title: "New reply",
|
|
141
|
+
description: "A reply is ready. Open the session to view it.",
|
|
142
|
+
}));
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("projects markdown replies into a readable plain-text preview", () => {
|
|
146
|
+
manager.start();
|
|
147
|
+
emit(createCompletedEvent(createMessage({
|
|
148
|
+
parts: [{
|
|
149
|
+
type: "text",
|
|
150
|
+
text: [
|
|
151
|
+
"# Release **complete**",
|
|
152
|
+
"",
|
|
153
|
+
"- [x] Open the [report](https://example.com/report)",
|
|
154
|
+
"> Review `details` and ~~obsolete notes~~",
|
|
155
|
+
"",
|
|
156
|
+
"```sh",
|
|
157
|
+
"pnpm test",
|
|
158
|
+
"```",
|
|
159
|
+
].join("\n"),
|
|
160
|
+
}],
|
|
161
|
+
})));
|
|
162
|
+
|
|
163
|
+
expect(show).toHaveBeenCalledWith(expect.objectContaining({
|
|
164
|
+
description: "Release complete Open the report Review details and obsolete notes pnpm test",
|
|
165
|
+
}));
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("owns an idempotent realtime subscription lifecycle", () => {
|
|
169
|
+
manager.start();
|
|
170
|
+
manager.start();
|
|
171
|
+
expect(mocks.on).toHaveBeenCalledOnce();
|
|
172
|
+
|
|
173
|
+
manager.stop();
|
|
174
|
+
manager.stop();
|
|
175
|
+
expect(mocks.unsubscribe).toHaveBeenCalledOnce();
|
|
176
|
+
});
|
|
177
|
+
});
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import {
|
|
2
|
+
isHiddenNcpMessage,
|
|
3
|
+
NcpEventType,
|
|
4
|
+
type NcpEndpointEvent,
|
|
5
|
+
type NcpMessage,
|
|
6
|
+
} from "@nextclaw/ncp";
|
|
7
|
+
import { eventKeys } from "@nextclaw/shared";
|
|
8
|
+
import type { AppNotificationManager } from "@/features/notifications";
|
|
9
|
+
import { buildSessionPath } from "@/features/chat/features/session/utils/chat-session-route.utils";
|
|
10
|
+
import { sessionDisplayName } from "@/features/chat/features/session/utils/chat-session-display.utils";
|
|
11
|
+
import { adaptNcpSessionSummary } from "@/features/chat/features/session/utils/ncp-session-adapter.utils";
|
|
12
|
+
import { useChatQueryStore } from "@/features/chat/stores/ncp-chat-query.store";
|
|
13
|
+
import { nextclawClient } from "@/shared/lib/api";
|
|
14
|
+
import { t } from "@/shared/lib/i18n";
|
|
15
|
+
|
|
16
|
+
const NOTIFICATION_PREVIEW_MAX_CHARACTERS = 120;
|
|
17
|
+
const NOTIFIED_MESSAGE_HISTORY_LIMIT = 200;
|
|
18
|
+
|
|
19
|
+
function buildNotificationPreview(value: string): string {
|
|
20
|
+
const normalized = value
|
|
21
|
+
.replace(/\r\n?/gu, "\n")
|
|
22
|
+
.replace(/^[ \t]*\[[^\]]+\]:\s+\S+.*$/gmu, " ")
|
|
23
|
+
.replace(/^[ \t]*(?:`{3,}|~{3,})[^\n]*$/gmu, " ")
|
|
24
|
+
.replace(/^[ \t]*\|?[ \t]*:?-{3,}:?[ \t]*(?:\|[ \t]*:?-{3,}:?[ \t]*)+\|?[ \t]*$/gmu, " ")
|
|
25
|
+
.replace(/^[ \t]*(?:[-*_][ \t]*){3,}$/gmu, " ")
|
|
26
|
+
.replace(/!\[([^\]]*)\]\([^\n)]+\)/gu, "$1")
|
|
27
|
+
.replace(/\[([^\]]+)\]\([^\n)]+\)/gu, "$1")
|
|
28
|
+
.replace(/\[([^\]]+)\]\[[^\]]*\]/gu, "$1")
|
|
29
|
+
.replace(/<(https?:\/\/[^>\s]+)>/gu, "$1")
|
|
30
|
+
.replace(/<[^>]+>/gu, " ")
|
|
31
|
+
.replace(/^[ \t]{0,3}#{1,6}[ \t]+/gmu, "")
|
|
32
|
+
.replace(/^[ \t]*>+[ \t]?/gmu, "")
|
|
33
|
+
.replace(/^[ \t]*(?:[-+*]|\d+[.)])[ \t]+/gmu, "")
|
|
34
|
+
.replace(/^[ \t]*\[(?: |x|X)\][ \t]*/gmu, "")
|
|
35
|
+
.replace(/(`+)([^`]*?)\1/gu, "$2")
|
|
36
|
+
.replace(/(\*\*|__)(?=\S)([\s\S]*?\S)\1/gu, "$2")
|
|
37
|
+
.replace(/~~(?=\S)([\s\S]*?\S)~~/gu, "$1")
|
|
38
|
+
.replace(/\*(?=\S)([^*\n]*?\S)\*/gu, "$1")
|
|
39
|
+
.replace(/\\([\\`*{}[\]()#+.!_>-])/gu, "$1")
|
|
40
|
+
.replace(/^[ \t]*\|(.+)\|[ \t]*$/gmu, (_match, row: string) =>
|
|
41
|
+
row.split("|").map((cell) => cell.trim()).filter(Boolean).join(" "),
|
|
42
|
+
)
|
|
43
|
+
.replace(/\s+/gu, " ")
|
|
44
|
+
.trim();
|
|
45
|
+
const characters = Array.from(normalized);
|
|
46
|
+
if (characters.length <= NOTIFICATION_PREVIEW_MAX_CHARACTERS) {
|
|
47
|
+
return normalized;
|
|
48
|
+
}
|
|
49
|
+
return `${characters.slice(0, NOTIFICATION_PREVIEW_MAX_CHARACTERS - 1).join("")}…`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function readAssistantReplyPreview(message: NcpMessage): string {
|
|
53
|
+
return buildNotificationPreview(
|
|
54
|
+
message.parts
|
|
55
|
+
.filter((part) => part.type === "text" || part.type === "rich-text")
|
|
56
|
+
.map((part) => part.text)
|
|
57
|
+
.join(" "),
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export class ChatCompletionNotificationManager {
|
|
62
|
+
private readonly cleanups: Array<() => void> = [];
|
|
63
|
+
private readonly handledMessageIds = new Set<string>();
|
|
64
|
+
private activeSessionId: string | null = null;
|
|
65
|
+
private started = false;
|
|
66
|
+
|
|
67
|
+
constructor(
|
|
68
|
+
private readonly notificationManager: Pick<AppNotificationManager, "show">,
|
|
69
|
+
) {}
|
|
70
|
+
|
|
71
|
+
start = (): void => {
|
|
72
|
+
if (this.started) {
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
this.started = true;
|
|
76
|
+
this.cleanups.push(
|
|
77
|
+
nextclawClient.eventBus.on(eventKeys.ncpEvent, this.handleNcpEvent),
|
|
78
|
+
);
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
stop = (): void => {
|
|
82
|
+
if (!this.started) {
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
this.started = false;
|
|
86
|
+
while (this.cleanups.length > 0) {
|
|
87
|
+
this.cleanups.pop()?.();
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
syncActiveSession = (sessionId: string | null): void => {
|
|
92
|
+
this.activeSessionId = sessionId?.trim() || null;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
private readonly handleNcpEvent = (event: NcpEndpointEvent): void => {
|
|
96
|
+
if (event.type !== NcpEventType.MessageCompleted) {
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const { message } = event.payload;
|
|
101
|
+
const sessionId = event.payload.sessionId.trim();
|
|
102
|
+
if (
|
|
103
|
+
!sessionId ||
|
|
104
|
+
this.handledMessageIds.has(message.id) ||
|
|
105
|
+
message.role !== "assistant" ||
|
|
106
|
+
message.status !== "final" ||
|
|
107
|
+
isHiddenNcpMessage(message)
|
|
108
|
+
) {
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
this.rememberHandledMessage(message.id);
|
|
112
|
+
if (sessionId === this.activeSessionId) {
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const title = this.resolveSessionTitle(sessionId);
|
|
117
|
+
const preview = readAssistantReplyPreview(message);
|
|
118
|
+
this.notificationManager.show({
|
|
119
|
+
id: `chat-reply:${message.id}`,
|
|
120
|
+
title,
|
|
121
|
+
description: preview || t("chatBackgroundReplyFallbackPreview"),
|
|
122
|
+
href: buildSessionPath(sessionId),
|
|
123
|
+
ariaLabel: t("chatBackgroundReplyOpenAriaLabel").replace("{title}", title),
|
|
124
|
+
});
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
private readonly resolveSessionTitle = (sessionId: string): string => {
|
|
128
|
+
const summaries =
|
|
129
|
+
useChatQueryStore.getState().snapshot.sessionsQuery?.data?.sessions ?? [];
|
|
130
|
+
const summary = summaries.find((item) => item.sessionId === sessionId);
|
|
131
|
+
return summary
|
|
132
|
+
? sessionDisplayName(adaptNcpSessionSummary(summary))
|
|
133
|
+
: t("chatBackgroundReplyFallbackTitle");
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
private readonly rememberHandledMessage = (messageId: string): void => {
|
|
137
|
+
this.handledMessageIds.add(messageId);
|
|
138
|
+
if (this.handledMessageIds.size <= NOTIFIED_MESSAGE_HISTORY_LIMIT) {
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
const oldestMessageId = this.handledMessageIds.values().next().value;
|
|
142
|
+
if (oldestMessageId) {
|
|
143
|
+
this.handledMessageIds.delete(oldestMessageId);
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
}
|
|
@@ -272,6 +272,32 @@ describe('buildChatRunMetadata', () => {
|
|
|
272
272
|
});
|
|
273
273
|
});
|
|
274
274
|
|
|
275
|
+
it('adds structured project references to run metadata', () => {
|
|
276
|
+
expect(
|
|
277
|
+
buildChatRunMetadata({
|
|
278
|
+
composerNodes: [
|
|
279
|
+
createChatComposerTokenNode({
|
|
280
|
+
tokenKind: 'project',
|
|
281
|
+
tokenKey: '/tmp/nextclaw',
|
|
282
|
+
label: 'NextClaw',
|
|
283
|
+
}),
|
|
284
|
+
],
|
|
285
|
+
}),
|
|
286
|
+
).toMatchObject({
|
|
287
|
+
ui_inline_tokens: {
|
|
288
|
+
schemaVersion: 2,
|
|
289
|
+
items: [
|
|
290
|
+
{
|
|
291
|
+
kind: 'project',
|
|
292
|
+
key: '/tmp/nextclaw',
|
|
293
|
+
label: 'NextClaw',
|
|
294
|
+
rawText: '@project:%2Ftmp%2Fnextclaw',
|
|
295
|
+
},
|
|
296
|
+
],
|
|
297
|
+
},
|
|
298
|
+
});
|
|
299
|
+
});
|
|
300
|
+
|
|
275
301
|
it('serializes child session materialization metadata for side chat drafts', () => {
|
|
276
302
|
expect(
|
|
277
303
|
buildChatRunMetadata({
|
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
import { render, screen } from '@testing-library/react';
|
|
2
|
-
import { MemoryRouter } from 'react-router-dom';
|
|
3
|
-
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { MemoryRouter, Route, Routes } from 'react-router-dom';
|
|
3
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
4
4
|
|
|
5
5
|
import { AppPresenterProvider } from '@/app/components/app-presenter-provider';
|
|
6
6
|
import { NcpChatPage } from '@/features/chat/pages/ncp-chat-page';
|
|
7
|
+
import { buildSessionPath } from '@/features/chat/features/session/utils/chat-session-route.utils';
|
|
7
8
|
|
|
8
9
|
const mocks = vi.hoisted(() => ({
|
|
9
10
|
confirm: vi.fn(),
|
|
10
11
|
consumePending: vi.fn(() => null),
|
|
11
12
|
markConsumed: vi.fn(),
|
|
12
13
|
subscribe: vi.fn(() => vi.fn()),
|
|
14
|
+
syncActiveSession: vi.fn(),
|
|
13
15
|
useChatSessionSync: vi.fn(),
|
|
14
16
|
}));
|
|
15
17
|
|
|
@@ -21,6 +23,9 @@ vi.mock('@/app/presenters/app.presenter', () => ({
|
|
|
21
23
|
markConsumed: mocks.markConsumed,
|
|
22
24
|
subscribe: mocks.subscribe,
|
|
23
25
|
},
|
|
26
|
+
chatCompletionNotificationManager: {
|
|
27
|
+
syncActiveSession: mocks.syncActiveSession,
|
|
28
|
+
},
|
|
24
29
|
}),
|
|
25
30
|
}));
|
|
26
31
|
|
|
@@ -57,6 +62,11 @@ vi.mock('@/features/chat/features/ncp/hooks/use-ui-show-content-event', () => ({
|
|
|
57
62
|
}));
|
|
58
63
|
|
|
59
64
|
describe('NcpChatPage render boundary', () => {
|
|
65
|
+
beforeEach(() => {
|
|
66
|
+
mocks.syncActiveSession.mockReset();
|
|
67
|
+
mocks.useChatSessionSync.mockReset();
|
|
68
|
+
});
|
|
69
|
+
|
|
60
70
|
it('creates its chat presenter from the global app presenter provider', () => {
|
|
61
71
|
render(
|
|
62
72
|
<MemoryRouter initialEntries={['/chat']}>
|
|
@@ -69,4 +79,21 @@ describe('NcpChatPage render boundary', () => {
|
|
|
69
79
|
expect(screen.getByTestId('chat-page-layout')).toBeTruthy();
|
|
70
80
|
expect(mocks.useChatSessionSync).toHaveBeenCalledOnce();
|
|
71
81
|
});
|
|
82
|
+
|
|
83
|
+
it('syncs the active route session and clears it when chat unmounts', () => {
|
|
84
|
+
const sessionPath = buildSessionPath('session-background');
|
|
85
|
+
const view = render(
|
|
86
|
+
<MemoryRouter initialEntries={[sessionPath]}>
|
|
87
|
+
<AppPresenterProvider>
|
|
88
|
+
<Routes>
|
|
89
|
+
<Route path="/chat/:sessionId?" element={<NcpChatPage view="chat" />} />
|
|
90
|
+
</Routes>
|
|
91
|
+
</AppPresenterProvider>
|
|
92
|
+
</MemoryRouter>,
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
expect(mocks.syncActiveSession).toHaveBeenCalledWith('session-background');
|
|
96
|
+
view.unmount();
|
|
97
|
+
expect(mocks.syncActiveSession).toHaveBeenLastCalledWith(null);
|
|
98
|
+
});
|
|
72
99
|
});
|
|
@@ -59,10 +59,19 @@ export function NcpChatPage({ view }: ChatPageProps) {
|
|
|
59
59
|
}
|
|
60
60
|
|
|
61
61
|
function NcpChatPageContent({ view }: ChatPageProps) {
|
|
62
|
+
const appPresenter = useAppPresenter();
|
|
62
63
|
const presenter = usePresenter();
|
|
63
64
|
const confirmDialog = useNcpChatUiBindings();
|
|
64
65
|
const routeSelection = useNcpChatRouteSelection();
|
|
65
66
|
const { routeSessionKey, sessionKey } = routeSelection;
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
appPresenter.chatCompletionNotificationManager.syncActiveSession(
|
|
69
|
+
sessionKey ?? null,
|
|
70
|
+
);
|
|
71
|
+
return () => {
|
|
72
|
+
appPresenter.chatCompletionNotificationManager.syncActiveSession(null);
|
|
73
|
+
};
|
|
74
|
+
}, [appPresenter.chatCompletionNotificationManager, sessionKey]);
|
|
66
75
|
useChatQueryStoreSync({
|
|
67
76
|
sessionKey: sessionKey ?? null,
|
|
68
77
|
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { render, screen } from "@testing-library/react";
|
|
2
|
+
import { describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { InboxDeliveryContent } from "@/features/inbox/components/inbox-delivery-content";
|
|
4
|
+
|
|
5
|
+
vi.mock("@nextclaw/agent-chat-ui", () => ({
|
|
6
|
+
ChatMessageMarkdown: ({ text }: { text: string }) => <article>{text}</article>,
|
|
7
|
+
}));
|
|
8
|
+
|
|
9
|
+
describe("InboxDeliveryContent", () => {
|
|
10
|
+
it("keeps Markdown on the existing renderer path", () => {
|
|
11
|
+
render(
|
|
12
|
+
<InboxDeliveryContent
|
|
13
|
+
content="# Markdown report"
|
|
14
|
+
contentType="markdown"
|
|
15
|
+
title="Markdown report"
|
|
16
|
+
/>,
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
expect(screen.getByText("# Markdown report")).toBeTruthy();
|
|
20
|
+
expect(screen.queryByTitle("Markdown report")).toBeNull();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("renders static HTML in an isolated document", () => {
|
|
24
|
+
render(
|
|
25
|
+
<InboxDeliveryContent
|
|
26
|
+
content={'<meta http-equiv="refresh" content="0;url=https://example.com"><h1 onclick="alert(1)">Report</h1><script>alert(1)</script>'}
|
|
27
|
+
contentType="html"
|
|
28
|
+
fillHeight
|
|
29
|
+
title="HTML report"
|
|
30
|
+
/>,
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
const frame = screen.getByTitle<HTMLIFrameElement>("HTML report");
|
|
34
|
+
expect(frame.getAttribute("sandbox")).toBe("");
|
|
35
|
+
expect(frame.className).toContain("h-full");
|
|
36
|
+
expect(frame.className).toContain("border-0");
|
|
37
|
+
expect(frame.srcdoc).toContain("Content-Security-Policy");
|
|
38
|
+
expect(frame.srcdoc).toContain("script-src 'none'");
|
|
39
|
+
expect(frame.srcdoc).toContain("connect-src 'none'");
|
|
40
|
+
expect(frame.srcdoc).not.toContain("http-equiv=\"refresh\"");
|
|
41
|
+
expect(frame.srcdoc).not.toContain("onclick");
|
|
42
|
+
expect(frame.srcdoc).not.toContain("<script");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("preserves the iframe instance while the surrounding view rerenders", () => {
|
|
46
|
+
const view = render(
|
|
47
|
+
<InboxDeliveryContent content="<h1>Report</h1>" contentType="html" title="Report" />,
|
|
48
|
+
);
|
|
49
|
+
const frame = screen.getByTitle("Report");
|
|
50
|
+
|
|
51
|
+
view.rerender(
|
|
52
|
+
<InboxDeliveryContent content="<h1>Report</h1>" contentType="html" title="Report" />,
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
expect(screen.getByTitle("Report")).toBe(frame);
|
|
56
|
+
});
|
|
57
|
+
});
|