mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-06-15 20:05:16 +02:00
Merge branch 'dev' into slack3
This commit is contained in:
commit
3e2ffa9eb0
12 changed files with 330 additions and 5 deletions
|
|
@ -42,6 +42,7 @@ import { knowledgeSourcesRepo } from '@x/core/dist/knowledge/sources/repo.js';
|
||||||
import { rankSlackHomeMessages } from '@x/core/dist/knowledge/sources/rank_slack_home.js';
|
import { rankSlackHomeMessages } from '@x/core/dist/knowledge/sources/rank_slack_home.js';
|
||||||
import { syncSlackKnowledgeSources, triggerSync as triggerSlackKnowledgeSync } from '@x/core/dist/knowledge/sources/sync_slack.js';
|
import { syncSlackKnowledgeSources, triggerSync as triggerSlackKnowledgeSync } from '@x/core/dist/knowledge/sources/sync_slack.js';
|
||||||
import { isOnboardingComplete, markOnboardingComplete } from '@x/core/dist/config/note_creation_config.js';
|
import { isOnboardingComplete, markOnboardingComplete } from '@x/core/dist/config/note_creation_config.js';
|
||||||
|
import { loadNotificationSettings, saveNotificationSettings } from '@x/core/dist/config/notification_config.js';
|
||||||
import * as composioHandler from './composio-handler.js';
|
import * as composioHandler from './composio-handler.js';
|
||||||
import { consumePendingDeepLink } from './deeplink.js';
|
import { consumePendingDeepLink } from './deeplink.js';
|
||||||
import { qualifyAndDisconnectComposioGoogle } from '@x/core/dist/migrations/composio-google-migration.js';
|
import { qualifyAndDisconnectComposioGoogle } from '@x/core/dist/migrations/composio-google-migration.js';
|
||||||
|
|
@ -1365,6 +1366,13 @@ export function setupIpcHandlers() {
|
||||||
'billing:getInfo': async () => {
|
'billing:getInfo': async () => {
|
||||||
return await getBillingInfo();
|
return await getBillingInfo();
|
||||||
},
|
},
|
||||||
|
'notifications:getSettings': async () => {
|
||||||
|
return loadNotificationSettings();
|
||||||
|
},
|
||||||
|
'notifications:setSettings': async (_event, args) => {
|
||||||
|
saveNotificationSettings(args);
|
||||||
|
return { success: true };
|
||||||
|
},
|
||||||
// Embedded browser handlers (WebContentsView + navigation)
|
// Embedded browser handlers (WebContentsView + navigation)
|
||||||
...browserIpcHandlers,
|
...browserIpcHandlers,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,15 @@ export class ElectronNotificationService implements INotificationService {
|
||||||
return Notification.isSupported();
|
return Notification.isSupported();
|
||||||
}
|
}
|
||||||
|
|
||||||
notify({ title = "Rowboat", message, link, actionLabel, secondaryActions }: NotifyInput): void {
|
notify({ title = "Rowboat", message, link, actionLabel, secondaryActions, onlyWhenBackground }: NotifyInput): void {
|
||||||
|
// Ambient notifications are suppressed while the app is in the
|
||||||
|
// foreground — the user is already looking at it. A window counts as
|
||||||
|
// foreground only if it's actually focused (minimized / other-space
|
||||||
|
// windows are not), so this correctly treats those as background.
|
||||||
|
if (onlyWhenBackground && BrowserWindow.getAllWindows().some((w) => w.isFocused())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Build the actions array AND a parallel index → link map.
|
// Build the actions array AND a parallel index → link map.
|
||||||
// macOS shows actions[0] inline (Banner) or all of them (Alert);
|
// macOS shows actions[0] inline (Banner) or all of them (Alert);
|
||||||
// additional ones live behind the chevron menu.
|
// additional ones live behind the chevron menu.
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { useState, useEffect, useCallback, useMemo } from "react"
|
import { useState, useEffect, useCallback, useMemo } from "react"
|
||||||
import { Server, Key, Shield, Palette, Monitor, Sun, Moon, Loader2, CheckCircle2, Plus, X, Wrench, Search, ChevronRight, Link2, Tags, Mail, BookOpen, User, Plug, HelpCircle, MessageCircle, Bug, Terminal, AlertTriangle, RefreshCw, PanelRight } from "lucide-react"
|
import { Server, Key, Shield, Palette, Monitor, Sun, Moon, Loader2, CheckCircle2, Plus, X, Wrench, Search, ChevronRight, Link2, Tags, Mail, BookOpen, User, Plug, HelpCircle, MessageCircle, Bug, Terminal, AlertTriangle, RefreshCw, PanelRight, Bell } from "lucide-react"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
|
|
@ -27,7 +27,7 @@ import { AccountSettings } from "@/components/settings/account-settings"
|
||||||
import { ConnectedAccountsSettings } from "@/components/settings/connected-accounts-settings"
|
import { ConnectedAccountsSettings } from "@/components/settings/connected-accounts-settings"
|
||||||
import type { ApprovalPolicy } from "@x/shared/src/code-mode.js"
|
import type { ApprovalPolicy } from "@x/shared/src/code-mode.js"
|
||||||
|
|
||||||
type ConfigTab = "account" | "connections" | "models" | "mcp" | "security" | "code-mode" | "appearance" | "note-tagging" | "help"
|
type ConfigTab = "account" | "connections" | "models" | "mcp" | "security" | "code-mode" | "appearance" | "notifications" | "note-tagging" | "help"
|
||||||
|
|
||||||
interface TabConfig {
|
interface TabConfig {
|
||||||
id: ConfigTab
|
id: ConfigTab
|
||||||
|
|
@ -83,6 +83,12 @@ const tabs: TabConfig[] = [
|
||||||
icon: Palette,
|
icon: Palette,
|
||||||
description: "Customize the look and feel",
|
description: "Customize the look and feel",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "notifications",
|
||||||
|
label: "Notifications",
|
||||||
|
icon: Bell,
|
||||||
|
description: "Choose which notifications you receive",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "note-tagging",
|
id: "note-tagging",
|
||||||
label: "Note Tagging",
|
label: "Note Tagging",
|
||||||
|
|
@ -1987,6 +1993,99 @@ function CodeModeSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Notification Settings ---
|
||||||
|
|
||||||
|
type NotificationCategoryKey = "chat_completion" | "new_email" | "agent_permission"
|
||||||
|
|
||||||
|
const NOTIFICATION_CATEGORIES: { key: NotificationCategoryKey; label: string; description: string }[] = [
|
||||||
|
{
|
||||||
|
key: "chat_completion",
|
||||||
|
label: "Chat responses",
|
||||||
|
description: "When an agent finishes responding while the app is in the background.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "new_email",
|
||||||
|
label: "New email",
|
||||||
|
description: "When a new email arrives during sync while the app is in the background.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "agent_permission",
|
||||||
|
label: "Permission requests",
|
||||||
|
description: "When an agent needs your approval to run a tool. Always shown, even when the app is focused.",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
function NotificationSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
||||||
|
const [categories, setCategories] = useState<Record<NotificationCategoryKey, boolean> | null>(null)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!dialogOpen) return
|
||||||
|
let cancelled = false
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
const result = await window.ipc.invoke("notifications:getSettings", null)
|
||||||
|
if (!cancelled) setCategories(result.categories)
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) toast.error("Failed to load notification settings")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
load()
|
||||||
|
return () => { cancelled = true }
|
||||||
|
}, [dialogOpen])
|
||||||
|
|
||||||
|
const handleToggle = useCallback(async (key: NotificationCategoryKey, next: boolean) => {
|
||||||
|
// Optimistic update with rollback on failure.
|
||||||
|
const previous = categories
|
||||||
|
if (!previous) return
|
||||||
|
const updated = { ...previous, [key]: next }
|
||||||
|
setCategories(updated)
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
await window.ipc.invoke("notifications:setSettings", { categories: updated })
|
||||||
|
} catch {
|
||||||
|
setCategories(previous)
|
||||||
|
toast.error("Failed to update notification settings")
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}, [categories])
|
||||||
|
|
||||||
|
if (!categories) {
|
||||||
|
return (
|
||||||
|
<div className="h-full flex items-center justify-center text-muted-foreground text-sm">
|
||||||
|
<Loader2 className="size-4 animate-spin mr-2" />
|
||||||
|
Loading...
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div className="text-sm text-muted-foreground leading-relaxed">
|
||||||
|
Choose which desktop notifications Rowboat sends you. Ambient notifications are only shown
|
||||||
|
when the app is in the background.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{NOTIFICATION_CATEGORIES.map((cat) => (
|
||||||
|
<div key={cat.key} className="rounded-md border px-3 py-3 flex items-start gap-3">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-sm font-medium">{cat.label}</div>
|
||||||
|
<div className="text-xs text-muted-foreground mt-0.5">{cat.description}</div>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={categories[cat.key]}
|
||||||
|
onCheckedChange={(next) => handleToggle(cat.key, next)}
|
||||||
|
disabled={saving}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// --- Main Settings Dialog ---
|
// --- Main Settings Dialog ---
|
||||||
|
|
||||||
export function SettingsDialog({ children, defaultTab = "account", open: controlledOpen, onOpenChange }: SettingsDialogProps) {
|
export function SettingsDialog({ children, defaultTab = "account", open: controlledOpen, onOpenChange }: SettingsDialogProps) {
|
||||||
|
|
@ -2034,7 +2133,7 @@ export function SettingsDialog({ children, defaultTab = "account", open: control
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadConfig = useCallback(async (tab: ConfigTab) => {
|
const loadConfig = useCallback(async (tab: ConfigTab) => {
|
||||||
if (tab === "appearance" || tab === "models" || tab === "note-tagging" || tab === "account" || tab === "connections" || tab === "help" || tab === "code-mode") return
|
if (tab === "appearance" || tab === "models" || tab === "note-tagging" || tab === "account" || tab === "connections" || tab === "help" || tab === "code-mode" || tab === "notifications") return
|
||||||
const tabConfig = tabs.find((t) => t.id === tab)!
|
const tabConfig = tabs.find((t) => t.id === tab)!
|
||||||
if (!tabConfig.path) return
|
if (!tabConfig.path) return
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
|
|
@ -2142,7 +2241,7 @@ export function SettingsDialog({ children, defaultTab = "account", open: control
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className={cn("flex-1 p-4 min-h-0", (activeTab === "models" || activeTab === "connections" || activeTab === "account" || activeTab === "code-mode") ? "overflow-y-auto" : activeTab === "note-tagging" ? "overflow-hidden flex flex-col" : "overflow-hidden")}>
|
<div className={cn("flex-1 p-4 min-h-0", (activeTab === "models" || activeTab === "connections" || activeTab === "account" || activeTab === "code-mode" || activeTab === "notifications") ? "overflow-y-auto" : activeTab === "note-tagging" ? "overflow-hidden flex flex-col" : "overflow-hidden")}>
|
||||||
{activeTab === "account" ? (
|
{activeTab === "account" ? (
|
||||||
<AccountSettings dialogOpen={open} />
|
<AccountSettings dialogOpen={open} />
|
||||||
) : activeTab === "connections" ? (
|
) : activeTab === "connections" ? (
|
||||||
|
|
@ -2165,6 +2264,8 @@ export function SettingsDialog({ children, defaultTab = "account", open: control
|
||||||
<NoteTaggingSettings dialogOpen={open} />
|
<NoteTaggingSettings dialogOpen={open} />
|
||||||
) : activeTab === "appearance" ? (
|
) : activeTab === "appearance" ? (
|
||||||
<AppearanceSettings />
|
<AppearanceSettings />
|
||||||
|
) : activeTab === "notifications" ? (
|
||||||
|
<NotificationSettings dialogOpen={open} />
|
||||||
) : activeTab === "help" ? (
|
) : activeTab === "help" ? (
|
||||||
<HelpSettings />
|
<HelpSettings />
|
||||||
) : activeTab === "code-mode" ? (
|
) : activeTab === "code-mode" ? (
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import { isBlocked, extractCommandNames } from "../application/lib/command-execu
|
||||||
import { getFileAccessAllowList, type FileAccessGrant, type FileAccessOperation } from "../config/security.js";
|
import { getFileAccessAllowList, type FileAccessGrant, type FileAccessOperation } from "../config/security.js";
|
||||||
import { resolveFilePathForPermission } from "../filesystem/files.js";
|
import { resolveFilePathForPermission } from "../filesystem/files.js";
|
||||||
import container from "../di/container.js";
|
import container from "../di/container.js";
|
||||||
|
import { notifyIfEnabled } from "../application/notification/notifier.js";
|
||||||
import { IModelConfigRepo } from "../models/repo.js";
|
import { IModelConfigRepo } from "../models/repo.js";
|
||||||
import { createProvider } from "../models/models.js";
|
import { createProvider } from "../models/models.js";
|
||||||
import { resolveProviderConfig } from "../models/defaults.js";
|
import { resolveProviderConfig } from "../models/defaults.js";
|
||||||
|
|
@ -377,6 +378,7 @@ export class AgentRuntime implements IAgentRuntime {
|
||||||
type: "run-processing-start",
|
type: "run-processing-start",
|
||||||
subflow: [],
|
subflow: [],
|
||||||
});
|
});
|
||||||
|
let totalEvents = 0;
|
||||||
while (true) {
|
while (true) {
|
||||||
// Check for abort before each iteration
|
// Check for abort before each iteration
|
||||||
if (signal.aborted) {
|
if (signal.aborted) {
|
||||||
|
|
@ -417,6 +419,7 @@ export class AgentRuntime implements IAgentRuntime {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
totalEvents += eventCount;
|
||||||
// if no events, break
|
// if no events, break
|
||||||
if (!eventCount) {
|
if (!eventCount) {
|
||||||
break;
|
break;
|
||||||
|
|
@ -433,6 +436,27 @@ export class AgentRuntime implements IAgentRuntime {
|
||||||
};
|
};
|
||||||
await this.runsRepo.appendEvents(runId, [stoppedEvent]);
|
await this.runsRepo.appendEvents(runId, [stoppedEvent]);
|
||||||
await this.bus.publish(stoppedEvent);
|
await this.bus.publish(stoppedEvent);
|
||||||
|
} else if (totalEvents > 0) {
|
||||||
|
// The run reached a natural stopping point and actually did
|
||||||
|
// something this cycle. Notify "chat completion" — unless it
|
||||||
|
// paused on a permission request, which surfaces its own
|
||||||
|
// notification (distinguish by inspecting the final state).
|
||||||
|
const finalRun = await this.runsRepo.fetch(runId);
|
||||||
|
if (finalRun) {
|
||||||
|
const finalState = new AgentState();
|
||||||
|
for (const event of finalRun.log) {
|
||||||
|
finalState.ingest(event);
|
||||||
|
}
|
||||||
|
if (finalState.getPendingPermissions().length === 0) {
|
||||||
|
void notifyIfEnabled("chat_completion", {
|
||||||
|
title: "Response ready",
|
||||||
|
message: "Your agent finished responding.",
|
||||||
|
link: `rowboat://open?type=chat&runId=${runId}`,
|
||||||
|
actionLabel: "Open",
|
||||||
|
onlyWhenBackground: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Run ${runId} failed:`, error);
|
console.error(`Run ${runId} failed:`, error);
|
||||||
|
|
@ -1545,6 +1569,16 @@ If the user's message is clearly NOT a coding request (small talk, an unrelated
|
||||||
}
|
}
|
||||||
|
|
||||||
if (permissionCandidates.length > 0) {
|
if (permissionCandidates.length > 0) {
|
||||||
|
// Permission prompts block the run, so they surface even when the
|
||||||
|
// app is focused (no onlyWhenBackground gate).
|
||||||
|
const notifyPermissionPrompt = (toolCall: typeof permissionCandidates[number]["toolCall"]) => {
|
||||||
|
void notifyIfEnabled("agent_permission", {
|
||||||
|
title: "Permission needed",
|
||||||
|
message: `${agent.name} wants to run "${toolCall.toolName}". Review to continue.`,
|
||||||
|
link: `rowboat://open?type=chat&runId=${runId}`,
|
||||||
|
actionLabel: "Review",
|
||||||
|
});
|
||||||
|
};
|
||||||
if (state.permissionMode === "auto") {
|
if (state.permissionMode === "auto") {
|
||||||
let decisionsByToolCallId = new Map<string, { decision: "allow" | "deny"; reason: string }>();
|
let decisionsByToolCallId = new Map<string, { decision: "allow" | "deny"; reason: string }>();
|
||||||
try {
|
try {
|
||||||
|
|
@ -1578,6 +1612,7 @@ If the user's message is clearly NOT a coding request (small talk, an unrelated
|
||||||
permission: candidate.permission,
|
permission: candidate.permission,
|
||||||
subflow: [],
|
subflow: [],
|
||||||
});
|
});
|
||||||
|
notifyPermissionPrompt(candidate.toolCall);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1609,6 +1644,7 @@ If the user's message is clearly NOT a coding request (small talk, an unrelated
|
||||||
permission: candidate.permission,
|
permission: candidate.permission,
|
||||||
subflow: [],
|
subflow: [],
|
||||||
});
|
});
|
||||||
|
notifyPermissionPrompt(candidate.toolCall);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -1621,6 +1657,7 @@ If the user's message is clearly NOT a coding request (small talk, an unrelated
|
||||||
permission: candidate.permission,
|
permission: candidate.permission,
|
||||||
subflow: [],
|
subflow: [],
|
||||||
});
|
});
|
||||||
|
notifyPermissionPrompt(candidate.toolCall);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
import type { NotificationCategory } from '@x/shared/dist/notification-settings.js';
|
||||||
|
import { isNotificationCategoryEnabled } from '../../config/notification_config.js';
|
||||||
|
import type { INotificationService, NotifyInput } from './service.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fire a notification for `category`, but only if the user has that category
|
||||||
|
* enabled and the platform supports notifications.
|
||||||
|
*
|
||||||
|
* Resolution of the notification service is done via a *dynamic* import of the
|
||||||
|
* DI container so that callers like the agent runtime — which the container
|
||||||
|
* itself imports — don't create a circular module dependency. The whole thing
|
||||||
|
* is wrapped so a missing service (very early startup), an unsupported
|
||||||
|
* platform, or a config read error can never disrupt the run/sync that
|
||||||
|
* triggered it. Callers should fire-and-forget (`void notifyIfEnabled(...)`).
|
||||||
|
*/
|
||||||
|
export async function notifyIfEnabled(
|
||||||
|
category: NotificationCategory,
|
||||||
|
input: NotifyInput,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
if (!isNotificationCategoryEnabled(category)) return;
|
||||||
|
const { default: container } = await import('../../di/container.js');
|
||||||
|
const service = container.resolve<INotificationService>('notificationService');
|
||||||
|
if (!service.isSupported()) return;
|
||||||
|
service.notify(input);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[notifier] failed to notify (category=${category}):`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,14 @@ export interface NotifyInput {
|
||||||
link?: string;
|
link?: string;
|
||||||
actionLabel?: string;
|
actionLabel?: string;
|
||||||
secondaryActions?: Array<{ label: string; link: string }>;
|
secondaryActions?: Array<{ label: string; link: string }>;
|
||||||
|
/**
|
||||||
|
* When true, the notification is suppressed if the app is currently in the
|
||||||
|
* foreground (any window focused). Use for ambient notifications the user
|
||||||
|
* doesn't need while actively looking at the app (e.g. chat completion, new
|
||||||
|
* email). Leave unset/false for notifications that must always surface
|
||||||
|
* regardless of focus (e.g. an agent permission request that blocks a run).
|
||||||
|
*/
|
||||||
|
onlyWhenBackground?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface INotificationService {
|
export interface INotificationService {
|
||||||
|
|
|
||||||
52
apps/x/packages/core/src/config/notification_config.ts
Normal file
52
apps/x/packages/core/src/config/notification_config.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import {
|
||||||
|
NotificationSettingsSchema,
|
||||||
|
DEFAULT_NOTIFICATION_SETTINGS,
|
||||||
|
type NotificationSettings,
|
||||||
|
type NotificationCategory,
|
||||||
|
} from '@x/shared/dist/notification-settings.js';
|
||||||
|
import { WorkDir } from './config.js';
|
||||||
|
|
||||||
|
const NOTIFICATION_CONFIG_PATH = path.join(WorkDir, 'config', 'notification_settings.json');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load notification settings, merging any persisted values over the defaults.
|
||||||
|
*
|
||||||
|
* Merging (rather than a strict parse) keeps the file forward/backward
|
||||||
|
* compatible: a category added in a newer build is filled in from defaults
|
||||||
|
* when an older file omits it, and a malformed file falls back to defaults
|
||||||
|
* instead of disabling notifications entirely.
|
||||||
|
*/
|
||||||
|
export function loadNotificationSettings(): NotificationSettings {
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(NOTIFICATION_CONFIG_PATH)) {
|
||||||
|
const content = fs.readFileSync(NOTIFICATION_CONFIG_PATH, 'utf-8');
|
||||||
|
const parsed = JSON.parse(content);
|
||||||
|
const categories = parsed?.categories ?? {};
|
||||||
|
return NotificationSettingsSchema.parse({
|
||||||
|
categories: {
|
||||||
|
...DEFAULT_NOTIFICATION_SETTINGS.categories,
|
||||||
|
...categories,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[NotificationConfig] Error loading notification settings:', error);
|
||||||
|
}
|
||||||
|
return DEFAULT_NOTIFICATION_SETTINGS;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveNotificationSettings(settings: NotificationSettings): void {
|
||||||
|
const dir = path.dirname(NOTIFICATION_CONFIG_PATH);
|
||||||
|
if (!fs.existsSync(dir)) {
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
}
|
||||||
|
const validated = NotificationSettingsSchema.parse(settings);
|
||||||
|
fs.writeFileSync(NOTIFICATION_CONFIG_PATH, JSON.stringify(validated, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convenience: is a single notification category currently enabled? */
|
||||||
|
export function isNotificationCategoryEnabled(category: NotificationCategory): boolean {
|
||||||
|
return loadNotificationSettings().categories[category];
|
||||||
|
}
|
||||||
|
|
@ -9,6 +9,7 @@ import { serviceLogger, type ServiceRunContext } from '../services/service_logge
|
||||||
import { limitEventItems } from './limit_event_items.js';
|
import { limitEventItems } from './limit_event_items.js';
|
||||||
import { createEvent } from '../events/producer.js';
|
import { createEvent } from '../events/producer.js';
|
||||||
import { classifyThread, getUserEmail } from './classify_thread.js';
|
import { classifyThread, getUserEmail } from './classify_thread.js';
|
||||||
|
import { notifyIfEnabled } from '../application/notification/notifier.js';
|
||||||
|
|
||||||
// Configuration
|
// Configuration
|
||||||
const SYNC_DIR = path.join(WorkDir, 'gmail_sync');
|
const SYNC_DIR = path.join(WorkDir, 'gmail_sync');
|
||||||
|
|
@ -220,6 +221,26 @@ function summarizeGmailSync(threads: SyncedThread[]): string {
|
||||||
return lines.join('\n');
|
return lines.join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fire one OS notification per genuinely-new email thread. Only ever called
|
||||||
|
* from the partial-sync (incremental) path, so the first-time connect — which
|
||||||
|
* goes through fullSync — never notifies. Suppressed while the app is focused.
|
||||||
|
*/
|
||||||
|
function notifyNewEmails(threads: SyncedThread[]): void {
|
||||||
|
for (const { threadId } of threads) {
|
||||||
|
const snapshot = readCachedSnapshot(threadId)?.snapshot;
|
||||||
|
const subject = snapshot?.subject?.trim() || '(no subject)';
|
||||||
|
const from = snapshot?.from?.trim();
|
||||||
|
void notifyIfEnabled('new_email', {
|
||||||
|
title: from ? `New email from ${from}` : 'New email',
|
||||||
|
message: subject,
|
||||||
|
link: 'rowboat://open?type=chat',
|
||||||
|
actionLabel: 'Open',
|
||||||
|
onlyWhenBackground: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function publishGmailSyncEvent(threads: SyncedThread[]): Promise<void> {
|
async function publishGmailSyncEvent(threads: SyncedThread[]): Promise<void> {
|
||||||
if (threads.length === 0) return;
|
if (threads.length === 0) return;
|
||||||
try {
|
try {
|
||||||
|
|
@ -1260,6 +1281,9 @@ async function partialSync(auth: OAuth2Client, startHistoryId: string, syncDir:
|
||||||
const result = await processThread(auth, tid, syncDir, attachmentsDir);
|
const result = await processThread(auth, tid, syncDir, attachmentsDir);
|
||||||
if (result) synced.push(result);
|
if (result) synced.push(result);
|
||||||
}
|
}
|
||||||
|
// Notify for the history-derived new threads only — before the older
|
||||||
|
// backfilled threads are merged in below, so backfill stays silent.
|
||||||
|
notifyNewEmails(synced);
|
||||||
const backfilled = await backfillMissingRecentThreads(auth, syncDir, attachmentsDir, stateFile, lookbackDays);
|
const backfilled = await backfillMissingRecentThreads(auth, syncDir, attachmentsDir, stateFile, lookbackDays);
|
||||||
synced.push(...backfilled);
|
synced.push(...backfilled);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,4 +17,5 @@ export * as frontmatter from './frontmatter.js';
|
||||||
export * as bases from './bases.js';
|
export * as bases from './bases.js';
|
||||||
export * as browserControl from './browser-control.js';
|
export * as browserControl from './browser-control.js';
|
||||||
export * as billing from './billing.js';
|
export * as billing from './billing.js';
|
||||||
|
export * as notificationSettings from './notification-settings.js';
|
||||||
export { PrefixLogger };
|
export { PrefixLogger };
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ import { BrowserStateSchema } from './browser-control.js';
|
||||||
import { BillingInfoSchema } from './billing.js';
|
import { BillingInfoSchema } from './billing.js';
|
||||||
import { EmailBlockSchema, GmailThreadSchema } from './blocks.js';
|
import { EmailBlockSchema, GmailThreadSchema } from './blocks.js';
|
||||||
import { PermissionDecision, ApprovalPolicy } from './code-mode.js';
|
import { PermissionDecision, ApprovalPolicy } from './code-mode.js';
|
||||||
|
import { NotificationSettingsSchema } from './notification-settings.js';
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Runtime Validation Schemas (Single Source of Truth)
|
// Runtime Validation Schemas (Single Source of Truth)
|
||||||
|
|
@ -1093,6 +1094,17 @@ const ipcSchemas = {
|
||||||
req: z.null(),
|
req: z.null(),
|
||||||
res: BillingInfoSchema,
|
res: BillingInfoSchema,
|
||||||
},
|
},
|
||||||
|
// Notification settings channels
|
||||||
|
'notifications:getSettings': {
|
||||||
|
req: z.null(),
|
||||||
|
res: NotificationSettingsSchema,
|
||||||
|
},
|
||||||
|
'notifications:setSettings': {
|
||||||
|
req: NotificationSettingsSchema,
|
||||||
|
res: z.object({
|
||||||
|
success: z.literal(true),
|
||||||
|
}),
|
||||||
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
|
||||||
36
apps/x/packages/shared/src/notification-settings.ts
Normal file
36
apps/x/packages/shared/src/notification-settings.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notification categories the user can independently toggle.
|
||||||
|
*
|
||||||
|
* - chat_completion: an agent finished generating a response
|
||||||
|
* - new_email: a new email arrived during incremental Gmail sync
|
||||||
|
* - agent_permission: an agent is requesting permission to run a tool
|
||||||
|
*/
|
||||||
|
export const NotificationCategorySchema = z.enum([
|
||||||
|
'chat_completion',
|
||||||
|
'new_email',
|
||||||
|
'agent_permission',
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const NotificationCategoriesSchema = z.object({
|
||||||
|
chat_completion: z.boolean(),
|
||||||
|
new_email: z.boolean(),
|
||||||
|
agent_permission: z.boolean(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const NotificationSettingsSchema = z.object({
|
||||||
|
categories: NotificationCategoriesSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const DEFAULT_NOTIFICATION_SETTINGS: NotificationSettings = {
|
||||||
|
categories: {
|
||||||
|
chat_completion: true,
|
||||||
|
new_email: true,
|
||||||
|
agent_permission: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export type NotificationCategory = z.infer<typeof NotificationCategorySchema>;
|
||||||
|
export type NotificationCategories = z.infer<typeof NotificationCategoriesSchema>;
|
||||||
|
export type NotificationSettings = z.infer<typeof NotificationSettingsSchema>;
|
||||||
|
|
@ -2,6 +2,15 @@ packages:
|
||||||
- apps/*
|
- apps/*
|
||||||
- packages/*
|
- packages/*
|
||||||
|
|
||||||
|
allowBuilds:
|
||||||
|
core-js: set this to true or false
|
||||||
|
electron: set this to true or false
|
||||||
|
electron-winstaller: set this to true or false
|
||||||
|
esbuild: set this to true or false
|
||||||
|
fs-xattr: set this to true or false
|
||||||
|
macos-alias: set this to true or false
|
||||||
|
protobufjs: set this to true or false
|
||||||
|
|
||||||
catalog:
|
catalog:
|
||||||
vitest: 4.1.7
|
vitest: 4.1.7
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue