@nextclaw/ui 0.15.20 → 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 +22 -0
- package/dist/assets/{api-tWUa6l9J.js → api-CHo2gekJ.js} +5 -5
- package/dist/assets/app-presenter-provider-2_DmWYT9.js +108 -0
- package/dist/assets/{appearance-settings-page-dMRBD7ni.js → appearance-settings-page-BEy2UE7R.js} +1 -1
- package/dist/assets/{book-open-BQ29ZmDE.js → book-open-BrZL4o9L.js} +1 -1
- package/dist/assets/{card-CtKueklV.js → card-B5rRLu_6.js} +1 -1
- package/dist/assets/{channels-list-page-C8y3rqYt.js → channels-list-page-C1dnm68I.js} +1 -1
- package/dist/assets/chat-page-Dz6tvZ91.js +1 -0
- package/dist/assets/{config-split-page-uC6d_r9c.js → config-split-page-6y6W6dz4.js} +1 -1
- package/dist/assets/{confirm-dialog-C1pbqHZX.js → confirm-dialog-q1CpEmR8.js} +1 -1
- package/dist/assets/{desktop-update-config-DssP8LCk.js → desktop-update-config-DrdlwP4j.js} +1 -1
- package/dist/assets/{dist-rYHnBo8t.js → dist-C9EX46wM.js} +1 -1
- package/dist/assets/doc-browser-BfttDSti.js +1 -0
- package/dist/assets/{doc-browser-D8ng4EUc.js → doc-browser-DH-3hDnI.js} +1 -1
- package/dist/assets/{doc-browser-context-CPHNzQ8m.js → doc-browser-context-TU2heon0.js} +1 -1
- package/dist/assets/{eye-CiJz3ODY.js → eye-B-3dyZNA.js} +1 -1
- package/dist/assets/{form-actions-BnZVzLZf.js → form-actions-DvvxCpL4.js} +1 -1
- 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-BXI-Kdhd.js → logo-badge-DB51c0cS.js} +1 -1
- package/dist/assets/mcp-marketplace-page-CkPtwPBk.js +1 -0
- package/dist/assets/{mcp-marketplace-page-CxNe-Q9Q.js → mcp-marketplace-page-DWtsIqml.js} +1 -1
- package/dist/assets/{model-config-page-CR_mgEVp.js → model-config-page-D6cGNnMM.js} +1 -1
- package/dist/assets/{navigation-link-C1cvB7y4.js → navigation-link-BSJEUiwD.js} +1 -1
- package/dist/assets/plus-BRVe5jSJ.js +1 -0
- package/dist/assets/{popover-ZoDwVwwd.js → popover-Bp9IpE_N.js} +1 -1
- package/dist/assets/{provider-scoped-model-input-DIL6Ex_d.js → provider-scoped-model-input-dk7fz9NL.js} +1 -1
- package/dist/assets/{providers-config-page-D2NXVctW.js → providers-config-page-DTP2YfxA.js} +1 -1
- package/dist/assets/react-D6ShNq95.js +8 -0
- package/dist/assets/{refresh-cw-DKewcdAl.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-BrckuAXY.js → save-CkwDOgvt.js} +1 -1
- package/dist/assets/{search-BhXQB2TO.js → search-CLXM2reO.js} +1 -1
- package/dist/assets/{search-config-page-DFOQ6SDJ.js → search-config-page-tnJXaaSr.js} +1 -1
- package/dist/assets/{secrets-config-page-BqqeClqE.js → secrets-config-page-DQ_Curaj.js} +1 -1
- package/dist/assets/security-config-S00DjFfO.js +1 -0
- package/dist/assets/{select-CVzGGT38.js → select-CNFBf00l.js} +1 -1
- package/dist/assets/{settings-2-BP6L2enG.js → settings-2-B9IT2HGd.js} +1 -1
- package/dist/assets/skeleton-Dc06gatK.js +1 -0
- package/dist/assets/{tabs-B-QWjKCL.js → tabs-BuNr8LSO.js} +1 -1
- package/dist/assets/{tag-chip-C88mxjwx.js → tag-chip-CDkB5smU.js} +1 -1
- package/dist/assets/{trash-2-ck3t33I3.js → trash-2-GchaJuEM.js} +1 -1
- package/dist/assets/{use-config-BAr5bH2c.js → use-config-C69rR15i.js} +1 -1
- package/dist/assets/{use-confirm-dialog-C4e0nG_4.js → use-confirm-dialog-DF_EgPYx.js} +1 -1
- package/dist/assets/{use-viewport-layout-k1isXfNm.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 +7 -7
- 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/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.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/lib/i18n/index.ts +4 -0
- package/src/shared/lib/i18n/locales/en-US/chat.json +3 -0
- package/src/shared/lib/i18n/locales/en-US/inbox.json +30 -0
- package/src/shared/lib/i18n/locales/zh-CN/chat.json +3 -0
- package/src/shared/lib/i18n/locales/zh-CN/inbox.json +30 -0
- package/dist/assets/app-presenter-provider-s-FrOinZ.js +0 -106
- package/dist/assets/chat-page-MXBltS3G.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/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/x-C1ijNWcE.js +0 -1
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
AlarmClock,
|
|
6
6
|
Cpu,
|
|
7
7
|
Download,
|
|
8
|
+
Inbox,
|
|
8
9
|
KeyRound,
|
|
9
10
|
MessageCircle,
|
|
10
11
|
MessageSquare,
|
|
@@ -44,6 +45,8 @@ export function isMainWorkspaceRoute(pathname: string): boolean {
|
|
|
44
45
|
return (
|
|
45
46
|
normalized === "/chat" ||
|
|
46
47
|
normalized.startsWith("/chat/") ||
|
|
48
|
+
normalized === "/inbox" ||
|
|
49
|
+
normalized.startsWith("/inbox/") ||
|
|
47
50
|
normalized === "/skills" ||
|
|
48
51
|
normalized.startsWith("/skills/") ||
|
|
49
52
|
normalized === "/cron" ||
|
|
@@ -67,6 +70,11 @@ export function getMobileBottomNavItems(
|
|
|
67
70
|
label: translate("chat"),
|
|
68
71
|
icon: MessageCircle,
|
|
69
72
|
},
|
|
73
|
+
{
|
|
74
|
+
target: "/inbox",
|
|
75
|
+
label: translate("inboxTitle"),
|
|
76
|
+
icon: Inbox,
|
|
77
|
+
},
|
|
70
78
|
{
|
|
71
79
|
target: "/skills",
|
|
72
80
|
label: translate("marketplaceFilterSkills"),
|
|
@@ -94,6 +102,11 @@ export function getMainSidebarNavItems(
|
|
|
94
102
|
label: translate("chat"),
|
|
95
103
|
icon: MessageCircle,
|
|
96
104
|
},
|
|
105
|
+
{
|
|
106
|
+
target: "/inbox",
|
|
107
|
+
label: translate("inboxTitle"),
|
|
108
|
+
icon: Inbox,
|
|
109
|
+
},
|
|
97
110
|
{
|
|
98
111
|
target: "/chat/cron",
|
|
99
112
|
label: translate("cron"),
|
|
@@ -227,6 +240,14 @@ export function resolveMobileRouteMeta(
|
|
|
227
240
|
};
|
|
228
241
|
}
|
|
229
242
|
|
|
243
|
+
if (normalized === "/inbox" || normalized.startsWith("/inbox/")) {
|
|
244
|
+
return {
|
|
245
|
+
title: translate("inboxTitle"),
|
|
246
|
+
backTarget: normalized === "/inbox" ? null : "/inbox",
|
|
247
|
+
backLabel: translate("inboxTitle"),
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
230
251
|
if (normalized === "/skills" || normalized.startsWith("/skills/")) {
|
|
231
252
|
return {
|
|
232
253
|
title: translate("marketplaceFilterSkills"),
|
package/src/app/index.tsx
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
import { lazy, Suspense, useEffect } from "react";
|
|
2
|
-
import type { ReactElement } from "react";
|
|
2
|
+
import type { CSSProperties, ReactElement } from "react";
|
|
3
3
|
import { QueryClientProvider } from "@tanstack/react-query";
|
|
4
4
|
import { Navigate, Route, Routes } from "react-router-dom";
|
|
5
5
|
import { Toaster } from "sonner";
|
|
6
6
|
import { appQueryClient } from "@/app-query-client";
|
|
7
7
|
import { AppPresenterProvider } from "@/app/components/app-presenter-provider";
|
|
8
|
+
import { AppNotificationRuntime } from "@/app/components/app-notification-runtime";
|
|
8
9
|
import { AppLayout } from "@/app/components/layout/app-layout";
|
|
10
|
+
import { SIDEBAR_RAIL_WIDTH_PX } from "@/app/components/layout/sidebar-rail.styles";
|
|
9
11
|
import { SettingsEntryPage } from "@/app/components/layout/settings-entry-page";
|
|
10
12
|
import { LoginPage } from "@/components/auth/login-page";
|
|
11
13
|
import { AccountPanel } from "@/features/account";
|
|
14
|
+
import { InboxRuntime } from "@/features/inbox";
|
|
12
15
|
import { ServiceActionAuthorizationDialog } from "@/features/service-apps";
|
|
13
16
|
import { runtimeUpdateManager, useSystemStatusSources } from "@/features/system-status";
|
|
14
17
|
import {
|
|
@@ -21,6 +24,8 @@ import {
|
|
|
21
24
|
} from "@/pwa/components/pwa-install-entry";
|
|
22
25
|
import { startNextClawPwa } from "@/pwa/register-pwa";
|
|
23
26
|
|
|
27
|
+
const NOTIFICATION_TOASTER_STYLE = { "--width": "320px" } as CSSProperties;
|
|
28
|
+
|
|
24
29
|
const ModelConfigPage = lazy(async () => ({
|
|
25
30
|
default: (await import("@/features/settings/pages/model-config-page"))
|
|
26
31
|
.ModelConfigPage,
|
|
@@ -117,6 +122,10 @@ const protectedRouteDefinitions: ProtectedRouteDefinition[] = [
|
|
|
117
122
|
path: "/cron",
|
|
118
123
|
element: createLazyElement(<ChatPage view="cron" />),
|
|
119
124
|
},
|
|
125
|
+
{
|
|
126
|
+
path: "/inbox/:deliveryId?",
|
|
127
|
+
element: createLazyElement(<ChatPage view="inbox" />),
|
|
128
|
+
},
|
|
120
129
|
{
|
|
121
130
|
path: "/appearance",
|
|
122
131
|
element: createLazyElement(<AppearanceSettingsPage />),
|
|
@@ -219,6 +228,8 @@ function ProtectedApp() {
|
|
|
219
228
|
|
|
220
229
|
return (
|
|
221
230
|
<AppPresenterProvider>
|
|
231
|
+
<AppNotificationRuntime />
|
|
232
|
+
<InboxRuntime />
|
|
222
233
|
<AppLayout>
|
|
223
234
|
<ProtectedRoutes />
|
|
224
235
|
</AppLayout>
|
|
@@ -257,7 +268,16 @@ export default function AppContent() {
|
|
|
257
268
|
<QueryClientProvider client={appQueryClient}>
|
|
258
269
|
<AuthGate />
|
|
259
270
|
<PwaInstallBanner />
|
|
260
|
-
<Toaster
|
|
271
|
+
<Toaster
|
|
272
|
+
position="top-right"
|
|
273
|
+
richColors
|
|
274
|
+
offset={{
|
|
275
|
+
top: window.nextclawDesktop?.platform === "win32" ? 56 : 44,
|
|
276
|
+
right: SIDEBAR_RAIL_WIDTH_PX + 16,
|
|
277
|
+
}}
|
|
278
|
+
mobileOffset={window.nextclawDesktop?.platform === "win32" ? 56 : undefined}
|
|
279
|
+
style={NOTIFICATION_TOASTER_STYLE}
|
|
280
|
+
/>
|
|
261
281
|
</QueryClientProvider>
|
|
262
282
|
);
|
|
263
283
|
}
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import { AccountManager } from '@/features/account';
|
|
2
2
|
import { viewportLayoutManager } from '@/app/managers/viewport-layout.manager';
|
|
3
3
|
import {
|
|
4
|
+
ChatCompletionNotificationManager,
|
|
4
5
|
ChatDraftIntentManager,
|
|
5
6
|
useChatThreadStore,
|
|
6
7
|
} from '@/features/chat';
|
|
8
|
+
import { AppNotificationManager } from '@/features/notifications';
|
|
7
9
|
import { PanelAppBridgeManager } from '@/features/panel-apps';
|
|
10
|
+
import { InboxManager } from '@/features/inbox';
|
|
8
11
|
import { RightPanelResourceRouteResolver } from '@/features/right-panel-resources';
|
|
9
12
|
import { RemoteAccessManager } from '@/features/remote';
|
|
10
13
|
import { ServiceActionAuthorizationManager } from '@/features/service-apps';
|
|
@@ -19,6 +22,11 @@ function isChatWorkspacePanelOpen(snapshot: ChatThreadSnapshot): boolean {
|
|
|
19
22
|
}
|
|
20
23
|
|
|
21
24
|
export class AppPresenter {
|
|
25
|
+
notificationManager = new AppNotificationManager();
|
|
26
|
+
inboxManager = new InboxManager();
|
|
27
|
+
chatCompletionNotificationManager = new ChatCompletionNotificationManager(
|
|
28
|
+
this.notificationManager,
|
|
29
|
+
);
|
|
22
30
|
accountManager = new AccountManager();
|
|
23
31
|
rightPanelResourceRouteResolver = new RightPanelResourceRouteResolver();
|
|
24
32
|
notifyRightPanelOpened = () => {
|
|
@@ -6,7 +6,8 @@ import { CronConfig } from "@/features/cron";
|
|
|
6
6
|
import { MarketplacePage } from "@/features/marketplace";
|
|
7
7
|
import { useViewportLayout } from "@/app/hooks/use-viewport-layout";
|
|
8
8
|
import { ChatMobileShell } from "@/platforms/mobile";
|
|
9
|
-
|
|
9
|
+
import { InboxPage } from "@/features/inbox";
|
|
10
|
+
export type MainPanelView = "chat" | "cron" | "skills" | "agents" | "inbox";
|
|
10
11
|
export type ChatPageProps = {
|
|
11
12
|
view: MainPanelView;
|
|
12
13
|
};
|
|
@@ -51,7 +52,11 @@ export function ChatPageLayout({ view, confirmDialog }: ChatPageLayoutProps) {
|
|
|
51
52
|
isMobile ? <ChatMobileShell /> : <ChatConversationPanel />
|
|
52
53
|
) : (
|
|
53
54
|
<section className="flex-1 min-h-0 overflow-hidden bg-background">
|
|
54
|
-
{view === "
|
|
55
|
+
{view === "inbox" ? (
|
|
56
|
+
<div className="mx-auto flex h-full min-h-0 w-full max-w-[min(1180px,100%)] flex-col py-4 sm:px-6 sm:py-5">
|
|
57
|
+
<InboxPage />
|
|
58
|
+
</div>
|
|
59
|
+
) : view === "cron" ? (
|
|
55
60
|
<div className="h-full overflow-auto custom-scrollbar">
|
|
56
61
|
<div className="mx-auto w-full max-w-[min(1120px,100%)] px-4 py-4 sm:px-6 sm:py-5">
|
|
57
62
|
<CronConfig />
|
|
@@ -16,10 +16,12 @@ import {
|
|
|
16
16
|
AlarmClock,
|
|
17
17
|
Bot,
|
|
18
18
|
BrainCircuit,
|
|
19
|
+
Inbox,
|
|
19
20
|
PanelLeftClose,
|
|
20
21
|
PanelLeftOpen,
|
|
21
22
|
Plus,
|
|
22
23
|
} from "lucide-react";
|
|
24
|
+
import { useInboxUnreadCount } from "@/features/inbox";
|
|
23
25
|
import { ChatSidebarSessionList } from "@/features/chat/features/session/components/chat-sidebar-session-list";
|
|
24
26
|
import { ChatSidebarUtilityMenu } from "@/features/chat/components/layout/chat-sidebar-utility-menu";
|
|
25
27
|
import { isWindowsDesktopHost } from "@/platforms/desktop";
|
|
@@ -34,6 +36,11 @@ import {
|
|
|
34
36
|
} from "@/app/components/layout/sidebar-rail.styles";
|
|
35
37
|
|
|
36
38
|
const navItems = [
|
|
39
|
+
{
|
|
40
|
+
target: "/inbox",
|
|
41
|
+
label: () => t("inboxTitle"),
|
|
42
|
+
icon: Inbox,
|
|
43
|
+
},
|
|
37
44
|
{
|
|
38
45
|
target: "/cron",
|
|
39
46
|
label: () => t("chatSidebarScheduledTasks"),
|
|
@@ -99,6 +106,7 @@ export function ChatSidebarDesktopNav({
|
|
|
99
106
|
}: {
|
|
100
107
|
isCollapsed: boolean;
|
|
101
108
|
}) {
|
|
109
|
+
const unreadCount = useInboxUnreadCount();
|
|
102
110
|
return (
|
|
103
111
|
<>
|
|
104
112
|
<div className={cn("pb-2", isCollapsed ? "px-0" : "px-3")}>
|
|
@@ -114,6 +122,10 @@ export function ChatSidebarDesktopNav({
|
|
|
114
122
|
icon={item.icon}
|
|
115
123
|
density="compact"
|
|
116
124
|
collapsed={isCollapsed}
|
|
125
|
+
indicator={item.target === "/inbox" && unreadCount > 0}
|
|
126
|
+
trailing={item.target === "/inbox" && unreadCount > 0
|
|
127
|
+
? unreadCount > 99 ? "99+" : unreadCount
|
|
128
|
+
: undefined}
|
|
117
129
|
/>
|
|
118
130
|
</li>
|
|
119
131
|
))}
|
|
@@ -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
|
+
}
|
|
@@ -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
|
});
|