Skip to content
Open
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
19 changes: 18 additions & 1 deletion src/main/ipc-handlers.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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();
});
}
5 changes: 5 additions & 0 deletions src/preload/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,11 @@ const api = {
canvasSave: (data: DrawingData): Promise<void> => {
return ipcRenderer.invoke(IPC.CANVAS_SAVE, data);
},

notifyWaiting: (id: string, name: string): void => {
ipcRenderer.send(IPC.NOTIFY_WAITING, { id, name });
},

};

contextBridge.exposeInMainWorld('agentPlex', api);
21 changes: 21 additions & 0 deletions src/renderer/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -40,6 +41,7 @@ export function App() {
const updateTask = useAppStore((s) => s.updateTask);
const reconcileTasks = useAppStore((s) => s.reconcileTasks);
const prevStatuses = useRef<Map<string, SessionStatus>>(new Map());
const lastNotified = useRef<Map<string, number>>(new Map());

const renameSession = useAppStore((s) => s.renameSession);

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -255,6 +275,7 @@ export function App() {
</div>
{sendDialogSourceId && <SendDialog />}
{launcherOpen && <ProjectLauncher />}
<ToastContainer />
</div>
);
}
97 changes: 95 additions & 2 deletions src/renderer/components/ActivityBar.tsx
Original file line number Diff line number Diff line change
@@ -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 },
Expand Down Expand Up @@ -33,6 +45,25 @@ export function ActivityBar() {
const [theme, setTheme] = useState<'dark' | 'light'>(getInitialTheme);
const [showColorPicker, setShowColorPicker] = useState(false);
const colorPickerRef = useRef<HTMLDivElement>(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<HTMLDivElement>(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);
Expand All @@ -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';
Expand Down Expand Up @@ -217,7 +270,47 @@ export function ActivityBar() {
)}
</div>

<div className="mt-auto">
<div className="mt-auto flex flex-col items-center gap-1">
<div className="relative" ref={notifRef}>
<button
onClick={() => setNotifOpen((v) => !v)}
className={`relative ${btnBase} ${btnInactive}`}
title="Sessions waiting for input"
>
<Bell size={20} />
{waitingSessions.length > 0 && (
<span className="absolute -top-0.5 -right-0.5 min-w-[18px] h-[18px] flex items-center justify-center bg-warning-bg text-surface text-[10px] font-bold rounded-full px-0.5">
{waitingSessions.length}
</span>
)}
</button>
{notifOpen && (
<div className="absolute left-[calc(100%+6px)] bottom-0 bg-elevated border border-border-strong rounded-lg p-1 shadow-[0_8px_24px_var(--shadow-heavy)] z-[100] min-w-[260px] max-h-[360px] overflow-y-auto">
{waitingSessions.length === 0 ? (
<div className="py-4 px-3 text-center text-fg-muted text-[13px]">No sessions waiting</div>
) : (
waitingSessions.map((s) => (
<button
key={s.id}
className="flex items-center gap-2.5 w-full py-2 px-2.5 bg-transparent border-none rounded-md text-left cursor-pointer transition-colors hover:bg-border"
onClick={() => { selectSession(s.id, true); setNotifOpen(false); }}
>
<CliIcon cli={s.cli} size={18} />
<div className="flex-1 min-w-0 flex flex-col gap-px">
<span className="text-[13px] font-medium text-fg whitespace-nowrap overflow-hidden text-ellipsis">
{displayNames[s.id] || s.title}
</span>
<span className="text-[11px] text-fg-muted">
Waiting {waitingSince[s.id] ? formatWaitingTime(waitingSince[s.id]) : ''}
</span>
</div>
<span className="w-2 h-2 rounded-full bg-warning-bg shrink-0 animate-[pulse-dot_1.5s_ease-in-out_infinite]" />
</button>
))
)}
</div>
)}
</div>
<button
onClick={toggleTheme}
className={`${btnBase} ${btnInactive}`}
Expand Down
2 changes: 1 addition & 1 deletion src/renderer/components/SessionNode.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ const CLI_ICONS: Record<string, { dark: string; light: string }> = {
copilot: { dark: copilotLight, light: copilotDark },
};

function CliIcon({ cli, size = 14 }: { cli?: CliTool; size?: number }) {
export function CliIcon({ cli, size = 14 }: { cli?: CliTool; size?: number }) {
if (!cli) return null;
const icons = CLI_ICONS[cli];
if (!icons) return <Terminal size={size} className="shrink-0 text-fg-muted" />;
Expand Down
43 changes: 43 additions & 0 deletions src/renderer/components/ToastContainer.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="fixed top-14 right-4 z-[900] flex flex-col gap-2 pointer-events-none">
{toasts.map((toast) => {
const session = sessions[toast.sessionId];
return (
<div
key={toast.id}
className="pointer-events-auto flex items-center gap-3 bg-elevated border border-border-strong rounded-lg px-4 py-3 shadow-[0_8px_24px_var(--shadow-heavy)] min-w-[280px] max-w-[380px] animate-[slide-in-right_0.2s_ease-out]"
>
<button
className="flex items-center gap-3 flex-1 min-w-0 bg-transparent border-none cursor-pointer text-left p-0"
onClick={() => { selectSession(toast.sessionId, true); dismissToast(toast.id); }}
>
<CliIcon cli={session?.cli} size={20} />
<div className="flex-1 min-w-0">
<div className="text-[13px] font-semibold text-fg truncate">{toast.name}</div>
<div className="text-[11px] text-warning">Waiting for input</div>
</div>
</button>
<button
className="shrink-0 w-6 h-6 flex items-center justify-center rounded bg-transparent border-none text-fg-muted cursor-pointer transition-colors hover:bg-border hover:text-fg"
onClick={() => dismissToast(toast.id)}
>
<X size={14} />
</button>
</div>
);
})}
</div>
);
}
8 changes: 4 additions & 4 deletions src/renderer/components/Toolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -177,11 +177,11 @@ export function Toolbar() {
<div className="[-webkit-app-region:no-drag] flex items-center gap-2.5">
<div className="relative" ref={discoverRef}>
<button
className="flex items-center gap-1 h-6 px-2 rounded text-fg-muted text-[11px] font-medium cursor-pointer transition-colors hover:bg-elevated hover:text-fg"
className="flex items-center gap-1.5 h-8 px-3 rounded text-fg-muted text-[13px] font-medium cursor-pointer transition-colors hover:bg-elevated hover:text-fg"
onClick={handleDiscover}
title="Find running Claude sessions not managed by AgentPlex"
>
<Radar size={14} />
<Radar size={16} />
<span>Discover</span>
</button>
{discoverOpen && (
Expand Down Expand Up @@ -222,10 +222,10 @@ export function Toolbar() {
</div>
<div className="relative" ref={menuRef}>
<button
className="flex items-center gap-1 h-6 px-2 bg-accent text-surface border-none rounded text-[11px] font-semibold cursor-pointer transition-colors hover:bg-accent-hover active:bg-accent-active"
className="flex items-center gap-1.5 h-8 px-3 bg-accent text-surface border-none rounded text-[13px] font-semibold cursor-pointer transition-colors hover:bg-accent-hover active:bg-accent-active"
onClick={() => setMenuOpen((v) => !v)}
>
<Plus size={14} />
<Plus size={16} />
<span>New Session</span>
</button>
{menuOpen && (
Expand Down
26 changes: 26 additions & 0 deletions src/renderer/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ export interface AppState {
shouldFocusNode: boolean;
sessionBuffers: Record<string, string>;
displayNames: Record<string, string>;
waitingSince: Record<string, number>;
nodeCounter: number;

// Actions
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -154,10 +160,22 @@ export const useAppStore = create<AppState>((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,
Expand Down Expand Up @@ -292,11 +310,19 @@ export const useAppStore = create<AppState>((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 } }
Expand Down
1 change: 1 addition & 0 deletions src/renderer/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ export interface AgentPlexAPI {
gitBranchInfo: (sessionId: string) => Promise<GitBranchInfo>;
canvasLoad: () => Promise<DrawingData>;
canvasSave: (data: DrawingData) => Promise<void>;
notifyWaiting: (id: string, name: string) => void;
}

declare global {
Expand Down
1 change: 1 addition & 0 deletions src/shared/ipc-channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
11 changes: 11 additions & 0 deletions styles/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -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
═══════════════════════════════════════════════════════════ */
Expand Down