diff --git a/src/main/ipc-handlers.ts b/src/main/ipc-handlers.ts index 9401a2d..3bc953e 100644 --- a/src/main/ipc-handlers.ts +++ b/src/main/ipc-handlers.ts @@ -1,4 +1,4 @@ -import { ipcMain, dialog, shell, BrowserWindow, app } from 'electron'; +import { ipcMain, dialog, shell, BrowserWindow, app, Notification, nativeImage } from 'electron'; import * as fs from 'fs'; import * as path from 'path'; import { IPC, CLI_TOOLS, RESUME_TOOL, type CliTool, type PinnedProject, type DrawingData } from '../shared/ipc-channels'; @@ -324,4 +324,21 @@ ${safeContext} fs.mkdirSync(canvasDir, { recursive: true }); fs.writeFileSync(canvasPath, JSON.stringify(data), 'utf-8'); }); + + // ── Notifications ───────────────────────────────────────────── + const iconBase = app.isPackaged ? path.join(process.resourcesPath) : path.resolve(__dirname, '../../'); + const iconPath = path.join(iconBase, 'assets', 'logo.png'); + const notificationIcon = nativeImage.createFromPath(iconPath); + + ipcMain.on(IPC.NOTIFY_WAITING, (_event, { id, name }: { id: string; name: string }) => { + if (typeof id !== 'string' || typeof name !== 'string') return; + if (!Notification.isSupported()) return; + const safeName = name.slice(0, 100); + const notification = new Notification({ + title: 'AgentPlex', + body: `${safeName} is waiting for input`, + icon: notificationIcon, + }); + notification.show(); + }); } diff --git a/src/preload/preload.ts b/src/preload/preload.ts index d5dab76..46fdb97 100644 --- a/src/preload/preload.ts +++ b/src/preload/preload.ts @@ -232,6 +232,11 @@ const api = { canvasSave: (data: DrawingData): Promise => { return ipcRenderer.invoke(IPC.CANVAS_SAVE, data); }, + + notifyWaiting: (id: string, name: string): void => { + ipcRenderer.send(IPC.NOTIFY_WAITING, { id, name }); + }, + }; contextBridge.exposeInMainWorld('agentPlex', api); diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 3f94836..d5755e5 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -7,6 +7,7 @@ import { SendDialog } from './components/SendDialog'; import { ProjectLauncher } from './components/ProjectLauncher'; import { ActivityBar } from './components/ActivityBar'; import { SidePanel } from './components/SidePanel'; +import { ToastContainer } from './components/ToastContainer'; import { useAppStore } from './store'; import { SessionStatus } from '../shared/ipc-channels'; import './types'; @@ -40,6 +41,7 @@ export function App() { const updateTask = useAppStore((s) => s.updateTask); const reconcileTasks = useAppStore((s) => s.reconcileTasks); const prevStatuses = useRef>(new Map()); + const lastNotified = useRef>(new Map()); const renameSession = useAppStore((s) => s.renameSession); @@ -166,6 +168,24 @@ export function App() { const prev = prevStatuses.current.get(id); if (status === SessionStatus.WaitingForInput && prev !== SessionStatus.WaitingForInput) { playBell(); + const currentSelected = useAppStore.getState().selectedSessionId; + const isSelected = id === currentSelected; + const hasFocus = document.hasFocus(); + if (!isSelected || !hasFocus) { + const now = Date.now(); + const last = lastNotified.current.get(id) || 0; + if (now - last > 15_000) { + lastNotified.current.set(id, now); + const sessions = useAppStore.getState().sessions; + const displayNames = useAppStore.getState().displayNames; + const name = displayNames[id] || sessions[id]?.title || id; + if (hasFocus) { + useAppStore.getState().addToast(id, name); + } else { + window.agentPlex.notifyWaiting(id, name); + } + } + } } prevStatuses.current.set(id, status); updateStatus(id, status); @@ -255,6 +275,7 @@ export function App() { {sendDialogSourceId && } {launcherOpen && } + ); } diff --git a/src/renderer/components/ActivityBar.tsx b/src/renderer/components/ActivityBar.tsx index e602abd..422c1ab 100644 --- a/src/renderer/components/ActivityBar.tsx +++ b/src/renderer/components/ActivityBar.tsx @@ -1,6 +1,18 @@ import { useCallback, useEffect, useRef, useState } from 'react'; -import { FolderOpen, Search, Pencil, Eraser, Square, Type, Undo2, Redo2, Trash2, Palette, Sun, Moon } from 'lucide-react'; +import { FolderOpen, Search, Pencil, Eraser, Square, Type, Undo2, Redo2, Trash2, Palette, Sun, Moon, Bell } from 'lucide-react'; import { useAppStore, type PanelId } from '../store'; +import { SessionStatus } from '../../shared/ipc-channels'; +import { CliIcon } from './SessionNode'; + +function formatWaitingTime(since: number): string { + const diff = Date.now() - since; + const seconds = Math.floor(diff / 1000); + if (seconds < 60) return 'just now'; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + return `${hours}h ago`; +} const PANELS: { id: PanelId; icon: typeof FolderOpen }[] = [ { id: 'explorer', icon: FolderOpen }, @@ -33,6 +45,25 @@ export function ActivityBar() { const [theme, setTheme] = useState<'dark' | 'light'>(getInitialTheme); const [showColorPicker, setShowColorPicker] = useState(false); const colorPickerRef = useRef(null); + const sessions = useAppStore((s) => s.sessions); + const displayNames = useAppStore((s) => s.displayNames); + const waitingSince = useAppStore((s) => s.waitingSince); + const selectedSessionId = useAppStore((s) => s.selectedSessionId); + const selectSession = useAppStore((s) => s.selectSession); + const [notifOpen, setNotifOpen] = useState(false); + const notifRef = useRef(null); + + const waitingSessions = Object.values(sessions).filter( + (s) => s.status === SessionStatus.WaitingForInput && s.id !== selectedSessionId + ); + + // Tick every 30s to keep timestamps fresh + const [, setTick] = useState(0); + useEffect(() => { + if (waitingSessions.length === 0) return; + const id = setInterval(() => setTick((t) => t + 1), 30_000); + return () => clearInterval(id); + }, [waitingSessions.length]); useEffect(() => { document.documentElement.setAttribute('data-theme', theme); @@ -56,6 +87,28 @@ export function ActivityBar() { return () => document.removeEventListener('mousedown', handler); }, [showColorPicker]); + // Close notification dropdown on outside click + useEffect(() => { + if (!notifOpen) return; + const handler = (e: MouseEvent) => { + if (notifRef.current && !notifRef.current.contains(e.target as Node)) { + setNotifOpen(false); + } + }; + document.addEventListener('mousedown', handler); + return () => document.removeEventListener('mousedown', handler); + }, [notifOpen]); + + // Close notification dropdown on Escape + useEffect(() => { + if (!notifOpen) return; + const handler = (e: KeyboardEvent) => { + if (e.key === 'Escape') setNotifOpen(false); + }; + window.addEventListener('keydown', handler); + return () => window.removeEventListener('keydown', handler); + }, [notifOpen]); + const btnBase = 'w-9 h-9 flex items-center justify-center rounded-md cursor-pointer transition-colors duration-[120ms]'; const btnInactive = 'text-fg-muted hover:bg-elevated hover:text-fg'; const btnDisabled = 'text-fg-muted opacity-30 pointer-events-none'; @@ -217,7 +270,47 @@ export function ActivityBar() { )} -
+
+
+ + {notifOpen && ( +
+ {waitingSessions.length === 0 ? ( +
No sessions waiting
+ ) : ( + waitingSessions.map((s) => ( + + )) + )} +
+ )} +
+ +
+ ); + })} +
+ ); +} diff --git a/src/renderer/components/Toolbar.tsx b/src/renderer/components/Toolbar.tsx index 7197ae0..ff07dc2 100644 --- a/src/renderer/components/Toolbar.tsx +++ b/src/renderer/components/Toolbar.tsx @@ -177,11 +177,11 @@ export function Toolbar() {
{discoverOpen && ( @@ -222,10 +222,10 @@ export function Toolbar() {
{menuOpen && ( diff --git a/src/renderer/store.ts b/src/renderer/store.ts index 24f7dbd..150eea9 100644 --- a/src/renderer/store.ts +++ b/src/renderer/store.ts @@ -67,6 +67,7 @@ export interface AppState { shouldFocusNode: boolean; sessionBuffers: Record; displayNames: Record; + waitingSince: Record; nodeCounter: number; // Actions @@ -111,6 +112,11 @@ export interface AppState { terminalTab: 'session' | 'git'; setTerminalTab: (tab: 'session' | 'git') => void; + // In-app toasts + toasts: { id: string; sessionId: string; name: string; timestamp: number }[]; + addToast: (sessionId: string, name: string) => void; + dismissToast: (id: string) => void; + // Project launcher launcherOpen: boolean; launcherMode: 'new' | 'resume'; @@ -154,10 +160,22 @@ export const useAppStore = create((set, get) => ({ shouldFocusNode: false, sessionBuffers: {}, displayNames: {}, + waitingSince: {}, nodeCounter: 0, sendDialogSourceId: null, terminalTab: 'session' as const, setTerminalTab: (tab: 'session' | 'git') => set({ terminalTab: tab }), + toasts: [], + addToast: (sessionId: string, name: string) => { + const id = `${sessionId}-${Date.now()}`; + set((state) => ({ toasts: [...state.toasts, { id, sessionId, name, timestamp: Date.now() }] })); + setTimeout(() => { + set((state) => ({ toasts: state.toasts.filter((t) => t.id !== id) })); + }, 8000); + }, + dismissToast: (id: string) => { + set((state) => ({ toasts: state.toasts.filter((t) => t.id !== id) })); + }, launcherOpen: false, launcherMode: 'new' as const, launcherCli: 'claude' as CliTool, @@ -292,11 +310,19 @@ export const useAppStore = create((set, get) => ({ updateStatus: (id: string, status: SessionStatus) => { set((state) => { if (!state.sessions[id]) return state; + const prev = state.sessions[id].status; + const waitingSince = { ...state.waitingSince }; + if (status === SessionStatus.WaitingForInput && prev !== SessionStatus.WaitingForInput) { + waitingSince[id] = Date.now(); + } else if (status !== SessionStatus.WaitingForInput) { + delete waitingSince[id]; + } return { sessions: { ...state.sessions, [id]: { ...state.sessions[id], status }, }, + waitingSince, nodes: state.nodes.map((n) => n.id === id && n.type === 'sessionNode' ? { ...n, data: { ...n.data, status } } diff --git a/src/renderer/types.ts b/src/renderer/types.ts index 587fae8..aeaa14d 100644 --- a/src/renderer/types.ts +++ b/src/renderer/types.ts @@ -49,6 +49,7 @@ export interface AgentPlexAPI { gitBranchInfo: (sessionId: string) => Promise; canvasLoad: () => Promise; canvasSave: (data: DrawingData) => Promise; + notifyWaiting: (id: string, name: string) => void; } declare global { diff --git a/src/shared/ipc-channels.ts b/src/shared/ipc-channels.ts index 1a599a4..60eb5f3 100644 --- a/src/shared/ipc-channels.ts +++ b/src/shared/ipc-channels.ts @@ -205,4 +205,5 @@ export const IPC = { GIT_BRANCH_INFO: 'git:branchInfo', CANVAS_LOAD: 'canvas:load', CANVAS_SAVE: 'canvas:save', + NOTIFY_WAITING: 'notify:waiting', } as const; diff --git a/styles/index.css b/styles/index.css index 51c1f85..149421a 100644 --- a/styles/index.css +++ b/styles/index.css @@ -157,6 +157,17 @@ html, body, #root { } } +@keyframes slide-in-right { + from { + transform: translateX(100%); + opacity: 0; + } + to { + transform: translateX(0); + opacity: 1; + } +} + /* ═══════════════════════════════════════════════════════════ React Flow overrides ═══════════════════════════════════════════════════════════ */