Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions electron/ipc/channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,4 +85,8 @@ export enum IPC {
// Ask about code
AskAboutCode = 'ask_about_code',
CancelAskAboutCode = 'cancel_ask_about_code',

// Notifications
ShowNotification = 'show_notification',
NotificationClicked = 'notification_clicked',
}
25 changes: 24 additions & 1 deletion electron/ipc/register.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ipcMain, dialog, shell, app, BrowserWindow } from 'electron';
import { ipcMain, dialog, shell, app, BrowserWindow, Notification } from 'electron';
import fs from 'fs';
import { fileURLToPath } from 'url';
import { IPC } from './channels.js';
Expand Down Expand Up @@ -323,6 +323,29 @@ export function registerAllHandlers(win: BrowserWindow): void {
cancelAskAboutCode(args.requestId);
});

// --- Notifications (fire-and-forget via ipcMain.on) ---
ipcMain.on(IPC.ShowNotification, (_e, args) => {
try {
assertString(args.title, 'title');
assertString(args.body, 'body');
assertStringArray(args.taskIds, 'taskIds');
const notification = new Notification({
title: args.title,
body: args.body,
});
notification.on('click', () => {
if (!win.isDestroyed()) {
win.show();
win.focus();
win.webContents.send(IPC.NotificationClicked, { taskIds: args.taskIds });
}
});
notification.show();
} catch (err) {
console.warn('ShowNotification failed:', err);
}
});

// --- Window management ---
ipcMain.handle(IPC.WindowIsFocused, () => win.isFocused());
ipcMain.handle(IPC.WindowIsMaximized, () => win.isMaximized());
Expand Down
7 changes: 7 additions & 0 deletions electron/preload.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,9 @@ const ALLOWED_CHANNELS = new Set([
// Ask about code
'ask_about_code',
'cancel_ask_about_code',
// Notifications
'show_notification',
'notification_clicked',
]);

