feat(FN-2880): refactor notification provider cards in settings

- Replace notifications UI with reusable provider cards for ntfy and webhook settings
- Add event option metadata rendering and provider-specific test notification actions
- Align notification card gutters with existing settings layout and mobile behavior
- Expand SettingsModal and mobile tests to cover provider card visibility and interactions
This commit is contained in:
Fusion
2026-04-28 15:06:16 -07:00
committed by gsxdsm
parent 753604d805
commit 5c84ce79e7
5 changed files with 448 additions and 279 deletions

View File

@@ -751,6 +751,42 @@
border-radius: var(--radius-sm);
}
/**
* Notification provider cards:
* reusable container pattern for per-provider notification configuration
* (ntfy, webhook, and future providers).
*/
.notification-provider-card {
border: var(--btn-border-width) solid var(--border);
border-radius: var(--radius-lg);
background: var(--surface);
margin: 0 var(--space-xl) var(--space-lg);
overflow: hidden;
}
.notification-provider-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-md) var(--space-lg);
border-bottom: var(--btn-border-width) solid var(--border);
}
.notification-provider-body {
padding: var(--space-lg);
display: flex;
flex-direction: column;
gap: var(--space-lg);
}
.notification-provider-actions {
display: flex;
gap: var(--space-sm);
padding-top: var(--space-md);
border-top: var(--btn-border-width) solid var(--border);
margin-top: var(--space-md);
}
/* === Memory Settings === */
.memory-status-message {
display: flex;
@@ -1185,6 +1221,18 @@
display: none;
}
.notification-provider-card {
margin: 0 var(--space-lg) var(--space-md);
}
.notification-provider-header {
padding: var(--space-sm) var(--space-md);
}
.notification-provider-body {
padding: var(--space-md);
}
.settings-scope-icon {
margin-right: 0;
}
@@ -1522,32 +1570,3 @@
.settings-node-status--connecting .settings-node-status__dot {
background: var(--color-warning);
}
.settings-node-status {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
margin-top: var(--space-sm);
font-size: 12px;
color: var(--text-muted);
}
.settings-node-status__dot {
width: var(--space-sm);
height: var(--space-sm);
border-radius: var(--radius-pill);
background: var(--color-muted);
}
.settings-node-status--online .settings-node-status__dot {
background: var(--color-success);
}
.settings-node-status--offline .settings-node-status__dot,
.settings-node-status--error .settings-node-status__dot {
background: var(--color-error);
}
.settings-node-status--connecting .settings-node-status__dot {
background: var(--color-warning);
}

View File

@@ -2,7 +2,8 @@ import { useState, useEffect, useCallback, useRef, lazy, Suspense, type MouseEve
import { Globe, Folder, RefreshCw, Star, HelpCircle, Loader2 } from "lucide-react";
import { THINKING_LEVELS, isGlobalSettingsKey, isProjectSettingsKey, getErrorMessage } from "@fusion/core";
import type { Settings, GlobalSettings, ThemeMode, ColorTheme, ModelPreset, NtfyNotificationEvent, AgentPromptsConfig, ThinkingLevel } from "@fusion/core";
import { fetchSettings, fetchSettingsByScope, updateSettings, updateGlobalSettings, fetchAuthStatus, loginProvider, logoutProvider, saveApiKey, clearApiKey, fetchModels, testNtfyNotification, fetchBackups, createBackup, exportSettings, importSettings, fetchMemoryFile, fetchMemoryFiles, saveMemoryFile, compactMemory, fetchGlobalConcurrency, updateGlobalConcurrency, installQmd, testMemoryRetrieval, triggerMemoryDreams, fetchGitRemotesDetailed, fetchDashboardHealth, checkForUpdates, fetchRemoteSettings, updateRemoteSettings, fetchRemoteStatus, startRemoteTunnel, stopRemoteTunnel, regenerateRemotePersistentToken, generateShortLivedRemoteToken, fetchRemoteQr, fetchRemoteUrl } from "../api";import type { AuthProvider, ModelInfo, BackupListResponse, SettingsExportData, MemoryFileInfo, MemoryRetrievalTestResult, GitRemoteDetailed, RemoteSettings, RemoteStatus, UpdateCheckResponse } from "../api";
import { fetchSettings, fetchSettingsByScope, updateSettings, updateGlobalSettings, fetchAuthStatus, loginProvider, logoutProvider, saveApiKey, clearApiKey, fetchModels, testNotification, fetchBackups, createBackup, exportSettings, importSettings, fetchMemoryFile, fetchMemoryFiles, saveMemoryFile, compactMemory, fetchGlobalConcurrency, updateGlobalConcurrency, installQmd, testMemoryRetrieval, triggerMemoryDreams, fetchGitRemotesDetailed, fetchDashboardHealth, checkForUpdates, fetchRemoteSettings, updateRemoteSettings, fetchRemoteStatus, startRemoteTunnel, stopRemoteTunnel, regenerateRemotePersistentToken, generateShortLivedRemoteToken, fetchRemoteQr, fetchRemoteUrl } from "../api";
import type { AuthProvider, ModelInfo, BackupListResponse, SettingsExportData, MemoryFileInfo, MemoryRetrievalTestResult, GitRemoteDetailed, RemoteSettings, RemoteStatus, UpdateCheckResponse } from "../api";
import { useMemoryBackendStatus } from "../hooks/useMemoryBackendStatus";
import { useOverlayDismiss } from "../hooks/useOverlayDismiss";
import type { ToastType } from "../hooks/useToast";
@@ -213,6 +214,16 @@ const DEFAULT_NTFY_EVENTS: NtfyNotificationEvent[] = [
"gridlock",
];
const NOTIFICATION_EVENT_OPTIONS: Array<{ event: NtfyNotificationEvent; label: string; description: string }> = [
{ event: "in-review", label: "Task completed (in-review)", description: "When a task moves to In Review (ready for review)" },
{ event: "merged", label: "Task merged", description: "When a task is successfully merged to main" },
{ event: "failed", label: "Task failed", description: "When a task fails during execution (high priority)" },
{ event: "awaiting-approval", label: "Plan needs approval", description: "When a task specification needs manual approval before execution" },
{ event: "awaiting-user-review", label: "User review needed", description: "When an agent hands off a task for human review (high priority)" },
{ event: "planning-awaiting-input", label: "Planning needs input", description: "When planning mode is waiting for your response to continue" },
{ event: "gridlock", label: "Pipeline gridlocked", description: "When all schedulable todo tasks are blocked and work cannot advance" },
];
/** Well-known experimental feature flags with display labels.
* These always appear in the Experimental Features settings tab,
* regardless of whether they exist in the project's settings blob.
@@ -322,6 +333,10 @@ export function SettingsModal({
worktreeInitCommand: "",
ntfyEnabled: false,
ntfyTopic: undefined,
webhookEnabled: false,
webhookUrl: undefined,
webhookFormat: "generic",
webhookEvents: undefined,
});
const [loading, setLoading] = useState(true);
// Track initial values to detect explicit clears for null-as-delete semantics
@@ -396,7 +411,7 @@ export function SettingsModal({
const [favoriteModels, setFavoriteModels] = useState<string[]>([]);
// Test notification state
const [testNotificationLoading, setTestNotificationLoading] = useState(false);
const [testNotificationLoading, setTestNotificationLoading] = useState<Record<string, boolean>>({});
const [editingPresetId, setEditingPresetId] = useState<string | null>(null);
const [presetDraft, setPresetDraft] = useState<ModelPreset | null>(null);
@@ -853,31 +868,52 @@ export function SettingsModal({
}
}, [addToast, loadAuthStatus]);
const handleTestNotification = useCallback(async () => {
// Validate ntfy is enabled and topic is valid
if (!form.ntfyEnabled || !form.ntfyTopic || !/^[a-zA-Z0-9_-]{1,64}$/.test(form.ntfyTopic)) {
return;
const handleTestProviderNotification = useCallback(async (providerId: "ntfy" | "webhook") => {
if (providerId === "ntfy") {
if (!form.ntfyEnabled || !form.ntfyTopic || !/^[a-zA-Z0-9_-]{1,64}$/.test(form.ntfyTopic)) {
return;
}
}
setTestNotificationLoading(true);
if (providerId === "webhook") {
if (!form.webhookEnabled || !form.webhookUrl?.trim()) {
return;
}
try {
const parsed = new URL(form.webhookUrl.trim());
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
return;
}
} catch {
return;
}
}
setTestNotificationLoading((prev) => ({ ...prev, [providerId]: true }));
try {
const ntfyBaseUrl = form.ntfyBaseUrl?.trim();
const result = await testNtfyNotification({
ntfyEnabled: form.ntfyEnabled,
ntfyTopic: form.ntfyTopic,
...(ntfyBaseUrl ? { ntfyBaseUrl } : {}),
}, projectId);
const config = providerId === "ntfy"
? {
ntfyEnabled: form.ntfyEnabled,
ntfyTopic: form.ntfyTopic,
...(form.ntfyBaseUrl?.trim() ? { ntfyBaseUrl: form.ntfyBaseUrl.trim() } : {}),
}
: {
webhookUrl: form.webhookUrl,
webhookFormat: form.webhookFormat || "generic",
};
const result = await testNotification(providerId, config, projectId);
if (result.success) {
addToast("Test notification sent — check your ntfy app!", "success");
const providerName = providerId === "ntfy" ? "ntfy app" : "webhook endpoint";
addToast(`Test notification sent — check your ${providerName}!`, "success");
} else {
addToast("Failed to send test notification", "error");
}
} catch (err) {
addToast(getErrorMessage(err) || "Failed to send test notification", "error");
} finally {
setTestNotificationLoading(false);
setTestNotificationLoading((prev) => ({ ...prev, [providerId]: false }));
}
}, [addToast, form.ntfyBaseUrl, form.ntfyEnabled, form.ntfyTopic, projectId]);
}, [addToast, form.ntfyBaseUrl, form.ntfyEnabled, form.ntfyTopic, form.webhookEnabled, form.webhookFormat, form.webhookUrl, projectId]);
const handleBackupNow = useCallback(async () => {
setBackupLoading(true);
@@ -3534,222 +3570,225 @@ export function SettingsModal({
<>
{renderScopeBanner()}
<h4 className="settings-section-heading">Notifications</h4>
<div className="form-group">
<label htmlFor="ntfyEnabled" className="checkbox-label">
<input
id="ntfyEnabled"
type="checkbox"
checked={form.ntfyEnabled || false}
onChange={(e) =>
setForm((f) => ({ ...f, ntfyEnabled: e.target.checked }))
}
/>
Enable ntfy.sh notifications
</label>
<small>Receive push notifications when tasks complete or fail via ntfy.sh</small>
</div>
{form.ntfyEnabled && (
<>
<div className="form-group">
<label htmlFor="ntfyTopic">ntfy Topic</label>
<input
id="ntfyTopic"
type="text"
placeholder="my-topic-name"
value={form.ntfyTopic || ""}
onChange={(e) => {
const val = e.target.value;
setForm((f) => ({ ...f, ntfyTopic: val || undefined }));
}}
/>
<small>
Your ntfy.sh topic name (1–64 alphanumeric/hyphen/underscore characters).{" "}
<a
href="https://ntfy.sh"
target="_blank"
rel="noopener noreferrer"
className="settings-inline-link"
>
Learn more about ntfy.sh
</a>
</small>
{form.ntfyTopic && !/^[a-zA-Z0-9_-]{1,64}$/.test(form.ntfyTopic) && (
<small className="field-error">
Topic must be 1–64 alphanumeric, hyphen, or underscore characters
</small>
)}
<details className="ntfy-advanced-disclosure">
<summary>Advanced</summary>
<div className="ntfy-advanced-content">
<label htmlFor="ntfyBaseUrl">Custom ntfy server URL (optional)</label>
<div className="notification-provider-card">
<div className="notification-provider-header">
<strong>ntfy</strong>
<label htmlFor="ntfyEnabled" className="checkbox-label">
<input
id="ntfyEnabled"
type="checkbox"
checked={form.ntfyEnabled || false}
onChange={(e) =>
setForm((f) => ({ ...f, ntfyEnabled: e.target.checked }))
}
/>
Enable
</label>
</div>
{form.ntfyEnabled && (
<div className="notification-provider-body">
<div className="form-group">
<label htmlFor="ntfyTopic">ntfy Topic</label>
<input
id="ntfyBaseUrl"
type="url"
placeholder="https://ntfy.sh"
value={form.ntfyBaseUrl || ""}
id="ntfyTopic"
type="text"
placeholder="my-topic-name"
value={form.ntfyTopic || ""}
onChange={(e) => {
const value = e.target.value;
setForm((f) => ({ ...f, ntfyBaseUrl: value || undefined }));
const val = e.target.value;
setForm((f) => ({ ...f, ntfyTopic: val || undefined }));
}}
/>
<small>
Leave blank to keep the default server: https://ntfy.sh. Custom servers must use http:// or https://.
Your ntfy.sh topic name (1–64 alphanumeric/hyphen/underscore characters).{" "}
<a
href="https://ntfy.sh"
target="_blank"
rel="noopener noreferrer"
className="settings-inline-link"
>
Learn more about ntfy.sh
</a>
</small>
{form.ntfyTopic && !/^[a-zA-Z0-9_-]{1,64}$/.test(form.ntfyTopic) && (
<small className="field-error">
Topic must be 1–64 alphanumeric, hyphen, or underscore characters
</small>
)}
<details className="ntfy-advanced-disclosure">
<summary>Advanced</summary>
<div className="ntfy-advanced-content">
<label htmlFor="ntfyBaseUrl">Custom ntfy server URL (optional)</label>
<input
id="ntfyBaseUrl"
type="url"
placeholder="https://ntfy.sh"
value={form.ntfyBaseUrl || ""}
onChange={(e) => {
const value = e.target.value;
setForm((f) => ({ ...f, ntfyBaseUrl: value || undefined }));
}}
/>
<small>
Leave blank to keep the default server: https://ntfy.sh. Custom servers must use http:// or https://.
</small>
</div>
</details>
</div>
</details>
<button
type="button"
className="btn btn-sm"
onClick={handleTestNotification}
disabled={
testNotificationLoading ||
!form.ntfyTopic ||
!/^[a-zA-Z0-9_-]{1,64}$/.test(form.ntfyTopic)
}
>
{testNotificationLoading ? "Sending…" : "Test notification"}
</button>
</div>
<div className="form-group">
<label>Notify on events</label>
<div className="ntfy-events-list">
<label className="checkbox-label">
<div className="form-group">
<label>Notify on events</label>
<div className="ntfy-events-list">
{NOTIFICATION_EVENT_OPTIONS.map(({ event, label, description }) => {
const checked = form.ntfyEvents?.includes(event) ?? true;
return (
<div key={`ntfy-${event}`}>
<label className="checkbox-label">
<input
type="checkbox"
checked={checked}
onChange={(e) => {
const current = form.ntfyEvents ?? [...DEFAULT_NTFY_EVENTS];
const newEvents = e.target.checked
? (current.includes(event) ? current : [...current, event])
: current.filter((ev): ev is NtfyNotificationEvent => ev !== event);
setForm((f) => ({ ...f, ntfyEvents: newEvents.length > 0 ? newEvents : undefined }));
}}
/>
{label}
</label>
<small>{description}</small>
</div>
);
})}
</div>
</div>
<div className="form-group">
<label htmlFor="ntfyDashboardHost">Dashboard Hostname</label>
<input
type="checkbox"
checked={form.ntfyEvents?.includes("in-review") ?? true}
id="ntfyDashboardHost"
type="text"
placeholder="http://localhost:3000"
value={form.ntfyDashboardHost || ""}
onChange={(e) => {
const current = form.ntfyEvents ?? [...DEFAULT_NTFY_EVENTS];
const newEvents = e.target.checked
? (current.includes("in-review") ? current : [...current, "in-review" as NtfyNotificationEvent])
: current.filter((ev): ev is NtfyNotificationEvent => ev !== "in-review");
setForm((f) => ({ ...f, ntfyEvents: newEvents.length > 0 ? newEvents : undefined }));
const val = e.target.value;
setForm((f) => ({ ...f, ntfyDashboardHost: val || undefined }));
}}
/>
Task completed (in-review)
</label>
<small>When a task moves to In Review (ready for review)</small>
<label className="checkbox-label">
<input
type="checkbox"
checked={form.ntfyEvents?.includes("merged") ?? true}
onChange={(e) => {
const current = form.ntfyEvents ?? [...DEFAULT_NTFY_EVENTS];
const newEvents = e.target.checked
? (current.includes("merged") ? current : [...current, "merged" as NtfyNotificationEvent])
: current.filter((ev): ev is NtfyNotificationEvent => ev !== "merged");
setForm((f) => ({ ...f, ntfyEvents: newEvents.length > 0 ? newEvents : undefined }));
}}
/>
Task merged
</label>
<small>When a task is successfully merged to main</small>
<label className="checkbox-label">
<input
type="checkbox"
checked={form.ntfyEvents?.includes("failed") ?? true}
onChange={(e) => {
const current = form.ntfyEvents ?? [...DEFAULT_NTFY_EVENTS];
const newEvents = e.target.checked
? (current.includes("failed") ? current : [...current, "failed" as NtfyNotificationEvent])
: current.filter((ev): ev is NtfyNotificationEvent => ev !== "failed");
setForm((f) => ({ ...f, ntfyEvents: newEvents.length > 0 ? newEvents : undefined }));
}}
/>
Task failed
</label>
<small>When a task fails during execution (high priority)</small>
<label className="checkbox-label">
<input
type="checkbox"
checked={form.ntfyEvents?.includes("awaiting-approval") ?? true}
onChange={(e) => {
const current = form.ntfyEvents ?? [...DEFAULT_NTFY_EVENTS];
const newEvents = e.target.checked
? (current.includes("awaiting-approval") ? current : [...current, "awaiting-approval" as NtfyNotificationEvent])
: current.filter((ev): ev is NtfyNotificationEvent => ev !== "awaiting-approval");
setForm((f) => ({ ...f, ntfyEvents: newEvents.length > 0 ? newEvents : undefined }));
}}
/>
Plan needs approval
</label>
<small>When a task specification needs manual approval before execution</small>
<label className="checkbox-label">
<input
type="checkbox"
checked={form.ntfyEvents?.includes("awaiting-user-review") ?? true}
onChange={(e) => {
const current = form.ntfyEvents ?? [...DEFAULT_NTFY_EVENTS];
const newEvents = e.target.checked
? (current.includes("awaiting-user-review") ? current : [...current, "awaiting-user-review" as NtfyNotificationEvent])
: current.filter((ev): ev is NtfyNotificationEvent => ev !== "awaiting-user-review");
setForm((f) => ({ ...f, ntfyEvents: newEvents.length > 0 ? newEvents : undefined }));
}}
/>
User review needed
</label>
<small>When an agent hands off a task for human review (high priority)</small>
<label className="checkbox-label">
<input
type="checkbox"
checked={form.ntfyEvents?.includes("planning-awaiting-input") ?? true}
onChange={(e) => {
const current = form.ntfyEvents ?? [...DEFAULT_NTFY_EVENTS];
const newEvents = e.target.checked
? (current.includes("planning-awaiting-input") ? current : [...current, "planning-awaiting-input" as NtfyNotificationEvent])
: current.filter((ev): ev is NtfyNotificationEvent => ev !== "planning-awaiting-input");
setForm((f) => ({ ...f, ntfyEvents: newEvents.length > 0 ? newEvents : undefined }));
}}
/>
Planning needs input
</label>
<small>When planning mode is waiting for your response to continue</small>
<label className="checkbox-label">
<input
type="checkbox"
checked={form.ntfyEvents?.includes("gridlock") ?? true}
onChange={(e) => {
const current = form.ntfyEvents ?? [...DEFAULT_NTFY_EVENTS];
const newEvents = e.target.checked
? (current.includes("gridlock") ? current : [...current, "gridlock" as NtfyNotificationEvent])
: current.filter((ev): ev is NtfyNotificationEvent => ev !== "gridlock");
setForm((f) => ({ ...f, ntfyEvents: newEvents.length > 0 ? newEvents : undefined }));
}}
/>
Pipeline gridlocked
</label>
<small>When all schedulable todo tasks are blocked and work cannot advance</small>
<small>
Base URL for deep links in notifications. When set, clicking a notification
opens the dashboard directly to the task.
</small>
{form.ntfyDashboardHost && !/^https?:\/\/.+/.test(form.ntfyDashboardHost) && (
<small className="field-error">
Must be a valid URL starting with http:// or https://
</small>
)}
</div>
<div className="notification-provider-actions">
<button
type="button"
className="btn btn-sm"
onClick={() => handleTestProviderNotification("ntfy")}
disabled={
testNotificationLoading["ntfy"] ||
!form.ntfyTopic ||
!/^[a-zA-Z0-9_-]{1,64}$/.test(form.ntfyTopic)
}
>
{testNotificationLoading["ntfy"] ? "Sending…" : "Test notification"}
</button>
</div>
</div>
)}
</div>
<div className="notification-provider-card">
<div className="notification-provider-header">
<strong>Webhook</strong>
<label htmlFor="webhookEnabled" className="checkbox-label">
<input
id="webhookEnabled"
type="checkbox"
checked={form.webhookEnabled || false}
onChange={(e) =>
setForm((f) => ({ ...f, webhookEnabled: e.target.checked }))
}
/>
Webhook notifications
</label>
</div>
<div className="form-group">
<label htmlFor="ntfyDashboardHost">Dashboard Hostname</label>
<input
id="ntfyDashboardHost"
type="text"
placeholder="http://localhost:3000"
value={form.ntfyDashboardHost || ""}
onChange={(e) => {
const val = e.target.value;
setForm((f) => ({ ...f, ntfyDashboardHost: val || undefined }));
}}
/>
<small>
Base URL for deep links in notifications. When set, clicking a notification
opens the dashboard directly to the task.
</small>
{form.ntfyDashboardHost && !/^https?:\/\/.+/.test(form.ntfyDashboardHost) && (
<small className="field-error">
Must be a valid URL starting with http:// or https://
</small>
)}
</div>
</>
)}
{form.webhookEnabled && (
<div className="notification-provider-body">
<div className="form-group">
<label htmlFor="webhookUrl">Webhook URL</label>
<input
id="webhookUrl"
type="text"
placeholder="https://hooks.example.com/..."
value={form.webhookUrl || ""}
onChange={(e) => {
const val = e.target.value;
setForm((f) => ({ ...f, webhookUrl: val || undefined }));
}}
/>
</div>
<div className="form-group">
<label htmlFor="webhookFormat">Format</label>
<select
id="webhookFormat"
value={form.webhookFormat || "generic"}
onChange={(e) => {
const val = e.target.value as "slack" | "discord" | "generic";
setForm((f) => ({ ...f, webhookFormat: val }));
}}
>
<option value="slack">Slack</option>
<option value="discord">Discord</option>
<option value="generic">Generic</option>
</select>
</div>
<div className="form-group">
<label>Notify on events</label>
<div className="ntfy-events-list">
{NOTIFICATION_EVENT_OPTIONS.map(({ event, label, description }) => {
const currentEvents = form.webhookEvents ?? [...DEFAULT_NTFY_EVENTS];
const checked = currentEvents.includes(event);
return (
<div key={`webhook-${event}`}>
<label className="checkbox-label">
<input
type="checkbox"
checked={checked}
onChange={(e) => {
const current = form.webhookEvents ?? [...DEFAULT_NTFY_EVENTS];
const newEvents = e.target.checked
? (current.includes(event) ? current : [...current, event])
: current.filter((ev) => ev !== event);
setForm((f) => ({ ...f, webhookEvents: newEvents.length > 0 ? newEvents : undefined }));
}}
/>
{label}
</label>
<small>{description}</small>
</div>
);
})}
</div>
</div>
<div className="notification-provider-actions">
<button
type="button"
className="btn btn-sm"
onClick={() => handleTestProviderNotification("webhook")}
disabled={testNotificationLoading["webhook"] || !form.webhookUrl}
>
{testNotificationLoading["webhook"] ? "Sending…" : "Test notification"}
</button>
</div>
</div>
)}
</div>
</>
);
case "node-sync":

View File

@@ -1702,6 +1702,31 @@ export function TaskDetailModal({
</div>
)}
<MergeDetails task={task} />
<div className="detail-section">
<h4>Node Routing</h4>
<dl className="detail-source-grid">
<div>
<dt>Task Override</dt>
<dd>{task.nodeId ?? <span className="detail-source-empty">(none)</span>}</dd>
</div>
<div>
<dt>Effective Node</dt>
<dd>{(task as Task & { effectiveNodeId?: string }).effectiveNodeId ?? "local execution"}</dd>
</div>
<div>
<dt>Routing Source</dt>
<dd>{(task as Task & { effectiveNodeSource?: string }).effectiveNodeSource ?? "local"}</dd>
</div>
<div>
<dt>Unavailable Node Policy</dt>
<dd>{(settings as Settings & { unavailableNodePolicy?: string } | undefined)?.unavailableNodePolicy ?? "block"}</dd>
</div>
<div>
<dt>Blocking Reason</dt>
<dd>{((task as Task & { blockedReason?: string; statusReason?: string }).blockedReason || (task as Task & { statusReason?: string }).statusReason) ?? <span className="detail-source-empty">(not blocked)</span>}</dd>
</div>
</dl>
</div>
{task.sourceIssue && (
<div className="detail-section detail-source-section">
<h4>Source Issue</h4>

View File

@@ -15,6 +15,7 @@ const mockLoginProvider = vi.fn();
const mockLogoutProvider = vi.fn();
const mockFetchModels = vi.fn();
const mockTestNtfyNotification = vi.fn();
const mockTestNotification = vi.fn();
const mockFetchBackups = vi.fn();
const mockCreateBackup = vi.fn();
const mockImportSettings = vi.fn();
@@ -54,6 +55,7 @@ vi.mock("../../api", () => ({
logoutProvider: (...args: unknown[]) => mockLogoutProvider(...args),
fetchModels: (...args: unknown[]) => mockFetchModels(...args),
testNtfyNotification: (...args: unknown[]) => mockTestNtfyNotification(...args),
testNotification: (...args: unknown[]) => mockTestNotification(...args),
fetchBackups: (...args: unknown[]) => mockFetchBackups(...args),
createBackup: (...args: unknown[]) => mockCreateBackup(...args),
fetchMemoryFiles: (...args: unknown[]) => mockFetchMemoryFiles(...args),
@@ -141,6 +143,10 @@ const defaultSettings = {
worktreeInitCommand: "",
ntfyEnabled: false,
ntfyTopic: undefined,
webhookEnabled: false,
webhookUrl: undefined,
webhookFormat: undefined,
webhookEvents: undefined,
};
function renderModal(props = {}) {
@@ -167,6 +173,7 @@ describe("SettingsModal", () => {
mockFetchSettingsByScope.mockResolvedValue({ global: defaultSettings, project: {} });
mockFetchAuthStatus.mockResolvedValue({ providers: [] });
mockFetchModels.mockResolvedValue({ models: [], favoriteProviders: [], favoriteModels: [] });
mockTestNotification.mockResolvedValue({ success: true });
mockFetchBackups.mockResolvedValue({ backups: [], totalSize: 0 });
mockFetchMemoryFiles.mockResolvedValue({
files: [
@@ -1757,54 +1764,112 @@ describe("SettingsModal", () => {
});
describe("memory dream trigger", () => {
const openMemorySection = async () => {
const [memorySectionButton] = await screen.findAllByRole("button", { name: /^Memory$/i });
await userEvent.click(memorySectionButton);
describe("Notifications provider cards", () => {
const openNotificationsSection = async () => {
await userEvent.click(await screen.findByRole("button", { name: /Notifications/ }));
};
it("shows Dream Now button when dreams are enabled", async () => {
mockFetchSettings.mockResolvedValueOnce({
...defaultSettings,
memoryEnabled: true,
memoryDreamsEnabled: true,
memoryDreamsSchedule: "0 4 * * *",
});
it("shows ntfy and webhook provider cards in notifications section", async () => {
renderModal();
await waitForSettingsModalReady();
await openMemorySection();
await openNotificationsSection();
expect(await screen.findByRole("button", { name: "Dream Now" })).toBeInTheDocument();
expect(screen.getByText("ntfy")).toBeInTheDocument();
expect(screen.getByText("Webhook")).toBeInTheDocument();
});
it("triggers dream processing from Dream Now button", async () => {
const addToast = vi.fn();
mockFetchSettings.mockResolvedValueOnce({
...defaultSettings,
memoryEnabled: true,
memoryDreamsEnabled: true,
});
mockTriggerMemoryDreams.mockResolvedValueOnce({ success: true, summary: "done" });
renderModal({ addToast });
it("shows ntfy fields when ntfy provider is enabled", async () => {
mockFetchSettings.mockResolvedValueOnce({ ...defaultSettings, ntfyEnabled: true, ntfyTopic: "test-topic" });
renderModal();
await waitForSettingsModalReady();
await openMemorySection();
await openNotificationsSection();
await userEvent.click(await screen.findByRole("button", { name: "Dream Now" }));
expect(screen.getByLabelText("ntfy Topic")).toBeInTheDocument();
expect(screen.getByLabelText("Dashboard Hostname")).toBeInTheDocument();
expect(screen.getByText("Notify on events")).toBeInTheDocument();
expect(screen.getByRole("button", { name: /Test notification/ })).toBeInTheDocument();
});
it("shows webhook fields when webhook provider is enabled", async () => {
renderModal();
await waitForSettingsModalReady();
await openNotificationsSection();
await userEvent.click(screen.getByLabelText("Webhook notifications"));
expect(screen.getByLabelText("Webhook URL")).toBeInTheDocument();
expect(screen.getByLabelText("Format")).toBeInTheDocument();
expect(screen.getByRole("button", { name: /Test notification/ })).toBeInTheDocument();
});
it("hides ntfy body when ntfy is disabled", async () => {
renderModal();
await waitForSettingsModalReady();
await openNotificationsSection();
expect(screen.queryByLabelText("ntfy Topic")).not.toBeInTheDocument();
});
it("hides webhook body when webhook is disabled", async () => {
renderModal();
await waitForSettingsModalReady();
await openNotificationsSection();
expect(screen.queryByLabelText("Webhook URL")).not.toBeInTheDocument();
});
it("calls testNotification with ntfy provider ID when ntfy test button clicked", async () => {
mockFetchSettings.mockResolvedValueOnce({ ...defaultSettings, ntfyEnabled: true, ntfyTopic: "test-topic" });
renderModal();
await waitForSettingsModalReady();
await openNotificationsSection();
await userEvent.click(screen.getByRole("button", { name: /Test notification/ }));
await waitFor(() => {
expect(mockTriggerMemoryDreams).toHaveBeenCalledWith(undefined);
expect(mockTestNotification).toHaveBeenCalledWith(
"ntfy",
expect.objectContaining({ ntfyEnabled: true, ntfyTopic: "test-topic" }),
undefined,
);
});
expect(addToast).toHaveBeenCalledWith("Dream processing completed", "success");
});
it("hides Dream Now button when dreams are disabled", async () => {
it("calls testNotification with webhook provider ID when webhook test button clicked", async () => {
renderModal();
await waitForSettingsModalReady();
await openMemorySection();
await openNotificationsSection();
await userEvent.click(screen.getByLabelText("Webhook notifications"));
await userEvent.type(screen.getByLabelText("Webhook URL"), "https://hooks.example.com/test");
expect(screen.queryByRole("button", { name: "Dream Now" })).not.toBeInTheDocument();
const webhookCard = screen.getByText("Webhook").closest(".notification-provider-card") as HTMLElement;
await userEvent.click(within(webhookCard).getByRole("button", { name: /Test notification/ }));
await waitFor(() => {
expect(mockTestNotification).toHaveBeenCalledWith(
"webhook",
expect.objectContaining({ webhookUrl: "https://hooks.example.com/test" }),
undefined,
);
});
});
it("preserves existing ntfy settings in backward compat", async () => {
mockFetchSettings.mockResolvedValueOnce({
...defaultSettings,
ntfyEnabled: true,
ntfyTopic: "my-existing-topic",
ntfyEvents: ["in-review", "failed"],
});
renderModal();
await waitForSettingsModalReady();
await openNotificationsSection();
expect(screen.getByLabelText("ntfy Topic")).toHaveValue("my-existing-topic");
const inReview = screen.getByLabelText("Task completed (in-review)") as HTMLInputElement;
const failed = screen.getByLabelText("Task failed") as HTMLInputElement;
const merged = screen.getByLabelText("Task merged") as HTMLInputElement;
expect(inReview.checked).toBe(true);
expect(failed.checked).toBe(true);
expect(merged.checked).toBe(false);
});
});

View File

@@ -29,6 +29,10 @@ const defaultSettings = {
ntfyEnabled: false,
ntfyTopic: undefined,
ntfyEvents: ["in-review", "merged", "failed", "awaiting-approval", "awaiting-user-review"],
webhookEnabled: false,
webhookUrl: undefined,
webhookFormat: undefined,
webhookEvents: undefined,
taskStuckTimeoutMs: undefined,
maxStuckKills: 6,
runStepsInNewSessions: false,
@@ -47,6 +51,7 @@ vi.mock("../../api", () => ({
clearApiKey: vi.fn(() => Promise.resolve({ success: true })),
fetchModels: vi.fn(() => Promise.resolve({ models: [], favoriteProviders: [], favoriteModels: [] })),
testNtfyNotification: vi.fn(() => Promise.resolve({ success: true })),
testNotification: vi.fn(() => Promise.resolve({ success: true })),
fetchBackups: vi.fn(() => Promise.resolve({ count: 0, totalSize: 0, backups: [] })),
createBackup: vi.fn(() => Promise.resolve({ success: true })),
exportSettings: vi.fn(() => Promise.resolve({ version: 1, exportedAt: new Date().toISOString(), global: undefined, project: {} })),
@@ -246,6 +251,20 @@ describe("SettingsModal mobile adaptations", () => {
expect(getByText("These settings are shared across all your Fusion projects.")).toBeTruthy();
});
it("renders notification provider cards responsively on mobile", async () => {
mockSettingsViewport(true);
Object.defineProperty(window, "innerWidth", { configurable: true, value: 375 });
const user = userEvent.setup();
const { getByLabelText, findByText, container } = render(<SettingsModal onClose={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
await user.selectOptions(getByLabelText("Settings Section"), "notifications");
expect(await findByText("ntfy")).toBeTruthy();
expect(await findByText("Webhook")).toBeTruthy();
expect(container.querySelectorAll(".notification-provider-card").length).toBeGreaterThan(1);
});
it("contains required mobile settings CSS overrides", () => {
const css = loadAllAppCss();
@@ -269,6 +288,8 @@ describe("SettingsModal mobile adaptations", () => {
expectMobileRule(css, ".auth-apikey-section", "align-items: flex-end;");
expectMobileRule(css, ".auth-apikey-input-row", "justify-content: flex-end;");
expectMobileRule(css, ".auth-apikey-input-row .btn", "margin-left: auto;");
expectMobileRule(css, ".notification-provider-header", "padding: var(--space-sm) var(--space-md);");
expectMobileRule(css, ".notification-provider-body", "padding: var(--space-md);");
});
it("styles settings scrollbar rules for sidebar and content", () => {