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
23 changes: 22 additions & 1 deletion src/main/ipc-handlers.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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", () => {
Expand Down
6 changes: 6 additions & 0 deletions src/main/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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);
7 changes: 7 additions & 0 deletions src/renderer/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand Down
32 changes: 32 additions & 0 deletions src/renderer/components/SettingsPanel/SettingsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export function SettingsPanel() {
const [iconDataUrls, setIconDataUrls] = useState<Record<string, string>>({});
const [appVersion, setAppVersion] = useState("");
const [worktreeBaseDir, setWorktreeBaseDir] = useState<string | undefined>();
const [notificationsEnabled, setNotificationsEnabled] = useState(true);

const [updateState, setUpdateState] = useState<UpdateState>("idle");
const [updateInfo, setUpdateInfo] = useState<UpdateInfo>({});
Expand All @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -207,6 +216,29 @@ export function SettingsPanel() {
</div>
</div>

{/* Notifications section */}
<div className="mb-6">
<h3 className="text-sm font-medium text-text-secondary mb-3">Notifications</h3>
<div className="rounded-lg bg-surface border border-border-subtle p-4">
<div className="flex items-center justify-between">
<span className="text-xs text-text-secondary">
Show macOS notifications when a session is waiting for input
</span>
<button
type="button"
onClick={handleToggleNotifications}
className={`text-xs font-medium px-3 py-1.5 rounded-md transition-colors cursor-pointer ${
notificationsEnabled
? "bg-accent/15 text-accent hover:bg-accent/25"
: "bg-white/5 text-text-muted hover:bg-white/10"
}`}
>
{notificationsEnabled ? "Enabled" : "Disabled"}
</button>
</div>
</div>
</div>

{/* Fonts section */}
<div className="mb-6">
<h3 className="text-sm font-medium text-text-secondary mb-3">Fonts</h3>
Expand Down
15 changes: 13 additions & 2 deletions src/renderer/components/Sidebar/SessionListItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -30,6 +32,8 @@ function folderName(repoPath: string): string {
export function SessionListItem({
session,
isActive,
isUnread,
isMarkUnreadTarget,
onClick,
onArchive,
onRestore,
Expand Down Expand Up @@ -98,13 +102,20 @@ export function SessionListItem({
</div>
)}

{/* 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 && (
<div className="shrink-0 text-warning group-hover:hidden">
<ChatBubbleIcon />
</div>
)}

{/* "U" badge — shown on active session when Cmd is held (mark-unread hint) */}
{isMarkUnreadTarget && (
<span className="absolute right-1 top-1/2 -translate-y-1/2 w-5 h-5 flex items-center justify-center rounded bg-warning/80 text-[10px] font-semibold text-white shadow-sm">
U
</span>
)}

{/* Hover actions */}
<div className="hidden group-hover:flex items-center gap-0.5 shrink-0">
{isArchived ? (
Expand Down
3 changes: 3 additions & 0 deletions src/renderer/components/Sidebar/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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)}
Expand Down
24 changes: 23 additions & 1 deletion src/renderer/hooks/useGlobalShortcuts.test.ts
Original file line number Diff line number Diff line change
@@ -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+,", () => {
Expand Down Expand Up @@ -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);
});
});
15 changes: 14 additions & 1 deletion src/renderer/hooks/useGlobalShortcuts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand All @@ -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]);
}
Loading
Loading