@nextclaw/ui 0.15.20 → 0.15.22
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 +42 -0
- package/dist/assets/{api-tWUa6l9J.js → api-B1yHQYs-.js} +6 -6
- package/dist/assets/app-presenter-provider-uP9waOzv.js +108 -0
- package/dist/assets/appearance-settings-page-C9Zq3hP9.js +1 -0
- package/dist/assets/{book-open-BQ29ZmDE.js → book-open-oSmyVXea.js} +1 -1
- package/dist/assets/{card-CtKueklV.js → card-CGvJZTZp.js} +1 -1
- package/dist/assets/{channels-list-page-C8y3rqYt.js → channels-list-page-5BTxpQHw.js} +2 -2
- package/dist/assets/chat-page-jTvli8Jp.js +1 -0
- package/dist/assets/config-split-page-BgBsUmp1.js +1 -0
- package/dist/assets/confirm-dialog-h849QRWO.js +5 -0
- package/dist/assets/desktop-update-config-BGGHxClR.js +1 -0
- package/dist/assets/{dist-rYHnBo8t.js → dist-CxUhurq9.js} +1 -1
- package/dist/assets/doc-browser-0eL8AX42.js +1 -0
- package/dist/assets/{doc-browser-D8ng4EUc.js → doc-browser-DEqE9lsh.js} +1 -1
- package/dist/assets/{doc-browser-context-CPHNzQ8m.js → doc-browser-context-DkzFIUoK.js} +1 -1
- package/dist/assets/{eye-CiJz3ODY.js → eye-gOWQGt8N.js} +1 -1
- package/dist/assets/{form-actions-BnZVzLZf.js → form-actions-CgjYZUzM.js} +1 -1
- package/dist/assets/index-DSAUWCfl.js +2 -0
- package/dist/assets/index-G7cBryJ4.css +1 -0
- package/dist/assets/loader-circle-BAuycGf6.js +1 -0
- package/dist/assets/{logo-badge-BXI-Kdhd.js → logo-badge-BNcsDsUM.js} +1 -1
- package/dist/assets/{mcp-marketplace-page-CxNe-Q9Q.js → mcp-marketplace-page-BX39MtPa.js} +3 -3
- package/dist/assets/mcp-marketplace-page-CkDtOEA4.js +1 -0
- package/dist/assets/{model-config-page-CR_mgEVp.js → model-config-page-7MIXg2fQ.js} +1 -1
- package/dist/assets/{navigation-link-C1cvB7y4.js → navigation-link-B3iFOdxx.js} +1 -1
- package/dist/assets/plus-yvhFgfal.js +1 -0
- package/dist/assets/{popover-ZoDwVwwd.js → popover-Cpep_rJe.js} +1 -1
- package/dist/assets/{provider-models-CXUgDxGR.js → provider-models-CUrptZ-o.js} +1 -1
- package/dist/assets/provider-scoped-model-input-CHmOzz7x.js +1 -0
- package/dist/assets/providers-config-page-A6J7Y0II.js +1 -0
- package/dist/assets/react-BCJGCjrP.js +8 -0
- package/dist/assets/{refresh-cw-DKewcdAl.js → refresh-cw-CkKwPztT.js} +1 -1
- package/dist/assets/remote-FD-ZfSIn.js +1 -0
- package/dist/assets/rotate-cw-DrJMfHhp.js +1 -0
- package/dist/assets/runtime-config-page-DwjO-Vs6.js +1 -0
- package/dist/assets/{save-BrckuAXY.js → save-BINMWate.js} +1 -1
- package/dist/assets/{search-BhXQB2TO.js → search-7M38azRp.js} +1 -1
- package/dist/assets/{search-config-page-DFOQ6SDJ.js → search-config-page-T6tMhXbP.js} +1 -1
- package/dist/assets/{secrets-config-page-BqqeClqE.js → secrets-config-page-2mVejXf2.js} +2 -2
- package/dist/assets/security-config-DD5RrjDe.js +1 -0
- package/dist/assets/{select-CVzGGT38.js → select-BvidP5Oa.js} +1 -1
- package/dist/assets/{settings-2-BP6L2enG.js → settings-2-DqZstREV.js} +1 -1
- package/dist/assets/skeleton-BEQW2Dva.js +1 -0
- package/dist/assets/{tabs-B-QWjKCL.js → tabs-BeLxVmkt.js} +1 -1
- package/dist/assets/{tag-chip-C88mxjwx.js → tag-chip-BoDaLnWt.js} +1 -1
- package/dist/assets/{trash-2-ck3t33I3.js → trash-2-CYUskMMN.js} +1 -1
- package/dist/assets/{use-config-BAr5bH2c.js → use-config-CxKRXXCl.js} +1 -1
- package/dist/assets/use-confirm-dialog-HASTvOP2.js +1 -0
- package/dist/assets/{use-viewport-layout-k1isXfNm.js → use-viewport-layout-CJxkjoRz.js} +1 -1
- package/dist/assets/x-DdUwjre5.js +1 -0
- package/dist/index.html +29 -29
- package/package.json +8 -8
- 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/components/layout/sidebar-rail.styles.ts +0 -2
- package/src/app/components/layout/sidebar.tsx +2 -2
- 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/app/styles/design-system.css +12 -0
- package/src/features/chat/components/chat-sidebar-list-mode-switch.tsx +2 -2
- package/src/features/chat/components/conversation/__tests__/chat-conversation-content.test.tsx +19 -1
- package/src/features/chat/components/conversation/__tests__/chat-conversation-header-section.test.tsx +1 -1
- package/src/features/chat/components/conversation/chat-conversation-content.tsx +5 -6
- package/src/features/chat/components/conversation/chat-conversation-header.tsx +1 -1
- package/src/features/chat/components/layout/__tests__/chat-sidebar-read-state.test.tsx +1 -1
- package/src/features/chat/components/layout/__tests__/chat-sidebar-session-area.test.tsx +52 -0
- package/src/features/chat/components/layout/__tests__/chat-sidebar.test.tsx +11 -17
- package/src/features/chat/components/layout/chat-page-shell.tsx +9 -13
- package/src/features/chat/components/layout/chat-sidebar-desktop-layout.tsx +40 -38
- package/src/features/chat/components/layout/chat-sidebar-toolbar.tsx +13 -64
- package/src/features/chat/components/layout/chat-sidebar-utility-menu.tsx +1 -4
- package/src/features/chat/features/conversation/components/__tests__/session-conversation-area.test.tsx +1 -1
- package/src/features/chat/features/conversation/components/__tests__/session-conversation-input.streaming.test.tsx +15 -4
- package/src/features/chat/features/conversation/components/session-conversation-area.tsx +7 -7
- package/src/features/chat/features/conversation/components/session-conversation-input.tsx +4 -2
- package/src/features/chat/features/conversation/hooks/use-session-conversation-input-attachments.ts +1 -0
- package/src/features/chat/features/conversation/utils/session-conversation-input-toolbar.utils.ts +1 -0
- package/src/features/chat/features/conversation/utils/session-queued-input.utils.ts +1 -0
- package/src/features/chat/features/input/utils/__tests__/chat-input-bar.utils.test.ts +3 -0
- package/src/features/chat/features/input/utils/chat-input-bar.utils.ts +0 -1
- package/src/features/chat/features/input/utils/chat-input-toolbar.utils.ts +3 -0
- package/src/features/chat/features/message/utils/__tests__/chat-message-timeline.utils.test.ts +29 -0
- package/src/features/chat/features/message/utils/chat-message-timeline.utils.ts +4 -1
- package/src/features/chat/features/session/components/chat-sidebar-session-item.tsx +10 -10
- package/src/features/chat/features/session/components/chat-sidebar-session-list.tsx +2 -2
- package/src/features/chat/features/session/components/session-header/chat-session-header-menu-item.tsx +1 -1
- package/src/features/chat/features/session/components/session-header/chat-session-project-badge.tsx +1 -1
- package/src/features/chat/features/session/components/session-header/chat-session-switcher-groups.tsx +1 -1
- package/src/features/chat/features/session/components/session-header/chat-session-title-switcher.tsx +1 -1
- package/src/features/chat/features/session/utils/__tests__/chat-session-display.utils.test.ts +26 -0
- package/src/features/chat/features/session/utils/__tests__/ncp-session-adapter.utils.test.ts +4 -2
- package/src/features/chat/features/session/utils/chat-session-display.utils.ts +48 -12
- package/src/features/chat/features/session/utils/ncp-session-adapter.utils.ts +13 -4
- package/src/features/chat/features/welcome/components/__tests__/chat-conversation-welcome.test.tsx +6 -0
- package/src/features/chat/features/welcome/components/__tests__/chat-welcome.test.tsx +10 -7
- package/src/features/chat/features/welcome/components/chat-welcome-agent-picker.tsx +1 -1
- package/src/features/chat/features/welcome/components/chat-welcome-project-picker.tsx +3 -14
- package/src/features/chat/features/welcome/components/chat-welcome-prompt-suggestions.tsx +37 -0
- package/src/features/chat/features/welcome/components/chat-welcome-session-type-picker.tsx +1 -1
- package/src/features/chat/features/welcome/components/chat-welcome.tsx +33 -31
- package/src/features/chat/features/workspace/components/__tests__/chat-session-workspace-panel-content.test.tsx +1 -1
- package/src/features/chat/features/workspace/components/__tests__/chat-session-workspace-panel.test.tsx +1 -1
- 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/__tests__/chat-session-list.manager.test.ts +15 -3
- package/src/features/chat/managers/__tests__/chat-thread.manager.test.ts +3 -10
- package/src/features/chat/managers/chat-completion-notification.manager.ts +146 -0
- package/src/features/chat/managers/chat-session-list.manager.ts +3 -23
- package/src/features/chat/managers/chat-thread.manager.ts +2 -5
- package/src/features/chat/pages/__tests__/ncp-chat-page.test.tsx +32 -11
- package/src/features/chat/pages/ncp-chat-page.tsx +9 -1
- package/src/features/chat/types/chat-input-bar.types.ts +1 -0
- package/src/features/cron/components/cron-task-discovery.tsx +1 -1
- 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/marketplace/components/marketplace-page-parts.tsx +1 -1
- package/src/features/notifications/components/__tests__/app-notification-toast.test.tsx +51 -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/features/panel-apps/components/panel-app-list-item.tsx +1 -1
- package/src/features/panel-apps/components/panel-app-toolbar.tsx +2 -2
- package/src/features/panel-apps/components/panel-apps-list.tsx +4 -4
- package/src/features/service-apps/components/service-apps-panel.tsx +1 -1
- package/src/features/settings/components/config/provider-auth-section.tsx +90 -59
- package/src/features/settings/components/config/provider-form.tsx +10 -17
- package/src/features/settings/components/config/provider-status-badge.tsx +4 -2
- package/src/features/settings/pages/__tests__/providers-config-page.test.tsx +12 -0
- package/src/features/settings/pages/providers-config-page.tsx +2 -1
- package/src/platforms/mobile/components/__tests__/mobile-app-shell.test.tsx +13 -7
- package/src/platforms/mobile/components/__tests__/mobile-bottom-nav.test.tsx +18 -20
- package/src/platforms/mobile/components/mobile-bottom-nav.tsx +14 -7
- package/src/shared/components/doc-browser/doc-browser-panel-parts.tsx +1 -1
- package/src/shared/components/path-picker/server-path-picker-directory-list.tsx +1 -1
- package/src/shared/components/path-picker/server-path-picker-locations.tsx +1 -1
- package/src/shared/components/path-picker/server-path-picker-navigation-bar.tsx +3 -3
- package/src/shared/components/ui/actions/__tests__/icon-action-button.test.tsx +1 -1
- package/src/shared/components/ui/actions/icon-action-button.tsx +1 -1
- package/src/shared/components/ui/button.tsx +4 -4
- package/src/shared/components/ui/dialog.tsx +1 -1
- package/src/shared/components/ui/input.tsx +1 -1
- package/src/shared/components/ui/scroll-area.tsx +2 -0
- package/src/shared/components/ui/select.tsx +1 -1
- package/src/shared/components/ui/switch.tsx +1 -1
- package/src/shared/components/ui/tabs.tsx +1 -1
- package/src/shared/components/ui/textarea.tsx +1 -1
- package/src/shared/lib/api/ncp-session.types.ts +1 -0
- package/src/shared/lib/api/types.ts +2 -0
- package/src/shared/lib/i18n/index.ts +4 -0
- package/src/shared/lib/i18n/locales/en-US/chat.json +16 -14
- package/src/shared/lib/i18n/locales/en-US/inbox.json +30 -0
- package/src/shared/lib/i18n/locales/zh-CN/chat.json +16 -14
- package/src/shared/lib/i18n/locales/zh-CN/inbox.json +30 -0
- package/src/shared/lib/provider-models/__tests__/anonymous-provider-configuration.test.ts +40 -0
- package/src/shared/lib/provider-models/index.ts +4 -3
- package/dist/assets/app-presenter-provider-s-FrOinZ.js +0 -106
- package/dist/assets/appearance-settings-page-dMRBD7ni.js +0 -1
- package/dist/assets/chat-page-MXBltS3G.js +0 -1
- package/dist/assets/config-split-page-uC6d_r9c.js +0 -1
- package/dist/assets/confirm-dialog-C1pbqHZX.js +0 -5
- package/dist/assets/desktop-update-config-DssP8LCk.js +0 -1
- package/dist/assets/doc-browser-AXF1zRsM.js +0 -1
- package/dist/assets/index-CocHWEsI.js +0 -2
- package/dist/assets/index-DN6KM7WD.css +0 -1
- package/dist/assets/loader-circle-AOpK5kkW.js +0 -1
- package/dist/assets/mcp-marketplace-page-5ogeBHnl.js +0 -1
- package/dist/assets/plus-DI37QuHc.js +0 -1
- package/dist/assets/provider-scoped-model-input-DIL6Ex_d.js +0 -1
- package/dist/assets/providers-config-page-D2NXVctW.js +0 -1
- package/dist/assets/react-39CDNZlM.js +0 -8
- package/dist/assets/remote-DYOOO1YG.js +0 -1
- package/dist/assets/rotate-cw-PmqA8LQT.js +0 -1
- package/dist/assets/runtime-config-page-0lb5NyKc.js +0 -1
- package/dist/assets/security-config-13AtPIlm.js +0 -1
- package/dist/assets/skeleton-OwCNQJ7M.js +0 -1
- package/dist/assets/use-confirm-dialog-C4e0nG_4.js +0 -1
- package/dist/assets/x-C1ijNWcE.js +0 -1
- package/src/features/chat/features/welcome/components/chat-welcome-capability-grid.tsx +0 -59
|
@@ -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
|
+
});
|
|
@@ -70,6 +70,18 @@ function resetChatSessionListManagerState() {
|
|
|
70
70
|
describe("ChatSessionListManager draft and selection flow", () => {
|
|
71
71
|
beforeEach(resetChatSessionListManagerState);
|
|
72
72
|
|
|
73
|
+
it("clears the route-derived session selection outside session routes", () => {
|
|
74
|
+
const manager = new ChatSessionListManager(
|
|
75
|
+
{} as ConstructorParameters<typeof ChatSessionListManager>[0],
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
manager.syncRouteSessionSelection(null);
|
|
79
|
+
|
|
80
|
+
expect(
|
|
81
|
+
useChatSessionListStore.getState().snapshot.selectedSessionKey,
|
|
82
|
+
).toBeNull();
|
|
83
|
+
});
|
|
84
|
+
|
|
73
85
|
it("applies the requested session type when creating a session", () => {
|
|
74
86
|
const uiManager = {
|
|
75
87
|
goToChatRoot: vi.fn(),
|
|
@@ -93,7 +105,7 @@ describe("ChatSessionListManager draft and selection flow", () => {
|
|
|
93
105
|
});
|
|
94
106
|
expect(
|
|
95
107
|
useChatSessionListStore.getState().snapshot.selectedSessionKey,
|
|
96
|
-
).
|
|
108
|
+
).toBe("session-1");
|
|
97
109
|
expect(useChatThreadStore.getState().snapshot.sessionKey).toBeNull();
|
|
98
110
|
expect(
|
|
99
111
|
useChatThreadStore.getState().snapshot.hasSubmittedDraftMessage,
|
|
@@ -126,7 +138,7 @@ describe("ChatSessionListManager draft and selection flow", () => {
|
|
|
126
138
|
);
|
|
127
139
|
expect(
|
|
128
140
|
useChatSessionListStore.getState().snapshot.selectedSessionKey,
|
|
129
|
-
).
|
|
141
|
+
).toBe("session-1");
|
|
130
142
|
expect(useChatThreadStore.getState().snapshot.sessionKey).toBeNull();
|
|
131
143
|
expect(
|
|
132
144
|
useChatThreadStore.getState().snapshot.hasSubmittedDraftMessage,
|
|
@@ -198,7 +210,7 @@ describe("ChatSessionListManager draft and selection flow", () => {
|
|
|
198
210
|
|
|
199
211
|
expect(
|
|
200
212
|
useChatSessionListStore.getState().snapshot.selectedSessionKey,
|
|
201
|
-
).
|
|
213
|
+
).toBe("session-1");
|
|
202
214
|
expect(uiManager.navigateTo).toHaveBeenCalledWith(
|
|
203
215
|
"/chat/draft",
|
|
204
216
|
expect.any(Object),
|
|
@@ -836,7 +836,7 @@ describe('ChatThreadManager visible workspace selection', () => {
|
|
|
836
836
|
});
|
|
837
837
|
|
|
838
838
|
describe('ChatThreadManager deletion', () => {
|
|
839
|
-
it('clears
|
|
839
|
+
it('clears thread state while leaving route-derived selection to navigation', async () => {
|
|
840
840
|
const removeQueries = vi.spyOn(appQueryClient, 'removeQueries').mockImplementation(async () => undefined);
|
|
841
841
|
const uiManager = {
|
|
842
842
|
goToSession: vi.fn(),
|
|
@@ -844,13 +844,7 @@ describe('ChatThreadManager deletion', () => {
|
|
|
844
844
|
goToProviders: vi.fn(),
|
|
845
845
|
confirm: vi.fn(async () => true),
|
|
846
846
|
} as unknown as ConstructorParameters<typeof ChatThreadManager>[0];
|
|
847
|
-
const sessionListManager = {
|
|
848
|
-
setSelectedSessionKey: vi.fn((value: string | null) => {
|
|
849
|
-
useChatSessionListStore.getState().setSnapshot({
|
|
850
|
-
selectedSessionKey: value,
|
|
851
|
-
});
|
|
852
|
-
}),
|
|
853
|
-
} as unknown as ConstructorParameters<typeof ChatThreadManager>[1];
|
|
847
|
+
const sessionListManager = {} as ConstructorParameters<typeof ChatThreadManager>[1];
|
|
854
848
|
const manager = new ChatThreadManager(
|
|
855
849
|
uiManager,
|
|
856
850
|
sessionListManager,
|
|
@@ -858,8 +852,7 @@ describe('ChatThreadManager deletion', () => {
|
|
|
858
852
|
|
|
859
853
|
await manager.deleteSession();
|
|
860
854
|
|
|
861
|
-
expect(
|
|
862
|
-
expect(useChatSessionListStore.getState().snapshot.selectedSessionKey).toBeNull();
|
|
855
|
+
expect(useChatSessionListStore.getState().snapshot.selectedSessionKey).toBe('parent-session-1');
|
|
863
856
|
expect(useChatThreadStore.getState().snapshot).toMatchObject({
|
|
864
857
|
sessionKey: null,
|
|
865
858
|
canDeleteSession: false,
|
|
@@ -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
|
+
}
|
|
@@ -100,9 +100,8 @@ export class ChatSessionListManager {
|
|
|
100
100
|
this.setSelectedAgentId(normalizedAgentId);
|
|
101
101
|
};
|
|
102
102
|
|
|
103
|
-
setSelectedSessionKey = (
|
|
103
|
+
private setSelectedSessionKey = (value: string | null) => {
|
|
104
104
|
const prev = useChatSessionListStore.getState().snapshot.selectedSessionKey;
|
|
105
|
-
const value = this.resolveUpdateValue(prev, next);
|
|
106
105
|
if (value === prev) {
|
|
107
106
|
return;
|
|
108
107
|
}
|
|
@@ -111,24 +110,8 @@ export class ChatSessionListManager {
|
|
|
111
110
|
.setSnapshot({ selectedSessionKey: value });
|
|
112
111
|
};
|
|
113
112
|
|
|
114
|
-
syncRouteSessionSelection = (
|
|
115
|
-
|
|
116
|
-
routeSessionKey: string | null;
|
|
117
|
-
}) => {
|
|
118
|
-
const { isChatView, routeSessionKey } = params;
|
|
119
|
-
if (!isChatView) {
|
|
120
|
-
return;
|
|
121
|
-
}
|
|
122
|
-
const { selectedSessionKey } = useChatSessionListStore.getState().snapshot;
|
|
123
|
-
if (routeSessionKey) {
|
|
124
|
-
if (selectedSessionKey !== routeSessionKey) {
|
|
125
|
-
this.setSelectedSessionKey(routeSessionKey);
|
|
126
|
-
}
|
|
127
|
-
return;
|
|
128
|
-
}
|
|
129
|
-
if (selectedSessionKey !== null) {
|
|
130
|
-
this.setSelectedSessionKey(null);
|
|
131
|
-
}
|
|
113
|
+
syncRouteSessionSelection = (routeSessionKey: string | null) => {
|
|
114
|
+
this.setSelectedSessionKey(routeSessionKey);
|
|
132
115
|
};
|
|
133
116
|
|
|
134
117
|
setListMode = (next: SetStateAction<"time-first" | "project-first">) => {
|
|
@@ -207,9 +190,6 @@ export class ChatSessionListManager {
|
|
|
207
190
|
);
|
|
208
191
|
const normalizedProjectRoot = normalizeSessionProjectRootValue(projectRoot);
|
|
209
192
|
const normalizedPrompt = prompt?.trim() || null;
|
|
210
|
-
useChatSessionListStore.getState().setSnapshot({
|
|
211
|
-
selectedSessionKey: null,
|
|
212
|
-
});
|
|
213
193
|
this.syncDraftThreadState();
|
|
214
194
|
this.uiManager.navigateTo(CHAT_DRAFT_SESSION_PATH, {
|
|
215
195
|
replace: this.uiManager.isAtChatRoot(),
|
|
@@ -75,10 +75,7 @@ export class ChatThreadManager {
|
|
|
75
75
|
useChatThreadStore.getState().setSnapshot(patch);
|
|
76
76
|
};
|
|
77
77
|
|
|
78
|
-
private clearDeletedSessionState = (
|
|
79
|
-
if (useChatSessionListStore.getState().snapshot.selectedSessionKey === sessionKey) {
|
|
80
|
-
this.sessionListManager.setSelectedSessionKey(null);
|
|
81
|
-
}
|
|
78
|
+
private clearDeletedSessionState = () => {
|
|
82
79
|
useChatThreadStore.getState().setSnapshot({
|
|
83
80
|
sessionKey: null,
|
|
84
81
|
sessionTypeLabel: null,
|
|
@@ -569,7 +566,7 @@ export class ChatThreadManager {
|
|
|
569
566
|
await deleteNcpSessionApi(selectedSessionKey);
|
|
570
567
|
deleteNcpSessionSummaryInQueryClient(appQueryClient, selectedSessionKey);
|
|
571
568
|
appQueryClient.removeQueries({ queryKey: ['ncp-session-messages', selectedSessionKey] });
|
|
572
|
-
this.clearDeletedSessionState(
|
|
569
|
+
this.clearDeletedSessionState();
|
|
573
570
|
this.uiManager.goToChatRoot({ replace: true });
|
|
574
571
|
} finally {
|
|
575
572
|
useChatThreadStore.getState().setSnapshot({ isDeletePending: false });
|
|
@@ -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
|
|
|
@@ -34,17 +39,11 @@ vi.mock('@/shared/hooks/use-confirm-dialog', () => ({
|
|
|
34
39
|
vi.mock('@/features/chat/components/layout/chat-page-shell', () => ({
|
|
35
40
|
ChatPageLayout: () => <div data-testid="chat-page-layout" />,
|
|
36
41
|
useChatSessionSync: (params: {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
routeSessionKey: string | null;
|
|
40
|
-
}) => void;
|
|
42
|
+
routeSessionKey: string | null;
|
|
43
|
+
syncRouteSessionSelection: (routeSessionKey: string | null) => void;
|
|
41
44
|
}) => {
|
|
42
45
|
mocks.useChatSessionSync(params);
|
|
43
|
-
|
|
44
|
-
syncRouteSessionSelection({
|
|
45
|
-
isChatView: true,
|
|
46
|
-
routeSessionKey: null,
|
|
47
|
-
});
|
|
46
|
+
params.syncRouteSessionSelection(params.routeSessionKey);
|
|
48
47
|
},
|
|
49
48
|
}));
|
|
50
49
|
|
|
@@ -57,6 +56,11 @@ vi.mock('@/features/chat/features/ncp/hooks/use-ui-show-content-event', () => ({
|
|
|
57
56
|
}));
|
|
58
57
|
|
|
59
58
|
describe('NcpChatPage render boundary', () => {
|
|
59
|
+
beforeEach(() => {
|
|
60
|
+
mocks.syncActiveSession.mockReset();
|
|
61
|
+
mocks.useChatSessionSync.mockReset();
|
|
62
|
+
});
|
|
63
|
+
|
|
60
64
|
it('creates its chat presenter from the global app presenter provider', () => {
|
|
61
65
|
render(
|
|
62
66
|
<MemoryRouter initialEntries={['/chat']}>
|
|
@@ -69,4 +73,21 @@ describe('NcpChatPage render boundary', () => {
|
|
|
69
73
|
expect(screen.getByTestId('chat-page-layout')).toBeTruthy();
|
|
70
74
|
expect(mocks.useChatSessionSync).toHaveBeenCalledOnce();
|
|
71
75
|
});
|
|
76
|
+
|
|
77
|
+
it('syncs the active route session and clears it when chat unmounts', () => {
|
|
78
|
+
const sessionPath = buildSessionPath('session-background');
|
|
79
|
+
const view = render(
|
|
80
|
+
<MemoryRouter initialEntries={[sessionPath]}>
|
|
81
|
+
<AppPresenterProvider>
|
|
82
|
+
<Routes>
|
|
83
|
+
<Route path="/chat/:sessionId?" element={<NcpChatPage view="chat" />} />
|
|
84
|
+
</Routes>
|
|
85
|
+
</AppPresenterProvider>
|
|
86
|
+
</MemoryRouter>,
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
expect(mocks.syncActiveSession).toHaveBeenCalledWith('session-background');
|
|
90
|
+
view.unmount();
|
|
91
|
+
expect(mocks.syncActiveSession).toHaveBeenLastCalledWith(null);
|
|
92
|
+
});
|
|
72
93
|
});
|
|
@@ -59,15 +59,23 @@ 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
|
});
|
|
69
78
|
useChatSessionSync({
|
|
70
|
-
view,
|
|
71
79
|
routeSessionKey,
|
|
72
80
|
syncRouteSessionSelection:
|
|
73
81
|
presenter.chatSessionListManager.syncRouteSessionSelection,
|
|
@@ -44,6 +44,7 @@ export type ChatInputBarAdapterTexts = {
|
|
|
44
44
|
modelSearchEmptyLabel: string;
|
|
45
45
|
favoriteModelLabel: string;
|
|
46
46
|
unfavoriteModelLabel: string;
|
|
47
|
+
manageModelsLabel: string;
|
|
47
48
|
thinkingLabels: Record<ChatThinkingLevel, string>;
|
|
48
49
|
noModelOptionsLabel: string;
|
|
49
50
|
configureProviderLabel: string;
|
|
@@ -94,7 +94,7 @@ export const CronTaskComposer = forwardRef<
|
|
|
94
94
|
ref={ref}
|
|
95
95
|
value={value}
|
|
96
96
|
onChange={(event) => onChange(event.target.value)}
|
|
97
|
-
className="pl-9 pr-11
|
|
97
|
+
className="pl-9 pr-11"
|
|
98
98
|
placeholder={t("cronComposerPlaceholder")}
|
|
99
99
|
aria-label={t("cronComposerLabel")}
|
|
100
100
|
/>
|
|
@@ -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
|
+
});
|