@nextclaw/ui 0.15.19 → 0.15.21

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