function isAllowedChannel(channel) {
Expand All @@ -91,6 +94,10 @@ contextBridge.exposeInMainWorld('electron', {
if (!isAllowedChannel(channel)) throw new Error(`Blocked IPC channel: ${channel}`);
return ipcRenderer.invoke(channel, ...args);
},
send: (channel, ...args) => {
if (!isAllowedChannel(channel)) throw new Error(`Blocked IPC channel: ${channel}`);
ipcRenderer.send(channel, ...args);
},
on: (channel, listener) => {
if (!isAllowedChannel(channel)) throw new Error(`Blocked IPC channel: ${channel}`);
const wrapped = (_event, ...eventArgs) => listener(...eventArgs);
Expand Down
3 changes: 3 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ import { setupAutosave } from './store/autosave';
import { isMac, mod } from './lib/platform';
import { createCtrlWheelZoomHandler } from './lib/wheelZoom';
import { ArenaOverlay } from './arena/ArenaOverlay';
import { startDesktopNotificationWatcher } from './store/desktopNotifications';

const MIN_WINDOW_DIMENSION = 100;

Expand Down Expand Up @@ -307,6 +308,7 @@ function App() {
await captureWindowState();
setupAutosave();
startTaskStatusPolling();
const stopNotificationWatcher = startDesktopNotificationWatcher(windowFocused);

// Listen for plan content pushed from backend plan watcher
const offPlanContent = window.electron.ipcRenderer.on(IPC.PlanContent, (data: unknown) => {
Expand Down Expand Up @@ -571,6 +573,7 @@ function App() {
unlistenCloseRequested();
cleanupShortcuts();
stopTaskStatusPolling();
stopNotificationWatcher();
offPlanContent();
unlistenFocusChanged?.();
unlistenResized?.();
Expand Down
26 changes: 26 additions & 0 deletions src/components/SettingsDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
setThemePreset,
setAutoTrustFolders,
setShowPlans,
setDesktopNotificationsEnabled,
setInactiveColumnOpacity,
setEditorCommand,
} from '../store/store';
Expand Down Expand Up @@ -177,6 +178,31 @@ export function SettingsDialog(props: SettingsDialogProps) {
</span>
</div>
</label>
<label
style={{
display: 'flex',
'align-items': 'center',
gap: '10px',
cursor: 'pointer',
padding: '8px 12px',
'border-radius': '8px',
background: theme.bgInput,
border: `1px solid ${theme.border}`,
}}
>
<input
type="checkbox"
checked={store.desktopNotificationsEnabled}
onChange={(e) => setDesktopNotificationsEnabled(e.currentTarget.checked)}
style={{ 'accent-color': theme.accent, cursor: 'pointer' }}
/>
<div style={{ display: 'flex', 'flex-direction': 'column', gap: '2px' }}>
<span style={{ 'font-size': '13px', color: theme.fg }}>Desktop notifications</span>
<span style={{ 'font-size': '11px', color: theme.fgSubtle }}>
Show native notifications when tasks finish or need attention
</span>
</div>
</label>
</div>

<div style={{ display: 'flex', 'flex-direction': 'column', gap: '10px' }}>
Expand Down
1 change: 1 addition & 0 deletions src/lib/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ declare global {
electron: {
ipcRenderer: {
invoke: (channel: string, ...args: unknown[]) => Promise<unknown>;
send: (channel: string, ...args: unknown[]) => void;
on: (channel: string, listener: (...args: unknown[]) => void) => () => void;
removeAllListeners: (channel: string) => void;
};
Expand Down
1 change: 1 addition & 0 deletions src/store/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export const [store, setStore] = createStore<AppStore>({
windowState: null,
autoTrustFolders: false,
showPlans: true,
desktopNotificationsEnabled: false,
inactiveColumnOpacity: 0.6,
editorCommand: '',
newTaskDropUrl: null,
Expand Down
127 changes: 127 additions & 0 deletions src/store/desktopNotifications.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import { createEffect, onCleanup, type Accessor } from 'solid-js';
import { store } from './store';
import { getTaskDotStatus, type TaskDotStatus } from './taskStatus';
import { setActiveTask } from './navigation';
import { IPC } from '../../electron/ipc/channels';

const DEBOUNCE_MS = 3_000;

interface PendingNotification {
type: 'ready' | 'waiting';
taskId: string;
}

export function startDesktopNotificationWatcher(windowFocused: Accessor<boolean>): () => void {
const previousStatus = new Map<string, TaskDotStatus>();
let pending: PendingNotification[] = [];
let debounceTimer: ReturnType<typeof setTimeout> | undefined;

function flushNotifications(): void {
debounceTimer = undefined;
if (windowFocused() || pending.length === 0) {
pending = [];
return;
}

const ready = pending.filter((n) => n.type === 'ready');
const waiting = pending.filter((n) => n.type === 'waiting');
pending = [];

if (ready.length > 0) {
const taskIds = ready.map((n) => n.taskId);
const body =
ready.length === 1
? `${taskName(taskIds[0])} is ready for review`
: `${ready.length} tasks ready for review`;
window.electron.ipcRenderer.send(IPC.ShowNotification, {
title: 'Task Ready',
body,
taskIds,
});
}

if (waiting.length > 0) {
const taskIds = waiting.map((n) => n.taskId);
const body =
waiting.length === 1
? `${taskName(taskIds[0])} needs your attention`
: `${waiting.length} tasks need your attention`;
window.electron.ipcRenderer.send(IPC.ShowNotification, {
title: 'Task Waiting',
body,
taskIds,
});
}
}

function taskName(taskId: string): string {
return store.tasks[taskId]?.name ?? taskId;
}

function scheduleBatch(notification: PendingNotification): void {
if (!store.desktopNotificationsEnabled) return;
pending.push(notification);
if (debounceTimer === undefined) {
debounceTimer = setTimeout(flushNotifications, DEBOUNCE_MS);
}
}

// Track status transitions
createEffect(() => {
const allTaskIds = [...store.taskOrder, ...store.collapsedTaskOrder];
const seen = new Set<string>();

for (const taskId of allTaskIds) {
seen.add(taskId);
const current = getTaskDotStatus(taskId);
const prev = previousStatus.get(taskId);
previousStatus.set(taskId, current);

// Skip initial population
if (prev === undefined) continue;
if (prev === current) continue;

if (current === 'ready' && prev !== 'ready') {
scheduleBatch({ type: 'ready', taskId });
} else if (current === 'waiting' && prev === 'busy') {
scheduleBatch({ type: 'waiting', taskId });
}
}

// Clean up removed tasks
for (const taskId of previousStatus.keys()) {
if (!seen.has(taskId)) previousStatus.delete(taskId);
}
});

// Clear pending when window regains focus
createEffect(() => {
if (windowFocused()) {
pending = [];
if (debounceTimer !== undefined) {
clearTimeout(debounceTimer);
debounceTimer = undefined;
}
}
});

// Listen for notification clicks from main process
const offNotificationClicked = window.electron.ipcRenderer.on(
IPC.NotificationClicked,
(data: unknown) => {
const msg = data as Record<string, unknown>;
const taskIds = Array.isArray(msg?.taskIds) ? (msg.taskIds as string[]) : [];
if (taskIds.length) {
setActiveTask(taskIds[0]);
}
},
);

const cleanup = (): void => {
if (debounceTimer !== undefined) clearTimeout(debounceTimer);
offNotificationClicked();
};

onCleanup(cleanup);
return cleanup;
}
6 changes: 6 additions & 0 deletions src/store/persistence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export async function saveState(): Promise<void> {
windowState: store.windowState ? { ...store.windowState } : undefined,
autoTrustFolders: store.autoTrustFolders,
showPlans: store.showPlans,
desktopNotificationsEnabled: store.desktopNotificationsEnabled,
inactiveColumnOpacity: store.inactiveColumnOpacity,
editorCommand: store.editorCommand || undefined,
customAgents: store.customAgents.length > 0 ? [...store.customAgents] : undefined,
Expand Down Expand Up @@ -171,6 +172,7 @@ interface LegacyPersistedState {
windowState?: unknown;
autoTrustFolders?: unknown;
showPlans?: unknown;
desktopNotificationsEnabled?: unknown;
inactiveColumnOpacity?: unknown;
editorCommand?: unknown;
customAgents?: unknown;
Expand Down Expand Up @@ -269,6 +271,10 @@ export async function loadState(): Promise<void> {
s.windowState = parsePersistedWindowState(raw.windowState);
s.autoTrustFolders = typeof raw.autoTrustFolders === 'boolean' ? raw.autoTrustFolders : false;
s.showPlans = typeof raw.showPlans === 'boolean' ? raw.showPlans : true;
s.desktopNotificationsEnabled =
typeof raw.desktopNotificationsEnabled === 'boolean'
? raw.desktopNotificationsEnabled
: false;
const rawOpacity = raw.inactiveColumnOpacity;
s.inactiveColumnOpacity =
typeof rawOpacity === 'number' &&
Expand Down
1 change: 1 addition & 0 deletions src/store/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ export {
setThemePreset,
setAutoTrustFolders,
setShowPlans,
setDesktopNotificationsEnabled,
setInactiveColumnOpacity,
setEditorCommand,
setWindowState,
Expand Down
2 changes: 2 additions & 0 deletions src/store/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ export interface PersistedState {
windowState?: PersistedWindowState;
autoTrustFolders?: boolean;
showPlans?: boolean;
desktopNotificationsEnabled?: boolean;
inactiveColumnOpacity?: number;
editorCommand?: string;
customAgents?: AgentDef[];
Expand Down Expand Up @@ -176,6 +177,7 @@ export interface AppStore {
windowState: PersistedWindowState | null;
autoTrustFolders: boolean;
showPlans: boolean;
desktopNotificationsEnabled: boolean;
inactiveColumnOpacity: number;
editorCommand: string;
newTaskDropUrl: string | null;
Expand Down
4 changes: 4 additions & 0 deletions src/store/ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,10 @@ export function setShowPlans(showPlans: boolean): void {
setStore('showPlans', showPlans);
}

export function setDesktopNotificationsEnabled(enabled: boolean): void {
setStore('desktopNotificationsEnabled', enabled);
}

export function setInactiveColumnOpacity(opacity: number): void {
setStore('inactiveColumnOpacity', Math.round(Math.max(0.3, Math.min(1.0, opacity)) * 100) / 100);
}
Expand Down
Loading