diff --git a/src/main/index.ts b/src/main/index.ts index 3e47ff5e..27e26fe9 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,4 +1,4 @@ -import { app, BrowserWindow, ipcMain, dialog, screen, globalShortcut, Tray, Menu, nativeImage, nativeTheme, shell, systemPreferences } from 'electron' +import { app, BrowserWindow, ipcMain, dialog, screen, globalShortcut, Tray, Menu, nativeImage, nativeTheme, shell, systemPreferences, Notification } from 'electron' import { join } from 'path' import { existsSync, readdirSync, statSync, createReadStream } from 'fs' import { createInterface } from 'readline' @@ -90,6 +90,80 @@ controlPlane.on('error', (tabId: string, error: EnrichedError) => { broadcast('clui:enriched-error', tabId, error) }) +// ─── Native Notifications ─── +// Track the last prompt per tab so we can use it as notification body text. +const lastPromptByTab = new Map() +// Notification mode: 'always' | 'tab-hidden' | 'window-hidden' +// The renderer sends updates via IPC; 'window-hidden' is the default. +let notificationMode: 'always' | 'tab-hidden' | 'window-hidden' = 'window-hidden' +// The renderer tells us which tab is actively visible (active + expanded). +let activeVisibleTabId: string | null = null + +ipcMain.on(IPC.SET_NOTIFICATION_MODE, (_event, mode: string) => { + if (mode === 'always' || mode === 'tab-hidden' || mode === 'window-hidden') { + log(`Notification mode: ${mode}`) + notificationMode = mode + } +}) + +ipcMain.on(IPC.SET_ACTIVE_TAB, (_event, tabId: string | null) => { + activeVisibleTabId = tabId +}) + +function shouldNotify(tabId: string): boolean { + switch (notificationMode) { + case 'always': + return true + case 'tab-hidden': + // Notify if the completed tab is not the one actively being viewed, + // or if the window is hidden entirely. + return !mainWindow?.isVisible() || activeVisibleTabId !== tabId + case 'window-hidden': + return !mainWindow?.isVisible() + default: + return false + } +} + +function showNotification(title: string, body: string, tabId: string): void { + if (!Notification.isSupported()) { + log('Notifications not supported on this platform') + return + } + + const notification = new Notification({ + title, + body, + silent: true, // the app already plays its own sound + }) + notification.on('click', () => { + showWindow('notification click') + broadcast(IPC.FOCUS_TAB, tabId) + }) + notification.show() +} + +controlPlane.on('event', (tabId: string, event: NormalizedEvent) => { + if (!shouldNotify(tabId)) return + + if (event.type === 'task_complete') { + const prompt = lastPromptByTab.get(tabId) || '' + const body = event.result + ? event.result.substring(0, 100) + (event.result.length > 100 ? '...' : '') + : prompt + ? prompt.substring(0, 100) + (prompt.length > 100 ? '...' : '') + : 'Your task has finished.' + + showNotification('Task Complete', body, tabId) + } else if (event.type === 'error') { + showNotification( + 'Task Failed', + event.message?.substring(0, 100) || 'An error occurred.', + tabId, + ) + } +}) + // ─── Window Creation ─── function createWindow(): void { @@ -295,6 +369,8 @@ ipcMain.handle(IPC.PROMPT, async (_event, { tabId, requestId, options }: { tabId throw new Error('No requestId provided — prompt rejected') } + lastPromptByTab.set(tabId, options.prompt) + try { await controlPlane.submitPrompt(tabId, requestId, options) } catch (err: unknown) { @@ -330,6 +406,7 @@ ipcMain.handle(IPC.TAB_HEALTH, () => { ipcMain.handle(IPC.CLOSE_TAB, (_event, tabId: string) => { log(`IPC CLOSE_TAB: ${tabId}`) controlPlane.closeTab(tabId) + lastPromptByTab.delete(tabId) }) ipcMain.on(IPC.SET_PERMISSION_MODE, (_event, mode: string) => { diff --git a/src/preload/index.ts b/src/preload/index.ts index 81344d61..45aebc64 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -31,6 +31,8 @@ export interface CluiAPI { installPlugin(repo: string, pluginName: string, marketplace: string, sourcePath?: string, isSkillMd?: boolean): Promise<{ ok: boolean; error?: string }> uninstallPlugin(pluginName: string): Promise<{ ok: boolean; error?: string }> setPermissionMode(mode: string): void + setNotificationMode(mode: string): void + setActiveTab(tabId: string | null): void getTheme(): Promise<{ isDark: boolean }> onThemeChange(callback: (isDark: boolean) => void): () => void @@ -49,6 +51,7 @@ export interface CluiAPI { onError(callback: (tabId: string, error: EnrichedError) => void): () => void onSkillStatus(callback: (status: { name: string; state: string; error?: string; reason?: string }) => void): () => void onWindowShown(callback: () => void): () => void + onFocusTab(callback: (tabId: string) => void): () => void } const api: CluiAPI = { @@ -83,6 +86,8 @@ const api: CluiAPI = { uninstallPlugin: (pluginName) => ipcRenderer.invoke(IPC.MARKETPLACE_UNINSTALL, { pluginName }), setPermissionMode: (mode) => ipcRenderer.send(IPC.SET_PERMISSION_MODE, mode), + setNotificationMode: (mode) => ipcRenderer.send(IPC.SET_NOTIFICATION_MODE, mode), + setActiveTab: (tabId) => ipcRenderer.send(IPC.SET_ACTIVE_TAB, tabId), getTheme: () => ipcRenderer.invoke(IPC.GET_THEME), onThemeChange: (callback) => { const handler = (_e: Electron.IpcRendererEvent, isDark: boolean) => callback(isDark) @@ -138,6 +143,12 @@ const api: CluiAPI = { ipcRenderer.on(IPC.WINDOW_SHOWN, handler) return () => ipcRenderer.removeListener(IPC.WINDOW_SHOWN, handler) }, + + onFocusTab: (callback) => { + const handler = (_e: Electron.IpcRendererEvent, tabId: string) => callback(tabId) + ipcRenderer.on(IPC.FOCUS_TAB, handler) + return () => ipcRenderer.removeListener(IPC.FOCUS_TAB, handler) + }, } contextBridge.exposeInMainWorld('clui', api) diff --git a/src/renderer/components/SettingsPopover.tsx b/src/renderer/components/SettingsPopover.tsx index f184fb0a..6bae5038 100644 --- a/src/renderer/components/SettingsPopover.tsx +++ b/src/renderer/components/SettingsPopover.tsx @@ -1,8 +1,8 @@ import React, { useState, useRef, useEffect, useCallback } from 'react' import { createPortal } from 'react-dom' import { motion } from 'framer-motion' -import { DotsThree, Bell, ArrowsOutSimple, Moon } from '@phosphor-icons/react' -import { useThemeStore } from '../theme' +import { DotsThree, Bell, ArrowsOutSimple, Moon, BellRinging } from '@phosphor-icons/react' +import { useThemeStore, type NotificationMode } from '../theme' import { useSessionStore } from '../stores/sessionStore' import { usePopoverLayer } from './PopoverLayer' import { useColors } from '../theme' @@ -50,6 +50,8 @@ export function SettingsPopover() { const setThemeMode = useThemeStore((s) => s.setThemeMode) const expandedUI = useThemeStore((s) => s.expandedUI) const setExpandedUI = useThemeStore((s) => s.setExpandedUI) + const notificationMode = useThemeStore((s) => s.notificationMode) + const setNotificationMode = useThemeStore((s) => s.setNotificationMode) const isExpanded = useSessionStore((s) => s.isExpanded) const popoverLayer = usePopoverLayer() const colors = useColors() @@ -204,6 +206,34 @@ export function SettingsPopover() {
+ {/* Notifications */} +
+
+
+ +
+ Notifications +
+
+ +
+
+ +
+ {/* Theme */}
diff --git a/src/renderer/hooks/useClaudeEvents.ts b/src/renderer/hooks/useClaudeEvents.ts index e4e1688f..2f52277e 100644 --- a/src/renderer/hooks/useClaudeEvents.ts +++ b/src/renderer/hooks/useClaudeEvents.ts @@ -1,5 +1,6 @@ import { useEffect, useRef } from 'react' import { useSessionStore } from '../stores/sessionStore' +import { useThemeStore } from '../theme' import type { NormalizedEvent } from '../../shared/types' /** @@ -73,16 +74,43 @@ export function useClaudeEvents() { } }) + const unsubFocusTab = window.clui.onFocusTab((tabId) => { + // Always switch to the tab and expand — don't toggle like selectTab does + const store = useSessionStore.getState() + if (store.tabs.some((t) => t.id === tabId)) { + useSessionStore.setState((prev) => ({ + activeTabId: tabId, + isExpanded: true, + marketplaceOpen: false, + tabs: prev.tabs.map((t) => + t.id === tabId ? { ...t, hasUnread: false } : t + ), + })) + } + }) + return () => { unsubEvent() unsubStatus() unsubError() unsubSkill() + unsubFocusTab() if (rafIdRef.current) cancelAnimationFrame(rafIdRef.current) chunkBufferRef.current.clear() } }, [handleNormalizedEvent, handleStatusChange, handleError]) - // Note: window.clui.start() is called via sessionStore.initStaticInfo() in App.tsx. - // No duplicate call needed here. + // Sync notification mode to main process + const notificationMode = useThemeStore((s) => s.notificationMode) + useEffect(() => { + window.clui.setNotificationMode(notificationMode) + }, [notificationMode]) + + // Tell main process which tab is actively visible (for 'tab-hidden' mode) + const activeTabId = useSessionStore((s) => s.activeTabId) + const isExpanded = useSessionStore((s) => s.isExpanded) + useEffect(() => { + // A tab is "visible" if it's the active tab and the card is expanded + window.clui.setActiveTab(isExpanded ? activeTabId : null) + }, [activeTabId, isExpanded]) } diff --git a/src/renderer/theme.ts b/src/renderer/theme.ts index bfe363c6..21e2f21b 100644 --- a/src/renderer/theme.ts +++ b/src/renderer/theme.ts @@ -272,17 +272,22 @@ export type ColorPalette = { [K in keyof typeof darkColors]: string } export type ThemeMode = 'system' | 'light' | 'dark' +/** When to show native OS notifications for task completion/errors. */ +export type NotificationMode = 'always' | 'tab-hidden' | 'window-hidden' + interface ThemeState { isDark: boolean themeMode: ThemeMode soundEnabled: boolean expandedUI: boolean + notificationMode: NotificationMode /** OS-reported dark mode — used when themeMode is 'system' */ _systemIsDark: boolean setIsDark: (isDark: boolean) => void setThemeMode: (mode: ThemeMode) => void setSoundEnabled: (enabled: boolean) => void setExpandedUI: (expanded: boolean) => void + setNotificationMode: (mode: NotificationMode) => void /** Called by OS theme change listener — updates system value */ setSystemTheme: (isDark: boolean) => void } @@ -308,7 +313,7 @@ function applyTheme(isDark: boolean): void { const SETTINGS_KEY = 'clui-settings' -function loadSettings(): { themeMode: ThemeMode; soundEnabled: boolean; expandedUI: boolean } { +function loadSettings(): { themeMode: ThemeMode; soundEnabled: boolean; expandedUI: boolean; notificationMode: NotificationMode } { try { const raw = localStorage.getItem(SETTINGS_KEY) if (raw) { @@ -317,24 +322,30 @@ function loadSettings(): { themeMode: ThemeMode; soundEnabled: boolean; expanded themeMode: ['light', 'dark'].includes(parsed.themeMode) ? parsed.themeMode : 'dark', soundEnabled: typeof parsed.soundEnabled === 'boolean' ? parsed.soundEnabled : true, expandedUI: typeof parsed.expandedUI === 'boolean' ? parsed.expandedUI : false, + notificationMode: ['always', 'tab-hidden', 'window-hidden'].includes(parsed.notificationMode) ? parsed.notificationMode : 'window-hidden', } } } catch {} - return { themeMode: 'dark', soundEnabled: true, expandedUI: false } + return { themeMode: 'dark', soundEnabled: true, expandedUI: false, notificationMode: 'window-hidden' } } -function saveSettings(s: { themeMode: ThemeMode; soundEnabled: boolean; expandedUI: boolean }): void { +function saveSettings(s: { themeMode: ThemeMode; soundEnabled: boolean; expandedUI: boolean; notificationMode: NotificationMode }): void { try { localStorage.setItem(SETTINGS_KEY, JSON.stringify(s)) } catch {} } // Always start in compact UI mode on launch. const saved = { ...loadSettings(), expandedUI: false } +function currentSettings(get: () => ThemeState) { + return { themeMode: get().themeMode, soundEnabled: get().soundEnabled, expandedUI: get().expandedUI, notificationMode: get().notificationMode } +} + export const useThemeStore = create((set, get) => ({ isDark: saved.themeMode === 'dark' ? true : saved.themeMode === 'light' ? false : true, themeMode: saved.themeMode, soundEnabled: saved.soundEnabled, expandedUI: saved.expandedUI, + notificationMode: saved.notificationMode, _systemIsDark: true, setIsDark: (isDark) => { set({ isDark }) @@ -344,19 +355,22 @@ export const useThemeStore = create((set, get) => ({ const resolved = mode === 'system' ? get()._systemIsDark : mode === 'dark' set({ themeMode: mode, isDark: resolved }) applyTheme(resolved) - saveSettings({ themeMode: mode, soundEnabled: get().soundEnabled, expandedUI: get().expandedUI }) + saveSettings({ ...currentSettings(get), themeMode: mode }) }, setSoundEnabled: (enabled) => { set({ soundEnabled: enabled }) - saveSettings({ themeMode: get().themeMode, soundEnabled: enabled, expandedUI: get().expandedUI }) + saveSettings({ ...currentSettings(get), soundEnabled: enabled }) }, setExpandedUI: (expanded) => { set({ expandedUI: expanded }) - saveSettings({ themeMode: get().themeMode, soundEnabled: get().soundEnabled, expandedUI: expanded }) + saveSettings({ ...currentSettings(get), expandedUI: expanded }) + }, + setNotificationMode: (mode) => { + set({ notificationMode: mode }) + saveSettings({ ...currentSettings(get), notificationMode: mode }) }, setSystemTheme: (isDark) => { set({ _systemIsDark: isDark }) - // Only apply if following system if (get().themeMode === 'system') { set({ isDark }) applyTheme(isDark) diff --git a/src/shared/types.ts b/src/shared/types.ts index 9745613e..a2c0f63e 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -358,6 +358,11 @@ export const IPC = { // Permission mode SET_PERMISSION_MODE: 'clui:set-permission-mode', + // Notifications + FOCUS_TAB: 'clui:focus-tab', + SET_NOTIFICATION_MODE: 'clui:set-notification-mode', + SET_ACTIVE_TAB: 'clui:set-active-tab', + // Legacy (kept for backward compat during migration) STREAM_EVENT: 'clui:stream-event', RUN_COMPLETE: 'clui:run-complete',