feat(FN-3800): add session switcher to chat header and mobile switcher
This merge delivers five features: a session switcher for the chat header with mobile-aware dropdown styling and proper ARIA state, mailbox notification events with deep-link highlighting to the unread task, a fix for org chart connector endpoints in wide subtrees, a correction to merge finalize so Fusion-Task-Id: FN-3800
This commit is contained in:
5
.changeset/FN-3793-mailbox-notifications.md
Normal file
5
.changeset/FN-3793-mailbox-notifications.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Add ntfy and webhook notifications for mailbox messages (agent→user and agent→agent), with deep links into the matching task or mailbox message.
|
||||||
5
.changeset/fn-3800-mobile-chat-switcher.md
Normal file
5
.changeset/fn-3800-mobile-chat-switcher.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Add a mobile-first chat session switcher in the ChatView thread header so users can open the title menu and switch conversations (or start a new chat) without returning to the sidebar.
|
||||||
@@ -583,7 +583,7 @@ Runtime action-gate flow (v1):
|
|||||||
- Compatibility scope: `NtfyNotifier` remains responsible for gridlock-only compatibility notifications (`notifyGridlock`) and legacy helper APIs.
|
- Compatibility scope: `NtfyNotifier` remains responsible for gridlock-only compatibility notifications (`notifyGridlock`) and legacy helper APIs.
|
||||||
- Legacy gridlock ntfy delivery is cooldown-throttled: first detection notifies immediately, subsequent detections are suppressed for 15 minutes (even if blocked-task membership changes), and the cooldown resets as soon as gridlock fully clears.
|
- Legacy gridlock ntfy delivery is cooldown-throttled: first detection notifies immediately, subsequent detections are suppressed for 15 minutes (even if blocked-task membership changes), and the cooldown resets as soon as gridlock fully clears.
|
||||||
- `NotificationService` (`notification/notification-service.ts`) — provider lifecycle + event dispatch orchestration
|
- `NotificationService` (`notification/notification-service.ts`) — provider lifecycle + event dispatch orchestration
|
||||||
- Subscribes to task lifecycle events plus non-task memory events. Manual `POST /api/memory/dream` processing emits `store.emit("memory:dreams-processed", payload)` when new DREAMS content is written, and `NotificationService` dispatches that as the `memory-dreams-processed` notification event to ntfy/webhook providers (event-filter controlled, no task deep link required).
|
- Subscribes to task lifecycle events plus mailbox and memory events. `message:sent` dispatches `message:agent-to-user` and `message:agent-to-agent` notification events (with message metadata for deep-links), and manual `POST /api/memory/dream` processing emits `store.emit("memory:dreams-processed", payload)` when new DREAMS content is written.
|
||||||
- `NotificationProvider` interface (`@fusion/core` `notification/provider.ts`) — pluggable provider contract
|
- `NotificationProvider` interface (`@fusion/core` `notification/provider.ts`) — pluggable provider contract
|
||||||
- Built-in providers: `NtfyNotificationProvider` (`notification/ntfy-provider.ts`), `WebhookNotificationProvider` (`notification/webhook-provider.ts`)
|
- Built-in providers: `NtfyNotificationProvider` (`notification/ntfy-provider.ts`), `WebhookNotificationProvider` (`notification/webhook-provider.ts`)
|
||||||
- `AgentReflection` (`agent-reflection.ts`) — reflection extraction and persistence
|
- `AgentReflection` (`agent-reflection.ts`) — reflection extraction and persistence
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ Defaults from `DEFAULT_GLOBAL_SETTINGS`; key scope from `GLOBAL_SETTINGS_KEYS`.
|
|||||||
| `ntfyEnabled` | `boolean` | `false` | Enable ntfy push notifications. |
|
| `ntfyEnabled` | `boolean` | `false` | Enable ntfy push notifications. |
|
||||||
| `ntfyTopic` | `string` | `undefined` | ntfy topic name. |
|
| `ntfyTopic` | `string` | `undefined` | ntfy topic name. |
|
||||||
| `ntfyBaseUrl` | `string` | `undefined` | Optional custom ntfy server base URL (must use `http://` or `https://`). If blank/unset, Fusion uses `https://ntfy.sh` for both runtime and test notifications. |
|
| `ntfyBaseUrl` | `string` | `undefined` | Optional custom ntfy server base URL (must use `http://` or `https://`). If blank/unset, Fusion uses `https://ntfy.sh` for both runtime and test notifications. |
|
||||||
| `ntfyEvents` | `("in-review" \| "merged" \| "failed" \| "awaiting-approval" \| "awaiting-user-review" \| "planning-awaiting-input" \| "gridlock" \| "fallback-used" \| "memory-dreams-processed")[]` | `["in-review","merged","failed","awaiting-approval","awaiting-user-review","planning-awaiting-input","gridlock","fallback-used","memory-dreams-processed"]` | Event types that trigger ntfy notifications. `planning-awaiting-input` fires when planning mode is waiting on user input. `gridlock` fires when all schedulable todo tasks are blocked; delivery is cooldown-throttled (first alert immediately, then suppressed for 15 minutes until gridlock resolves). `fallback-used` fires when Fusion recovers from a retryable model failure by switching to a configured fallback model. `memory-dreams-processed` fires when manual dream processing writes a new `DREAMS.md` entry (project and/or agent); disable it via ntfy/webhook event filters if you want to opt out. |
|
| `ntfyEvents` | `("in-review" \| "merged" \| "failed" \| "awaiting-approval" \| "awaiting-user-review" \| "planning-awaiting-input" \| "gridlock" \| "fallback-used" \| "memory-dreams-processed" \| "message:agent-to-user" \| "message:agent-to-agent")[]` | `["in-review","merged","failed","awaiting-approval","awaiting-user-review","planning-awaiting-input","gridlock","fallback-used","memory-dreams-processed","message:agent-to-user","message:agent-to-agent"]` | Event types that trigger ntfy notifications. `planning-awaiting-input` fires when planning mode is waiting on user input. `gridlock` fires when all schedulable todo tasks are blocked; delivery is cooldown-throttled (first alert immediately, then suppressed for 15 minutes until gridlock resolves). `fallback-used` fires when Fusion recovers from a retryable model failure by switching to a configured fallback model. `memory-dreams-processed` fires when manual dream processing writes a new `DREAMS.md` entry (project and/or agent); disable it via ntfy/webhook event filters if you want to opt out. `message:agent-to-user` fires when an agent sends a direct message to the user. `message:agent-to-agent` fires when an agent sends a message to another agent (including replies). |
|
||||||
| `ntfyDashboardHost` | `string` | `undefined` | Dashboard host used to build deep links in notifications. |
|
| `ntfyDashboardHost` | `string` | `undefined` | Dashboard host used to build deep links in notifications. |
|
||||||
| `webhookEnabled` | `boolean` | `false` | Enable webhook notifications for task lifecycle events. Part of the legacy flat settings; prefer `notificationProviders` for new setups. |
|
| `webhookEnabled` | `boolean` | `false` | Enable webhook notifications for task lifecycle events. Part of the legacy flat settings; prefer `notificationProviders` for new setups. |
|
||||||
| `webhookUrl` | `string` | `undefined` | Webhook endpoint URL. Must be `http://` or `https://`. Part of legacy flat settings. |
|
| `webhookUrl` | `string` | `undefined` | Webhook endpoint URL. Must be `http://` or `https://`. Part of legacy flat settings. |
|
||||||
@@ -140,7 +140,7 @@ When `id` is `"ntfy"` in `notificationProviders`, the provider `config` supports
|
|||||||
|---|---|---:|---|
|
|---|---|---:|---|
|
||||||
| `topic` | `string` | _required_ | ntfy topic name (1–64 chars, alphanumeric + `-_`). |
|
| `topic` | `string` | _required_ | ntfy topic name (1–64 chars, alphanumeric + `-_`). |
|
||||||
| `ntfyBaseUrl` | `string` | `"https://ntfy.sh"` | Optional custom ntfy server URL. |
|
| `ntfyBaseUrl` | `string` | `"https://ntfy.sh"` | Optional custom ntfy server URL. |
|
||||||
| `events` | `("in-review" \| "merged" \| "failed" \| "awaiting-approval" \| "awaiting-user-review" \| "planning-awaiting-input" \| "gridlock" \| "fallback-used" \| "memory-dreams-processed")[]` | `DEFAULT_NTFY_EVENTS` | Event filter list used by the provider. For `gridlock`, enabled events are still cooldown-throttled at runtime (15-minute suppression window, reset on full resolution). `memory-dreams-processed` is emitted when manual dream processing appends a new project/agent `DREAMS.md` entry. |
|
| `events` | `("in-review" \| "merged" \| "failed" \| "awaiting-approval" \| "awaiting-user-review" \| "planning-awaiting-input" \| "gridlock" \| "fallback-used" \| "memory-dreams-processed" \| "message:agent-to-user" \| "message:agent-to-agent")[]` | `DEFAULT_NTFY_EVENTS` | Event filter list used by the provider. For `gridlock`, enabled events are still cooldown-throttled at runtime (15-minute suppression window, reset on full resolution). `memory-dreams-processed` is emitted when manual dream processing appends a new project/agent `DREAMS.md` entry. `message:agent-to-user`/`message:agent-to-agent` are emitted for mailbox messages and deep-link to the specific message when `dashboardHost` is configured. |
|
||||||
| `dashboardHost` | `string` | `undefined` | Dashboard host for deep links in notifications. |
|
| `dashboardHost` | `string` | `undefined` | Dashboard host for deep links in notifications. |
|
||||||
|
|
||||||
Disable daily update checks globally:
|
Disable daily update checks globally:
|
||||||
|
|||||||
@@ -228,7 +228,9 @@ export type NtfyNotificationEvent =
|
|||||||
| "planning-awaiting-input"
|
| "planning-awaiting-input"
|
||||||
| "gridlock"
|
| "gridlock"
|
||||||
| "fallback-used"
|
| "fallback-used"
|
||||||
| "memory-dreams-processed";
|
| "memory-dreams-processed"
|
||||||
|
| "message:agent-to-user"
|
||||||
|
| "message:agent-to-agent";
|
||||||
|
|
||||||
/** Known notification event types. Providers may support additional custom events. */
|
/** Known notification event types. Providers may support additional custom events. */
|
||||||
export const NOTIFICATION_EVENTS = [
|
export const NOTIFICATION_EVENTS = [
|
||||||
@@ -241,6 +243,8 @@ export const NOTIFICATION_EVENTS = [
|
|||||||
"gridlock",
|
"gridlock",
|
||||||
"fallback-used",
|
"fallback-used",
|
||||||
"memory-dreams-processed",
|
"memory-dreams-processed",
|
||||||
|
"message:agent-to-user",
|
||||||
|
"message:agent-to-agent",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
/** Notification event type. Known events plus provider-specific custom events. */
|
/** Notification event type. Known events plus provider-specific custom events. */
|
||||||
|
|||||||
@@ -366,6 +366,19 @@
|
|||||||
margin-bottom: var(--space-xs);
|
margin-bottom: var(--space-xs);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mailbox-reply-context-static {
|
||||||
|
margin: 0;
|
||||||
|
padding: var(--space-sm) var(--space-md);
|
||||||
|
border: var(--btn-border-width) solid color-mix(in srgb, var(--border) 80%, transparent);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: color-mix(in srgb, var(--surface) 85%, transparent);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
.mailbox-reply-context {
|
.mailbox-reply-context {
|
||||||
display: flex;
|
display: flex;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -1042,3 +1055,17 @@
|
|||||||
gap: var(--space-sm);
|
gap: var(--space-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.mailbox-message-highlight {
|
||||||
|
animation: mailbox-flash 2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes mailbox-flash {
|
||||||
|
0% {
|
||||||
|
background: color-mix(in srgb, var(--color-info) 20%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -88,6 +88,21 @@ function messagePreview(content: string, max = 80): string {
|
|||||||
return `${content.slice(0, max)}…`;
|
return `${content.slice(0, max)}…`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getDeepLinkedMessageId(): string | null {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const paramId = params.get("mailbox-message");
|
||||||
|
if (paramId) {
|
||||||
|
return paramId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hashMatch = /^#message-(.+)$/.exec(window.location.hash);
|
||||||
|
return hashMatch?.[1] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
function buildReplyThread(messages: Message[], selectedMessage: Message): Message[] {
|
function buildReplyThread(messages: Message[], selectedMessage: Message): Message[] {
|
||||||
const allMessages = [...messages];
|
const allMessages = [...messages];
|
||||||
if (!allMessages.some((message) => message.id === selectedMessage.id)) {
|
if (!allMessages.some((message) => message.id === selectedMessage.id)) {
|
||||||
@@ -294,6 +309,58 @@ export function MailboxModal({
|
|||||||
}
|
}
|
||||||
}, [projectId, activeTab]);
|
}, [projectId, activeTab]);
|
||||||
|
|
||||||
|
// Deep-link: open and highlight a specific message from URL params.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const deepLinkedMessageId = getDeepLinkedMessageId();
|
||||||
|
if (!deepLinkedMessageId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = [
|
||||||
|
...(inbox?.messages ?? []),
|
||||||
|
...(outbox?.messages ?? []),
|
||||||
|
...(agentMailbox?.inbox ?? []),
|
||||||
|
...(agentMailbox?.outbox ?? []),
|
||||||
|
...conversationMessages,
|
||||||
|
].find((candidate) => candidate.id === deepLinkedMessageId);
|
||||||
|
|
||||||
|
if (!message) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void handleOpenMessage(message);
|
||||||
|
}, [isOpen, inbox, outbox, agentMailbox, conversationMessages, handleOpenMessage]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const deepLinkedMessageId = getDeepLinkedMessageId();
|
||||||
|
if (!deepLinkedMessageId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const element = document.getElementById(`message-${deepLinkedMessageId}`);
|
||||||
|
if (!element) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
element.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||||
|
element.classList.add("mailbox-message-highlight");
|
||||||
|
const timer = window.setTimeout(() => {
|
||||||
|
element.classList.remove("mailbox-message-highlight");
|
||||||
|
}, 2000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.clearTimeout(timer);
|
||||||
|
};
|
||||||
|
}, [isOpen, selectedMessage, conversationMessages]);
|
||||||
|
|
||||||
const handleCloseMessage = useCallback(() => {
|
const handleCloseMessage = useCallback(() => {
|
||||||
setSelectedMessage(null);
|
setSelectedMessage(null);
|
||||||
setConversationMessages([]);
|
setConversationMessages([]);
|
||||||
@@ -587,7 +654,7 @@ export function MailboxModal({
|
|||||||
<div className="mailbox-content" data-testid="mailbox-content">
|
<div className="mailbox-content" data-testid="mailbox-content">
|
||||||
{/* Message Detail View */}
|
{/* Message Detail View */}
|
||||||
{selectedMessage && !showComposer && (
|
{selectedMessage && !showComposer && (
|
||||||
<div className="mailbox-message-detail" data-testid="mailbox-message-detail">
|
<div className="mailbox-message-detail" data-testid="mailbox-message-detail" id={`message-${selectedMessage.id}`}>
|
||||||
<div className="mailbox-message-detail-header">
|
<div className="mailbox-message-detail-header">
|
||||||
<button
|
<button
|
||||||
className="btn btn-sm btn-secondary"
|
className="btn btn-sm btn-secondary"
|
||||||
@@ -650,6 +717,7 @@ export function MailboxModal({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={msg.id}
|
key={msg.id}
|
||||||
|
id={`message-${msg.id}`}
|
||||||
className={`mailbox-conversation-msg ${msg.id === selectedMessage.id ? "current" : ""}`}
|
className={`mailbox-conversation-msg ${msg.id === selectedMessage.id ? "current" : ""}`}
|
||||||
>
|
>
|
||||||
<div className="mailbox-conversation-msg-header">
|
<div className="mailbox-conversation-msg-header">
|
||||||
@@ -725,6 +793,7 @@ export function MailboxModal({
|
|||||||
{inbox?.messages.map((msg) => (
|
{inbox?.messages.map((msg) => (
|
||||||
<div
|
<div
|
||||||
key={msg.id}
|
key={msg.id}
|
||||||
|
id={`message-${msg.id}`}
|
||||||
className={`mailbox-item ${!msg.read ? "unread" : ""}`}
|
className={`mailbox-item ${!msg.read ? "unread" : ""}`}
|
||||||
onClick={() => handleOpenMessage(msg)}
|
onClick={() => handleOpenMessage(msg)}
|
||||||
data-testid={`mailbox-item-${msg.id}`}
|
data-testid={`mailbox-item-${msg.id}`}
|
||||||
@@ -760,6 +829,7 @@ export function MailboxModal({
|
|||||||
{outbox?.messages.map((msg) => (
|
{outbox?.messages.map((msg) => (
|
||||||
<div
|
<div
|
||||||
key={msg.id}
|
key={msg.id}
|
||||||
|
id={`message-${msg.id}`}
|
||||||
className="mailbox-item"
|
className="mailbox-item"
|
||||||
onClick={() => handleOpenMessage(msg)}
|
onClick={() => handleOpenMessage(msg)}
|
||||||
data-testid={`mailbox-item-${msg.id}`}
|
data-testid={`mailbox-item-${msg.id}`}
|
||||||
@@ -864,6 +934,7 @@ export function MailboxModal({
|
|||||||
{selectedAgentId && agentMailbox && agentSubTab === "inbox" && agentMailbox.inbox.map((msg) => (
|
{selectedAgentId && agentMailbox && agentSubTab === "inbox" && agentMailbox.inbox.map((msg) => (
|
||||||
<div
|
<div
|
||||||
key={msg.id}
|
key={msg.id}
|
||||||
|
id={`message-${msg.id}`}
|
||||||
className={`mailbox-item ${!msg.read ? "unread" : ""}`}
|
className={`mailbox-item ${!msg.read ? "unread" : ""}`}
|
||||||
onClick={() => handleOpenMessage(msg)}
|
onClick={() => handleOpenMessage(msg)}
|
||||||
data-testid={`mailbox-item-${msg.id}`}
|
data-testid={`mailbox-item-${msg.id}`}
|
||||||
@@ -887,6 +958,7 @@ export function MailboxModal({
|
|||||||
{selectedAgentId && agentMailbox && agentSubTab === "outbox" && agentMailbox.outbox.map((msg) => (
|
{selectedAgentId && agentMailbox && agentSubTab === "outbox" && agentMailbox.outbox.map((msg) => (
|
||||||
<div
|
<div
|
||||||
key={msg.id}
|
key={msg.id}
|
||||||
|
id={`message-${msg.id}`}
|
||||||
className="mailbox-item"
|
className="mailbox-item"
|
||||||
onClick={() => handleOpenMessage(msg)}
|
onClick={() => handleOpenMessage(msg)}
|
||||||
data-testid={`mailbox-item-${msg.id}`}
|
data-testid={`mailbox-item-${msg.id}`}
|
||||||
|
|||||||
@@ -93,6 +93,29 @@ function messagePreview(content: string, max = 80): string {
|
|||||||
return `${content.slice(0, max)}…`;
|
return `${content.slice(0, max)}…`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getDeepLinkedMessageId(): string | null {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const paramId = params.get("mailbox-message");
|
||||||
|
if (paramId) {
|
||||||
|
return paramId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hashMatch = /^#message-(.+)$/.exec(window.location.hash);
|
||||||
|
return hashMatch?.[1] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function listMessageAnchorId(messageId: string): string {
|
||||||
|
return `mailbox-list-message-${messageId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function detailMessageAnchorId(messageId: string): string {
|
||||||
|
return `mailbox-detail-message-${messageId}`;
|
||||||
|
}
|
||||||
|
|
||||||
function buildReplyThread(messages: Message[], selectedMessage: Message): Message[] {
|
function buildReplyThread(messages: Message[], selectedMessage: Message): Message[] {
|
||||||
const allMessages = [...messages];
|
const allMessages = [...messages];
|
||||||
if (!allMessages.some((message) => message.id === selectedMessage.id)) {
|
if (!allMessages.some((message) => message.id === selectedMessage.id)) {
|
||||||
@@ -321,6 +344,50 @@ export function MailboxView({
|
|||||||
}
|
}
|
||||||
}, [projectId, unreadCount, onUnreadCountChange, activeTab]);
|
}, [projectId, unreadCount, onUnreadCountChange, activeTab]);
|
||||||
|
|
||||||
|
// Deep-link: open and highlight a specific message from URL params.
|
||||||
|
useEffect(() => {
|
||||||
|
const deepLinkedMessageId = getDeepLinkedMessageId();
|
||||||
|
if (!deepLinkedMessageId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = [
|
||||||
|
...(inbox?.messages ?? []),
|
||||||
|
...(outbox?.messages ?? []),
|
||||||
|
...(agentMailbox?.inbox ?? []),
|
||||||
|
...(agentMailbox?.outbox ?? []),
|
||||||
|
...conversationMessages,
|
||||||
|
].find((candidate) => candidate.id === deepLinkedMessageId);
|
||||||
|
|
||||||
|
if (!message) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void handleOpenMessage(message);
|
||||||
|
}, [inbox, outbox, agentMailbox, conversationMessages, handleOpenMessage]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const deepLinkedMessageId = getDeepLinkedMessageId();
|
||||||
|
if (!deepLinkedMessageId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const element = document.getElementById(detailMessageAnchorId(deepLinkedMessageId));
|
||||||
|
if (!element) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
element.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||||
|
element.classList.add("mailbox-message-highlight");
|
||||||
|
const timer = window.setTimeout(() => {
|
||||||
|
element.classList.remove("mailbox-message-highlight");
|
||||||
|
}, 2000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.clearTimeout(timer);
|
||||||
|
};
|
||||||
|
}, [selectedMessage, conversationMessages]);
|
||||||
|
|
||||||
const handleCloseMessage = useCallback(() => {
|
const handleCloseMessage = useCallback(() => {
|
||||||
setSelectedMessage(null);
|
setSelectedMessage(null);
|
||||||
setConversationMessages([]);
|
setConversationMessages([]);
|
||||||
@@ -406,7 +473,7 @@ export function MailboxView({
|
|||||||
const threadMessages = buildReplyThread(conversationMessages, selectedMessage);
|
const threadMessages = buildReplyThread(conversationMessages, selectedMessage);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mailbox-message-detail" data-testid="mailbox-message-detail">
|
<div className="mailbox-message-detail" data-testid="mailbox-message-detail" id={detailMessageAnchorId(selectedMessage.id)}>
|
||||||
<div className="mailbox-message-detail-header">
|
<div className="mailbox-message-detail-header">
|
||||||
{isMobile && (
|
{isMobile && (
|
||||||
<button
|
<button
|
||||||
@@ -470,6 +537,7 @@ export function MailboxView({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={msg.id}
|
key={msg.id}
|
||||||
|
id={detailMessageAnchorId(msg.id)}
|
||||||
className={`mailbox-conversation-msg ${msg.id === selectedMessage.id ? "current" : ""}`}
|
className={`mailbox-conversation-msg ${msg.id === selectedMessage.id ? "current" : ""}`}
|
||||||
>
|
>
|
||||||
<div className="mailbox-conversation-msg-header">
|
<div className="mailbox-conversation-msg-header">
|
||||||
@@ -477,7 +545,7 @@ export function MailboxView({
|
|||||||
<span className="mailbox-message-time">{formatTimestamp(msg.createdAt)}</span>
|
<span className="mailbox-message-time">{formatTimestamp(msg.createdAt)}</span>
|
||||||
</div>
|
</div>
|
||||||
{replyToId && (
|
{replyToId && (
|
||||||
<div className="mailbox-reply-context" data-testid={`mailbox-reply-context-${msg.id}`}>
|
<div className="mailbox-reply-context-static" data-testid={`mailbox-reply-context-${msg.id}`}>
|
||||||
↪ Replying to {replyToMessage ? messagePreview(replyToMessage.content, 60) : `message ${replyToId}`}
|
↪ Replying to {replyToMessage ? messagePreview(replyToMessage.content, 60) : `message ${replyToId}`}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -493,7 +561,7 @@ export function MailboxView({
|
|||||||
{(threadMessages.length <= 1) && (
|
{(threadMessages.length <= 1) && (
|
||||||
<>
|
<>
|
||||||
{selectedMessage.metadata?.replyTo?.messageId && (
|
{selectedMessage.metadata?.replyTo?.messageId && (
|
||||||
<div className="mailbox-reply-context" data-testid="mailbox-selected-reply-context">
|
<div className="mailbox-reply-context-static" data-testid="mailbox-selected-reply-context">
|
||||||
↪ Replying to message {selectedMessage.metadata.replyTo.messageId}
|
↪ Replying to message {selectedMessage.metadata.replyTo.messageId}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -522,6 +590,7 @@ export function MailboxView({
|
|||||||
{inbox?.messages.map((msg) => (
|
{inbox?.messages.map((msg) => (
|
||||||
<div
|
<div
|
||||||
key={msg.id}
|
key={msg.id}
|
||||||
|
id={listMessageAnchorId(msg.id)}
|
||||||
className={`mailbox-item ${!msg.read ? "unread" : ""}`}
|
className={`mailbox-item ${!msg.read ? "unread" : ""}`}
|
||||||
onClick={() => handleOpenMessage(msg)}
|
onClick={() => handleOpenMessage(msg)}
|
||||||
data-testid={`mailbox-item-${msg.id}`}
|
data-testid={`mailbox-item-${msg.id}`}
|
||||||
@@ -556,6 +625,7 @@ export function MailboxView({
|
|||||||
{outbox?.messages.map((msg) => (
|
{outbox?.messages.map((msg) => (
|
||||||
<div
|
<div
|
||||||
key={msg.id}
|
key={msg.id}
|
||||||
|
id={listMessageAnchorId(msg.id)}
|
||||||
className="mailbox-item"
|
className="mailbox-item"
|
||||||
onClick={() => handleOpenMessage(msg)}
|
onClick={() => handleOpenMessage(msg)}
|
||||||
data-testid={`mailbox-item-${msg.id}`}
|
data-testid={`mailbox-item-${msg.id}`}
|
||||||
@@ -658,6 +728,7 @@ export function MailboxView({
|
|||||||
{selectedAgentId && agentMailbox && agentSubTab === "inbox" && agentMailbox.inbox.map((msg) => (
|
{selectedAgentId && agentMailbox && agentSubTab === "inbox" && agentMailbox.inbox.map((msg) => (
|
||||||
<div
|
<div
|
||||||
key={msg.id}
|
key={msg.id}
|
||||||
|
id={listMessageAnchorId(msg.id)}
|
||||||
className={`mailbox-item ${!msg.read ? "unread" : ""}`}
|
className={`mailbox-item ${!msg.read ? "unread" : ""}`}
|
||||||
onClick={() => handleOpenMessage(msg)}
|
onClick={() => handleOpenMessage(msg)}
|
||||||
data-testid={`mailbox-item-${msg.id}`}
|
data-testid={`mailbox-item-${msg.id}`}
|
||||||
@@ -679,6 +750,7 @@ export function MailboxView({
|
|||||||
{selectedAgentId && agentMailbox && agentSubTab === "outbox" && agentMailbox.outbox.map((msg) => (
|
{selectedAgentId && agentMailbox && agentSubTab === "outbox" && agentMailbox.outbox.map((msg) => (
|
||||||
<div
|
<div
|
||||||
key={msg.id}
|
key={msg.id}
|
||||||
|
id={listMessageAnchorId(msg.id)}
|
||||||
className="mailbox-item"
|
className="mailbox-item"
|
||||||
onClick={() => handleOpenMessage(msg)}
|
onClick={() => handleOpenMessage(msg)}
|
||||||
data-testid={`mailbox-item-${msg.id}`}
|
data-testid={`mailbox-item-${msg.id}`}
|
||||||
|
|||||||
@@ -246,6 +246,8 @@ const DEFAULT_NTFY_EVENTS: NtfyNotificationEvent[] = [
|
|||||||
"gridlock",
|
"gridlock",
|
||||||
"fallback-used",
|
"fallback-used",
|
||||||
"memory-dreams-processed",
|
"memory-dreams-processed",
|
||||||
|
"message:agent-to-user",
|
||||||
|
"message:agent-to-agent",
|
||||||
];
|
];
|
||||||
|
|
||||||
const NOTIFICATION_EVENT_OPTIONS: Array<{ event: NtfyNotificationEvent; label: string; description: string }> = [
|
const NOTIFICATION_EVENT_OPTIONS: Array<{ event: NtfyNotificationEvent; label: string; description: string }> = [
|
||||||
@@ -258,6 +260,8 @@ const NOTIFICATION_EVENT_OPTIONS: Array<{ event: NtfyNotificationEvent; label: s
|
|||||||
{ event: "gridlock", label: "Pipeline gridlocked", description: "When all schedulable todo tasks are blocked and work cannot advance" },
|
{ event: "gridlock", label: "Pipeline gridlocked", description: "When all schedulable todo tasks are blocked and work cannot advance" },
|
||||||
{ event: "fallback-used", label: "Fallback model used (recovered)", description: "When Fusion recovers from a retryable model failure by switching to a fallback model" },
|
{ event: "fallback-used", label: "Fallback model used (recovered)", description: "When Fusion recovers from a retryable model failure by switching to a fallback model" },
|
||||||
{ event: "memory-dreams-processed", label: "DREAMS.md entry added", description: "When manual dream processing writes a new entry to project or agent DREAMS.md" },
|
{ event: "memory-dreams-processed", label: "DREAMS.md entry added", description: "When manual dream processing writes a new entry to project or agent DREAMS.md" },
|
||||||
|
{ event: "message:agent-to-user", label: "Agent → user message", description: "An agent sent you a direct message" },
|
||||||
|
{ event: "message:agent-to-agent", label: "Agent → agent message", description: "Agents are talking to each other (including replies)" },
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Well-known experimental feature flags with display labels.
|
/** Well-known experimental feature flags with display labels.
|
||||||
|
|||||||
@@ -1191,4 +1191,21 @@ describe("MailboxModal", () => {
|
|||||||
expect(lightContent).toContain("--star-active");
|
expect(lightContent).toContain("--star-active");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("highlights hash-linked message when modal opens", async () => {
|
||||||
|
const scrollIntoView = vi.fn();
|
||||||
|
Element.prototype.scrollIntoView = scrollIntoView;
|
||||||
|
window.history.replaceState({}, "", "#message-msg-001");
|
||||||
|
|
||||||
|
render(<MailboxModal {...defaultProps} />);
|
||||||
|
|
||||||
|
// The deep-link opens the message detail view, so the element with id="message-msg-001"
|
||||||
|
// is in the detail section, not the inbox list.
|
||||||
|
const messageNode = await screen.findByTestId("mailbox-message-detail");
|
||||||
|
expect(messageNode).toHaveAttribute("id", "message-msg-001");
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(messageNode).toHaveClass("mailbox-message-highlight");
|
||||||
|
});
|
||||||
|
expect(scrollIntoView).toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -734,7 +734,7 @@ describe("MailboxView", () => {
|
|||||||
expect(screen.getByTestId("mailbox-conversation")).toBeDefined();
|
expect(screen.getByTestId("mailbox-conversation")).toBeDefined();
|
||||||
const replyContext = screen.getByTestId("mailbox-reply-context-msg-thread-reply");
|
const replyContext = screen.getByTestId("mailbox-reply-context-msg-thread-reply");
|
||||||
expect(replyContext).toBeDefined();
|
expect(replyContext).toBeDefined();
|
||||||
expect(replyContext).toHaveClass("mailbox-reply-context");
|
expect(replyContext).toHaveClass("mailbox-reply-context-static");
|
||||||
expect(screen.getByText(/Replying to Can you share your current status\?/)).toBeDefined();
|
expect(screen.getByText(/Replying to Can you share your current status\?/)).toBeDefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -806,7 +806,7 @@ describe("MailboxView", () => {
|
|||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
const replyContext = screen.getByTestId("mailbox-selected-reply-context");
|
const replyContext = screen.getByTestId("mailbox-selected-reply-context");
|
||||||
expect(replyContext).toBeDefined();
|
expect(replyContext).toBeDefined();
|
||||||
expect(replyContext).toHaveClass("mailbox-reply-context");
|
expect(replyContext).toHaveClass("mailbox-reply-context-static");
|
||||||
expect(screen.getByTestId("mailbox-message-body")).toHaveTextContent("I have the answer now");
|
expect(screen.getByTestId("mailbox-message-body")).toHaveTextContent("I have the answer now");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1454,5 +1454,23 @@ describe("MailboxView", () => {
|
|||||||
const content = container.querySelector(".mailbox-content");
|
const content = container.querySelector(".mailbox-content");
|
||||||
expect(content).toBeTruthy();
|
expect(content).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("highlights deep-linked mailbox message from URL", async () => {
|
||||||
|
const scrollIntoView = vi.fn();
|
||||||
|
Element.prototype.scrollIntoView = scrollIntoView;
|
||||||
|
window.history.replaceState({}, "", "?view=mailbox&mailbox-message=msg-001#message-msg-001");
|
||||||
|
|
||||||
|
mockFetchInbox.mockResolvedValue(makeInboxResponse([mockMessage], 1));
|
||||||
|
mockFetchConversation.mockResolvedValue([mockMessage]);
|
||||||
|
|
||||||
|
render(<MailboxView {...defaultProps} />);
|
||||||
|
|
||||||
|
const messageNode = await screen.findByTestId("mailbox-message-detail");
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(messageNode).toHaveAttribute("id", "mailbox-detail-message-msg-001");
|
||||||
|
expect(messageNode).toHaveClass("mailbox-message-highlight");
|
||||||
|
});
|
||||||
|
expect(scrollIntoView).toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2710,7 +2710,7 @@ describe("SettingsModal", () => {
|
|||||||
expect(screen.getByRole("button", { name: /Test notification/ })).toBeInTheDocument();
|
expect(screen.getByRole("button", { name: /Test notification/ })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows fallback-used and dreams events for both providers", async () => {
|
it("shows fallback, dreams, and mailbox message events for both providers", async () => {
|
||||||
mockFetchSettings.mockResolvedValueOnce({ ...defaultSettings, ntfyEnabled: true, ntfyTopic: "test-topic" });
|
mockFetchSettings.mockResolvedValueOnce({ ...defaultSettings, ntfyEnabled: true, ntfyTopic: "test-topic" });
|
||||||
renderModal();
|
renderModal();
|
||||||
await waitForSettingsModalReady();
|
await waitForSettingsModalReady();
|
||||||
@@ -2718,10 +2718,18 @@ describe("SettingsModal", () => {
|
|||||||
|
|
||||||
expect(screen.getByLabelText("Fallback model used (recovered)")).toBeInTheDocument();
|
expect(screen.getByLabelText("Fallback model used (recovered)")).toBeInTheDocument();
|
||||||
expect(screen.getByLabelText("DREAMS.md entry added")).toBeInTheDocument();
|
expect(screen.getByLabelText("DREAMS.md entry added")).toBeInTheDocument();
|
||||||
|
const agentToUserNtfy = screen.getByLabelText("Agent → user message") as HTMLInputElement;
|
||||||
|
const agentToAgentNtfy = screen.getByLabelText("Agent → agent message") as HTMLInputElement;
|
||||||
|
expect(agentToUserNtfy.checked).toBe(true);
|
||||||
|
expect(agentToAgentNtfy.checked).toBe(true);
|
||||||
|
|
||||||
await userEvent.click(screen.getByLabelText("Webhook notifications"));
|
await userEvent.click(screen.getByLabelText("Webhook notifications"));
|
||||||
expect(screen.getAllByLabelText("Fallback model used (recovered)").length).toBeGreaterThan(0);
|
expect(screen.getAllByLabelText("Fallback model used (recovered)").length).toBeGreaterThan(0);
|
||||||
expect(screen.getAllByLabelText("DREAMS.md entry added").length).toBeGreaterThan(0);
|
expect(screen.getAllByLabelText("DREAMS.md entry added").length).toBeGreaterThan(0);
|
||||||
|
const [agentToUserWebhook] = screen.getAllByLabelText("Agent → user message") as HTMLInputElement[];
|
||||||
|
const [agentToAgentWebhook] = screen.getAllByLabelText("Agent → agent message") as HTMLInputElement[];
|
||||||
|
expect(agentToUserWebhook.checked).toBe(true);
|
||||||
|
expect(agentToAgentWebhook.checked).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows webhook fields when webhook provider is enabled", async () => {
|
it("shows webhook fields when webhook provider is enabled", async () => {
|
||||||
|
|||||||
@@ -125,6 +125,17 @@ export function useViewState(options: UseViewStateOptions): UseViewStateResult {
|
|||||||
setScopedItem("kb-dashboard-task-view", taskView, currentProject?.id);
|
setScopedItem("kb-dashboard-task-view", taskView, currentProject?.id);
|
||||||
}, [currentProject?.id, taskView]);
|
}, [currentProject?.id, taskView]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const viewParam = new URLSearchParams(window.location.search).get("view");
|
||||||
|
if (viewParam && isTaskView(viewParam)) {
|
||||||
|
setTaskView(normalizeTaskView(viewParam));
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (projectsLoading || currentProjectLoading) return;
|
if (projectsLoading || currentProjectLoading) return;
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import type { NotificationProvider, Settings, Task } from "@fusion/core";
|
import { EventEmitter } from "node:events";
|
||||||
|
import type { Message, NotificationProvider, Settings, Task } from "@fusion/core";
|
||||||
import { NotificationService } from "../notification/notification-service.js";
|
import { NotificationService } from "../notification/notification-service.js";
|
||||||
import { NtfyNotificationProvider } from "../notification/ntfy-provider.js";
|
import { NtfyNotificationProvider } from "../notification/ntfy-provider.js";
|
||||||
|
|
||||||
@@ -44,6 +45,22 @@ function task(overrides: Partial<Task> = {}): Task {
|
|||||||
} as Task;
|
} as Task;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createMessage(overrides: Partial<Message> = {}): Message {
|
||||||
|
return {
|
||||||
|
id: "msg-1",
|
||||||
|
fromId: "agent-1",
|
||||||
|
fromType: "agent",
|
||||||
|
toId: "user:dashboard",
|
||||||
|
toType: "user",
|
||||||
|
content: "hello from agent",
|
||||||
|
type: "agent-to-user",
|
||||||
|
read: false,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
...overrides,
|
||||||
|
} as Message;
|
||||||
|
}
|
||||||
|
|
||||||
describe("NotificationService", () => {
|
describe("NotificationService", () => {
|
||||||
it("dispatches in-review event to registered provider", async () => {
|
it("dispatches in-review event to registered provider", async () => {
|
||||||
const store = createStore({ ntfyEnabled: true, ntfyTopic: "topic" });
|
const store = createStore({ ntfyEnabled: true, ntfyTopic: "topic" });
|
||||||
@@ -129,6 +146,138 @@ describe("NotificationService", () => {
|
|||||||
initSpy.mockRestore();
|
initSpy.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("dispatches message:agent-to-user from message:sent", async () => {
|
||||||
|
const store = createStore({ ntfyEnabled: true, ntfyTopic: "topic" });
|
||||||
|
const messageStore = new EventEmitter();
|
||||||
|
const sendNotification = vi.fn(async () => ({ success: true, providerId: "mock" }));
|
||||||
|
const provider: NotificationProvider = {
|
||||||
|
getProviderId: () => "mock",
|
||||||
|
isEventSupported: () => true,
|
||||||
|
sendNotification,
|
||||||
|
};
|
||||||
|
|
||||||
|
const service = new NotificationService(store as any, { messageStore: messageStore as any });
|
||||||
|
service.registerProvider(provider);
|
||||||
|
await service.start();
|
||||||
|
|
||||||
|
messageStore.emit("message:sent", createMessage());
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
expect(sendNotification).toHaveBeenCalledWith(
|
||||||
|
"message:agent-to-user",
|
||||||
|
expect.objectContaining({
|
||||||
|
event: "message:agent-to-user",
|
||||||
|
metadata: expect.objectContaining({
|
||||||
|
messageId: "msg-1",
|
||||||
|
fromId: "agent-1",
|
||||||
|
toId: "user:dashboard",
|
||||||
|
preview: "hello from agent",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dispatches message:agent-to-agent with reply metadata", async () => {
|
||||||
|
const store = createStore({ ntfyEnabled: true, ntfyTopic: "topic" });
|
||||||
|
const messageStore = new EventEmitter();
|
||||||
|
const sendNotification = vi.fn(async () => ({ success: true, providerId: "mock" }));
|
||||||
|
const provider: NotificationProvider = {
|
||||||
|
getProviderId: () => "mock",
|
||||||
|
isEventSupported: () => true,
|
||||||
|
sendNotification,
|
||||||
|
};
|
||||||
|
|
||||||
|
const service = new NotificationService(store as any, { messageStore: messageStore as any });
|
||||||
|
service.registerProvider(provider);
|
||||||
|
await service.start();
|
||||||
|
|
||||||
|
messageStore.emit(
|
||||||
|
"message:sent",
|
||||||
|
createMessage({
|
||||||
|
id: "msg-2",
|
||||||
|
type: "agent-to-agent",
|
||||||
|
toId: "agent-2",
|
||||||
|
toType: "agent",
|
||||||
|
metadata: { replyTo: { messageId: "msg-1" } },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
expect(sendNotification).toHaveBeenCalledWith(
|
||||||
|
"message:agent-to-agent",
|
||||||
|
expect.objectContaining({
|
||||||
|
event: "message:agent-to-agent",
|
||||||
|
metadata: expect.objectContaining({
|
||||||
|
messageId: "msg-2",
|
||||||
|
replyToMessageId: "msg-1",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores user-to-agent message:sent events", async () => {
|
||||||
|
const store = createStore({ ntfyEnabled: true, ntfyTopic: "topic" });
|
||||||
|
const messageStore = new EventEmitter();
|
||||||
|
const sendNotification = vi.fn(async () => ({ success: true, providerId: "mock" }));
|
||||||
|
const provider: NotificationProvider = {
|
||||||
|
getProviderId: () => "mock",
|
||||||
|
isEventSupported: () => true,
|
||||||
|
sendNotification,
|
||||||
|
};
|
||||||
|
|
||||||
|
const service = new NotificationService(store as any, { messageStore: messageStore as any });
|
||||||
|
service.registerProvider(provider);
|
||||||
|
await service.start();
|
||||||
|
|
||||||
|
messageStore.emit("message:sent", createMessage({ type: "user-to-agent", fromType: "user", toType: "agent" }));
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
expect(sendNotification).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not dispatch mailbox notifications when disabled", async () => {
|
||||||
|
const store = createStore({ ntfyEnabled: false, webhookEnabled: false });
|
||||||
|
const messageStore = new EventEmitter();
|
||||||
|
const sendNotification = vi.fn(async () => ({ success: true, providerId: "mock" }));
|
||||||
|
const provider: NotificationProvider = {
|
||||||
|
getProviderId: () => "mock",
|
||||||
|
isEventSupported: () => true,
|
||||||
|
sendNotification,
|
||||||
|
};
|
||||||
|
|
||||||
|
const service = new NotificationService(store as any, { messageStore: messageStore as any });
|
||||||
|
service.registerProvider(provider);
|
||||||
|
await service.start();
|
||||||
|
|
||||||
|
messageStore.emit("message:sent", createMessage());
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
expect(sendNotification).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stop unsubscribes message:sent listener", async () => {
|
||||||
|
const store = createStore({ ntfyEnabled: true, ntfyTopic: "topic" });
|
||||||
|
const messageStore = new EventEmitter();
|
||||||
|
const sendNotification = vi.fn(async () => ({ success: true, providerId: "mock" }));
|
||||||
|
const provider: NotificationProvider = {
|
||||||
|
getProviderId: () => "mock",
|
||||||
|
isEventSupported: () => true,
|
||||||
|
sendNotification,
|
||||||
|
};
|
||||||
|
|
||||||
|
const service = new NotificationService(store as any, { messageStore: messageStore as any });
|
||||||
|
service.registerProvider(provider);
|
||||||
|
await service.start();
|
||||||
|
|
||||||
|
expect(messageStore.listenerCount("message:sent")).toBeGreaterThan(0);
|
||||||
|
await service.stop();
|
||||||
|
expect(messageStore.listenerCount("message:sent")).toBe(0);
|
||||||
|
|
||||||
|
messageStore.emit("message:sent", createMessage());
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(sendNotification).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it("duplicates merged dispatch when multiple NotificationService instances subscribe to the same store", async () => {
|
it("duplicates merged dispatch when multiple NotificationService instances subscribe to the same store", async () => {
|
||||||
const store = createStore({ ntfyEnabled: true, ntfyTopic: "topic" });
|
const store = createStore({ ntfyEnabled: true, ntfyTopic: "topic" });
|
||||||
const sendNotification = vi.fn(async () => ({ success: true, providerId: "mock" }));
|
const sendNotification = vi.fn(async () => ({ success: true, providerId: "mock" }));
|
||||||
|
|||||||
@@ -65,11 +65,13 @@ class MockTaskStore extends EventEmitter<MockTaskStoreEvents> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("Ntfy notifier helpers", () => {
|
describe("Ntfy notifier helpers", () => {
|
||||||
it("includes planning-awaiting-input in default events", () => {
|
it("includes mailbox message events in default events", () => {
|
||||||
expect(DEFAULT_NTFY_EVENTS).toContain("planning-awaiting-input");
|
expect(DEFAULT_NTFY_EVENTS).toContain("planning-awaiting-input");
|
||||||
expect(resolveNtfyEvents(undefined)).toContain("planning-awaiting-input");
|
expect(resolveNtfyEvents(undefined)).toContain("planning-awaiting-input");
|
||||||
expect(DEFAULT_NTFY_EVENTS).toContain("gridlock");
|
expect(DEFAULT_NTFY_EVENTS).toContain("gridlock");
|
||||||
expect(DEFAULT_NTFY_EVENTS).toContain("fallback-used");
|
expect(DEFAULT_NTFY_EVENTS).toContain("fallback-used");
|
||||||
|
expect(DEFAULT_NTFY_EVENTS).toContain("message:agent-to-user");
|
||||||
|
expect(DEFAULT_NTFY_EVENTS).toContain("message:agent-to-agent");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("checks planning-awaiting-input event enablement", () => {
|
it("checks planning-awaiting-input event enablement", () => {
|
||||||
@@ -82,6 +84,27 @@ describe("Ntfy notifier helpers", () => {
|
|||||||
"http://localhost:4040/?project=proj-1",
|
"http://localhost:4040/?project=proj-1",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("builds task message deep links", () => {
|
||||||
|
expect(
|
||||||
|
buildNtfyClickUrl({
|
||||||
|
dashboardHost: "http://localhost:4040/",
|
||||||
|
projectId: "proj-1",
|
||||||
|
taskId: "FN-1",
|
||||||
|
messageId: "msg-1",
|
||||||
|
}),
|
||||||
|
).toBe("http://localhost:4040/?project=proj-1&task=FN-1#message-msg-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds standalone mailbox message deep links", () => {
|
||||||
|
expect(
|
||||||
|
buildNtfyClickUrl({
|
||||||
|
dashboardHost: "http://localhost:4040/",
|
||||||
|
projectId: "proj-1",
|
||||||
|
messageId: "msg-1",
|
||||||
|
}),
|
||||||
|
).toBe("http://localhost:4040/?project=proj-1&view=mailbox&mailbox-message=msg-1#message-msg-1");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("NtfyNotifier", () => {
|
describe("NtfyNotifier", () => {
|
||||||
|
|||||||
@@ -43,8 +43,15 @@ describe("NtfyNotificationProvider", () => {
|
|||||||
["awaiting-user-review", "User review needed for FN-1", "needs human review", "high"],
|
["awaiting-user-review", "User review needed for FN-1", "needs human review", "high"],
|
||||||
["planning-awaiting-input", "Planning input needed for FN-1", "awaiting your input", "high"],
|
["planning-awaiting-input", "Planning input needed for FN-1", "awaiting your input", "high"],
|
||||||
["fallback-used", "Fallback model used for FN-1", "switched from", "high"],
|
["fallback-used", "Fallback model used for FN-1", "switched from", "high"],
|
||||||
|
["message:agent-to-user", "New message from agent-1", "agent-1 → you: preview text", "high"],
|
||||||
|
["message:agent-to-agent", "agent-1 → agent-2", "agent-1 messaged agent-2: preview text", "default"],
|
||||||
])("maps %s event correctly", async (event, expectedTitle, messagePart, priority) => {
|
])("maps %s event correctly", async (event, expectedTitle, messagePart, priority) => {
|
||||||
await provider.sendNotification(event as any, { taskId: "FN-1", taskTitle: "T", event: event as any });
|
await provider.sendNotification(event as any, {
|
||||||
|
taskId: "FN-1",
|
||||||
|
taskTitle: "T",
|
||||||
|
event: event as any,
|
||||||
|
metadata: { fromId: "agent-1", toId: "agent-2", preview: "preview text", messageId: "msg-1" },
|
||||||
|
});
|
||||||
|
|
||||||
expect(mocks.sendNtfyNotification).toHaveBeenCalledWith(
|
expect(mocks.sendNtfyNotification).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
@@ -64,6 +71,8 @@ describe("NtfyNotificationProvider", () => {
|
|||||||
expect(provider.isEventSupported("awaiting-user-review" as any)).toBe(true);
|
expect(provider.isEventSupported("awaiting-user-review" as any)).toBe(true);
|
||||||
expect(provider.isEventSupported("planning-awaiting-input" as any)).toBe(true);
|
expect(provider.isEventSupported("planning-awaiting-input" as any)).toBe(true);
|
||||||
expect(provider.isEventSupported("fallback-used" as any)).toBe(true);
|
expect(provider.isEventSupported("fallback-used" as any)).toBe(true);
|
||||||
|
expect(provider.isEventSupported("message:agent-to-user" as any)).toBe(true);
|
||||||
|
expect(provider.isEventSupported("message:agent-to-agent" as any)).toBe(true);
|
||||||
expect(provider.isEventSupported("custom-event" as any)).toBe(false);
|
expect(provider.isEventSupported("custom-event" as any)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -85,12 +94,53 @@ describe("NtfyNotificationProvider", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("builds click URL from config", async () => {
|
it("builds click URL from config for task message events", async () => {
|
||||||
await provider.sendNotification("merged" as any, { taskId: "FN-1", taskTitle: "T", event: "merged" as any });
|
await provider.sendNotification("message:agent-to-user" as any, {
|
||||||
|
taskId: "FN-1",
|
||||||
|
taskTitle: "T",
|
||||||
|
event: "message:agent-to-user" as any,
|
||||||
|
metadata: { messageId: "msg-1", fromId: "agent-1", preview: "hello" },
|
||||||
|
});
|
||||||
expect(mocks.buildNtfyClickUrl).toHaveBeenCalledWith({
|
expect(mocks.buildNtfyClickUrl).toHaveBeenCalledWith({
|
||||||
dashboardHost: "http://dash",
|
dashboardHost: "http://dash",
|
||||||
projectId: "p1",
|
projectId: "p1",
|
||||||
taskId: "FN-1",
|
taskId: "FN-1",
|
||||||
|
messageId: "msg-1",
|
||||||
|
view: "mailbox",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("uses mailbox deep link when message is not task-bound", async () => {
|
||||||
|
await provider.sendNotification("message:agent-to-agent" as any, {
|
||||||
|
event: "message:agent-to-agent" as any,
|
||||||
|
metadata: { messageId: "msg-2", fromId: "agent-1", toId: "agent-2", preview: "hello" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mocks.buildNtfyClickUrl).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
taskId: undefined,
|
||||||
|
messageId: "msg-2",
|
||||||
|
view: "mailbox",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses Re: title for agent-to-agent replies", async () => {
|
||||||
|
await provider.sendNotification("message:agent-to-agent" as any, {
|
||||||
|
event: "message:agent-to-agent" as any,
|
||||||
|
metadata: {
|
||||||
|
messageId: "msg-3",
|
||||||
|
fromId: "agent-1",
|
||||||
|
toId: "agent-2",
|
||||||
|
preview: "reply preview",
|
||||||
|
replyToMessageId: "msg-1",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mocks.sendNtfyNotification).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
title: "Re: reply preview",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -65,15 +65,27 @@ describe("WebhookNotificationProvider", () => {
|
|||||||
|
|
||||||
it("sendNotification formats Generic correctly", async () => {
|
it("sendNotification formats Generic correctly", async () => {
|
||||||
fetchMock.mockResolvedValue({ ok: true, status: 200, statusText: "OK" });
|
fetchMock.mockResolvedValue({ ok: true, status: 200, statusText: "OK" });
|
||||||
await provider.initialize({ webhookUrl: "https://example.com/hook", webhookFormat: "generic" });
|
await provider.initialize({
|
||||||
|
webhookUrl: "https://example.com/hook",
|
||||||
|
webhookFormat: "generic",
|
||||||
|
dashboardHost: "http://dash",
|
||||||
|
projectId: "p1",
|
||||||
|
});
|
||||||
|
|
||||||
await provider.sendNotification("in-review", { taskId: "FN-1", taskTitle: "My Task", event: "in-review" });
|
await provider.sendNotification("message:agent-to-user", {
|
||||||
|
taskId: "FN-1",
|
||||||
|
taskTitle: "My Task",
|
||||||
|
event: "message:agent-to-user",
|
||||||
|
metadata: { messageId: "msg-1", fromId: "agent-1", toId: "user:dashboard", preview: "hello" },
|
||||||
|
});
|
||||||
|
|
||||||
const [, requestInit] = fetchMock.mock.calls[0] as [string, RequestInit];
|
const [, requestInit] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||||
const payload = JSON.parse(String(requestInit.body));
|
const payload = JSON.parse(String(requestInit.body));
|
||||||
expect(payload.event).toBe("in-review");
|
expect(payload.event).toBe("message:agent-to-user");
|
||||||
expect(payload.timestamp).toEqual(expect.any(String));
|
expect(payload.timestamp).toEqual(expect.any(String));
|
||||||
expect(payload.task).toEqual({ id: "FN-1", title: "My Task" });
|
expect(payload.task).toEqual({ id: "FN-1", title: "My Task" });
|
||||||
|
expect(payload.metadata).toEqual(expect.objectContaining({ messageId: "msg-1" }));
|
||||||
|
expect(payload.clickUrl).toBe("http://dash/?project=p1&task=FN-1#message-msg-1");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("sendNotification returns success on HTTP 200", async () => {
|
it("sendNotification returns success on HTTP 200", async () => {
|
||||||
@@ -138,6 +150,8 @@ describe("WebhookNotificationProvider", () => {
|
|||||||
["planning-awaiting-input", "is awaiting your input during planning"],
|
["planning-awaiting-input", "is awaiting your input during planning"],
|
||||||
["gridlock", "Pipeline gridlocked"],
|
["gridlock", "Pipeline gridlocked"],
|
||||||
["fallback-used", "Fusion recovered by switching from"],
|
["fallback-used", "Fusion recovered by switching from"],
|
||||||
|
["message:agent-to-user", 'Event "message:agent-to-user" for task My Task'],
|
||||||
|
["message:agent-to-agent", 'Event "message:agent-to-agent" for task My Task'],
|
||||||
["unknown-event", 'Event "unknown-event" for task My Task'],
|
["unknown-event", 'Event "unknown-event" for task My Task'],
|
||||||
])("message formatting for %s", async (event, expectedPart) => {
|
])("message formatting for %s", async (event, expectedPart) => {
|
||||||
fetchMock.mockResolvedValue({ ok: true, status: 200, statusText: "OK" });
|
fetchMock.mockResolvedValue({ ok: true, status: 200, statusText: "OK" });
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type {
|
import type {
|
||||||
Column,
|
Column,
|
||||||
MergeResult,
|
MergeResult,
|
||||||
|
Message,
|
||||||
NotificationEvent,
|
NotificationEvent,
|
||||||
NotificationPayload,
|
NotificationPayload,
|
||||||
NotificationProvider,
|
NotificationProvider,
|
||||||
@@ -18,6 +19,8 @@ export interface NotificationServiceOptions {
|
|||||||
projectId?: string;
|
projectId?: string;
|
||||||
/** Base URL for ntfy.sh (backward compat with NtfyNotifierOptions) */
|
/** Base URL for ntfy.sh (backward compat with NtfyNotifierOptions) */
|
||||||
ntfyBaseUrl?: string;
|
ntfyBaseUrl?: string;
|
||||||
|
/** Optional message store for mailbox message notifications */
|
||||||
|
messageStore?: NotificationMessageStore;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface NotificationServiceStore {
|
interface NotificationServiceStore {
|
||||||
@@ -26,6 +29,11 @@ interface NotificationServiceStore {
|
|||||||
off(event: string, listener: (...args: any[]) => void): void;
|
off(event: string, listener: (...args: any[]) => void): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface NotificationMessageStore {
|
||||||
|
on(event: "message:sent", listener: (message: Message) => void): void;
|
||||||
|
off?(event: "message:sent", listener: (message: Message) => void): void;
|
||||||
|
}
|
||||||
|
|
||||||
export class NotificationService {
|
export class NotificationService {
|
||||||
private readonly dispatcher = new NotificationDispatcher();
|
private readonly dispatcher = new NotificationDispatcher();
|
||||||
private readonly notifiedEvents = new Set<string>();
|
private readonly notifiedEvents = new Set<string>();
|
||||||
@@ -59,6 +67,7 @@ export class NotificationService {
|
|||||||
this.store.on("task:updated", this.handleTaskUpdated);
|
this.store.on("task:updated", this.handleTaskUpdated);
|
||||||
this.store.on("task:merged", this.handleTaskMerged);
|
this.store.on("task:merged", this.handleTaskMerged);
|
||||||
this.store.on("settings:updated", this.handleSettingsUpdated);
|
this.store.on("settings:updated", this.handleSettingsUpdated);
|
||||||
|
this.options.messageStore?.on("message:sent", this.handleMessageSent);
|
||||||
|
|
||||||
this.started = true;
|
this.started = true;
|
||||||
schedulerLog.log("NotificationService started");
|
schedulerLog.log("NotificationService started");
|
||||||
@@ -74,6 +83,9 @@ export class NotificationService {
|
|||||||
this.store.off("task:updated", this.handleTaskUpdated);
|
this.store.off("task:updated", this.handleTaskUpdated);
|
||||||
this.store.off("task:merged", this.handleTaskMerged);
|
this.store.off("task:merged", this.handleTaskMerged);
|
||||||
this.store.off("settings:updated", this.handleSettingsUpdated);
|
this.store.off("settings:updated", this.handleSettingsUpdated);
|
||||||
|
if (typeof this.options.messageStore?.off === "function") {
|
||||||
|
this.options.messageStore.off("message:sent", this.handleMessageSent);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.dispatcher.shutdownAll();
|
await this.dispatcher.shutdownAll();
|
||||||
@@ -218,9 +230,48 @@ export class NotificationService {
|
|||||||
webhookUrl: settings.webhookUrl,
|
webhookUrl: settings.webhookUrl,
|
||||||
webhookFormat: settings.webhookFormat ?? "generic",
|
webhookFormat: settings.webhookFormat ?? "generic",
|
||||||
events: settings.webhookEvents ?? [],
|
events: settings.webhookEvents ?? [],
|
||||||
|
dashboardHost: settings.ntfyDashboardHost,
|
||||||
|
projectId: this.options.projectId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private handleMessageSent = (message: Message): void => {
|
||||||
|
if (!this.notificationsEnabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let eventType: NotificationEvent;
|
||||||
|
if (message.type === "agent-to-user") {
|
||||||
|
eventType = "message:agent-to-user";
|
||||||
|
} else if (message.type === "agent-to-agent") {
|
||||||
|
eventType = "message:agent-to-agent";
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const preview = message.content.length > 100
|
||||||
|
? `${message.content.slice(0, 100)}…`
|
||||||
|
: message.content;
|
||||||
|
|
||||||
|
const taskId = typeof message.metadata?.taskId === "string" ? message.metadata.taskId : undefined;
|
||||||
|
|
||||||
|
this.maybeNotify(message.id, eventType, {
|
||||||
|
taskId,
|
||||||
|
taskTitle: undefined,
|
||||||
|
event: eventType,
|
||||||
|
metadata: {
|
||||||
|
messageId: message.id,
|
||||||
|
fromId: message.fromId,
|
||||||
|
fromType: message.fromType,
|
||||||
|
toId: message.toId,
|
||||||
|
toType: message.toType,
|
||||||
|
type: message.type,
|
||||||
|
replyToMessageId: message.metadata?.replyTo?.messageId,
|
||||||
|
preview,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
private setNotificationsEnabledFromSettings(settings: Settings): void {
|
private setNotificationsEnabledFromSettings(settings: Settings): void {
|
||||||
this.notificationsEnabled = Boolean(
|
this.notificationsEnabled = Boolean(
|
||||||
(settings.ntfyEnabled && settings.ntfyTopic) ||
|
(settings.ntfyEnabled && settings.ntfyTopic) ||
|
||||||
|
|||||||
@@ -34,7 +34,9 @@ type SupportedNtfyEvent =
|
|||||||
| "awaiting-approval"
|
| "awaiting-approval"
|
||||||
| "awaiting-user-review"
|
| "awaiting-user-review"
|
||||||
| "planning-awaiting-input"
|
| "planning-awaiting-input"
|
||||||
| "fallback-used";
|
| "fallback-used"
|
||||||
|
| "message:agent-to-user"
|
||||||
|
| "message:agent-to-agent";
|
||||||
|
|
||||||
const SUPPORTED_EVENTS = new Set<SupportedNtfyEvent>([
|
const SUPPORTED_EVENTS = new Set<SupportedNtfyEvent>([
|
||||||
"in-review",
|
"in-review",
|
||||||
@@ -44,6 +46,8 @@ const SUPPORTED_EVENTS = new Set<SupportedNtfyEvent>([
|
|||||||
"awaiting-user-review",
|
"awaiting-user-review",
|
||||||
"planning-awaiting-input",
|
"planning-awaiting-input",
|
||||||
"fallback-used",
|
"fallback-used",
|
||||||
|
"message:agent-to-user",
|
||||||
|
"message:agent-to-agent",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export class NtfyNotificationProvider implements NotificationProvider {
|
export class NtfyNotificationProvider implements NotificationProvider {
|
||||||
@@ -102,10 +106,22 @@ export class NtfyNotificationProvider implements NotificationProvider {
|
|||||||
} as Pick<Task, "id" | "title" | "description"> as Task;
|
} as Pick<Task, "id" | "title" | "description"> as Task;
|
||||||
|
|
||||||
const identifier = formatTaskIdentifier(taskLike);
|
const identifier = formatTaskIdentifier(taskLike);
|
||||||
|
const messageId = typeof payload.metadata?.messageId === "string" ? payload.metadata.messageId : undefined;
|
||||||
|
const senderLabel = typeof payload.metadata?.fromId === "string" ? payload.metadata.fromId : "agent";
|
||||||
|
const recipientLabel = typeof payload.metadata?.toId === "string" ? payload.metadata.toId : "recipient";
|
||||||
|
const preview = typeof payload.metadata?.preview === "string"
|
||||||
|
? payload.metadata.preview
|
||||||
|
: "(no preview)";
|
||||||
|
const replyToMessageId = typeof payload.metadata?.replyToMessageId === "string"
|
||||||
|
? payload.metadata.replyToMessageId
|
||||||
|
: undefined;
|
||||||
|
|
||||||
const clickUrl = buildNtfyClickUrl({
|
const clickUrl = buildNtfyClickUrl({
|
||||||
dashboardHost: this.config.dashboardHost,
|
dashboardHost: this.config.dashboardHost,
|
||||||
projectId: this.config.projectId,
|
projectId: this.config.projectId,
|
||||||
taskId: payload.taskId,
|
taskId: payload.taskId,
|
||||||
|
messageId,
|
||||||
|
view: "mailbox",
|
||||||
});
|
});
|
||||||
|
|
||||||
const contentByEvent: Record<SupportedNtfyEvent, { title: string; message: string; priority: "default" | "high" }> = {
|
const contentByEvent: Record<SupportedNtfyEvent, { title: string; message: string; priority: "default" | "high" }> = {
|
||||||
@@ -144,6 +160,16 @@ export class NtfyNotificationProvider implements NotificationProvider {
|
|||||||
message: `Fusion switched from ${String(payload.metadata?.primaryModel ?? "primary model")} to ${String(payload.metadata?.fallbackModel ?? "fallback model")} after a retryable failure (${String(payload.metadata?.triggerPoint ?? "unknown trigger")}).`,
|
message: `Fusion switched from ${String(payload.metadata?.primaryModel ?? "primary model")} to ${String(payload.metadata?.fallbackModel ?? "fallback model")} after a retryable failure (${String(payload.metadata?.triggerPoint ?? "unknown trigger")}).`,
|
||||||
priority: "high",
|
priority: "high",
|
||||||
},
|
},
|
||||||
|
"message:agent-to-user": {
|
||||||
|
title: `New message from ${senderLabel}`,
|
||||||
|
message: `${senderLabel} → you: ${preview}`,
|
||||||
|
priority: "high",
|
||||||
|
},
|
||||||
|
"message:agent-to-agent": {
|
||||||
|
title: replyToMessageId ? `Re: ${preview}` : `${senderLabel} → ${recipientLabel}`,
|
||||||
|
message: `${senderLabel} messaged ${recipientLabel}: ${preview}`,
|
||||||
|
priority: "default",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const content = contentByEvent[event as SupportedNtfyEvent];
|
const content = contentByEvent[event as SupportedNtfyEvent];
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type {
|
|||||||
NotificationResult,
|
NotificationResult,
|
||||||
} from "@fusion/core";
|
} from "@fusion/core";
|
||||||
import { schedulerLog } from "../logger.js";
|
import { schedulerLog } from "../logger.js";
|
||||||
|
import { buildNtfyClickUrl } from "../notifier.js";
|
||||||
|
|
||||||
export interface WebhookProviderConfig {
|
export interface WebhookProviderConfig {
|
||||||
/** Webhook endpoint URL */
|
/** Webhook endpoint URL */
|
||||||
@@ -13,6 +14,10 @@ export interface WebhookProviderConfig {
|
|||||||
webhookFormat: "slack" | "discord" | "generic";
|
webhookFormat: "slack" | "discord" | "generic";
|
||||||
/** Events to send (empty = all events) */
|
/** Events to send (empty = all events) */
|
||||||
events?: string[];
|
events?: string[];
|
||||||
|
/** Dashboard host for click-through deep links */
|
||||||
|
dashboardHost?: string;
|
||||||
|
/** Project identifier for deep links */
|
||||||
|
projectId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class WebhookNotificationProvider implements NotificationProvider {
|
export class WebhookNotificationProvider implements NotificationProvider {
|
||||||
@@ -49,6 +54,8 @@ export class WebhookNotificationProvider implements NotificationProvider {
|
|||||||
webhookUrl,
|
webhookUrl,
|
||||||
webhookFormat,
|
webhookFormat,
|
||||||
events: Array.isArray(config.events) ? config.events.filter((event): event is string => typeof event === "string") : [],
|
events: Array.isArray(config.events) ? config.events.filter((event): event is string => typeof event === "string") : [],
|
||||||
|
dashboardHost: typeof config.dashboardHost === "string" ? config.dashboardHost : undefined,
|
||||||
|
projectId: typeof config.projectId === "string" ? config.projectId : undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
this.abortController?.abort();
|
this.abortController?.abort();
|
||||||
@@ -163,6 +170,8 @@ export class WebhookNotificationProvider implements NotificationProvider {
|
|||||||
return { content: message };
|
return { content: message };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const messageId = typeof payload.metadata?.messageId === "string" ? payload.metadata.messageId : undefined;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
event: payload.event,
|
event: payload.event,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
@@ -171,6 +180,13 @@ export class WebhookNotificationProvider implements NotificationProvider {
|
|||||||
title: payload.taskTitle,
|
title: payload.taskTitle,
|
||||||
},
|
},
|
||||||
metadata: payload.metadata,
|
metadata: payload.metadata,
|
||||||
|
clickUrl: buildNtfyClickUrl({
|
||||||
|
dashboardHost: this.config.dashboardHost,
|
||||||
|
projectId: this.config.projectId,
|
||||||
|
taskId: payload.taskId,
|
||||||
|
messageId,
|
||||||
|
view: "mailbox",
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ export const DEFAULT_NTFY_EVENTS: readonly NtfyNotificationEvent[] = [
|
|||||||
"planning-awaiting-input",
|
"planning-awaiting-input",
|
||||||
"gridlock",
|
"gridlock",
|
||||||
"fallback-used",
|
"fallback-used",
|
||||||
|
"message:agent-to-user",
|
||||||
|
"message:agent-to-agent",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export interface NtfyNotificationConfigInput {
|
export interface NtfyNotificationConfigInput {
|
||||||
@@ -99,8 +101,10 @@ export function buildNtfyClickUrl(options: {
|
|||||||
dashboardHost?: string;
|
dashboardHost?: string;
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
taskId?: string;
|
taskId?: string;
|
||||||
|
messageId?: string;
|
||||||
|
view?: string;
|
||||||
}): string | undefined {
|
}): string | undefined {
|
||||||
const { dashboardHost, projectId, taskId } = options;
|
const { dashboardHost, projectId, taskId, messageId, view } = options;
|
||||||
if (!dashboardHost) {
|
if (!dashboardHost) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
@@ -113,10 +117,19 @@ export function buildNtfyClickUrl(options: {
|
|||||||
}
|
}
|
||||||
if (taskId) {
|
if (taskId) {
|
||||||
queryParts.push(`task=${encodeURIComponent(taskId)}`);
|
queryParts.push(`task=${encodeURIComponent(taskId)}`);
|
||||||
|
} else if (messageId) {
|
||||||
|
queryParts.push(`view=${encodeURIComponent(view ?? "mailbox")}`);
|
||||||
|
queryParts.push(`mailbox-message=${encodeURIComponent(messageId)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const query = queryParts.join("&");
|
const query = queryParts.join("&");
|
||||||
return query ? `${normalizedHost}/?${query}` : `${normalizedHost}/`;
|
const baseUrl = query ? `${normalizedHost}/?${query}` : `${normalizedHost}/`;
|
||||||
|
|
||||||
|
if (messageId) {
|
||||||
|
return `${baseUrl}#message-${encodeURIComponent(messageId)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return baseUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -289,6 +289,7 @@ export class ProjectEngine {
|
|||||||
this.notificationService = new NotificationService(store, {
|
this.notificationService = new NotificationService(store, {
|
||||||
projectId: this.options.projectId,
|
projectId: this.options.projectId,
|
||||||
ntfyBaseUrl: this.options.ntfyBaseUrl,
|
ntfyBaseUrl: this.options.ntfyBaseUrl,
|
||||||
|
messageStore: this.runtime.getMessageStore(),
|
||||||
});
|
});
|
||||||
await this.notificationService.start();
|
await this.notificationService.start();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user