@nextclaw/ui 0.15.20 → 0.15.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (191) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/dist/assets/{api-tWUa6l9J.js → api-B1yHQYs-.js} +6 -6
  3. package/dist/assets/app-presenter-provider-uP9waOzv.js +108 -0
  4. package/dist/assets/appearance-settings-page-C9Zq3hP9.js +1 -0
  5. package/dist/assets/{book-open-BQ29ZmDE.js → book-open-oSmyVXea.js} +1 -1
  6. package/dist/assets/{card-CtKueklV.js → card-CGvJZTZp.js} +1 -1
  7. package/dist/assets/{channels-list-page-C8y3rqYt.js → channels-list-page-5BTxpQHw.js} +2 -2
  8. package/dist/assets/chat-page-jTvli8Jp.js +1 -0
  9. package/dist/assets/config-split-page-BgBsUmp1.js +1 -0
  10. package/dist/assets/confirm-dialog-h849QRWO.js +5 -0
  11. package/dist/assets/desktop-update-config-BGGHxClR.js +1 -0
  12. package/dist/assets/{dist-rYHnBo8t.js → dist-CxUhurq9.js} +1 -1
  13. package/dist/assets/doc-browser-0eL8AX42.js +1 -0
  14. package/dist/assets/{doc-browser-D8ng4EUc.js → doc-browser-DEqE9lsh.js} +1 -1
  15. package/dist/assets/{doc-browser-context-CPHNzQ8m.js → doc-browser-context-DkzFIUoK.js} +1 -1
  16. package/dist/assets/{eye-CiJz3ODY.js → eye-gOWQGt8N.js} +1 -1
  17. package/dist/assets/{form-actions-BnZVzLZf.js → form-actions-CgjYZUzM.js} +1 -1
  18. package/dist/assets/index-DSAUWCfl.js +2 -0
  19. package/dist/assets/index-G7cBryJ4.css +1 -0
  20. package/dist/assets/loader-circle-BAuycGf6.js +1 -0
  21. package/dist/assets/{logo-badge-BXI-Kdhd.js → logo-badge-BNcsDsUM.js} +1 -1
  22. package/dist/assets/{mcp-marketplace-page-CxNe-Q9Q.js → mcp-marketplace-page-BX39MtPa.js} +3 -3
  23. package/dist/assets/mcp-marketplace-page-CkDtOEA4.js +1 -0
  24. package/dist/assets/{model-config-page-CR_mgEVp.js → model-config-page-7MIXg2fQ.js} +1 -1
  25. package/dist/assets/{navigation-link-C1cvB7y4.js → navigation-link-B3iFOdxx.js} +1 -1
  26. package/dist/assets/plus-yvhFgfal.js +1 -0
  27. package/dist/assets/{popover-ZoDwVwwd.js → popover-Cpep_rJe.js} +1 -1
  28. package/dist/assets/{provider-models-CXUgDxGR.js → provider-models-CUrptZ-o.js} +1 -1
  29. package/dist/assets/provider-scoped-model-input-CHmOzz7x.js +1 -0
  30. package/dist/assets/providers-config-page-A6J7Y0II.js +1 -0
  31. package/dist/assets/react-BCJGCjrP.js +8 -0
  32. package/dist/assets/{refresh-cw-DKewcdAl.js → refresh-cw-CkKwPztT.js} +1 -1
  33. package/dist/assets/remote-FD-ZfSIn.js +1 -0
  34. package/dist/assets/rotate-cw-DrJMfHhp.js +1 -0
  35. package/dist/assets/runtime-config-page-DwjO-Vs6.js +1 -0
  36. package/dist/assets/{save-BrckuAXY.js → save-BINMWate.js} +1 -1
  37. package/dist/assets/{search-BhXQB2TO.js → search-7M38azRp.js} +1 -1
  38. package/dist/assets/{search-config-page-DFOQ6SDJ.js → search-config-page-T6tMhXbP.js} +1 -1
  39. package/dist/assets/{secrets-config-page-BqqeClqE.js → secrets-config-page-2mVejXf2.js} +2 -2
  40. package/dist/assets/security-config-DD5RrjDe.js +1 -0
  41. package/dist/assets/{select-CVzGGT38.js → select-BvidP5Oa.js} +1 -1
  42. package/dist/assets/{settings-2-BP6L2enG.js → settings-2-DqZstREV.js} +1 -1
  43. package/dist/assets/skeleton-BEQW2Dva.js +1 -0
  44. package/dist/assets/{tabs-B-QWjKCL.js → tabs-BeLxVmkt.js} +1 -1
  45. package/dist/assets/{tag-chip-C88mxjwx.js → tag-chip-BoDaLnWt.js} +1 -1
  46. package/dist/assets/{trash-2-ck3t33I3.js → trash-2-CYUskMMN.js} +1 -1
  47. package/dist/assets/{use-config-BAr5bH2c.js → use-config-CxKRXXCl.js} +1 -1
  48. package/dist/assets/use-confirm-dialog-HASTvOP2.js +1 -0
  49. package/dist/assets/{use-viewport-layout-k1isXfNm.js → use-viewport-layout-CJxkjoRz.js} +1 -1
  50. package/dist/assets/x-DdUwjre5.js +1 -0
  51. package/dist/index.html +29 -29
  52. package/package.json +8 -8
  53. package/src/app/components/__tests__/app-notification-runtime.test.tsx +32 -0
  54. package/src/app/components/app-notification-runtime.tsx +15 -0
  55. package/src/app/components/layout/sidebar-items.tsx +23 -9
  56. package/src/app/components/layout/sidebar-rail.styles.ts +0 -2
  57. package/src/app/components/layout/sidebar.tsx +2 -2
  58. package/src/app/configs/app-navigation.config.ts +21 -0
  59. package/src/app/index.tsx +22 -2
  60. package/src/app/presenters/app.presenter.ts +8 -0
  61. package/src/app/styles/design-system.css +12 -0
  62. package/src/features/chat/components/chat-sidebar-list-mode-switch.tsx +2 -2
  63. package/src/features/chat/components/conversation/__tests__/chat-conversation-content.test.tsx +19 -1
  64. package/src/features/chat/components/conversation/__tests__/chat-conversation-header-section.test.tsx +1 -1
  65. package/src/features/chat/components/conversation/chat-conversation-content.tsx +5 -6
  66. package/src/features/chat/components/conversation/chat-conversation-header.tsx +1 -1
  67. package/src/features/chat/components/layout/__tests__/chat-sidebar-read-state.test.tsx +1 -1
  68. package/src/features/chat/components/layout/__tests__/chat-sidebar-session-area.test.tsx +52 -0
  69. package/src/features/chat/components/layout/__tests__/chat-sidebar.test.tsx +11 -17
  70. package/src/features/chat/components/layout/chat-page-shell.tsx +9 -13
  71. package/src/features/chat/components/layout/chat-sidebar-desktop-layout.tsx +40 -38
  72. package/src/features/chat/components/layout/chat-sidebar-toolbar.tsx +13 -64
  73. package/src/features/chat/components/layout/chat-sidebar-utility-menu.tsx +1 -4
  74. package/src/features/chat/features/conversation/components/__tests__/session-conversation-area.test.tsx +1 -1
  75. package/src/features/chat/features/conversation/components/__tests__/session-conversation-input.streaming.test.tsx +15 -4
  76. package/src/features/chat/features/conversation/components/session-conversation-area.tsx +7 -7
  77. package/src/features/chat/features/conversation/components/session-conversation-input.tsx +4 -2
  78. package/src/features/chat/features/conversation/hooks/use-session-conversation-input-attachments.ts +1 -0
  79. package/src/features/chat/features/conversation/utils/session-conversation-input-toolbar.utils.ts +1 -0
  80. package/src/features/chat/features/conversation/utils/session-queued-input.utils.ts +1 -0
  81. package/src/features/chat/features/input/utils/__tests__/chat-input-bar.utils.test.ts +3 -0
  82. package/src/features/chat/features/input/utils/chat-input-bar.utils.ts +0 -1
  83. package/src/features/chat/features/input/utils/chat-input-toolbar.utils.ts +3 -0
  84. package/src/features/chat/features/message/utils/__tests__/chat-message-timeline.utils.test.ts +29 -0
  85. package/src/features/chat/features/message/utils/chat-message-timeline.utils.ts +4 -1
  86. package/src/features/chat/features/session/components/chat-sidebar-session-item.tsx +10 -10
  87. package/src/features/chat/features/session/components/chat-sidebar-session-list.tsx +2 -2
  88. package/src/features/chat/features/session/components/session-header/chat-session-header-menu-item.tsx +1 -1
  89. package/src/features/chat/features/session/components/session-header/chat-session-project-badge.tsx +1 -1
  90. package/src/features/chat/features/session/components/session-header/chat-session-switcher-groups.tsx +1 -1
  91. package/src/features/chat/features/session/components/session-header/chat-session-title-switcher.tsx +1 -1
  92. package/src/features/chat/features/session/utils/__tests__/chat-session-display.utils.test.ts +26 -0
  93. package/src/features/chat/features/session/utils/__tests__/ncp-session-adapter.utils.test.ts +4 -2
  94. package/src/features/chat/features/session/utils/chat-session-display.utils.ts +48 -12
  95. package/src/features/chat/features/session/utils/ncp-session-adapter.utils.ts +13 -4
  96. package/src/features/chat/features/welcome/components/__tests__/chat-conversation-welcome.test.tsx +6 -0
  97. package/src/features/chat/features/welcome/components/__tests__/chat-welcome.test.tsx +10 -7
  98. package/src/features/chat/features/welcome/components/chat-welcome-agent-picker.tsx +1 -1
  99. package/src/features/chat/features/welcome/components/chat-welcome-project-picker.tsx +3 -14
  100. package/src/features/chat/features/welcome/components/chat-welcome-prompt-suggestions.tsx +37 -0
  101. package/src/features/chat/features/welcome/components/chat-welcome-session-type-picker.tsx +1 -1
  102. package/src/features/chat/features/welcome/components/chat-welcome.tsx +33 -31
  103. package/src/features/chat/features/workspace/components/__tests__/chat-session-workspace-panel-content.test.tsx +1 -1
  104. package/src/features/chat/features/workspace/components/__tests__/chat-session-workspace-panel.test.tsx +1 -1
  105. package/src/features/chat/index.ts +1 -0
  106. package/src/features/chat/managers/__tests__/chat-completion-notification.manager.test.ts +177 -0
  107. package/src/features/chat/managers/__tests__/chat-session-list.manager.test.ts +15 -3
  108. package/src/features/chat/managers/__tests__/chat-thread.manager.test.ts +3 -10
  109. package/src/features/chat/managers/chat-completion-notification.manager.ts +146 -0
  110. package/src/features/chat/managers/chat-session-list.manager.ts +3 -23
  111. package/src/features/chat/managers/chat-thread.manager.ts +2 -5
  112. package/src/features/chat/pages/__tests__/ncp-chat-page.test.tsx +32 -11
  113. package/src/features/chat/pages/ncp-chat-page.tsx +9 -1
  114. package/src/features/chat/types/chat-input-bar.types.ts +1 -0
  115. package/src/features/cron/components/cron-task-discovery.tsx +1 -1
  116. package/src/features/inbox/components/inbox-delivery-content.test.tsx +57 -0
  117. package/src/features/inbox/components/inbox-delivery-content.tsx +118 -0
  118. package/src/features/inbox/components/inbox-reader-dialog.test.tsx +77 -0
  119. package/src/features/inbox/components/inbox-reader-dialog.tsx +224 -0
  120. package/src/features/inbox/components/inbox-runtime.tsx +14 -0
  121. package/src/features/inbox/hooks/use-inbox-deliveries.ts +14 -0
  122. package/src/features/inbox/index.ts +7 -0
  123. package/src/features/inbox/managers/inbox.manager.test.ts +86 -0
  124. package/src/features/inbox/managers/inbox.manager.ts +178 -0
  125. package/src/features/inbox/pages/inbox-page.test.ts +28 -0
  126. package/src/features/inbox/pages/inbox-page.tsx +385 -0
  127. package/src/features/inbox/stores/inbox.store.ts +23 -0
  128. package/src/features/marketplace/components/marketplace-page-parts.tsx +1 -1
  129. package/src/features/notifications/components/__tests__/app-notification-toast.test.tsx +51 -0
  130. package/src/features/notifications/components/app-notification-toast.tsx +80 -0
  131. package/src/features/notifications/index.ts +8 -0
  132. package/src/features/notifications/managers/__tests__/app-notification.manager.test.ts +75 -0
  133. package/src/features/notifications/managers/app-notification.manager.ts +41 -0
  134. package/src/features/panel-apps/components/panel-app-list-item.tsx +1 -1
  135. package/src/features/panel-apps/components/panel-app-toolbar.tsx +2 -2
  136. package/src/features/panel-apps/components/panel-apps-list.tsx +4 -4
  137. package/src/features/service-apps/components/service-apps-panel.tsx +1 -1
  138. package/src/features/settings/components/config/provider-auth-section.tsx +90 -59
  139. package/src/features/settings/components/config/provider-form.tsx +10 -17
  140. package/src/features/settings/components/config/provider-status-badge.tsx +4 -2
  141. package/src/features/settings/pages/__tests__/providers-config-page.test.tsx +12 -0
  142. package/src/features/settings/pages/providers-config-page.tsx +2 -1
  143. package/src/platforms/mobile/components/__tests__/mobile-app-shell.test.tsx +13 -7
  144. package/src/platforms/mobile/components/__tests__/mobile-bottom-nav.test.tsx +18 -20
  145. package/src/platforms/mobile/components/mobile-bottom-nav.tsx +14 -7
  146. package/src/shared/components/doc-browser/doc-browser-panel-parts.tsx +1 -1
  147. package/src/shared/components/path-picker/server-path-picker-directory-list.tsx +1 -1
  148. package/src/shared/components/path-picker/server-path-picker-locations.tsx +1 -1
  149. package/src/shared/components/path-picker/server-path-picker-navigation-bar.tsx +3 -3
  150. package/src/shared/components/ui/actions/__tests__/icon-action-button.test.tsx +1 -1
  151. package/src/shared/components/ui/actions/icon-action-button.tsx +1 -1
  152. package/src/shared/components/ui/button.tsx +4 -4
  153. package/src/shared/components/ui/dialog.tsx +1 -1
  154. package/src/shared/components/ui/input.tsx +1 -1
  155. package/src/shared/components/ui/scroll-area.tsx +2 -0
  156. package/src/shared/components/ui/select.tsx +1 -1
  157. package/src/shared/components/ui/switch.tsx +1 -1
  158. package/src/shared/components/ui/tabs.tsx +1 -1
  159. package/src/shared/components/ui/textarea.tsx +1 -1
  160. package/src/shared/lib/api/ncp-session.types.ts +1 -0
  161. package/src/shared/lib/api/types.ts +2 -0
  162. package/src/shared/lib/i18n/index.ts +4 -0
  163. package/src/shared/lib/i18n/locales/en-US/chat.json +16 -14
  164. package/src/shared/lib/i18n/locales/en-US/inbox.json +30 -0
  165. package/src/shared/lib/i18n/locales/zh-CN/chat.json +16 -14
  166. package/src/shared/lib/i18n/locales/zh-CN/inbox.json +30 -0
  167. package/src/shared/lib/provider-models/__tests__/anonymous-provider-configuration.test.ts +40 -0
  168. package/src/shared/lib/provider-models/index.ts +4 -3
  169. package/dist/assets/app-presenter-provider-s-FrOinZ.js +0 -106
  170. package/dist/assets/appearance-settings-page-dMRBD7ni.js +0 -1
  171. package/dist/assets/chat-page-MXBltS3G.js +0 -1
  172. package/dist/assets/config-split-page-uC6d_r9c.js +0 -1
  173. package/dist/assets/confirm-dialog-C1pbqHZX.js +0 -5
  174. package/dist/assets/desktop-update-config-DssP8LCk.js +0 -1
  175. package/dist/assets/doc-browser-AXF1zRsM.js +0 -1
  176. package/dist/assets/index-CocHWEsI.js +0 -2
  177. package/dist/assets/index-DN6KM7WD.css +0 -1
  178. package/dist/assets/loader-circle-AOpK5kkW.js +0 -1
  179. package/dist/assets/mcp-marketplace-page-5ogeBHnl.js +0 -1
  180. package/dist/assets/plus-DI37QuHc.js +0 -1
  181. package/dist/assets/provider-scoped-model-input-DIL6Ex_d.js +0 -1
  182. package/dist/assets/providers-config-page-D2NXVctW.js +0 -1
  183. package/dist/assets/react-39CDNZlM.js +0 -8
  184. package/dist/assets/remote-DYOOO1YG.js +0 -1
  185. package/dist/assets/rotate-cw-PmqA8LQT.js +0 -1
  186. package/dist/assets/runtime-config-page-0lb5NyKc.js +0 -1
  187. package/dist/assets/security-config-13AtPIlm.js +0 -1
  188. package/dist/assets/skeleton-OwCNQJ7M.js +0 -1
  189. package/dist/assets/use-confirm-dialog-C4e0nG_4.js +0 -1
  190. package/dist/assets/x-C1ijNWcE.js +0 -1
  191. package/src/features/chat/features/welcome/components/chat-welcome-capability-grid.tsx +0 -59
