@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.
Files changed (102) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/dist/assets/{api-tWUa6l9J.js → api-CHo2gekJ.js} +5 -5
  3. package/dist/assets/app-presenter-provider-2_DmWYT9.js +108 -0
  4. package/dist/assets/{appearance-settings-page-dMRBD7ni.js → appearance-settings-page-BEy2UE7R.js} +1 -1
  5. package/dist/assets/{book-open-BQ29ZmDE.js → book-open-BrZL4o9L.js} +1 -1
  6. package/dist/assets/{card-CtKueklV.js → card-B5rRLu_6.js} +1 -1
  7. package/dist/assets/{channels-list-page-C8y3rqYt.js → channels-list-page-C1dnm68I.js} +1 -1
  8. package/dist/assets/chat-page-Dz6tvZ91.js +1 -0
  9. package/dist/assets/{config-split-page-uC6d_r9c.js → config-split-page-6y6W6dz4.js} +1 -1
  10. package/dist/assets/{confirm-dialog-C1pbqHZX.js → confirm-dialog-q1CpEmR8.js} +1 -1
  11. package/dist/assets/{desktop-update-config-DssP8LCk.js → desktop-update-config-DrdlwP4j.js} +1 -1
  12. package/dist/assets/{dist-rYHnBo8t.js → dist-C9EX46wM.js} +1 -1
  13. package/dist/assets/doc-browser-BfttDSti.js +1 -0
  14. package/dist/assets/{doc-browser-D8ng4EUc.js → doc-browser-DH-3hDnI.js} +1 -1
  15. package/dist/assets/{doc-browser-context-CPHNzQ8m.js → doc-browser-context-TU2heon0.js} +1 -1
  16. package/dist/assets/{eye-CiJz3ODY.js → eye-B-3dyZNA.js} +1 -1
  17. package/dist/assets/{form-actions-BnZVzLZf.js → form-actions-DvvxCpL4.js} +1 -1
  18. package/dist/assets/index-CXDPLDFD.css +1 -0
  19. package/dist/assets/index-k_eWScvN.js +2 -0
  20. package/dist/assets/loader-circle-B35NlHTI.js +1 -0
  21. package/dist/assets/{logo-badge-BXI-Kdhd.js → logo-badge-DB51c0cS.js} +1 -1
  22. package/dist/assets/mcp-marketplace-page-CkPtwPBk.js +1 -0
  23. package/dist/assets/{mcp-marketplace-page-CxNe-Q9Q.js → mcp-marketplace-page-DWtsIqml.js} +1 -1
  24. package/dist/assets/{model-config-page-CR_mgEVp.js → model-config-page-D6cGNnMM.js} +1 -1
  25. package/dist/assets/{navigation-link-C1cvB7y4.js → navigation-link-BSJEUiwD.js} +1 -1
  26. package/dist/assets/plus-BRVe5jSJ.js +1 -0
  27. package/dist/assets/{popover-ZoDwVwwd.js → popover-Bp9IpE_N.js} +1 -1
  28. package/dist/assets/{provider-scoped-model-input-DIL6Ex_d.js → provider-scoped-model-input-dk7fz9NL.js} +1 -1
  29. package/dist/assets/{providers-config-page-D2NXVctW.js → providers-config-page-DTP2YfxA.js} +1 -1
  30. package/dist/assets/react-D6ShNq95.js +8 -0
  31. package/dist/assets/{refresh-cw-DKewcdAl.js → refresh-cw-C2e94pmp.js} +1 -1
  32. package/dist/assets/remote-FllhXW3o.js +1 -0
  33. package/dist/assets/rotate-cw-CTC-Qszi.js +1 -0
  34. package/dist/assets/runtime-config-page-C57OLdPm.js +1 -0
  35. package/dist/assets/{save-BrckuAXY.js → save-CkwDOgvt.js} +1 -1
  36. package/dist/assets/{search-BhXQB2TO.js → search-CLXM2reO.js} +1 -1
  37. package/dist/assets/{search-config-page-DFOQ6SDJ.js → search-config-page-tnJXaaSr.js} +1 -1
  38. package/dist/assets/{secrets-config-page-BqqeClqE.js → secrets-config-page-DQ_Curaj.js} +1 -1
  39. package/dist/assets/security-config-S00DjFfO.js +1 -0
  40. package/dist/assets/{select-CVzGGT38.js → select-CNFBf00l.js} +1 -1
  41. package/dist/assets/{settings-2-BP6L2enG.js → settings-2-B9IT2HGd.js} +1 -1
  42. package/dist/assets/skeleton-Dc06gatK.js +1 -0
  43. package/dist/assets/{tabs-B-QWjKCL.js → tabs-BuNr8LSO.js} +1 -1
  44. package/dist/assets/{tag-chip-C88mxjwx.js → tag-chip-CDkB5smU.js} +1 -1
  45. package/dist/assets/{trash-2-ck3t33I3.js → trash-2-GchaJuEM.js} +1 -1
  46. package/dist/assets/{use-config-BAr5bH2c.js → use-config-C69rR15i.js} +1 -1
  47. package/dist/assets/{use-confirm-dialog-C4e0nG_4.js → use-confirm-dialog-DF_EgPYx.js} +1 -1
  48. package/dist/assets/{use-viewport-layout-k1isXfNm.js → use-viewport-layout-BACNZyMY.js} +1 -1
  49. package/dist/assets/x-BXFquY9e.js +1 -0
  50. package/dist/index.html +28 -28
  51. package/package.json +7 -7
  52. package/src/app/components/__tests__/app-notification-runtime.test.tsx +32 -0
  53. package/src/app/components/app-notification-runtime.tsx +15 -0
  54. package/src/app/components/layout/sidebar-items.tsx +23 -9
  55. package/src/app/configs/app-navigation.config.ts +21 -0
  56. package/src/app/index.tsx +22 -2
  57. package/src/app/presenters/app.presenter.ts +8 -0
  58. package/src/features/chat/components/layout/chat-page-shell.tsx +7 -2
  59. package/src/features/chat/components/layout/chat-sidebar-desktop-layout.tsx +12 -0
  60. package/src/features/chat/index.ts +1 -0
  61. package/src/features/chat/managers/__tests__/chat-completion-notification.manager.test.ts +177 -0
  62. package/src/features/chat/managers/chat-completion-notification.manager.ts +146 -0
  63. package/src/features/chat/pages/__tests__/ncp-chat-page.test.tsx +29 -2
  64. package/src/features/chat/pages/ncp-chat-page.tsx +9 -0
  65. package/src/features/inbox/components/inbox-delivery-content.test.tsx +57 -0
  66. package/src/features/inbox/components/inbox-delivery-content.tsx +118 -0
  67. package/src/features/inbox/components/inbox-reader-dialog.test.tsx +77 -0
  68. package/src/features/inbox/components/inbox-reader-dialog.tsx +224 -0
  69. package/src/features/inbox/components/inbox-runtime.tsx +14 -0
  70. package/src/features/inbox/hooks/use-inbox-deliveries.ts +14 -0
  71. package/src/features/inbox/index.ts +7 -0
  72. package/src/features/inbox/managers/inbox.manager.test.ts +86 -0
  73. package/src/features/inbox/managers/inbox.manager.ts +178 -0
  74. package/src/features/inbox/pages/inbox-page.test.ts +28 -0
  75. package/src/features/inbox/pages/inbox-page.tsx +385 -0
  76. package/src/features/inbox/stores/inbox.store.ts +23 -0
  77. package/src/features/notifications/components/__tests__/app-notification-toast.test.tsx +48 -0
  78. package/src/features/notifications/components/app-notification-toast.tsx +80 -0
  79. package/src/features/notifications/index.ts +8 -0
  80. package/src/features/notifications/managers/__tests__/app-notification.manager.test.ts +75 -0
  81. package/src/features/notifications/managers/app-notification.manager.ts +41 -0
  82. package/src/platforms/mobile/components/mobile-bottom-nav.tsx +14 -7
  83. package/src/shared/lib/i18n/index.ts +4 -0
  84. package/src/shared/lib/i18n/locales/en-US/chat.json +3 -0
  85. package/src/shared/lib/i18n/locales/en-US/inbox.json +30 -0
  86. package/src/shared/lib/i18n/locales/zh-CN/chat.json +3 -0
  87. package/src/shared/lib/i18n/locales/zh-CN/inbox.json +30 -0
  88. package/dist/assets/app-presenter-provider-s-FrOinZ.js +0 -106
  89. package/dist/assets/chat-page-MXBltS3G.js +0 -1
  90. package/dist/assets/doc-browser-AXF1zRsM.js +0 -1
  91. package/dist/assets/index-CocHWEsI.js +0 -2
  92. package/dist/assets/index-DN6KM7WD.css +0 -1
  93. package/dist/assets/loader-circle-AOpK5kkW.js +0 -1
  94. package/dist/assets/mcp-marketplace-page-5ogeBHnl.js +0 -1
  95. package/dist/assets/plus-DI37QuHc.js +0 -1
  96. package/dist/assets/react-39CDNZlM.js +0 -8
  97. package/dist/assets/remote-DYOOO1YG.js +0 -1
  98. package/dist/assets/rotate-cw-PmqA8LQT.js +0 -1
  99. package/dist/assets/runtime-config-page-0lb5NyKc.js +0 -1
  100. package/dist/assets/security-config-13AtPIlm.js +0 -1
  101. package/dist/assets/skeleton-OwCNQJ7M.js +0 -1
  102. 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 position="top-right" richColors offset={window.nextclawDesktop?.platform === "win32" ? 56 : undefined} mobileOffset={window.nextclawDesktop?.platform === "win32" ? 56 : undefined} />
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
- export type MainPanelView = "chat" | "cron" | "skills" | "agents";
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 === "cron" ? (
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
  });