diff --git a/src/main/ipc-handlers.ts b/src/main/ipc-handlers.ts index df89183..6c62505 100644 --- a/src/main/ipc-handlers.ts +++ b/src/main/ipc-handlers.ts @@ -1,7 +1,7 @@ import { execFileSync, spawn } from "node:child_process"; import fs from "node:fs"; import type Database from "better-sqlite3"; -import { app, type BrowserWindow, dialog, ipcMain } from "electron"; +import { app, type BrowserWindow, dialog, ipcMain, Notification } from "electron"; import { getFonts2 } from "font-list"; import * as pty from "node-pty"; import type { AgentType } from "../shared/agent-types.js"; @@ -85,6 +85,27 @@ export function registerIpcHandlers(options: RegisterHandlersOptions): void { if (window) { window.webContents.send(IPC.EVENT_SESSION_STATUS, sessionId, status); } + + if (status === "waiting_for_input") { + const settings = readSettings(settingsPath); + if (settings.notificationsEnabled !== false && !window?.isFocused()) { + const session = getSession(db, sessionId); + const sessionName = session?.name || "Session"; + const notification = new Notification({ + title: sessionName, + body: "Waiting for your input", + }); + notification.on("click", () => { + const win = getMainWindow(); + if (win) { + win.show(); + win.focus(); + win.webContents.send(IPC.EVENT_NAVIGATE_TO_SESSION, sessionId); + } + }); + notification.show(); + } + } }); app.on("before-quit", () => { diff --git a/src/main/preload.ts b/src/main/preload.ts index fa11ee2..34d064f 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -50,6 +50,7 @@ const CH = { EVENT_UPDATE_PROGRESS: "updater:progress", EVENT_UPDATE_ERROR: "updater:error", EVENT_MENU_SETTINGS: "menu:settings", + EVENT_NAVIGATE_TO_SESSION: "event:navigateToSession", } as const; const api = { @@ -167,6 +168,11 @@ const api = { ipcRenderer.on(CH.EVENT_MENU_SETTINGS, handler); return () => ipcRenderer.removeListener(CH.EVENT_MENU_SETTINGS, handler); }, + onNavigateToSession: (callback: (sessionId: string) => void) => { + const handler = (_event: unknown, sessionId: string) => callback(sessionId); + ipcRenderer.on(CH.EVENT_NAVIGATE_TO_SESSION, handler); + return () => ipcRenderer.removeListener(CH.EVENT_NAVIGATE_TO_SESSION, handler); + }, }; contextBridge.exposeInMainWorld("electronAPI", api); diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 3b49078..c169baa 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -9,6 +9,7 @@ import { useThemeStore } from "./stores/themeStore"; export function App() { const handleStatusChange = useSessionStore((state) => state.handleStatusChange); + const setActiveSession = useSessionStore((state) => state.setActiveSession); const loadTheme = useThemeStore((state) => state.loadTheme); const loadFonts = useFontStore((state) => state.loadFonts); const toggleSettings = useThemeStore((state) => state.toggleSettings); @@ -43,6 +44,12 @@ export function App() { return () => document.removeEventListener("visibilitychange", handleVisibilityChange); }, []); + // Navigate to session when notification is clicked + useEffect(() => { + if (!window.electronAPI) return; + return window.electronAPI.onNavigateToSession(setActiveSession); + }, [setActiveSession]); + // Subscribe to menu → Settings useEffect(() => { if (!window.electronAPI) return; diff --git a/src/renderer/components/SettingsPanel/SettingsPanel.tsx b/src/renderer/components/SettingsPanel/SettingsPanel.tsx index 430b79d..1812cd4 100644 --- a/src/renderer/components/SettingsPanel/SettingsPanel.tsx +++ b/src/renderer/components/SettingsPanel/SettingsPanel.tsx @@ -38,6 +38,7 @@ export function SettingsPanel() { const [iconDataUrls, setIconDataUrls] = useState>({}); const [appVersion, setAppVersion] = useState(""); const [worktreeBaseDir, setWorktreeBaseDir] = useState(); + const [notificationsEnabled, setNotificationsEnabled] = useState(true); const [updateState, setUpdateState] = useState("idle"); const [updateInfo, setUpdateInfo] = useState({}); @@ -48,6 +49,7 @@ export function SettingsPanel() { window.electronAPI.getSettings().then((settings) => { setActiveIcon(settings.appIcon ?? "icon-01"); setWorktreeBaseDir(settings.worktreeBaseDir); + setNotificationsEnabled(settings.notificationsEnabled !== false); }); window.electronAPI.getIconDataUrls().then(setIconDataUrls); window.electronAPI.listFonts().then(setFonts); @@ -129,6 +131,13 @@ export function SettingsPanel() { } }, []); + const handleToggleNotifications = useCallback(async () => { + if (!window.electronAPI) return; + const next = !notificationsEnabled; + setNotificationsEnabled(next); + await window.electronAPI.saveSettings({ notificationsEnabled: next }); + }, [notificationsEnabled]); + const handleClearWorktreeDir = useCallback(async () => { if (!window.electronAPI) return; setWorktreeBaseDir(undefined); @@ -207,6 +216,29 @@ export function SettingsPanel() { + {/* Notifications section */} +
+

Notifications

+
+
+ + Show macOS notifications when a session is waiting for input + + +
+
+
+ {/* Fonts section */}

Fonts

diff --git a/src/renderer/components/Sidebar/SessionListItem.tsx b/src/renderer/components/Sidebar/SessionListItem.tsx index 34442e6..acf5edb 100644 --- a/src/renderer/components/Sidebar/SessionListItem.tsx +++ b/src/renderer/components/Sidebar/SessionListItem.tsx @@ -4,6 +4,8 @@ import type { SessionInfo } from "@shared/agent-types"; export interface SessionListItemProps { session: SessionInfo; isActive: boolean; + isUnread?: boolean; + isMarkUnreadTarget?: boolean; onClick: () => void; onArchive?: () => void; onRestore?: () => void; @@ -30,6 +32,8 @@ function folderName(repoPath: string): string { export function SessionListItem({ session, isActive, + isUnread, + isMarkUnreadTarget, onClick, onArchive, onRestore, @@ -98,13 +102,20 @@ export function SessionListItem({
)} - {/* Chat bubble for waiting sessions — hidden when active or on hover */} - {session.status === "waiting_for_input" && !isActive && ( + {/* Chat bubble for unread sessions — hidden when active or on hover */} + {isUnread && !isActive && (
)} + {/* "U" badge — shown on active session when Cmd is held (mark-unread hint) */} + {isMarkUnreadTarget && ( + + U + + )} + {/* Hover actions */}
{isArchived ? ( diff --git a/src/renderer/components/Sidebar/Sidebar.tsx b/src/renderer/components/Sidebar/Sidebar.tsx index 06a4e71..f1a0b41 100644 --- a/src/renderer/components/Sidebar/Sidebar.tsx +++ b/src/renderer/components/Sidebar/Sidebar.tsx @@ -32,6 +32,7 @@ export function Sidebar() { const loadRepos = useRepoStore((state) => state.loadRepos); const addRepoViaDialog = useRepoStore((state) => state.addRepoViaDialog); + const unreadSessionIds = useSessionStore((state) => state.unreadSessionIds); const reorderSessions = useSessionStore((state) => state.reorderSessions); const [archiveOpen, setArchiveOpen] = useState(false); @@ -201,6 +202,8 @@ export function Sidebar() { key={session.id} session={session} isActive={session.id === activeSessionId} + isUnread={unreadSessionIds.has(session.id)} + isMarkUnreadTarget={metaHeld && session.id === activeSessionId} onClick={() => setActiveSession(session.id)} onArchive={() => handleArchiveSession(session)} branchName={session.branchName ?? branches.get(session.repoPath)} diff --git a/src/renderer/hooks/useGlobalShortcuts.test.ts b/src/renderer/hooks/useGlobalShortcuts.test.ts index a363971..0e889db 100644 --- a/src/renderer/hooks/useGlobalShortcuts.test.ts +++ b/src/renderer/hooks/useGlobalShortcuts.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { isNewSessionShortcut, isSettingsShortcut } from "./useGlobalShortcuts"; +import { isMarkUnreadShortcut, isNewSessionShortcut, isSettingsShortcut } from "./useGlobalShortcuts"; describe("isSettingsShortcut", () => { it("returns true for Cmd+,", () => { @@ -44,3 +44,25 @@ describe("isNewSessionShortcut", () => { expect(isNewSessionShortcut(event as KeyboardEvent)).toBe(false); }); }); + +describe("isMarkUnreadShortcut", () => { + it("returns true for Cmd+U", () => { + const event = { key: "u", metaKey: true, ctrlKey: false, shiftKey: false, altKey: false }; + expect(isMarkUnreadShortcut(event as KeyboardEvent)).toBe(true); + }); + + it("returns false without meta", () => { + const event = { key: "u", metaKey: false, ctrlKey: false, shiftKey: false, altKey: false }; + expect(isMarkUnreadShortcut(event as KeyboardEvent)).toBe(false); + }); + + it("returns false for Cmd+Shift+U", () => { + const event = { key: "u", metaKey: true, ctrlKey: false, shiftKey: true, altKey: false }; + expect(isMarkUnreadShortcut(event as KeyboardEvent)).toBe(false); + }); + + it("returns false for Cmd+Alt+U", () => { + const event = { key: "u", metaKey: true, ctrlKey: false, shiftKey: false, altKey: true }; + expect(isMarkUnreadShortcut(event as KeyboardEvent)).toBe(false); + }); +}); diff --git a/src/renderer/hooks/useGlobalShortcuts.ts b/src/renderer/hooks/useGlobalShortcuts.ts index 8c5e980..c5b1705 100644 --- a/src/renderer/hooks/useGlobalShortcuts.ts +++ b/src/renderer/hooks/useGlobalShortcuts.ts @@ -11,9 +11,14 @@ export function isNewSessionShortcut(event: KeyboardEvent): boolean { return event.key === "n" && event.metaKey && !event.shiftKey && !event.altKey && !event.ctrlKey; } +export function isMarkUnreadShortcut(event: KeyboardEvent): boolean { + return event.key === "u" && event.metaKey && !event.shiftKey && !event.altKey && !event.ctrlKey; +} + export function useGlobalShortcuts(): void { const toggleSettings = useThemeStore((state) => state.toggleSettings); const setPendingNewSessionRepo = useSessionStore((state) => state.setPendingNewSessionRepo); + const markUnread = useSessionStore((state) => state.markUnread); const addRepoViaDialog = useRepoStore((state) => state.addRepoViaDialog); useEffect(() => { @@ -30,9 +35,17 @@ export function useGlobalShortcuts(): void { setPendingNewSessionRepo(repo); } } + + if (isMarkUnreadShortcut(event)) { + event.preventDefault(); + const activeSessionId = useSessionStore.getState().activeSessionId; + if (activeSessionId) { + markUnread(activeSessionId); + } + } } window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); - }, [toggleSettings, setPendingNewSessionRepo, addRepoViaDialog]); + }, [toggleSettings, setPendingNewSessionRepo, markUnread, addRepoViaDialog]); } diff --git a/src/renderer/stores/sessionStore.test.ts b/src/renderer/stores/sessionStore.test.ts index 6eddc48..cacb83b 100644 --- a/src/renderer/stores/sessionStore.test.ts +++ b/src/renderer/stores/sessionStore.test.ts @@ -1,7 +1,137 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useSessionStore } from "./sessionStore"; -describe("sessionStore", () => { - it("placeholder — store tests use Zustand internals and need jsdom", () => { - expect(true).toBe(true); +// Minimal mock for window.electronAPI — only methods called by the store +const mockElectronAPI = { + ptyKill: vi.fn().mockResolvedValue(undefined), + deleteSession: vi.fn().mockResolvedValue(undefined), + archiveSession: vi.fn().mockResolvedValue(undefined), + listSessions: vi.fn().mockResolvedValue([]), + listArchivedSessions: vi.fn().mockResolvedValue([]), +}; + +beforeEach(() => { + vi.useFakeTimers(); + (globalThis as unknown as { window: { electronAPI: typeof mockElectronAPI } }).window = { + electronAPI: mockElectronAPI, + }; +}); + +afterEach(() => { + vi.useRealTimers(); + useSessionStore.setState({ + sessions: [], + archivedSessions: [], + activeSessionId: null, + unreadSessionIds: new Set(), + }); +}); + +function seedSessions() { + useSessionStore.setState({ + sessions: [ + { + id: "s1", + repoPath: "/repo", + worktreePath: "/repo", + agentType: "claude", + status: "running", + name: "S1", + branchName: null, + agentSessionId: null, + createdAt: "", + sortOrder: 0, + }, + { + id: "s2", + repoPath: "/repo", + worktreePath: "/repo", + agentType: "claude", + status: "running", + name: "S2", + branchName: null, + agentSessionId: null, + createdAt: "", + sortOrder: 1, + }, + ], + activeSessionId: "s1", + }); +} + +describe("unread state", () => { + it("marks non-active session unread on waiting_for_input", () => { + seedSessions(); + useSessionStore.getState().handleStatusChange("s2", "waiting_for_input"); + expect(useSessionStore.getState().unreadSessionIds.has("s2")).toBe(true); + }); + + it("does NOT mark the active session unread", () => { + seedSessions(); + useSessionStore.getState().handleStatusChange("s1", "waiting_for_input"); + expect(useSessionStore.getState().unreadSessionIds.has("s1")).toBe(false); + }); + + it("clears unread when status changes to running", () => { + seedSessions(); + useSessionStore.setState({ unreadSessionIds: new Set(["s2"]) }); + useSessionStore.getState().handleStatusChange("s2", "running"); + expect(useSessionStore.getState().unreadSessionIds.has("s2")).toBe(false); + }); + + it("clears unread when status changes to archived", () => { + seedSessions(); + useSessionStore.setState({ unreadSessionIds: new Set(["s2"]) }); + useSessionStore.getState().handleStatusChange("s2", "archived"); + expect(useSessionStore.getState().unreadSessionIds.has("s2")).toBe(false); + }); + + it("markUnread adds to the set", () => { + seedSessions(); + useSessionStore.getState().markUnread("s1"); + expect(useSessionStore.getState().unreadSessionIds.has("s1")).toBe(true); + }); + + it("markRead removes from the set", () => { + seedSessions(); + useSessionStore.setState({ unreadSessionIds: new Set(["s1"]) }); + useSessionStore.getState().markRead("s1"); + expect(useSessionStore.getState().unreadSessionIds.has("s1")).toBe(false); + }); + + it("setActiveSession clears unread after 1500ms", () => { + seedSessions(); + useSessionStore.setState({ unreadSessionIds: new Set(["s2"]) }); + useSessionStore.getState().setActiveSession("s2"); + // Not cleared immediately + expect(useSessionStore.getState().unreadSessionIds.has("s2")).toBe(true); + vi.advanceTimersByTime(1500); + expect(useSessionStore.getState().unreadSessionIds.has("s2")).toBe(false); + }); + + it("setActiveSession cancels timer if session changes before 1500ms", () => { + seedSessions(); + useSessionStore.setState({ unreadSessionIds: new Set(["s2"]) }); + useSessionStore.getState().setActiveSession("s2"); + // Switch away before timer fires + vi.advanceTimersByTime(500); + useSessionStore.getState().setActiveSession("s1"); + vi.advanceTimersByTime(1500); + // s2 should still be unread — timer was cancelled + expect(useSessionStore.getState().unreadSessionIds.has("s2")).toBe(true); + }); + + it("deleteSession removes from unread set", async () => { + seedSessions(); + useSessionStore.setState({ unreadSessionIds: new Set(["s2"]) }); + await useSessionStore.getState().deleteSession("s2"); + expect(useSessionStore.getState().unreadSessionIds.has("s2")).toBe(false); + }); + + it("archiveSession removes from unread set", async () => { + seedSessions(); + useSessionStore.setState({ unreadSessionIds: new Set(["s2"]) }); + await useSessionStore.getState().archiveSession("s2"); + expect(useSessionStore.getState().unreadSessionIds.has("s2")).toBe(false); }); }); diff --git a/src/renderer/stores/sessionStore.ts b/src/renderer/stores/sessionStore.ts index dc727bf..915cc10 100644 --- a/src/renderer/stores/sessionStore.ts +++ b/src/renderer/stores/sessionStore.ts @@ -4,10 +4,13 @@ import { create } from "zustand"; type WorktreeDialogContext = "archive" | "delete"; +let readTimerId: ReturnType | null = null; + interface SessionState { sessions: SessionInfo[]; archivedSessions: SessionInfo[]; activeSessionId: string | null; + unreadSessionIds: Set; pendingNewSessionRepo: RepoInfo | null; pendingWorktreeSession: SessionInfo | null; pendingWorktreeContext: WorktreeDialogContext | null; @@ -17,6 +20,8 @@ interface SessionState { loadArchivedSessions: () => Promise; createSession: (repoPath: string, agentType: "claude" | "gemini", branchName?: string) => Promise; setActiveSession: (sessionId: string | null) => void; + markUnread: (sessionId: string) => void; + markRead: (sessionId: string) => void; deleteSession: (sessionId: string) => Promise; archiveSession: (sessionId: string) => Promise; archiveSessionWithWorktreeCleanup: (sessionId: string, deleteBranch: boolean) => Promise; @@ -29,10 +34,11 @@ interface SessionState { dismissWorktreeDialog: () => void; } -export const useSessionStore = create((set) => ({ +export const useSessionStore = create((set, get) => ({ sessions: [], archivedSessions: [], activeSessionId: null, + unreadSessionIds: new Set(), pendingNewSessionRepo: null, pendingWorktreeSession: null, pendingWorktreeContext: null, @@ -54,17 +60,53 @@ export const useSessionStore = create((set) => ({ }, setActiveSession: (sessionId) => { + if (readTimerId !== null) { + clearTimeout(readTimerId); + readTimerId = null; + } set({ activeSessionId: sessionId }); + if (sessionId && get().unreadSessionIds.has(sessionId)) { + readTimerId = setTimeout(() => { + readTimerId = null; + const state = get(); + if (state.activeSessionId === sessionId) { + const next = new Set(state.unreadSessionIds); + next.delete(sessionId); + set({ unreadSessionIds: next }); + } + }, 1500); + } + }, + + markUnread: (sessionId) => { + set((state) => { + const next = new Set(state.unreadSessionIds); + next.add(sessionId); + return { unreadSessionIds: next }; + }); + }, + + markRead: (sessionId) => { + set((state) => { + const next = new Set(state.unreadSessionIds); + next.delete(sessionId); + return { unreadSessionIds: next }; + }); }, deleteSession: async (sessionId) => { await window.electronAPI.ptyKill(sessionId).catch(() => {}); await window.electronAPI.deleteSession(sessionId); - set((state) => ({ - sessions: state.sessions.filter((s) => s.id !== sessionId), - archivedSessions: state.archivedSessions.filter((s) => s.id !== sessionId), - activeSessionId: state.activeSessionId === sessionId ? null : state.activeSessionId, - })); + set((state) => { + const next = new Set(state.unreadSessionIds); + next.delete(sessionId); + return { + sessions: state.sessions.filter((s) => s.id !== sessionId), + archivedSessions: state.archivedSessions.filter((s) => s.id !== sessionId), + activeSessionId: state.activeSessionId === sessionId ? null : state.activeSessionId, + unreadSessionIds: next, + }; + }); }, archiveSession: async (sessionId) => { @@ -73,10 +115,13 @@ export const useSessionStore = create((set) => ({ set((state) => { const session = state.sessions.find((s) => s.id === sessionId); const archived = session ? { ...session, status: "archived" as const } : null; + const nextUnread = new Set(state.unreadSessionIds); + nextUnread.delete(sessionId); return { sessions: state.sessions.filter((s) => s.id !== sessionId), archivedSessions: archived ? [archived, ...state.archivedSessions] : state.archivedSessions, activeSessionId: state.activeSessionId === sessionId ? null : state.activeSessionId, + unreadSessionIds: nextUnread, }; }); }, @@ -149,23 +194,33 @@ export const useSessionStore = create((set) => ({ set((state) => { const session = state.sessions.find((s) => s.id === sessionId); const archived = session ? { ...session, status: "archived" as const } : null; - - // If session had a worktree, show cleanup dialog const showDialog = session?.branchName != null; + const nextUnread = new Set(state.unreadSessionIds); + nextUnread.delete(sessionId); return { sessions: state.sessions.filter((s) => s.id !== sessionId), archivedSessions: archived ? [archived, ...state.archivedSessions] : state.archivedSessions, activeSessionId: state.activeSessionId === sessionId ? null : state.activeSessionId, + unreadSessionIds: nextUnread, ...(showDialog && archived ? { pendingWorktreeSession: archived, pendingWorktreeContext: "archive" as const } : {}), }; }); } else { - set((state) => ({ - sessions: state.sessions.map((s) => (s.id === sessionId ? { ...s, status } : s)), - })); + set((state) => { + const nextUnread = new Set(state.unreadSessionIds); + if (status === "waiting_for_input" && sessionId !== state.activeSessionId) { + nextUnread.add(sessionId); + } else if (status === "running") { + nextUnread.delete(sessionId); + } + return { + sessions: state.sessions.map((s) => (s.id === sessionId ? { ...s, status } : s)), + unreadSessionIds: nextUnread, + }; + }); } }, diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 3b163a7..63957d1 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -66,4 +66,5 @@ export const IPC = { EVENT_UPDATE_PROGRESS: "updater:progress", EVENT_UPDATE_ERROR: "updater:error", EVENT_MENU_SETTINGS: "menu:settings", + EVENT_NAVIGATE_TO_SESSION: "event:navigateToSession", } as const; diff --git a/src/shared/types.ts b/src/shared/types.ts index e73a76a..29e0104 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -30,6 +30,7 @@ export interface AppSettings { appIcon?: string; /** Base directory for worktrees. Defaults to sibling of repo (--). */ worktreeBaseDir?: string; + notificationsEnabled?: boolean; } export interface ElectronAPI { @@ -109,6 +110,7 @@ export interface ElectronAPI { ) => () => void; onUpdateError: (callback: (info: { error: string }) => void) => () => void; onMenuSettings: (callback: () => void) => () => void; + onNavigateToSession: (callback: (sessionId: string) => void) => () => void; } declare global {