From b73566a22a8df6d83f3d4662652ff7eef549da97 Mon Sep 17 00:00:00 2001 From: Zubeir Mohamed Date: Sun, 29 Mar 2026 20:56:59 +0800 Subject: [PATCH 1/8] feat(notifications): add IPC channel constants for NOTIFY_WAITING and SELECT_SESSION --- src/shared/ipc-channels.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/shared/ipc-channels.ts b/src/shared/ipc-channels.ts index 1a599a4..b8da401 100644 --- a/src/shared/ipc-channels.ts +++ b/src/shared/ipc-channels.ts @@ -205,4 +205,6 @@ export const IPC = { GIT_BRANCH_INFO: 'git:branchInfo', CANVAS_LOAD: 'canvas:load', CANVAS_SAVE: 'canvas:save', + NOTIFY_WAITING: 'notify:waiting', + SELECT_SESSION: 'notify:selectSession', } as const; From 52e2994b9ddfb375f74b65a55b971b07e5d33124 Mon Sep 17 00:00:00 2001 From: Zubeir Mohamed Date: Sun, 29 Mar 2026 20:57:39 +0800 Subject: [PATCH 2/8] feat(notifications): add preload bridge for notifyWaiting and onSelectSession --- src/preload/preload.ts | 12 ++++++++++++ src/renderer/types.ts | 2 ++ 2 files changed, 14 insertions(+) diff --git a/src/preload/preload.ts b/src/preload/preload.ts index d5dab76..bd16e67 100644 --- a/src/preload/preload.ts +++ b/src/preload/preload.ts @@ -232,6 +232,18 @@ 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 }); + }, + + onSelectSession: (callback: (data: { id: string }) => void): (() => void) => { + const handler = (_event: Electron.IpcRendererEvent, payload: { id: string }) => { + callback(payload); + }; + ipcRenderer.on(IPC.SELECT_SESSION, handler); + return () => ipcRenderer.removeListener(IPC.SELECT_SESSION, handler); + }, }; contextBridge.exposeInMainWorld('agentPlex', api); diff --git a/src/renderer/types.ts b/src/renderer/types.ts index 587fae8..d170760 100644 --- a/src/renderer/types.ts +++ b/src/renderer/types.ts @@ -49,6 +49,8 @@ export interface AgentPlexAPI { gitBranchInfo: (sessionId: string) => Promise; canvasLoad: () => Promise; canvasSave: (data: DrawingData) => Promise; + notifyWaiting: (id: string, name: string) => void; + onSelectSession: (callback: (data: { id: string }) => void) => () => void; } declare global { From b5bb6e17ed52ec778bfa7f46082cd9a7acb07f9b Mon Sep 17 00:00:00 2001 From: Zubeir Mohamed Date: Sun, 29 Mar 2026 20:59:00 +0800 Subject: [PATCH 3/8] feat(notifications): handle NOTIFY_WAITING with native Electron notification Co-Authored-By: Claude Sonnet 4.6 --- src/main/ipc-handlers.ts | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/src/main/ipc-handlers.ts b/src/main/ipc-handlers.ts index 9401a2d..89d254b 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 } 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,22 @@ ${safeContext} fs.mkdirSync(canvasDir, { recursive: true }); fs.writeFileSync(canvasPath, JSON.stringify(data), 'utf-8'); }); + + // ── Notifications ───────────────────────────────────────────── + ipcMain.on(IPC.NOTIFY_WAITING, (_event, { id, name }: { id: string; name: string }) => { + if (typeof id !== 'string' || typeof name !== 'string') return; + const notification = new Notification({ + title: 'AgentPlex', + body: `${name} is waiting for input`, + }); + notification.on('click', () => { + const win = BrowserWindow.getAllWindows()[0]; + if (win) { + if (win.isMinimized()) win.restore(); + win.focus(); + win.webContents.send(IPC.SELECT_SESSION, { id }); + } + }); + notification.show(); + }); } From 8f6285f4c2504cd8f620b34ec7982be518e20e6c Mon Sep 17 00:00:00 2001 From: Zubeir Mohamed Date: Sun, 29 Mar 2026 20:59:59 +0800 Subject: [PATCH 4/8] feat(notifications): send native toast for non-selected sessions waiting for input Co-Authored-By: Claude Sonnet 4.6 --- src/renderer/App.tsx | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 3f94836..3839033 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -166,6 +166,13 @@ export function App() { const prev = prevStatuses.current.get(id); if (status === SessionStatus.WaitingForInput && prev !== SessionStatus.WaitingForInput) { playBell(); + const currentSelected = useAppStore.getState().selectedSessionId; + if (id !== currentSelected) { + const sessions = useAppStore.getState().sessions; + const displayNames = useAppStore.getState().displayNames; + const name = displayNames[id] || sessions[id]?.title || id; + window.agentPlex.notifyWaiting(id, name); + } } prevStatuses.current.set(id, status); updateStatus(id, status); @@ -203,6 +210,10 @@ export function App() { reconcileTasks(sessionId, tasks); }); + const cleanupSelectSession = window.agentPlex.onSelectSession(({ id }) => { + useAppStore.getState().selectSession(id, true); + }); + return () => { cleanupData(); cleanupStatus(); @@ -214,6 +225,7 @@ export function App() { cleanupTaskCreate(); cleanupTaskUpdate(); cleanupTaskList(); + cleanupSelectSession(); }; }, [appendBuffer, updateStatus, spawnSubagent, completeSubagent, enterPlan, exitPlan, createTask, updateTask, reconcileTasks]); From 7a9b992b64155249b9652ad2840f1c9a7cc65dc2 Mon Sep 17 00:00:00 2001 From: Zubeir Mohamed Date: Sun, 29 Mar 2026 21:04:48 +0800 Subject: [PATCH 5/8] fix(notifications): address code review feedback - Add Notification.isSupported() guard for robustness - Use BrowserWindow.fromWebContents() instead of getAllWindows()[0] - Add 15s per-session notification cooldown to prevent spam - Truncate display name to 100 chars in notification body - Guard selectSession against stale/killed sessions Co-Authored-By: Claude Opus 4.6 (1M context) --- src/main/ipc-handlers.ts | 20 +++++++++++++------- src/renderer/App.tsx | 17 ++++++++++++----- 2 files changed, 25 insertions(+), 12 deletions(-) diff --git a/src/main/ipc-handlers.ts b/src/main/ipc-handlers.ts index 89d254b..c889071 100644 --- a/src/main/ipc-handlers.ts +++ b/src/main/ipc-handlers.ts @@ -326,18 +326,24 @@ ${safeContext} }); // ── Notifications ───────────────────────────────────────────── - ipcMain.on(IPC.NOTIFY_WAITING, (_event, { id, name }: { id: string; name: string }) => { + let mainWin: BrowserWindow | null = null; + + ipcMain.on(IPC.NOTIFY_WAITING, (event, { id, name }: { id: string; name: string }) => { if (typeof id !== 'string' || typeof name !== 'string') return; + if (!Notification.isSupported()) return; + if (!mainWin || mainWin.isDestroyed()) { + mainWin = BrowserWindow.fromWebContents(event.sender); + } + const safeName = name.slice(0, 100); const notification = new Notification({ title: 'AgentPlex', - body: `${name} is waiting for input`, + body: `${safeName} is waiting for input`, }); notification.on('click', () => { - const win = BrowserWindow.getAllWindows()[0]; - if (win) { - if (win.isMinimized()) win.restore(); - win.focus(); - win.webContents.send(IPC.SELECT_SESSION, { id }); + if (mainWin && !mainWin.isDestroyed()) { + if (mainWin.isMinimized()) mainWin.restore(); + mainWin.focus(); + mainWin.webContents.send(IPC.SELECT_SESSION, { id }); } }); notification.show(); diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 3839033..e8798de 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -40,6 +40,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); @@ -168,10 +169,15 @@ export function App() { playBell(); const currentSelected = useAppStore.getState().selectedSessionId; if (id !== currentSelected) { - const sessions = useAppStore.getState().sessions; - const displayNames = useAppStore.getState().displayNames; - const name = displayNames[id] || sessions[id]?.title || id; - window.agentPlex.notifyWaiting(id, name); + 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; + window.agentPlex.notifyWaiting(id, name); + } } } prevStatuses.current.set(id, status); @@ -211,7 +217,8 @@ export function App() { }); const cleanupSelectSession = window.agentPlex.onSelectSession(({ id }) => { - useAppStore.getState().selectSession(id, true); + const state = useAppStore.getState(); + if (state.sessions[id]) state.selectSession(id, true); }); return () => { From 082b6989bcc0b6777872e546ac6de3e98ab2dfef Mon Sep 17 00:00:00 2001 From: Zubeir Mohamed Date: Sun, 29 Mar 2026 21:44:47 +0800 Subject: [PATCH 6/8] feat(notifications): show app icon in Windows toast notifications - Set AppUserModelId so Windows uses the correct app icon - Pass logo.png as notification icon via nativeImage Co-Authored-By: Claude Opus 4.6 (1M context) --- src/main/ipc-handlers.ts | 6 +++++- src/main/main.ts | 3 +++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/main/ipc-handlers.ts b/src/main/ipc-handlers.ts index c889071..d1f7e9b 100644 --- a/src/main/ipc-handlers.ts +++ b/src/main/ipc-handlers.ts @@ -1,4 +1,4 @@ -import { ipcMain, dialog, shell, BrowserWindow, app, Notification } 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'; @@ -327,6 +327,9 @@ ${safeContext} // ── Notifications ───────────────────────────────────────────── let mainWin: BrowserWindow | null = null; + 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; @@ -338,6 +341,7 @@ ${safeContext} const notification = new Notification({ title: 'AgentPlex', body: `${safeName} is waiting for input`, + icon: notificationIcon, }); notification.on('click', () => { if (mainWin && !mainWin.isDestroyed()) { diff --git a/src/main/main.ts b/src/main/main.ts index 844ffdf..7743861 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -7,6 +7,9 @@ import { detectShells } from './shell-detector'; declare const MAIN_WINDOW_VITE_DEV_SERVER_URL: string; declare const MAIN_WINDOW_VITE_NAME: string; +// Set AppUserModelId so Windows toast notifications show the correct icon +app.setAppUserModelId('com.agentplex.app'); + // Prevent remote debugging port from being opened (blocks --remote-debugging-port flag) app.commandLine.appendSwitch('remote-debugging-port', '0'); // Disable exposing the app over any network interface From 2e2d68343efe52494a92248c51974d791ec29d26 Mon Sep 17 00:00:00 2001 From: Zubeir Mohamed Date: Sun, 29 Mar 2026 22:43:03 +0800 Subject: [PATCH 7/8] feat(notifications): add in-app notification center and cleanup - Add bell icon with badge count to ActivityBar above theme toggle - Notification dropdown lists sessions waiting for input with CLI icon and timestamp - Clicking a notification selects that session - Remove SELECT_SESSION IPC (toast click does nothing special) - Revert setAppUserModelId (pre-existing icon issue, not our fix) - Add waitingSince tracking to store for timestamp display - Bigger toolbar buttons (h-8, 16px icons, 13px text) Co-Authored-By: Claude Opus 4.6 (1M context) --- src/main/ipc-handlers.ts | 13 +--- src/main/main.ts | 3 - src/preload/preload.ts | 7 -- src/renderer/App.tsx | 6 -- src/renderer/components/ActivityBar.tsx | 97 ++++++++++++++++++++++++- src/renderer/components/SessionNode.tsx | 2 +- src/renderer/components/Toolbar.tsx | 8 +- src/renderer/store.ts | 10 +++ src/renderer/types.ts | 1 - src/shared/ipc-channels.ts | 1 - 10 files changed, 111 insertions(+), 37 deletions(-) diff --git a/src/main/ipc-handlers.ts b/src/main/ipc-handlers.ts index d1f7e9b..3bc953e 100644 --- a/src/main/ipc-handlers.ts +++ b/src/main/ipc-handlers.ts @@ -326,30 +326,19 @@ ${safeContext} }); // ── Notifications ───────────────────────────────────────────── - let mainWin: BrowserWindow | null = null; 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 }) => { + ipcMain.on(IPC.NOTIFY_WAITING, (_event, { id, name }: { id: string; name: string }) => { if (typeof id !== 'string' || typeof name !== 'string') return; if (!Notification.isSupported()) return; - if (!mainWin || mainWin.isDestroyed()) { - mainWin = BrowserWindow.fromWebContents(event.sender); - } const safeName = name.slice(0, 100); const notification = new Notification({ title: 'AgentPlex', body: `${safeName} is waiting for input`, icon: notificationIcon, }); - notification.on('click', () => { - if (mainWin && !mainWin.isDestroyed()) { - if (mainWin.isMinimized()) mainWin.restore(); - mainWin.focus(); - mainWin.webContents.send(IPC.SELECT_SESSION, { id }); - } - }); notification.show(); }); } diff --git a/src/main/main.ts b/src/main/main.ts index 7743861..844ffdf 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -7,9 +7,6 @@ import { detectShells } from './shell-detector'; declare const MAIN_WINDOW_VITE_DEV_SERVER_URL: string; declare const MAIN_WINDOW_VITE_NAME: string; -// Set AppUserModelId so Windows toast notifications show the correct icon -app.setAppUserModelId('com.agentplex.app'); - // Prevent remote debugging port from being opened (blocks --remote-debugging-port flag) app.commandLine.appendSwitch('remote-debugging-port', '0'); // Disable exposing the app over any network interface diff --git a/src/preload/preload.ts b/src/preload/preload.ts index bd16e67..46fdb97 100644 --- a/src/preload/preload.ts +++ b/src/preload/preload.ts @@ -237,13 +237,6 @@ const api = { ipcRenderer.send(IPC.NOTIFY_WAITING, { id, name }); }, - onSelectSession: (callback: (data: { id: string }) => void): (() => void) => { - const handler = (_event: Electron.IpcRendererEvent, payload: { id: string }) => { - callback(payload); - }; - ipcRenderer.on(IPC.SELECT_SESSION, handler); - return () => ipcRenderer.removeListener(IPC.SELECT_SESSION, handler); - }, }; contextBridge.exposeInMainWorld('agentPlex', api); diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index e8798de..5a8b386 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -216,11 +216,6 @@ export function App() { reconcileTasks(sessionId, tasks); }); - const cleanupSelectSession = window.agentPlex.onSelectSession(({ id }) => { - const state = useAppStore.getState(); - if (state.sessions[id]) state.selectSession(id, true); - }); - return () => { cleanupData(); cleanupStatus(); @@ -232,7 +227,6 @@ export function App() { cleanupTaskCreate(); cleanupTaskUpdate(); cleanupTaskList(); - cleanupSelectSession(); }; }, [appendBuffer, updateStatus, spawnSubagent, completeSubagent, enterPlan, exitPlan, createTask, updateTask, reconcileTasks]); 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) => ( + + )) + )} +
+ )} +
{discoverOpen && ( @@ -222,10 +222,10 @@ export function Toolbar() {
{menuOpen && ( diff --git a/src/renderer/store.ts b/src/renderer/store.ts index 24f7dbd..f2cbee0 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 @@ -154,6 +155,7 @@ export const useAppStore = create((set, get) => ({ shouldFocusNode: false, sessionBuffers: {}, displayNames: {}, + waitingSince: {}, nodeCounter: 0, sendDialogSourceId: null, terminalTab: 'session' as const, @@ -292,11 +294,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 d170760..aeaa14d 100644 --- a/src/renderer/types.ts +++ b/src/renderer/types.ts @@ -50,7 +50,6 @@ export interface AgentPlexAPI { canvasLoad: () => Promise; canvasSave: (data: DrawingData) => Promise; notifyWaiting: (id: string, name: string) => void; - onSelectSession: (callback: (data: { id: string }) => void) => () => void; } declare global { diff --git a/src/shared/ipc-channels.ts b/src/shared/ipc-channels.ts index b8da401..60eb5f3 100644 --- a/src/shared/ipc-channels.ts +++ b/src/shared/ipc-channels.ts @@ -206,5 +206,4 @@ export const IPC = { CANVAS_LOAD: 'canvas:load', CANVAS_SAVE: 'canvas:save', NOTIFY_WAITING: 'notify:waiting', - SELECT_SESSION: 'notify:selectSession', } as const; From e2b4bdb69d4a4adde773d420d4d4ae93d8bd9999 Mon Sep 17 00:00:00 2001 From: Zubeir Mohamed Date: Sun, 29 Mar 2026 22:57:58 +0800 Subject: [PATCH 8/8] feat(notifications): add in-app toast notifications for focused window - In-app toast slides in top-right when a non-selected session needs input and window is focused - Native Windows toast fires when window is unfocused/minimized - Toast shows CLI icon, session name, click to select session - Auto-dismisses after 8 seconds, or click X to dismiss Co-Authored-By: Claude Opus 4.6 (1M context) --- src/renderer/App.tsx | 12 +++++- src/renderer/components/ToastContainer.tsx | 43 ++++++++++++++++++++++ src/renderer/store.ts | 16 ++++++++ styles/index.css | 11 ++++++ 4 files changed, 80 insertions(+), 2 deletions(-) create mode 100644 src/renderer/components/ToastContainer.tsx diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 5a8b386..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'; @@ -168,7 +169,9 @@ export function App() { if (status === SessionStatus.WaitingForInput && prev !== SessionStatus.WaitingForInput) { playBell(); const currentSelected = useAppStore.getState().selectedSessionId; - if (id !== currentSelected) { + 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) { @@ -176,7 +179,11 @@ export function App() { const sessions = useAppStore.getState().sessions; const displayNames = useAppStore.getState().displayNames; const name = displayNames[id] || sessions[id]?.title || id; - window.agentPlex.notifyWaiting(id, name); + if (hasFocus) { + useAppStore.getState().addToast(id, name); + } else { + window.agentPlex.notifyWaiting(id, name); + } } } } @@ -268,6 +275,7 @@ export function App() {
{sendDialogSourceId && } {launcherOpen && } +
); } diff --git a/src/renderer/components/ToastContainer.tsx b/src/renderer/components/ToastContainer.tsx new file mode 100644 index 0000000..b05bdd2 --- /dev/null +++ b/src/renderer/components/ToastContainer.tsx @@ -0,0 +1,43 @@ +import { X } from 'lucide-react'; +import { useAppStore } from '../store'; +import { CliIcon } from './SessionNode'; + +export function ToastContainer() { + const toasts = useAppStore((s) => s.toasts); + const dismissToast = useAppStore((s) => s.dismissToast); + const selectSession = useAppStore((s) => s.selectSession); + const sessions = useAppStore((s) => s.sessions); + + if (toasts.length === 0) return null; + + return ( +
+ {toasts.map((toast) => { + const session = sessions[toast.sessionId]; + return ( +
+ + +
+ ); + })} +
+ ); +} diff --git a/src/renderer/store.ts b/src/renderer/store.ts index f2cbee0..150eea9 100644 --- a/src/renderer/store.ts +++ b/src/renderer/store.ts @@ -112,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'; @@ -160,6 +165,17 @@ export const useAppStore = create((set, get) => ({ 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, 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 ═══════════════════════════════════════════════════════════ */