@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
@@ -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
+ });