@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,118 @@
1
+ import { useMemo } from "react";
2
+ import type { InboxDeliveryContentType } from "@nextclaw/shared";
3
+ import { ChatMessageMarkdown } from "@nextclaw/agent-chat-ui";
4
+ import { t } from "@/shared/lib/i18n";
5
+ import { cn } from "@/shared/lib/utils";
6
+
7
+ const HTML_PREVIEW_CONTENT_SECURITY_POLICY = [
8
+ "default-src 'none'",
9
+ "base-uri 'none'",
10
+ "connect-src 'none'",
11
+ "font-src data:",
12
+ "form-action 'none'",
13
+ "frame-src 'none'",
14
+ "img-src data: blob:",
15
+ "media-src data: blob:",
16
+ "object-src 'none'",
17
+ "script-src 'none'",
18
+ "style-src 'unsafe-inline'",
19
+ ].join("; ");
20
+
21
+ function createIsolatedHtmlDocument(content: string): string {
22
+ const document = new DOMParser().parseFromString(content, "text/html");
23
+ document.querySelectorAll("base, script").forEach((element) => element.remove());
24
+ document.querySelectorAll("meta[http-equiv]").forEach((element) => {
25
+ const directive = element.getAttribute("http-equiv")?.toLowerCase();
26
+ if (directive === "content-security-policy" || directive === "refresh") {
27
+ element.remove();
28
+ }
29
+ });
30
+ document.querySelectorAll("*").forEach((element) => {
31
+ element.getAttributeNames()
32
+ .filter((attribute) => attribute.toLowerCase().startsWith("on"))
33
+ .forEach((attribute) => element.removeAttribute(attribute));
34
+ });
35
+ document.querySelectorAll("a[href]").forEach((element) => {
36
+ const href = element.getAttribute("href")?.trim() ?? "";
37
+ if (href.toLowerCase().startsWith("javascript:")) {
38
+ element.removeAttribute("href");
39
+ return;
40
+ }
41
+ element.setAttribute("target", href.startsWith("#") ? "_self" : "_blank");
42
+ element.setAttribute("rel", "noopener noreferrer");
43
+ });
44
+
45
+ const policy = document.createElement("meta");
46
+ policy.setAttribute("http-equiv", "Content-Security-Policy");
47
+ policy.setAttribute("content", HTML_PREVIEW_CONTENT_SECURITY_POLICY);
48
+ document.head.prepend(policy);
49
+
50
+ const defaults = document.createElement("style");
51
+ defaults.dataset.nextclawInboxDefaults = "";
52
+ defaults.textContent = [
53
+ ":root { color-scheme: light dark; }",
54
+ "*, *::before, *::after { box-sizing: border-box; }",
55
+ "body { margin: 0; padding: 24px; color: CanvasText; background: Canvas; font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, sans-serif; line-height: 1.6; overflow-wrap: anywhere; }",
56
+ "img, svg, video { max-width: 100%; height: auto; }",
57
+ "pre { max-width: 100%; overflow: auto; }",
58
+ ].join("\n");
59
+ policy.after(defaults);
60
+
61
+ return `<!doctype html>${document.documentElement.outerHTML}`;
62
+ }
63
+
64
+ function InboxHtmlContent({
65
+ content,
66
+ fillHeight,
67
+ title,
68
+ }: {
69
+ content: string;
70
+ fillHeight: boolean;
71
+ title: string;
72
+ }) {
73
+ const document = useMemo(() => createIsolatedHtmlDocument(content), [content]);
74
+ return (
75
+ <iframe
76
+ className={cn(
77
+ "w-full rounded-xl bg-background",
78
+ fillHeight
79
+ ? "h-full min-h-0 border-0"
80
+ : "h-[min(60vh,680px)] min-h-[360px] border border-border/70",
81
+ )}
82
+ sandbox=""
83
+ srcDoc={document}
84
+ title={title}
85
+ />
86
+ );
87
+ }
88
+
89
+ export function InboxDeliveryContent({
90
+ className,
91
+ content,
92
+ contentType,
93
+ fillHeight = false,
94
+ title,
95
+ }: {
96
+ className?: string;
97
+ content: string;
98
+ contentType: InboxDeliveryContentType;
99
+ fillHeight?: boolean;
100
+ title: string;
101
+ }) {
102
+ return (
103
+ <div className={cn("inbox-delivery-content", className)}>
104
+ {contentType === "html" ? (
105
+ <InboxHtmlContent content={content} fillHeight={fillHeight} title={title} />
106
+ ) : (
107
+ <ChatMessageMarkdown
108
+ text={content}
109
+ role="assistant"
110
+ texts={{
111
+ copyCodeLabel: t("chatCodeCopy"),
112
+ copiedCodeLabel: t("chatCodeCopied"),
113
+ }}
114
+ />
115
+ )}
116
+ </div>
117
+ );
118
+ }
@@ -0,0 +1,77 @@
1
+ import { render, screen } from "@testing-library/react";
2
+ import { MemoryRouter } from "react-router-dom";
3
+ import { beforeEach, describe, expect, it, vi } from "vitest";
4
+ import { InboxReaderDialog } from "@/features/inbox/components/inbox-reader-dialog";
5
+ import { useInboxStore } from "@/features/inbox/stores/inbox.store";
6
+
7
+ const mocks = vi.hoisted(() => ({
8
+ closeReader: vi.fn(),
9
+ contentType: "markdown",
10
+ continueInChat: vi.fn(),
11
+ markRead: vi.fn(),
12
+ selectInReader: vi.fn(),
13
+ }));
14
+
15
+ vi.mock("@/app/components/app-presenter-provider", () => ({
16
+ useAppPresenter: () => ({ inboxManager: mocks }),
17
+ }));
18
+
19
+ vi.mock("@/features/inbox/hooks/use-inbox-deliveries", () => ({
20
+ useInboxDeliveries: () => ({
21
+ data: {
22
+ deliveries: [{
23
+ id: "delivery-1",
24
+ title: "A considered report",
25
+ summary: "A concise summary",
26
+ content: mocks.contentType === "html"
27
+ ? "<h1>HTML finding</h1>"
28
+ : "# Finding\n\n**Important** result",
29
+ contentType: mocks.contentType,
30
+ source: { kind: "agent", agentId: null, sessionId: null, toolCallId: null, filePath: null },
31
+ createdAt: "2026-08-06T00:00:00.000Z",
32
+ updatedAt: "2026-08-06T00:00:00.000Z",
33
+ presentedAt: "2026-08-06T00:01:00.000Z",
34
+ readAt: null,
35
+ archivedAt: null,
36
+ conversationSessionId: null,
37
+ }],
38
+ },
39
+ }),
40
+ }));
41
+
42
+ vi.mock("@nextclaw/agent-chat-ui", () => ({
43
+ ChatMessageMarkdown: ({ text }: { text: string }) => (
44
+ <article>{text.replace(/[*#]/g, "").trim()}</article>
45
+ ),
46
+ }));
47
+
48
+ describe("InboxReaderDialog", () => {
49
+ beforeEach(() => {
50
+ vi.clearAllMocks();
51
+ mocks.contentType = "markdown";
52
+ useInboxStore.setState({
53
+ snapshot: { readerOpen: true, activeDeliveryId: "delivery-1" },
54
+ });
55
+ });
56
+
57
+ it("renders one accessible reader with friendly Markdown output", () => {
58
+ render(<MemoryRouter><InboxReaderDialog /></MemoryRouter>);
59
+
60
+ expect(screen.getAllByRole("dialog")).toHaveLength(1);
61
+ const title = screen.getByRole("heading", { name: "A considered report" });
62
+ expect(title).toBeTruthy();
63
+ expect(document.activeElement).toBe(title);
64
+ expect(screen.getByText(/Important result/)).toBeTruthy();
65
+ expect(screen.queryByText("**Important** result")).toBeNull();
66
+ });
67
+
68
+ it("uses compact chrome and lets HTML fill the reading area", () => {
69
+ mocks.contentType = "html";
70
+ render(<MemoryRouter><InboxReaderDialog /></MemoryRouter>);
71
+
72
+ const title = screen.getByRole("heading", { name: "A considered report" });
73
+ expect(title.className).toContain("text-sm");
74
+ expect(screen.getByText("A concise summary").className).toContain("sr-only");
75
+ expect(screen.getByTitle("A considered report").className).toContain("h-full");
76
+ });
77
+ });
@@ -0,0 +1,224 @@
1
+ import { useRef, useState } from "react";
2
+ import { ChevronLeft, ChevronRight, Inbox } from "lucide-react";
3
+ import { useNavigate } from "react-router-dom";
4
+ import { useAppPresenter } from "@/app/components/app-presenter-provider";
5
+ import { InboxDeliveryContent } from "@/features/inbox/components/inbox-delivery-content";
6
+ import { useInboxDeliveries } from "@/features/inbox/hooks/use-inbox-deliveries";
7
+ import { useInboxStore } from "@/features/inbox/stores/inbox.store";
8
+ import { Button } from "@/shared/components/ui/button";
9
+ import {
10
+ Dialog,
11
+ DialogContent,
12
+ DialogDescription,
13
+ DialogTitle,
14
+ } from "@/shared/components/ui/dialog";
15
+ import { IconActionButton } from "@/shared/components/ui/actions/icon-action-button";
16
+ import { formatDateTime, t } from "@/shared/lib/i18n";
17
+ import { cn } from "@/shared/lib/utils";
18
+
19
+ function formatPosition(current: number, total: number): string {
20
+ return t("inboxReaderPosition")
21
+ .replace("{current}", String(current))
22
+ .replace("{total}", String(total));
23
+ }
24
+
25
+ export function InboxReaderDialog() {
26
+ const titleRef = useRef<HTMLHeadingElement>(null);
27
+ const navigate = useNavigate();
28
+ const { inboxManager } = useAppPresenter();
29
+ const { data } = useInboxDeliveries();
30
+ const { activeDeliveryId, readerOpen } = useInboxStore((state) => state.snapshot);
31
+ const [pendingAction, setPendingAction] = useState<string | null>(null);
32
+ const [error, setError] = useState<string | null>(null);
33
+ const unreadDeliveries = (data?.deliveries ?? []).filter(
34
+ (delivery) => !delivery.readAt && !delivery.archivedAt,
35
+ );
36
+ const activeIndex = unreadDeliveries.findIndex(({ id }) => id === activeDeliveryId);
37
+ const activeDelivery = activeIndex >= 0 ? unreadDeliveries[activeIndex] : null;
38
+ const isHtml = activeDelivery?.contentType === "html";
39
+
40
+ const runAction = async (name: string, action: () => Promise<void>) => {
41
+ setPendingAction(name);
42
+ setError(null);
43
+ try {
44
+ await action();
45
+ } catch {
46
+ setError(t("inboxActionError"));
47
+ } finally {
48
+ setPendingAction(null);
49
+ }
50
+ };
51
+
52
+ const selectAt = (index: number) => {
53
+ const delivery = unreadDeliveries[index];
54
+ if (delivery) {
55
+ void runAction("select", async () => {
56
+ await inboxManager.selectInReader(delivery.id);
57
+ });
58
+ }
59
+ };
60
+
61
+ const openInbox = () => {
62
+ if (!activeDelivery) {
63
+ return;
64
+ }
65
+ void runAction("open", async () => {
66
+ await inboxManager.markRead(activeDelivery.id);
67
+ inboxManager.closeReader();
68
+ navigate(`/inbox/${encodeURIComponent(activeDelivery.id)}`);
69
+ });
70
+ };
71
+
72
+ const continueInChat = () => {
73
+ if (!activeDelivery) {
74
+ return;
75
+ }
76
+ void runAction("continue", async () => {
77
+ const result = await inboxManager.continueInChat(activeDelivery.id);
78
+ navigate(`/chat/${encodeURIComponent(result.sessionId)}`);
79
+ });
80
+ };
81
+
82
+ const markRead = () => {
83
+ if (!activeDelivery) {
84
+ return;
85
+ }
86
+ void runAction("read", async () => {
87
+ await inboxManager.markRead(activeDelivery.id);
88
+ inboxManager.closeReader();
89
+ });
90
+ };
91
+
92
+ const positionControls = (
93
+ <div className="flex shrink-0 items-center gap-1 pr-1">
94
+ <span className="mr-1 text-[11px] tabular-nums text-muted-foreground">
95
+ {formatPosition(activeIndex + 1, unreadDeliveries.length)}
96
+ </span>
97
+ <IconActionButton
98
+ icon={<ChevronLeft className="h-4 w-4" />}
99
+ label={t("inboxPrevious")}
100
+ disabled={activeIndex <= 0 || pendingAction === "select"}
101
+ onClick={() => selectAt(activeIndex - 1)}
102
+ />
103
+ <IconActionButton
104
+ icon={<ChevronRight className="h-4 w-4" />}
105
+ label={t("inboxNext")}
106
+ disabled={activeIndex >= unreadDeliveries.length - 1 || pendingAction === "select"}
107
+ onClick={() => selectAt(activeIndex + 1)}
108
+ />
109
+ </div>
110
+ );
111
+
112
+ return (
113
+ <Dialog
114
+ open={readerOpen && Boolean(activeDelivery)}
115
+ onOpenChange={(open) => {
116
+ if (!open) {
117
+ inboxManager.closeReader();
118
+ }
119
+ }}
120
+ >
121
+ <DialogContent
122
+ className="flex h-[min(82vh,760px)] w-[calc(100vw-2rem)] max-w-[820px] flex-col gap-0 overflow-hidden rounded-[24px] border-border/70 bg-background p-0 shadow-[0_20px_55px_-22px_rgba(15,23,42,0.32)] max-sm:h-[100dvh] max-sm:max-h-none max-sm:w-screen max-sm:rounded-none max-sm:border-0"
123
+ onOpenAutoFocus={(event) => {
124
+ event.preventDefault();
125
+ titleRef.current?.focus();
126
+ }}
127
+ >
128
+ {activeDelivery ? (
129
+ <>
130
+ {isHtml ? (
131
+ <header className="shrink-0 border-b border-border/60 px-6 py-3 pr-14 sm:px-8">
132
+ <div className="flex min-h-8 items-center justify-between gap-4">
133
+ <div className="flex min-w-0 items-center gap-2 text-xs text-muted-foreground">
134
+ <span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-muted">
135
+ <Inbox className="h-3.5 w-3.5" />
136
+ </span>
137
+ <DialogTitle
138
+ ref={titleRef}
139
+ tabIndex={-1}
140
+ className="truncate text-sm font-semibold text-foreground outline-none"
141
+ >
142
+ {activeDelivery.title}
143
+ </DialogTitle>
144
+ <span aria-hidden="true">·</span>
145
+ <time className="shrink-0" dateTime={activeDelivery.createdAt}>
146
+ {formatDateTime(activeDelivery.createdAt)}
147
+ </time>
148
+ </div>
149
+ {positionControls}
150
+ </div>
151
+ <DialogDescription className="sr-only">
152
+ {activeDelivery.summary ?? t("inboxNoSummary")}
153
+ </DialogDescription>
154
+ </header>
155
+ ) : (
156
+ <header className="shrink-0 border-b border-border/60 px-6 pb-4 pt-5 pr-14 sm:px-8 sm:pb-5 sm:pt-6">
157
+ <div className="mb-5 flex min-h-8 items-center justify-between gap-4">
158
+ <div className="flex min-w-0 items-center gap-2 text-xs text-muted-foreground">
159
+ <span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-muted">
160
+ <Inbox className="h-3.5 w-3.5" />
161
+ </span>
162
+ <span className="truncate">{t("inboxDeliveredBy")}</span>
163
+ <span aria-hidden="true">·</span>
164
+ <time dateTime={activeDelivery.createdAt}>
165
+ {formatDateTime(activeDelivery.createdAt)}
166
+ </time>
167
+ </div>
168
+ {positionControls}
169
+ </div>
170
+ <DialogTitle
171
+ ref={titleRef}
172
+ tabIndex={-1}
173
+ className="max-w-[680px] text-balance text-2xl font-semibold leading-tight tracking-[-0.02em] outline-none sm:text-[28px]"
174
+ >
175
+ {activeDelivery.title}
176
+ </DialogTitle>
177
+ <DialogDescription className="mt-2 max-w-[680px] text-sm leading-6 text-muted-foreground">
178
+ {activeDelivery.summary ?? t("inboxNoSummary")}
179
+ </DialogDescription>
180
+ </header>
181
+ )}
182
+
183
+ <div className={cn(
184
+ "min-h-0 flex-1",
185
+ isHtml
186
+ ? "p-4 sm:p-5"
187
+ : "custom-scrollbar overflow-y-auto px-6 py-6 sm:px-8 sm:py-7",
188
+ )}>
189
+ <InboxDeliveryContent
190
+ className={isHtml ? "h-full" : undefined}
191
+ content={activeDelivery.content}
192
+ contentType={activeDelivery.contentType}
193
+ fillHeight={isHtml}
194
+ title={activeDelivery.title}
195
+ />
196
+ </div>
197
+
198
+ <footer className="shrink-0 border-t border-border/60 bg-background/95 px-6 py-4 backdrop-blur sm:px-8">
199
+ {error ? (
200
+ <p role="alert" className="mb-3 text-sm text-destructive">{error}</p>
201
+ ) : null}
202
+ <div className="flex flex-col-reverse gap-2 sm:flex-row sm:items-center sm:justify-between">
203
+ <div className="flex flex-col-reverse gap-2 sm:flex-row">
204
+ <Button variant="ghost" onClick={inboxManager.closeReader}>
205
+ {t("inboxReadLater")}
206
+ </Button>
207
+ <Button variant="outline" disabled={pendingAction !== null} onClick={markRead}>
208
+ {t("inboxMarkRead")}
209
+ </Button>
210
+ <Button variant="outline" disabled={pendingAction !== null} onClick={openInbox}>
211
+ {t("inboxOpenInbox")}
212
+ </Button>
213
+ </div>
214
+ <Button disabled={pendingAction !== null} onClick={continueInChat}>
215
+ {t("inboxContinueChat")}
216
+ </Button>
217
+ </div>
218
+ </footer>
219
+ </>
220
+ ) : null}
221
+ </DialogContent>
222
+ </Dialog>
223
+ );
224
+ }
@@ -0,0 +1,14 @@
1
+ import { useEffect } from "react";
2
+ import { useAppPresenter } from "@/app/components/app-presenter-provider";
3
+ import { InboxReaderDialog } from "@/features/inbox/components/inbox-reader-dialog";
4
+
5
+ export function InboxRuntime() {
6
+ const { inboxManager } = useAppPresenter();
7
+
8
+ useEffect(() => {
9
+ inboxManager.start();
10
+ return () => inboxManager.stop();
11
+ }, [inboxManager]);
12
+
13
+ return <InboxReaderDialog />;
14
+ }
@@ -0,0 +1,14 @@
1
+ import { useQuery } from "@tanstack/react-query";
2
+ import { INBOX_DELIVERIES_QUERY_KEY } from "@/features/inbox/managers/inbox.manager";
3
+ import { nextclawClient } from "@/shared/lib/api";
4
+
5
+ export function useInboxDeliveries() {
6
+ return useQuery({
7
+ queryKey: INBOX_DELIVERIES_QUERY_KEY,
8
+ queryFn: () => nextclawClient.inboxDeliveries.list(),
9
+ });
10
+ }
11
+
12
+ export function useInboxUnreadCount(): number {
13
+ return useInboxDeliveries().data?.unreadCount ?? 0;
14
+ }
@@ -0,0 +1,7 @@
1
+ export { InboxRuntime } from "./components/inbox-runtime";
2
+ export { InboxPage } from "./pages/inbox-page";
3
+ export { InboxManager } from "./managers/inbox.manager";
4
+ export {
5
+ useInboxDeliveries,
6
+ useInboxUnreadCount,
7
+ } from "./hooks/use-inbox-deliveries";
@@ -0,0 +1,86 @@
1
+ import { QueryClient } from "@tanstack/react-query";
2
+ import { beforeEach, describe, expect, it, vi } from "vitest";
3
+ import type { InboxDelivery } from "@nextclaw/shared";
4
+ import { InboxManager } from "@/features/inbox/managers/inbox.manager";
5
+ import { useInboxStore } from "@/features/inbox/stores/inbox.store";
6
+
7
+ const mocks = vi.hoisted(() => ({
8
+ delete: vi.fn(),
9
+ continueInChat: vi.fn(),
10
+ list: vi.fn(),
11
+ on: vi.fn(() => vi.fn()),
12
+ updateState: vi.fn(),
13
+ }));
14
+
15
+ vi.mock("@/shared/lib/api", () => ({
16
+ nextclawClient: {
17
+ eventBus: { on: mocks.on },
18
+ inboxDeliveries: {
19
+ delete: mocks.delete,
20
+ continueInChat: mocks.continueInChat,
21
+ list: mocks.list,
22
+ updateState: mocks.updateState,
23
+ },
24
+ },
25
+ }));
26
+
27
+ const delivery: InboxDelivery = {
28
+ id: "delivery-1",
29
+ title: "Brief",
30
+ summary: null,
31
+ content: "# Brief",
32
+ contentType: "markdown",
33
+ source: { kind: "agent", agentId: null, sessionId: null, toolCallId: null, filePath: null },
34
+ createdAt: "2026-08-06T00:00:00.000Z",
35
+ updatedAt: "2026-08-06T00:00:00.000Z",
36
+ presentedAt: null,
37
+ readAt: null,
38
+ archivedAt: null,
39
+ conversationSessionId: null,
40
+ };
41
+
42
+ describe("InboxManager", () => {
43
+ beforeEach(() => {
44
+ vi.clearAllMocks();
45
+ Object.defineProperty(document, "visibilityState", { configurable: true, value: "visible" });
46
+ useInboxStore.setState({
47
+ snapshot: { readerOpen: false, activeDeliveryId: null },
48
+ });
49
+ mocks.list.mockResolvedValue({
50
+ deliveries: [delivery],
51
+ total: 1,
52
+ unreadCount: 1,
53
+ unpresentedCount: 1,
54
+ });
55
+ mocks.updateState.mockImplementation(async (_id: string, action: string) => ({
56
+ ...delivery,
57
+ presentedAt: action === "present" || action === "read" ? "2026-08-06T01:00:00.000Z" : null,
58
+ readAt: action === "read" ? "2026-08-06T01:00:00.000Z" : null,
59
+ }));
60
+ });
61
+
62
+ it("auto-opens the oldest unpresented item and only marks it presented", async () => {
63
+ const manager = new InboxManager(new QueryClient());
64
+ manager.start();
65
+
66
+ await vi.waitFor(() => {
67
+ expect(useInboxStore.getState().snapshot).toEqual({
68
+ readerOpen: true,
69
+ activeDeliveryId: "delivery-1",
70
+ });
71
+ });
72
+ expect(mocks.updateState).toHaveBeenCalledWith("delivery-1", "present");
73
+ expect(mocks.updateState).not.toHaveBeenCalledWith("delivery-1", "read");
74
+ manager.stop();
75
+ });
76
+
77
+ it("closes the reader without changing unread state or reopening it", async () => {
78
+ const manager = new InboxManager(new QueryClient());
79
+ await manager.selectInReader(delivery.id);
80
+ manager.closeReader();
81
+
82
+ expect(useInboxStore.getState().snapshot.readerOpen).toBe(false);
83
+ expect(mocks.updateState).toHaveBeenCalledTimes(1);
84
+ expect(mocks.updateState).toHaveBeenCalledWith(delivery.id, "present");
85
+ });
86
+ });