@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,178 @@
1
+ import type { QueryClient } from "@tanstack/react-query";
2
+ import type {
3
+ InboxDelivery,
4
+ InboxDeliveryListView,
5
+ InboxDeliveryStateAction,
6
+ } from "@nextclaw/shared";
7
+ import { eventKeys } from "@nextclaw/shared";
8
+ import { appQueryClient } from "@/app-query-client";
9
+ import { useInboxStore } from "@/features/inbox/stores/inbox.store";
10
+ import { nextclawClient } from "@/shared/lib/api";
11
+
12
+ export const INBOX_DELIVERIES_QUERY_KEY = ["inbox-deliveries"] as const;
13
+
14
+ function buildListView(deliveries: InboxDelivery[]): InboxDeliveryListView {
15
+ return {
16
+ deliveries,
17
+ total: deliveries.length,
18
+ unreadCount: deliveries.filter((delivery) => !delivery.readAt && !delivery.archivedAt).length,
19
+ unpresentedCount: deliveries.filter((delivery) =>
20
+ !delivery.presentedAt && !delivery.readAt && !delivery.archivedAt
21
+ ).length,
22
+ };
23
+ }
24
+
25
+ export class InboxManager {
26
+ private readonly cleanups: Array<() => void> = [];
27
+ private offerPromise: Promise<void> | null = null;
28
+ private started = false;
29
+
30
+ constructor(private readonly queryClient: QueryClient = appQueryClient) {}
31
+
32
+ start = (): void => {
33
+ if (this.started) {
34
+ return;
35
+ }
36
+ this.started = true;
37
+ this.cleanups.push(
38
+ nextclawClient.eventBus.on(
39
+ eventKeys.inboxDeliveryChanged,
40
+ this.handleDeliveryChanged,
41
+ ),
42
+ );
43
+ if (typeof document !== "undefined") {
44
+ document.addEventListener("visibilitychange", this.handleVisibilityChange);
45
+ this.cleanups.push(() =>
46
+ document.removeEventListener("visibilitychange", this.handleVisibilityChange)
47
+ );
48
+ }
49
+ this.offerPendingDelivery();
50
+ };
51
+
52
+ stop = (): void => {
53
+ if (!this.started) {
54
+ return;
55
+ }
56
+ this.started = false;
57
+ while (this.cleanups.length > 0) {
58
+ this.cleanups.pop()?.();
59
+ }
60
+ };
61
+
62
+ closeReader = (): void => {
63
+ useInboxStore.getState().setSnapshot({ readerOpen: false });
64
+ };
65
+
66
+ selectInReader = async (deliveryId: string): Promise<InboxDelivery> => {
67
+ const delivery = await this.updateState(deliveryId, "present");
68
+ useInboxStore.getState().setSnapshot({
69
+ activeDeliveryId: delivery.id,
70
+ readerOpen: true,
71
+ });
72
+ return delivery;
73
+ };
74
+
75
+ markRead = async (deliveryId: string): Promise<InboxDelivery> =>
76
+ await this.updateState(deliveryId, "read");
77
+
78
+ markUnread = async (deliveryId: string): Promise<InboxDelivery> =>
79
+ await this.updateState(deliveryId, "mark_unread");
80
+
81
+ archive = async (deliveryId: string): Promise<InboxDelivery> =>
82
+ await this.updateState(deliveryId, "archive");
83
+
84
+ restore = async (deliveryId: string): Promise<InboxDelivery> =>
85
+ await this.updateState(deliveryId, "restore");
86
+
87
+ delete = async (deliveryId: string): Promise<boolean> => {
88
+ const deleted = await nextclawClient.inboxDeliveries.delete(deliveryId);
89
+ if (deleted) {
90
+ this.queryClient.setQueryData<InboxDeliveryListView>(
91
+ INBOX_DELIVERIES_QUERY_KEY,
92
+ (current) => current
93
+ ? buildListView(current.deliveries.filter(({ id }) => id !== deliveryId))
94
+ : current,
95
+ );
96
+ const { snapshot } = useInboxStore.getState();
97
+ if (snapshot.activeDeliveryId === deliveryId) {
98
+ useInboxStore.getState().setSnapshot({
99
+ activeDeliveryId: null,
100
+ readerOpen: false,
101
+ });
102
+ }
103
+ }
104
+ return deleted;
105
+ };
106
+
107
+ continueInChat = async (deliveryId: string) => {
108
+ const result = await nextclawClient.inboxDeliveries.continueInChat(deliveryId);
109
+ this.replaceCachedDelivery(result.delivery);
110
+ this.closeReader();
111
+ return result;
112
+ };
113
+
114
+ private updateState = async (
115
+ deliveryId: string,
116
+ action: InboxDeliveryStateAction,
117
+ ): Promise<InboxDelivery> => {
118
+ const delivery = await nextclawClient.inboxDeliveries.updateState(deliveryId, action);
119
+ this.replaceCachedDelivery(delivery);
120
+ return delivery;
121
+ };
122
+
123
+ private replaceCachedDelivery = (delivery: InboxDelivery): void => {
124
+ this.queryClient.setQueryData<InboxDeliveryListView>(
125
+ INBOX_DELIVERIES_QUERY_KEY,
126
+ (current) => {
127
+ if (!current) {
128
+ return current;
129
+ }
130
+ const deliveries = current.deliveries.some(({ id }) => id === delivery.id)
131
+ ? current.deliveries.map((item) => item.id === delivery.id ? delivery : item)
132
+ : [delivery, ...current.deliveries];
133
+ return buildListView(deliveries);
134
+ },
135
+ );
136
+ };
137
+
138
+ private handleDeliveryChanged = (): void => {
139
+ void this.queryClient.invalidateQueries({ queryKey: INBOX_DELIVERIES_QUERY_KEY });
140
+ this.offerPendingDelivery();
141
+ };
142
+
143
+ private handleVisibilityChange = (): void => {
144
+ if (document.visibilityState === "visible") {
145
+ this.offerPendingDelivery();
146
+ }
147
+ };
148
+
149
+ private offerPendingDelivery = (): void => {
150
+ if (this.offerPromise || !this.isVisible() || useInboxStore.getState().snapshot.readerOpen) {
151
+ return;
152
+ }
153
+ this.offerPromise = this.openOldestPendingDelivery()
154
+ .catch(() => undefined)
155
+ .finally(() => {
156
+ this.offerPromise = null;
157
+ });
158
+ };
159
+
160
+ private openOldestPendingDelivery = async (): Promise<void> => {
161
+ const view = await this.queryClient.fetchQuery({
162
+ queryKey: INBOX_DELIVERIES_QUERY_KEY,
163
+ queryFn: () => nextclawClient.inboxDeliveries.list(),
164
+ staleTime: 0,
165
+ });
166
+ const pending = view.deliveries.filter((delivery) =>
167
+ !delivery.presentedAt && !delivery.readAt && !delivery.archivedAt
168
+ );
169
+ const delivery = pending[pending.length - 1];
170
+ if (!delivery || !this.isVisible() || useInboxStore.getState().snapshot.readerOpen) {
171
+ return;
172
+ }
173
+ await this.selectInReader(delivery.id);
174
+ };
175
+
176
+ private isVisible = (): boolean =>
177
+ typeof document === "undefined" || document.visibilityState === "visible";
178
+ }
@@ -0,0 +1,28 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { resolveInboxFilter } from "@/features/inbox/pages/inbox-page";
3
+
4
+ const readDelivery = {
5
+ archivedAt: null,
6
+ readAt: "2026-08-06T08:30:00.000Z",
7
+ };
8
+ const unreadDelivery = {
9
+ archivedAt: null,
10
+ readAt: null,
11
+ };
12
+
13
+ describe("resolveInboxFilter", () => {
14
+ it("defaults to all when there is no unread delivery", () => {
15
+ expect(resolveInboxFilter([], null)).toBe("all");
16
+ expect(resolveInboxFilter([readDelivery], null)).toBe("all");
17
+ expect(resolveInboxFilter([{ ...unreadDelivery, archivedAt: readDelivery.readAt }], null))
18
+ .toBe("all");
19
+ });
20
+
21
+ it("defaults to unread when actionable unread deliveries exist", () => {
22
+ expect(resolveInboxFilter([readDelivery, unreadDelivery], null)).toBe("unread");
23
+ });
24
+
25
+ it("preserves the user's explicit filter", () => {
26
+ expect(resolveInboxFilter([unreadDelivery], "archived")).toBe("archived");
27
+ });
28
+ });
@@ -0,0 +1,385 @@
1
+ import { useEffect, useMemo, useState } from "react";
2
+ import type { InboxDelivery } from "@nextclaw/shared";
3
+ import { Archive, ArrowLeft, Inbox, MessageCircle, RotateCcw, Trash2 } from "lucide-react";
4
+ import { Link, useNavigate, useParams } from "react-router-dom";
5
+ import { useAppPresenter } from "@/app/components/app-presenter-provider";
6
+ import { useViewportLayout } from "@/app/hooks/use-viewport-layout";
7
+ import { InboxDeliveryContent } from "@/features/inbox/components/inbox-delivery-content";
8
+ import { useInboxDeliveries } from "@/features/inbox/hooks/use-inbox-deliveries";
9
+ import { Button } from "@/shared/components/ui/button";
10
+ import { useConfirmDialog } from "@/shared/hooks/use-confirm-dialog";
11
+ import { formatDateTime, t } from "@/shared/lib/i18n";
12
+ import { cn } from "@/shared/lib/utils";
13
+
14
+ type InboxFilter = "unread" | "all" | "archived";
15
+
16
+ export function resolveInboxFilter(
17
+ deliveries: readonly Pick<InboxDelivery, "archivedAt" | "readAt">[],
18
+ selectedFilter: InboxFilter | null,
19
+ ): InboxFilter {
20
+ if (selectedFilter) {
21
+ return selectedFilter;
22
+ }
23
+ return deliveries.some((delivery) => !delivery.readAt && !delivery.archivedAt)
24
+ ? "unread"
25
+ : "all";
26
+ }
27
+
28
+ function filterDeliveries(deliveries: InboxDelivery[], filter: InboxFilter): InboxDelivery[] {
29
+ if (filter === "unread") {
30
+ return deliveries.filter((delivery) => !delivery.readAt && !delivery.archivedAt);
31
+ }
32
+ if (filter === "archived") {
33
+ return deliveries.filter((delivery) => Boolean(delivery.archivedAt));
34
+ }
35
+ return deliveries.filter((delivery) => !delivery.archivedAt);
36
+ }
37
+
38
+ function InboxEmptyState({ selection = false }: { selection?: boolean }) {
39
+ return (
40
+ <div className="flex h-full min-h-56 flex-col items-center justify-center px-8 text-center">
41
+ <span className="mb-4 flex h-12 w-12 items-center justify-center rounded-2xl bg-muted text-muted-foreground">
42
+ <Inbox className="h-5 w-5" />
43
+ </span>
44
+ <h2 className="text-base font-semibold text-foreground">
45
+ {t(selection ? "inboxSelectTitle" : "inboxEmptyTitle")}
46
+ </h2>
47
+ <p className="mt-2 max-w-sm text-sm leading-6 text-muted-foreground">
48
+ {t(selection ? "inboxSelectDescription" : "inboxEmptyDescription")}
49
+ </p>
50
+ </div>
51
+ );
52
+ }
53
+
54
+ function InboxPageHeader({ unreadCount }: { unreadCount: number }) {
55
+ return (
56
+ <header className="shrink-0 px-4 pb-4 pt-1 sm:px-0">
57
+ <div className="flex items-end justify-between gap-4">
58
+ <div>
59
+ <h1 className="text-xl font-semibold tracking-tight text-foreground sm:text-2xl">
60
+ {t("inboxTitle")}
61
+ </h1>
62
+ <p className="mt-1 text-sm text-muted-foreground">{t("inboxDescription")}</p>
63
+ </div>
64
+ {unreadCount > 0 ? (
65
+ <span className="shrink-0 rounded-full bg-primary/10 px-2.5 py-1 text-xs font-medium text-primary">
66
+ {t("inboxUnreadCount").replace("{count}", String(unreadCount))}
67
+ </span>
68
+ ) : null}
69
+ </div>
70
+ </header>
71
+ );
72
+ }
73
+
74
+ function InboxListPane({
75
+ activeDeliveryId,
76
+ deliveries,
77
+ filter,
78
+ onFilterChange,
79
+ }: {
80
+ activeDeliveryId: string | null;
81
+ deliveries: InboxDelivery[];
82
+ filter: InboxFilter;
83
+ onFilterChange: (filter: InboxFilter) => void;
84
+ }) {
85
+ const filterItems: Array<{ id: InboxFilter; label: string }> = [
86
+ { id: "unread", label: t("inboxUnread") },
87
+ { id: "all", label: t("inboxAll") },
88
+ { id: "archived", label: t("inboxArchived") },
89
+ ];
90
+ const filteredDeliveries = filterDeliveries(deliveries, filter);
91
+ return (
92
+ <aside className="flex min-h-0 flex-col border-border/60 md:border-r">
93
+ <div className="flex shrink-0 gap-1 border-b border-border/60 p-3">
94
+ {filterItems.map((item) => (
95
+ <button
96
+ key={item.id}
97
+ type="button"
98
+ onClick={() => onFilterChange(item.id)}
99
+ className={cn(
100
+ "rounded-full px-3 py-1.5 text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-border",
101
+ filter === item.id
102
+ ? "bg-foreground text-background"
103
+ : "text-muted-foreground hover:bg-muted hover:text-foreground",
104
+ )}
105
+ >
106
+ {item.label}
107
+ </button>
108
+ ))}
109
+ </div>
110
+ <div className="custom-scrollbar min-h-0 flex-1 overflow-y-auto p-2">
111
+ {filteredDeliveries.length > 0 ? (
112
+ <ul className="space-y-1">
113
+ {filteredDeliveries.map((delivery) => (
114
+ <li key={delivery.id}>
115
+ <Link
116
+ to={`/inbox/${encodeURIComponent(delivery.id)}`}
117
+ className={cn(
118
+ "block rounded-xl px-3 py-3 transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-border",
119
+ activeDeliveryId === delivery.id ? "bg-muted" : "hover:bg-muted/70",
120
+ )}
121
+ >
122
+ <div className="flex items-start gap-2">
123
+ <span
124
+ className={cn(
125
+ "mt-1.5 h-2 w-2 shrink-0 rounded-full",
126
+ !delivery.readAt && "bg-primary",
127
+ )}
128
+ aria-label={!delivery.readAt ? t("inboxUnread") : undefined}
129
+ aria-hidden={delivery.readAt ? "true" : undefined}
130
+ />
131
+ <div className="min-w-0 flex-1">
132
+ <h2 className={cn(
133
+ "line-clamp-2 text-sm leading-5 text-foreground",
134
+ !delivery.readAt && "font-semibold",
135
+ )}>
136
+ {delivery.title}
137
+ </h2>
138
+ <p className="mt-1 line-clamp-2 text-xs leading-5 text-muted-foreground">
139
+ {delivery.summary ?? t("inboxNoSummary")}
140
+ </p>
141
+ <time
142
+ className="mt-2 block text-[11px] text-muted-foreground/80"
143
+ dateTime={delivery.createdAt}
144
+ >
145
+ {formatDateTime(delivery.createdAt)}
146
+ </time>
147
+ </div>
148
+ </div>
149
+ </Link>
150
+ </li>
151
+ ))}
152
+ </ul>
153
+ ) : <InboxEmptyState />}
154
+ </div>
155
+ </aside>
156
+ );
157
+ }
158
+
159
+ function InboxDetailPane({
160
+ delivery,
161
+ error,
162
+ isMobile,
163
+ pending,
164
+ onArchiveToggle,
165
+ onBack,
166
+ onContinue,
167
+ onDelete,
168
+ onReadToggle,
169
+ }: {
170
+ delivery: InboxDelivery | null;
171
+ error: string | null;
172
+ isMobile: boolean;
173
+ pending: boolean;
174
+ onArchiveToggle: () => void;
175
+ onBack: () => void;
176
+ onContinue: () => void;
177
+ onDelete: () => void;
178
+ onReadToggle: () => void;
179
+ }) {
180
+ if (!delivery) {
181
+ return <main className="flex min-h-0 flex-col"><InboxEmptyState selection /></main>;
182
+ }
183
+ const isHtml = delivery.contentType === "html";
184
+ return (
185
+ <main className="flex min-h-0 flex-col">
186
+ <div className={cn(
187
+ "shrink-0 border-b border-border/60 px-5 sm:px-8",
188
+ isHtml ? "py-3 sm:py-4" : "py-4 sm:py-6",
189
+ )}>
190
+ {isMobile ? (
191
+ <button
192
+ type="button"
193
+ onClick={onBack}
194
+ className="mb-4 inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground"
195
+ >
196
+ <ArrowLeft className="h-4 w-4" />
197
+ {t("inboxTitle")}
198
+ </button>
199
+ ) : null}
200
+ <div className="text-xs text-muted-foreground">
201
+ {t("inboxDeliveredBy")} · {formatDateTime(delivery.createdAt)}
202
+ </div>
203
+ <h2 className={cn(
204
+ "text-balance font-semibold leading-tight tracking-[-0.02em] text-foreground",
205
+ isHtml ? "mt-2 text-lg sm:text-xl" : "mt-3 text-2xl",
206
+ )}>
207
+ {delivery.title}
208
+ </h2>
209
+ {delivery.summary && !isHtml ? (
210
+ <p className="mt-2 max-w-3xl text-sm leading-6 text-muted-foreground">
211
+ {delivery.summary}
212
+ </p>
213
+ ) : null}
214
+ </div>
215
+ <div className={cn(
216
+ "min-h-0 flex-1",
217
+ isHtml
218
+ ? "p-4 sm:p-5"
219
+ : "custom-scrollbar overflow-y-auto px-5 py-6 sm:px-8 sm:py-8",
220
+ )}>
221
+ <div className={cn("mx-auto", isHtml ? "h-full max-w-5xl" : "max-w-3xl")}>
222
+ <InboxDeliveryContent
223
+ className={isHtml ? "h-full" : undefined}
224
+ content={delivery.content}
225
+ contentType={delivery.contentType}
226
+ fillHeight={isHtml}
227
+ title={delivery.title}
228
+ />
229
+ </div>
230
+ </div>
231
+ <div className="shrink-0 border-t border-border/60 px-5 py-3 sm:px-8">
232
+ {error ? <p role="alert" className="mb-2 text-sm text-destructive">{error}</p> : null}
233
+ <div className="flex flex-wrap items-center gap-2">
234
+ <Button disabled={pending} onClick={onContinue}>
235
+ <MessageCircle className="mr-2 h-4 w-4" />
236
+ {t("inboxContinueChat")}
237
+ </Button>
238
+ <Button variant="outline" disabled={pending} onClick={onReadToggle}>
239
+ {delivery.readAt ? t("inboxMarkUnread") : t("inboxMarkRead")}
240
+ </Button>
241
+ <Button variant="ghost" disabled={pending} onClick={onArchiveToggle}>
242
+ {delivery.archivedAt
243
+ ? <RotateCcw className="mr-2 h-4 w-4" />
244
+ : <Archive className="mr-2 h-4 w-4" />}
245
+ {delivery.archivedAt ? t("inboxRestore") : t("inboxArchive")}
246
+ </Button>
247
+ <Button variant="ghost" disabled={pending} onClick={onDelete}>
248
+ <Trash2 className="mr-2 h-4 w-4" />
249
+ {t("inboxDelete")}
250
+ </Button>
251
+ </div>
252
+ </div>
253
+ </main>
254
+ );
255
+ }
256
+
257
+ export function InboxPage() {
258
+ const navigate = useNavigate();
259
+ const { deliveryId } = useParams<{ deliveryId?: string }>();
260
+ const { isMobile } = useViewportLayout();
261
+ const { inboxManager } = useAppPresenter();
262
+ const deliveriesQuery = useInboxDeliveries();
263
+ const { confirm, ConfirmDialog } = useConfirmDialog();
264
+ const [selectedFilter, setSelectedFilter] = useState<InboxFilter | null>(null);
265
+ const [pendingAction, setPendingAction] = useState<string | null>(null);
266
+ const [error, setError] = useState<string | null>(null);
267
+ const deliveries = useMemo(
268
+ () => deliveriesQuery.data?.deliveries ?? [],
269
+ [deliveriesQuery.data?.deliveries],
270
+ );
271
+ const activeDelivery = useMemo(
272
+ () => deliveryId
273
+ ? deliveries.find(({ id }) => id === deliveryId) ?? null
274
+ : null,
275
+ [deliveries, deliveryId],
276
+ );
277
+ const filter = resolveInboxFilter(deliveries, selectedFilter);
278
+
279
+ useEffect(() => {
280
+ if (activeDelivery && !activeDelivery.readAt) {
281
+ void inboxManager.markRead(activeDelivery.id).catch(() => setError(t("inboxActionError")));
282
+ }
283
+ }, [activeDelivery, inboxManager]);
284
+
285
+ const runAction = async (name: string, action: () => Promise<void>) => {
286
+ setPendingAction(name);
287
+ setError(null);
288
+ try {
289
+ await action();
290
+ } catch {
291
+ setError(t("inboxActionError"));
292
+ } finally {
293
+ setPendingAction(null);
294
+ }
295
+ };
296
+
297
+ const continueInChat = () => {
298
+ if (!activeDelivery) {
299
+ return;
300
+ }
301
+ void runAction("continue", async () => {
302
+ const result = await inboxManager.continueInChat(activeDelivery.id);
303
+ navigate(`/chat/${encodeURIComponent(result.sessionId)}`);
304
+ });
305
+ };
306
+
307
+ const deleteDelivery = async () => {
308
+ if (!activeDelivery || !await confirm({
309
+ title: t("inboxDeleteTitle"),
310
+ description: t("inboxDeleteDescription"),
311
+ confirmLabel: t("inboxDeleteConfirm"),
312
+ variant: "destructive",
313
+ })) {
314
+ return;
315
+ }
316
+ void runAction("delete", async () => {
317
+ await inboxManager.delete(activeDelivery.id);
318
+ navigate("/inbox", { replace: true });
319
+ });
320
+ };
321
+
322
+ const toggleRead = () => {
323
+ if (!activeDelivery) {
324
+ return;
325
+ }
326
+ void runAction("read", async () => {
327
+ if (activeDelivery.readAt) {
328
+ await inboxManager.markUnread(activeDelivery.id);
329
+ navigate("/inbox", { replace: true });
330
+ return;
331
+ }
332
+ await inboxManager.markRead(activeDelivery.id);
333
+ });
334
+ };
335
+
336
+ const toggleArchive = () => {
337
+ if (!activeDelivery) {
338
+ return;
339
+ }
340
+ void runAction("archive", async () => {
341
+ if (activeDelivery.archivedAt) {
342
+ await inboxManager.restore(activeDelivery.id);
343
+ return;
344
+ }
345
+ await inboxManager.archive(activeDelivery.id);
346
+ navigate("/inbox", { replace: true });
347
+ });
348
+ };
349
+
350
+ const showList = !isMobile || !activeDelivery;
351
+ const showDetail = !isMobile || Boolean(activeDelivery);
352
+
353
+ return (
354
+ <div className="flex h-full min-h-0 flex-col">
355
+ <InboxPageHeader unreadCount={deliveriesQuery.data?.unreadCount ?? 0} />
356
+
357
+ <div className="min-h-0 flex-1 overflow-hidden border-y border-border/60 bg-background sm:rounded-2xl sm:border">
358
+ {deliveriesQuery.isError ? (
359
+ <div role="alert" className="p-6 text-sm text-destructive">{t("inboxLoadError")}</div>
360
+ ) : (
361
+ <div className="grid h-full min-h-0 grid-cols-1 md:grid-cols-[320px_minmax(0,1fr)]">
362
+ {showList ? <InboxListPane
363
+ activeDeliveryId={activeDelivery?.id ?? null}
364
+ deliveries={deliveries}
365
+ filter={filter}
366
+ onFilterChange={setSelectedFilter}
367
+ /> : null}
368
+ {showDetail ? <InboxDetailPane
369
+ delivery={activeDelivery}
370
+ error={error}
371
+ isMobile={isMobile}
372
+ pending={pendingAction !== null}
373
+ onArchiveToggle={toggleArchive}
374
+ onBack={() => navigate("/inbox")}
375
+ onContinue={continueInChat}
376
+ onDelete={() => void deleteDelivery()}
377
+ onReadToggle={toggleRead}
378
+ /> : null}
379
+ </div>
380
+ )}
381
+ </div>
382
+ <ConfirmDialog />
383
+ </div>
384
+ );
385
+ }
@@ -0,0 +1,23 @@
1
+ import { create } from "zustand";
2
+
3
+ export type InboxSnapshot = {
4
+ readerOpen: boolean;
5
+ activeDeliveryId: string | null;
6
+ };
7
+
8
+ type InboxStore = {
9
+ snapshot: InboxSnapshot;
10
+ setSnapshot: (patch: Partial<InboxSnapshot>) => void;
11
+ };
12
+
13
+ const initialSnapshot: InboxSnapshot = {
14
+ readerOpen: false,
15
+ activeDeliveryId: null,
16
+ };
17
+
18
+ export const useInboxStore = create<InboxStore>((set) => ({
19
+ snapshot: initialSnapshot,
20
+ setSnapshot: (patch) => set((state) => ({
21
+ snapshot: { ...state.snapshot, ...patch },
22
+ })),
23
+ }));
@@ -0,0 +1,48 @@
1
+ import { render, screen } from "@testing-library/react";
2
+ import userEvent from "@testing-library/user-event";
3
+ import { MemoryRouter } from "react-router-dom";
4
+ import { describe, expect, it, vi } from "vitest";
5
+ import { AppNotificationToast } from "@/features/notifications";
6
+
7
+ describe("AppNotificationToast", () => {
8
+ it("renders a keyboard-accessible route notification and dismisses it on activation", async () => {
9
+ const onDismiss = vi.fn();
10
+ const user = userEvent.setup();
11
+ render(
12
+ <MemoryRouter>
13
+ <AppNotificationToast
14
+ title="Background task"
15
+ description="The requested summary is ready."
16
+ href="/chat/session-1"
17
+ ariaLabel="Open the background task reply"
18
+ onDismiss={onDismiss}
19
+ />
20
+ </MemoryRouter>,
21
+ );
22
+
23
+ const notification = screen.getByRole("link", {
24
+ name: "Open the background task reply",
25
+ });
26
+ expect(notification.getAttribute("href")).toBe("/chat/session-1");
27
+ expect(screen.getByText("Background task")).toBeTruthy();
28
+ expect(screen.getByText("The requested summary is ready.")).toBeTruthy();
29
+
30
+ await user.click(notification);
31
+ expect(onDismiss).toHaveBeenCalledOnce();
32
+ });
33
+
34
+ it("keeps informational notifications non-interactive when no route exists", () => {
35
+ render(
36
+ <MemoryRouter>
37
+ <AppNotificationToast
38
+ title="All caught up"
39
+ description="There is nothing else to review."
40
+ onDismiss={vi.fn()}
41
+ />
42
+ </MemoryRouter>,
43
+ );
44
+
45
+ expect(screen.getByRole("status")).toBeTruthy();
46
+ expect(screen.queryByRole("link")).toBeNull();
47
+ });
48
+ });