@@ -0,0 +1,177 @@
1
+ import {
2
+ NCP_INTERNAL_VISIBILITY_METADATA_KEY,
3
+ NcpEventType,
4
+ type NcpEndpointEvent,
5
+ type NcpMessage,
6
+ } from "@nextclaw/ncp";
7
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
8
+ import { ChatCompletionNotificationManager } from "@/features/chat/managers/chat-completion-notification.manager";
9
+ import { buildSessionPath } from "@/features/chat/features/session/utils/chat-session-route.utils";
10
+ import { useChatQueryStore } from "@/features/chat/stores/ncp-chat-query.store";
11
+
12
+ const mocks = vi.hoisted(() => ({
13
+ eventHandler: null as ((event: NcpEndpointEvent) => void) | null,
14
+ on: vi.fn(),
15
+ unsubscribe: vi.fn(),
16
+ }));
17
+
18
+ vi.mock("@/shared/lib/api", () => ({
19
+ nextclawClient: {
20
+ eventBus: {
21
+ on: mocks.on,
22
+ },
23
+ },
24
+ }));
25
+
26
+ function createMessage(overrides: Partial<NcpMessage> = {}): NcpMessage {
27
+ return {
28
+ id: "assistant-message-1",
29
+ sessionId: "session-background",
30
+ role: "assistant",
31
+ status: "final",
32
+ timestamp: "2026-08-05T10:00:00.000Z",
33
+ parts: [{ type: "text", text: " The requested research is complete. " }],
34
+ ...overrides,
35
+ };
36
+ }
37
+
38
+ function createCompletedEvent(message = createMessage()): NcpEndpointEvent {
39
+ return {
40
+ type: NcpEventType.MessageCompleted,
41
+ payload: {
42
+ sessionId: message.sessionId,
43
+ message,
44
+ },
45
+ };
46
+ }
47
+
48
+ function emit(event: NcpEndpointEvent): void {
49
+ if (!mocks.eventHandler) {
50
+ throw new Error("Notification manager has not subscribed");
51
+ }
52
+ mocks.eventHandler(event);
53
+ }
54
+
55
+ describe("ChatCompletionNotificationManager", () => {
56
+ const show = vi.fn();
57
+ let manager: ChatCompletionNotificationManager;
58
+
59
+ beforeEach(() => {
60
+ mocks.eventHandler = null;
61
+ mocks.on.mockReset();
62
+ mocks.unsubscribe.mockReset();
63
+ mocks.on.mockImplementation((_key, handler: (event: NcpEndpointEvent) => void) => {
64
+ mocks.eventHandler = handler;
65
+ return mocks.unsubscribe;
66
+ });
67
+ show.mockReset();
68
+ useChatQueryStore.setState({ snapshot: {} });
69
+ manager = new ChatCompletionNotificationManager({ show });
70
+ });
71
+
72
+ afterEach(() => {
73
+ manager.stop();
74
+ });
75
+
76
+ it("maps a background assistant final reply to a navigable global notification", () => {
77
+ useChatQueryStore.setState({
78
+ snapshot: {
79
+ sessionsQuery: {
80
+ data: {
81
+ sessions: [{
82
+ sessionId: "session-background",
83
+ messageCount: 2,
84
+ updatedAt: "2026-08-05T10:00:00.000Z",
85
+ metadata: { label: "Quarterly research" },
86
+ }],
87
+ total: 1,
88
+ },
89
+ } as never,
90
+ },
91
+ });
92
+ manager.start();
93
+ emit(createCompletedEvent());
94
+
95
+ expect(show).toHaveBeenCalledWith({
96
+ id: "chat-reply:assistant-message-1",
97
+ title: "Quarterly research",
98
+ description: "The requested research is complete.",
99
+ href: buildSessionPath("session-background"),
100
+ ariaLabel: "Open the new reply in Quarterly research",
101
+ });
102
+ });
103
+
104
+ it("suppresses messages completed in the active session, including later replays", () => {
105
+ manager.start();
106
+ manager.syncActiveSession("session-background");
107
+ emit(createCompletedEvent());
108
+ expect(show).not.toHaveBeenCalled();
109
+
110
+ manager.syncActiveSession(null);
111
+ emit(createCompletedEvent());
112
+ expect(show).not.toHaveBeenCalled();
113
+
114
+ const nextMessage = createMessage({ id: "assistant-message-2" });
115
+ emit(createCompletedEvent(nextMessage));
116
+ emit(createCompletedEvent(nextMessage));
117
+ expect(show).toHaveBeenCalledOnce();
118
+ });
119
+
120
+ it("ignores non-final, non-assistant, hidden, and unrelated events", () => {
121
+ manager.start();
122
+ emit(createCompletedEvent(createMessage({ status: "streaming" })));
123
+ emit(createCompletedEvent(createMessage({ role: "service" })));
124
+ emit(createCompletedEvent(createMessage({
125
+ metadata: { [NCP_INTERNAL_VISIBILITY_METADATA_KEY]: "hidden" },
126
+ })));
127
+ emit({
128
+ type: NcpEventType.RunFinished,
129
+ payload: { sessionId: "session-background" },
130
+ });
131
+
132
+ expect(show).not.toHaveBeenCalled();
133
+ });
134
+
135
+ it("uses localized fallbacks when the session and reply have no display text", () => {
136
+ manager.start();
137
+ emit(createCompletedEvent(createMessage({ parts: [] })));
138
+
139
+ expect(show).toHaveBeenCalledWith(expect.objectContaining({
140
+ title: "New reply",
141
+ description: "A reply is ready. Open the session to view it.",
142
+ }));
143
+ });
144
+
145
+ it("projects markdown replies into a readable plain-text preview", () => {
146
+ manager.start();
147
+ emit(createCompletedEvent(createMessage({
148
+ parts: [{
149
+ type: "text",
150
+ text: [
151
+ "# Release **complete**",
152
+ "",
153
+ "- [x] Open the [report](https://example.com/report)",
154
+ "> Review `details` and ~~obsolete notes~~",
155
+ "",
156
+ "```sh",
157
+ "pnpm test",
158
+ "```",
159
+ ].join("\n"),
160
+ }],
161
+ })));
162
+
163
+ expect(show).toHaveBeenCalledWith(expect.objectContaining({
164
+ description: "Release complete Open the report Review details and obsolete notes pnpm test",
165
+ }));
166
+ });
167
+
168
+ it("owns an idempotent realtime subscription lifecycle", () => {
169
+ manager.start();
170
+ manager.start();
171
+ expect(mocks.on).toHaveBeenCalledOnce();
172
+
173
+ manager.stop();
174
+ manager.stop();
175
+ expect(mocks.unsubscribe).toHaveBeenCalledOnce();
176
+ });
177
+ });
@@ -70,6 +70,18 @@ function resetChatSessionListManagerState() {
70
70
  describe("ChatSessionListManager draft and selection flow", () => {
71
71
  beforeEach(resetChatSessionListManagerState);
72
72
 
73
+ it("clears the route-derived session selection outside session routes", () => {
74
+ const manager = new ChatSessionListManager(
75
+ {} as ConstructorParameters<typeof ChatSessionListManager>[0],
76
+ );
77
+
78
+ manager.syncRouteSessionSelection(null);
79
+
80
+ expect(
81
+ useChatSessionListStore.getState().snapshot.selectedSessionKey,
82
+ ).toBeNull();
83
+ });
84
+
73
85
  it("applies the requested session type when creating a session", () => {
74
86
  const uiManager = {
75
87
  goToChatRoot: vi.fn(),
@@ -93,7 +105,7 @@ describe("ChatSessionListManager draft and selection flow", () => {
93
105
  });
94
106
  expect(
95
107
  useChatSessionListStore.getState().snapshot.selectedSessionKey,
96
- ).toBeNull();
108
+ ).toBe("session-1");
97
109
  expect(useChatThreadStore.getState().snapshot.sessionKey).toBeNull();
98
110
  expect(
99
111
  useChatThreadStore.getState().snapshot.hasSubmittedDraftMessage,
@@ -126,7 +138,7 @@ describe("ChatSessionListManager draft and selection flow", () => {
126
138
  );
127
139
  expect(
128
140
  useChatSessionListStore.getState().snapshot.selectedSessionKey,
129
- ).toBeNull();
141
+ ).toBe("session-1");
130
142
  expect(useChatThreadStore.getState().snapshot.sessionKey).toBeNull();
131
143
  expect(
132
144
  useChatThreadStore.getState().snapshot.hasSubmittedDraftMessage,
@@ -198,7 +210,7 @@ describe("ChatSessionListManager draft and selection flow", () => {
198
210
 
199
211
  expect(
200
212
  useChatSessionListStore.getState().snapshot.selectedSessionKey,
201
- ).toBeNull();
213
+ ).toBe("session-1");
202
214
  expect(uiManager.navigateTo).toHaveBeenCalledWith(
203
215
  "/chat/draft",
204
216
  expect.any(Object),
@@ -836,7 +836,7 @@ describe('ChatThreadManager visible workspace selection', () => {
836
836
  });
837
837
 
838
838
  describe('ChatThreadManager deletion', () => {
839
- it('clears the selected thread state after deleting the current session', async () => {
839
+ it('clears thread state while leaving route-derived selection to navigation', async () => {
840
840
  const removeQueries = vi.spyOn(appQueryClient, 'removeQueries').mockImplementation(async () => undefined);
841
841
  const uiManager = {
842
842
  goToSession: vi.fn(),
@@ -844,13 +844,7 @@ describe('ChatThreadManager deletion', () => {
844
844
  goToProviders: vi.fn(),
845
845
  confirm: vi.fn(async () => true),
846
846
  } as unknown as ConstructorParameters<typeof ChatThreadManager>[0];
847
- const sessionListManager = {
848
- setSelectedSessionKey: vi.fn((value: string | null) => {
849
- useChatSessionListStore.getState().setSnapshot({
850
- selectedSessionKey: value,
851
- });
852
- }),
853
- } as unknown as ConstructorParameters<typeof ChatThreadManager>[1];
847
+ const sessionListManager = {} as ConstructorParameters<typeof ChatThreadManager>[1];
854
848
  const manager = new ChatThreadManager(
855
849
  uiManager,
856
850
  sessionListManager,
@@ -858,8 +852,7 @@ describe('ChatThreadManager deletion', () => {
858
852
 
859
853
  await manager.deleteSession();
860
854
 
861
- expect(sessionListManager.setSelectedSessionKey).toHaveBeenCalledWith(null);
862
- expect(useChatSessionListStore.getState().snapshot.selectedSessionKey).toBeNull();
855
+ expect(useChatSessionListStore.getState().snapshot.selectedSessionKey).toBe('parent-session-1');
863
856
  expect(useChatThreadStore.getState().snapshot).toMatchObject({
864
857
  sessionKey: null,
865
858
  canDeleteSession: false,
@@ -0,0 +1,146 @@
1
+ import {
2
+ isHiddenNcpMessage,
3
+ NcpEventType,
4
+ type NcpEndpointEvent,
5
+ type NcpMessage,
6
+ } from "@nextclaw/ncp";
7
+ import { eventKeys } from "@nextclaw/shared";
8
+ import type { AppNotificationManager } from "@/features/notifications";
9
+ import { buildSessionPath } from "@/features/chat/features/session/utils/chat-session-route.utils";
10
+ import { sessionDisplayName } from "@/features/chat/features/session/utils/chat-session-display.utils";
11
+ import { adaptNcpSessionSummary } from "@/features/chat/features/session/utils/ncp-session-adapter.utils";
12
+ import { useChatQueryStore } from "@/features/chat/stores/ncp-chat-query.store";
13
+ import { nextclawClient } from "@/shared/lib/api";
14
+ import { t } from "@/shared/lib/i18n";
15
+
16
+ const NOTIFICATION_PREVIEW_MAX_CHARACTERS = 120;
17
+ const NOTIFIED_MESSAGE_HISTORY_LIMIT = 200;
18
+
19
+ function buildNotificationPreview(value: string): string {
20
+ const normalized = value
21
+ .replace(/\r\n?/gu, "\n")
22
+ .replace(/^[ \t]*\[[^\]]+\]:\s+\S+.*$/gmu, " ")
23
+ .replace(/^[ \t]*(?:`{3,}|~{3,})[^\n]*$/gmu, " ")
24
+ .replace(/^[ \t]*\|?[ \t]*:?-{3,}:?[ \t]*(?:\|[ \t]*:?-{3,}:?[ \t]*)+\|?[ \t]*$/gmu, " ")
25
+ .replace(/^[ \t]*(?:[-*_][ \t]*){3,}$/gmu, " ")
26
+ .replace(/!\[([^\]]*)\]\([^\n)]+\)/gu, "$1")
27
+ .replace(/\[([^\]]+)\]\([^\n)]+\)/gu, "$1")
28
+ .replace(/\[([^\]]+)\]\[[^\]]*\]/gu, "$1")
29
+ .replace(/<(https?:\/\/[^>\s]+)>/gu, "$1")
30
+ .replace(/<[^>]+>/gu, " ")
31
+ .replace(/^[ \t]{0,3}#{1,6}[ \t]+/gmu, "")
32
+ .replace(/^[ \t]*>+[ \t]?/gmu, "")
33
+ .replace(/^[ \t]*(?:[-+*]|\d+[.)])[ \t]+/gmu, "")
34
+ .replace(/^[ \t]*\[(?: |x|X)\][ \t]*/gmu, "")
35
+ .replace(/(`+)([^`]*?)\1/gu, "$2")
36
+ .replace(/(\*\*|__)(?=\S)([\s\S]*?\S)\1/gu, "$2")
37
+ .replace(/~~(?=\S)([\s\S]*?\S)~~/gu, "$1")
38
+ .replace(/\*(?=\S)([^*\n]*?\S)\*/gu, "$1")
39
+ .replace(/\\([\\`*{}[\]()#+.!_>-])/gu, "$1")
40
+ .replace(/^[ \t]*\|(.+)\|[ \t]*$/gmu, (_match, row: string) =>
41
+ row.split("|").map((cell) => cell.trim()).filter(Boolean).join(" "),
42
+ )
43
+ .replace(/\s+/gu, " ")
44
+ .trim();
45
+ const characters = Array.from(normalized);
46
+ if (characters.length <= NOTIFICATION_PREVIEW_MAX_CHARACTERS) {
47
+ return normalized;
48
+ }
49
+ return `${characters.slice(0, NOTIFICATION_PREVIEW_MAX_CHARACTERS - 1).join("")}…`;
50
+ }
51
+
52
+ function readAssistantReplyPreview(message: NcpMessage): string {
53
+ return buildNotificationPreview(
54
+ message.parts
55
+ .filter((part) => part.type === "text" || part.type === "rich-text")
56
+ .map((part) => part.text)
57
+ .join(" "),
58
+ );
59
+ }
60
+
61
+ export class ChatCompletionNotificationManager {
62
+ private readonly cleanups: Array<() => void> = [];
63
+ private readonly handledMessageIds = new Set<string>();
64
+ private activeSessionId: string | null = null;
65
+ private started = false;
66
+
67
+ constructor(
68
+ private readonly notificationManager: Pick<AppNotificationManager, "show">,
69
+ ) {}
70
+
71
+ start = (): void => {
72
+ if (this.started) {
73
+ return;
74
+ }
75
+ this.started = true;
76
+ this.cleanups.push(
77
+ nextclawClient.eventBus.on(eventKeys.ncpEvent, this.handleNcpEvent),
78
+ );
79
+ };
80
+
81
+ stop = (): void => {
82
+ if (!this.started) {
83
+ return;
84
+ }
85
+ this.started = false;
86
+ while (this.cleanups.length > 0) {
87
+ this.cleanups.pop()?.();
88
+ }
89
+ };
90
+
91
+ syncActiveSession = (sessionId: string | null): void => {
92
+ this.activeSessionId = sessionId?.trim() || null;
93
+ };
94
+
95
+ private readonly handleNcpEvent = (event: NcpEndpointEvent): void => {
96
+ if (event.type !== NcpEventType.MessageCompleted) {
97
+ return;
98
+ }
99
+
100
+ const { message } = event.payload;
101
+ const sessionId = event.payload.sessionId.trim();
102
+ if (
103
+ !sessionId ||
104
+ this.handledMessageIds.has(message.id) ||
105
+ message.role !== "assistant" ||
106
+ message.status !== "final" ||
107
+ isHiddenNcpMessage(message)
108
+ ) {
109
+ return;
110
+ }
111
+ this.rememberHandledMessage(message.id);
112
+ if (sessionId === this.activeSessionId) {
113
+ return;
114
+ }
115
+
116
+ const title = this.resolveSessionTitle(sessionId);
117
+ const preview = readAssistantReplyPreview(message);
118
+ this.notificationManager.show({
119
+ id: `chat-reply:${message.id}`,
120
+ title,
121
+ description: preview || t("chatBackgroundReplyFallbackPreview"),
122
+ href: buildSessionPath(sessionId),
123
+ ariaLabel: t("chatBackgroundReplyOpenAriaLabel").replace("{title}", title),
124
+ });
125
+ };
126
+
127
+ private readonly resolveSessionTitle = (sessionId: string): string => {
128
+ const summaries =
129
+ useChatQueryStore.getState().snapshot.sessionsQuery?.data?.sessions ?? [];
130
+ const summary = summaries.find((item) => item.sessionId === sessionId);
131
+ return summary
132
+ ? sessionDisplayName(adaptNcpSessionSummary(summary))
133
+ : t("chatBackgroundReplyFallbackTitle");
134
+ };
135
+
136
+ private readonly rememberHandledMessage = (messageId: string): void => {
137
+ this.handledMessageIds.add(messageId);
138
+ if (this.handledMessageIds.size <= NOTIFIED_MESSAGE_HISTORY_LIMIT) {
139
+ return;
140
+ }
141
+ const oldestMessageId = this.handledMessageIds.values().next().value;
142
+ if (oldestMessageId) {
143
+ this.handledMessageIds.delete(oldestMessageId);
144
+ }
145
+ };
146
+ }
@@ -100,9 +100,8 @@ export class ChatSessionListManager {
100
100
  this.setSelectedAgentId(normalizedAgentId);
101
101
  };
102
102
 
103
- setSelectedSessionKey = (next: SetStateAction<string | null>) => {
103
+ private setSelectedSessionKey = (value: string | null) => {
104
104
  const prev = useChatSessionListStore.getState().snapshot.selectedSessionKey;
105
- const value = this.resolveUpdateValue(prev, next);
106
105
  if (value === prev) {
107
106
  return;
108
107
  }
@@ -111,24 +110,8 @@ export class ChatSessionListManager {
111
110
  .setSnapshot({ selectedSessionKey: value });
112
111
  };
113
112
 
114
- syncRouteSessionSelection = (params: {
115
- isChatView: boolean;
116
- routeSessionKey: string | null;
117
- }) => {
118
- const { isChatView, routeSessionKey } = params;
119
- if (!isChatView) {
120
- return;
121
- }
122
- const { selectedSessionKey } = useChatSessionListStore.getState().snapshot;
123
- if (routeSessionKey) {
124
- if (selectedSessionKey !== routeSessionKey) {
125
- this.setSelectedSessionKey(routeSessionKey);
126
- }
127
- return;
128
- }
129
- if (selectedSessionKey !== null) {
130
- this.setSelectedSessionKey(null);
131
- }
113
+ syncRouteSessionSelection = (routeSessionKey: string | null) => {
114
+ this.setSelectedSessionKey(routeSessionKey);
132
115
  };
133
116
 
134
117
  setListMode = (next: SetStateAction<"time-first" | "project-first">) => {
@@ -207,9 +190,6 @@ export class ChatSessionListManager {
207
190
  );
208
191
  const normalizedProjectRoot = normalizeSessionProjectRootValue(projectRoot);
209
192
  const normalizedPrompt = prompt?.trim() || null;
210
- useChatSessionListStore.getState().setSnapshot({
211
- selectedSessionKey: null,
212
- });
213
193
  this.syncDraftThreadState();
214
194
  this.uiManager.navigateTo(CHAT_DRAFT_SESSION_PATH, {
215
195
  replace: this.uiManager.isAtChatRoot(),
@@ -75,10 +75,7 @@ export class ChatThreadManager {
75
75
  useChatThreadStore.getState().setSnapshot(patch);
76
76
  };
77
77
 
78
- private clearDeletedSessionState = (sessionKey: string) => {
79
- if (useChatSessionListStore.getState().snapshot.selectedSessionKey === sessionKey) {
80
- this.sessionListManager.setSelectedSessionKey(null);
81
- }
78
+ private clearDeletedSessionState = () => {
82
79
  useChatThreadStore.getState().setSnapshot({
83
80
  sessionKey: null,
84
81
  sessionTypeLabel: null,
@@ -569,7 +566,7 @@ export class ChatThreadManager {
569
566
  await deleteNcpSessionApi(selectedSessionKey);
570
567
  deleteNcpSessionSummaryInQueryClient(appQueryClient, selectedSessionKey);
571
568
  appQueryClient.removeQueries({ queryKey: ['ncp-session-messages', selectedSessionKey] });
572
- this.clearDeletedSessionState(selectedSessionKey);
569
+ this.clearDeletedSessionState();
573
570
  this.uiManager.goToChatRoot({ replace: true });
574
571
  } finally {
575
572
  useChatThreadStore.getState().setSnapshot({ isDeletePending: false });
@@ -1,15 +1,17 @@
1
1
  import { render, screen } from '@testing-library/react';
2
- import { MemoryRouter } from 'react-router-dom';
3
- import { describe, expect, it, vi } from 'vitest';
2
+ import { MemoryRouter, Route, Routes } from 'react-router-dom';
3
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
4
4
 
5
5
  import { AppPresenterProvider } from '@/app/components/app-presenter-provider';
6
6
  import { NcpChatPage } from '@/features/chat/pages/ncp-chat-page';
7
+ import { buildSessionPath } from '@/features/chat/features/session/utils/chat-session-route.utils';
7
8
 
8
9
  const mocks = vi.hoisted(() => ({
9
10
  confirm: vi.fn(),
10
11
  consumePending: vi.fn(() => null),
11
12
  markConsumed: vi.fn(),
12
13
  subscribe: vi.fn(() => vi.fn()),
14
+ syncActiveSession: vi.fn(),
13
15
  useChatSessionSync: vi.fn(),
14
16
  }));
15
17
 
@@ -21,6 +23,9 @@ vi.mock('@/app/presenters/app.presenter', () => ({
21
23
  markConsumed: mocks.markConsumed,
22
24
  subscribe: mocks.subscribe,
23
25
  },
26
+ chatCompletionNotificationManager: {
27
+ syncActiveSession: mocks.syncActiveSession,
28
+ },
24
29
  }),
25
30
  }));
26
31
 
@@ -34,17 +39,11 @@ vi.mock('@/shared/hooks/use-confirm-dialog', () => ({
34
39
  vi.mock('@/features/chat/components/layout/chat-page-shell', () => ({
35
40
  ChatPageLayout: () => <div data-testid="chat-page-layout" />,
36
41
  useChatSessionSync: (params: {
37
- syncRouteSessionSelection: (value: {
38
- isChatView: boolean;
39
- routeSessionKey: string | null;
40
- }) => void;
42
+ routeSessionKey: string | null;
43
+ syncRouteSessionSelection: (routeSessionKey: string | null) => void;
41
44
  }) => {
42
45
  mocks.useChatSessionSync(params);
43
- const { syncRouteSessionSelection } = params;
44
- syncRouteSessionSelection({
45
- isChatView: true,
46
- routeSessionKey: null,
47
- });
46
+ params.syncRouteSessionSelection(params.routeSessionKey);
48
47
  },
49
48
  }));
50
49
 
@@ -57,6 +56,11 @@ vi.mock('@/features/chat/features/ncp/hooks/use-ui-show-content-event', () => ({
57
56
  }));
58
57
 
59
58
  describe('NcpChatPage render boundary', () => {
59
+ beforeEach(() => {
60
+ mocks.syncActiveSession.mockReset();
61
+ mocks.useChatSessionSync.mockReset();
62
+ });
63
+
60
64
  it('creates its chat presenter from the global app presenter provider', () => {
61
65
  render(
62
66
  <MemoryRouter initialEntries={['/chat']}>
@@ -69,4 +73,21 @@ describe('NcpChatPage render boundary', () => {
69
73
  expect(screen.getByTestId('chat-page-layout')).toBeTruthy();
70
74
  expect(mocks.useChatSessionSync).toHaveBeenCalledOnce();
71
75
  });
76
+
77
+ it('syncs the active route session and clears it when chat unmounts', () => {
78
+ const sessionPath = buildSessionPath('session-background');
79
+ const view = render(
80
+ <MemoryRouter initialEntries={[sessionPath]}>
81
+ <AppPresenterProvider>
82
+ <Routes>
83
+ <Route path="/chat/:sessionId?" element={<NcpChatPage view="chat" />} />
84
+ </Routes>
85
+ </AppPresenterProvider>
86
+ </MemoryRouter>,
87
+ );
88
+
89
+ expect(mocks.syncActiveSession).toHaveBeenCalledWith('session-background');
90
+ view.unmount();
91
+ expect(mocks.syncActiveSession).toHaveBeenLastCalledWith(null);
92
+ });
72
93
  });
@@ -59,15 +59,23 @@ export function NcpChatPage({ view }: ChatPageProps) {
59
59
  }
60
60
 
61
61
  function NcpChatPageContent({ view }: ChatPageProps) {
62
+ const appPresenter = useAppPresenter();
62
63
  const presenter = usePresenter();
63
64
  const confirmDialog = useNcpChatUiBindings();
64
65
  const routeSelection = useNcpChatRouteSelection();
65
66
  const { routeSessionKey, sessionKey } = routeSelection;
67
+ useEffect(() => {
68
+ appPresenter.chatCompletionNotificationManager.syncActiveSession(
69
+ sessionKey ?? null,
70
+ );
71
+ return () => {
72
+ appPresenter.chatCompletionNotificationManager.syncActiveSession(null);
73
+ };
74
+ }, [appPresenter.chatCompletionNotificationManager, sessionKey]);
66
75
  useChatQueryStoreSync({
67
76
  sessionKey: sessionKey ?? null,
68
77
  });
69
78
  useChatSessionSync({
70
- view,
71
79
  routeSessionKey,
72
80
  syncRouteSessionSelection:
73
81
  presenter.chatSessionListManager.syncRouteSessionSelection,
@@ -44,6 +44,7 @@ export type ChatInputBarAdapterTexts = {
44
44
  modelSearchEmptyLabel: string;
45
45
  favoriteModelLabel: string;
46
46
  unfavoriteModelLabel: string;
47
+ manageModelsLabel: string;
47
48
  thinkingLabels: Record<ChatThinkingLevel, string>;
48
49
  noModelOptionsLabel: string;
49
50
  configureProviderLabel: string;
@@ -94,7 +94,7 @@ export const CronTaskComposer = forwardRef<
94
94
  ref={ref}
95
95
  value={value}
96
96
  onChange={(event) => onChange(event.target.value)}
97
- className="pl-9 pr-11 focus:border-border/75"
97
+ className="pl-9 pr-11"
98
98
  placeholder={t("cronComposerPlaceholder")}
99
99
  aria-label={t("cronComposerLabel")}
100
100
  />
@@ -0,0 +1,57 @@
1
+ import { render, screen } from "@testing-library/react";
2
+ import { describe, expect, it, vi } from "vitest";
3
+ import { InboxDeliveryContent } from "@/features/inbox/components/inbox-delivery-content";
4
+
5
+ vi.mock("@nextclaw/agent-chat-ui", () => ({
6
+ ChatMessageMarkdown: ({ text }: { text: string }) => <article>{text}</article>,
7
+ }));
8
+
9
+ describe("InboxDeliveryContent", () => {
10
+ it("keeps Markdown on the existing renderer path", () => {
11
+ render(
12
+ <InboxDeliveryContent
13
+ content="# Markdown report"
14
+ contentType="markdown"
15
+ title="Markdown report"
16
+ />,
17
+ );
18
+
19
+ expect(screen.getByText("# Markdown report")).toBeTruthy();
20
+ expect(screen.queryByTitle("Markdown report")).toBeNull();
21
+ });
22
+
23
+ it("renders static HTML in an isolated document", () => {
24
+ render(
25
+ <InboxDeliveryContent
26
+ content={'<meta http-equiv="refresh" content="0;url=https://example.com"><h1 onclick="alert(1)">Report</h1><script>alert(1)</script>'}
27
+ contentType="html"
28
+ fillHeight
29
+ title="HTML report"
30
+ />,
31
+ );
32
+
33
+ const frame = screen.getByTitle<HTMLIFrameElement>("HTML report");
34
+ expect(frame.getAttribute("sandbox")).toBe("");
35
+ expect(frame.className).toContain("h-full");
36
+ expect(frame.className).toContain("border-0");
37
+ expect(frame.srcdoc).toContain("Content-Security-Policy");
38
+ expect(frame.srcdoc).toContain("script-src 'none'");
39
+ expect(frame.srcdoc).toContain("connect-src 'none'");
40
+ expect(frame.srcdoc).not.toContain("http-equiv=\"refresh\"");
41
+ expect(frame.srcdoc).not.toContain("onclick");
42
+ expect(frame.srcdoc).not.toContain("<script");
43
+ });
44
+
45
+ it("preserves the iframe instance while the surrounding view rerenders", () => {
46
+ const view = render(
47
+ <InboxDeliveryContent content="<h1>Report</h1>" contentType="html" title="Report" />,
48
+ );
49
+ const frame = screen.getByTitle("Report");
50
+
51
+ view.rerender(
52
+ <InboxDeliveryContent content="<h1>Report</h1>" contentType="html" title="Report" />,
53
+ );
54
+
55
+ expect(screen.getByTitle("Report")).toBe(frame);
56
+ });
57
+ });