Skip to content
Merged
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
3 changes: 3 additions & 0 deletions app/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { registerSystemHandlers } from './ipc/system.js';
import { registerGithubHandlers } from './ipc/github.js';
import { registerHookHandlers } from './ipc/hooks.js';
import { registerAgentHandlers } from './ipc/agents.js';
import { registerNotificationHandlers } from './ipc/notifications.js';
import { registerChatHandlers } from './ipc/chat.js';
import { registerCommitMessageGeneratorHandlers } from './ipc/commit-message-generator.js';
import { registerProjectIdeaGeneratorHandlers } from './ipc/project-idea-generator.js';
Expand Down Expand Up @@ -303,6 +304,8 @@ app.whenReady().then(() => {
if (isE2EMode) log.info('[e2e] hook handlers ok');
registerAgentHandlers(configDb, userDataPath);
if (isE2EMode) log.info('[e2e] agent handlers ok');
registerNotificationHandlers();
if (isE2EMode) log.info('[e2e] notification handlers ok');
registerChatHandlers(configDb, userDataPath);
if (isE2EMode) log.info('[e2e] chat handlers ok');
registerCommitMessageGeneratorHandlers();
Expand Down
35 changes: 35 additions & 0 deletions app/src/main/ipc/notifications.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/**
* Native OS notification for a background agent session finishing/idling.
*
* The renderer owns the *policy* (is the setting enabled, is this worktree
* currently out of view) since only it knows which worktree/tab is active —
* this module just owns the Electron `Notification` API and the
* focus-and-jump-back behaviour on click.
*/
import { BrowserWindow, Notification } from 'electron';
import { IPC } from '@sproutgit/types';
import type { ShowAgentNotificationArgs, NotificationClickedEvent } from '@sproutgit/types';
import { handle } from './handle.js';

export function registerNotificationHandlers(): void {
handle(IPC.NOTIFICATION_SHOW, (_e, args: ShowAgentNotificationArgs) => {
if (!Notification.isSupported()) return;

const win = BrowserWindow.fromWebContents(_e.sender);
const notification = new Notification({ title: args.title, body: args.body });

notification.on('click', () => {
if (win && !win.isDestroyed()) {
if (win.isMinimized()) win.restore();
win.show();
win.focus();
win.webContents.send(IPC.EVENT_NOTIFICATION_CLICKED, {
worktreePath: args.worktreePath,
terminalId: args.terminalId,
} satisfies NotificationClickedEvent);
}
});

notification.show();
});
}
27 changes: 27 additions & 0 deletions app/src/main/ipc/terminal.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { ipcMain, BrowserWindow } from 'electron';
import { IPC } from '@sproutgit/types';
import type { AgentSessionStatusEvent } from '@sproutgit/types';
import { TerminalManagerWithMeta } from '@sproutgit/terminal';
import { handle } from './handle.js';
import { log } from '../telemetry.js';
Expand All @@ -22,9 +23,24 @@ export const manager = new TerminalManagerWithMeta(
}
},
(id, exitCode) => {
// Read before handleSessionExit() (which runs after this callback, per
// TerminalManagerWithMeta's exit ordering) deletes it — a session
// closed deliberately via close() has already had its metadata removed
// by the time this fires, so `meta` is undefined and no status event is
// sent for that case, which is the desired behaviour (no notification
// for a terminal the user closed on purpose).
const meta = manager.getMeta(id);
const win = sessionWindows.get(id);
if (win && !win.isDestroyed()) {
win.webContents.send(IPC.TERMINAL_EXIT, { id });
if (meta && meta.agentId !== null) {
win.webContents.send(IPC.EVENT_AGENT_SESSION_STATUS, {
id,
cwd: meta.cwd,
agentName: meta.agentName,
reason: 'exited',
} satisfies AgentSessionStatusEvent);
}
}
const hookHandler = hookExitHandlers.get(id);
if (hookHandler) {
Expand All @@ -33,6 +49,17 @@ export const manager = new TerminalManagerWithMeta(
}
sessionWindows.delete(id);
},
(id, meta) => {
const win = sessionWindows.get(id);
if (win && !win.isDestroyed()) {
win.webContents.send(IPC.EVENT_AGENT_SESSION_STATUS, {
id,
cwd: meta.cwd,
agentName: meta.agentName,
reason: 'idle',
} satisfies AgentSessionStatusEvent);
}
},
);

export function registerTerminalHandlers(): void {
Expand Down
19 changes: 19 additions & 0 deletions app/src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,9 @@ import type {
McpConfigWriteResult,
GlobalErrorEvent,
ProjectIdeaGenerateResult,
AgentSessionStatusEvent,
ShowAgentNotificationArgs,
NotificationClickedEvent,
} from '@sproutgit/types';

/**
Expand Down Expand Up @@ -226,6 +229,22 @@ const api = {
return () => ipcRenderer.off(IPC.TERMINAL_EXIT, handler);
},

onAgentSessionStatus: (callback: (event: AgentSessionStatusEvent) => void) => {
const handler = (_e: Electron.IpcRendererEvent, payload: AgentSessionStatusEvent) => callback(payload);
ipcRenderer.on(IPC.EVENT_AGENT_SESSION_STATUS, handler);
return () => ipcRenderer.off(IPC.EVENT_AGENT_SESSION_STATUS, handler);
},

// ── Notifications ─────────────────────────────────────────────────────────
showAgentSessionNotification: (args: ShowAgentNotificationArgs): Promise<void> =>
invoke(IPC.NOTIFICATION_SHOW, args),

onNotificationClicked: (callback: (event: NotificationClickedEvent) => void) => {
const handler = (_e: Electron.IpcRendererEvent, payload: NotificationClickedEvent) => callback(payload);
ipcRenderer.on(IPC.EVENT_NOTIFICATION_CLICKED, handler);
return () => ipcRenderer.off(IPC.EVENT_NOTIFICATION_CLICKED, handler);
},

// ── Workspace / recent ────────────────────────────────────────────────────
listRecentWorkspaces: (): Promise<RecentWorkspace[]> =>
invoke(IPC.WORKSPACE_LIST_RECENT),
Expand Down
13 changes: 13 additions & 0 deletions app/src/renderer/agent-session-notification-settings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { api } from './api.js';

const AGENT_SESSION_NOTIFICATIONS_KEY = 'agentSessionNotificationsEnabled';

/** Whether to show an OS notification when a background worktree's agent session finishes/idles. Defaults to enabled. */
export async function loadAgentSessionNotificationsEnabled(): Promise<boolean> {
const raw = await api.getSetting(AGENT_SESSION_NOTIFICATIONS_KEY);
return raw !== 'false';
}

export async function saveAgentSessionNotificationsEnabled(enabled: boolean): Promise<void> {
await api.setSetting(AGENT_SESSION_NOTIFICATIONS_KEY, String(enabled));
}
70 changes: 70 additions & 0 deletions app/src/renderer/hooks/useAgentSessionNotifications.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { useEffect, useRef } from 'react';
import { api } from '../api.js';
import { useWorkspaceStore } from '../stores/workspace-store.js';
import { loadAgentSessionNotificationsEnabled } from '../agent-session-notification-settings.js';
import type { WorktreeInfo, AgentSessionStatusEvent, NotificationClickedEvent } from '@sproutgit/types';

function worktreeLabel(cwd: string, worktrees: WorktreeInfo[]): string {
const wt = worktrees.find(w => w.path === cwd);
if (!wt) return cwd.split(/[\\/]/).pop() ?? cwd;
return wt.branch ?? (wt.detached ? 'detached HEAD' : (wt.path.split(/[\\/]/).pop() ?? wt.path));
}

/**
* Fires a native OS notification when a background worktree's agent session
* finishes or goes idle, and jumps back to that session when the
* notification is clicked.
*
* "Background" means the app window isn't focused, a different worktree is
* selected, or the Terminal tab isn't active — only the renderer has all
* three signals, so (unlike idle *detection*, which lives in the main
* process's TerminalManager) the decision of whether to actually show a
* notification is made here, not in main.
*/
export function useAgentSessionNotifications(params: {
worktrees: WorktreeInfo[];
onJump: (session: { id: string; cwd: string }) => void;
}) {
// Refs (not effect deps) so the listeners below can read the latest
// worktrees/onJump without unsubscribing/resubscribing on every render.
const worktreesRef = useRef(params.worktrees);
worktreesRef.current = params.worktrees;
const onJumpRef = useRef(params.onJump);
onJumpRef.current = params.onJump;

useEffect(() => {
const offStatus = api.onAgentSessionStatus((event: AgentSessionStatusEvent) => {
void (async () => {
const enabled = await loadAgentSessionNotificationsEnabled();
if (!enabled) return;

const { activeWorktree, activeTab } = useWorkspaceStore.getState();
const isForeground =
document.hasFocus() && activeWorktree?.path === event.cwd && activeTab === 'terminal';
if (isForeground) return;

const agentLabel = event.agentName ? `"${event.agentName}"` : 'Agent';
const title = event.reason === 'exited'
? `${agentLabel} session finished`
: `${agentLabel} session went idle`;
const body = `${worktreeLabel(event.cwd, worktreesRef.current)} — click to jump back in`;

await api.showAgentSessionNotification({
title,
body,
worktreePath: event.cwd,
terminalId: event.id,
});
})().catch(err => console.error('[agent-session-notifications]', err));

Check warning on line 58 in app/src/renderer/hooks/useAgentSessionNotifications.ts

View workflow job for this annotation

GitHub Actions / Lint & Typecheck

eslint(no-console)

Unexpected console statement.
});

const offClicked = api.onNotificationClicked((event: NotificationClickedEvent) => {
onJumpRef.current({ id: event.terminalId, cwd: event.worktreePath });
});

return () => {
offStatus();
offClicked();
};
}, []);
}
2 changes: 2 additions & 0 deletions app/src/renderer/routes/settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { ShellSection } from '../settings/ShellSection.js';
import { AppSection } from '../settings/AppSection.js';
import { AISection } from '../settings/AISection.js';
import { McpSection } from '../settings/McpSection.js';
import { NotificationsSection } from '../settings/NotificationsSection.js';

// ── Route definition ──────────────────────────────────────────────────────────

Expand Down Expand Up @@ -91,6 +92,7 @@ function SettingsPage() {

<div className="flex flex-col gap-6">
<ShellSection onToast={toast} />
<NotificationsSection onToast={toast} />
<AppSection />
</div>
</div>
Expand Down
12 changes: 8 additions & 4 deletions app/src/renderer/routes/workspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { StagingPanel, Spinner, ContextMenuProvider } from "@sproutgit/ui";
import { GitBranch, GitMerge, Terminal, Bot, FileCode2 } from "lucide-react";
import type { WorktreeInfo, TerminalInfo } from "@sproutgit/types";
import type { WorktreeInfo } from "@sproutgit/types";
import { useToast } from "../toast-context.js";
import { useWorkspaceStore } from "../stores/workspace-store.js";
import {
Expand All @@ -32,6 +32,7 @@ import { useScaffoldKickoff } from "../hooks/useScaffoldKickoff.js";
import { useWorktreeSelection } from "../hooks/useWorktreeSelection.js";
import { useCommitDiffState } from "../hooks/useCommitDiffState.js";
import { useTerminalManager } from "../hooks/useTerminalManager.js";
import { useAgentSessionNotifications } from "../hooks/useAgentSessionNotifications.js";
import { useAutoUpdateListeners } from "../hooks/useAutoUpdateListeners.js";
import { useWorkspaceFileWatchers } from "../hooks/useWorkspaceFileWatchers.js";
import { useWorkspaceUiPersistence } from "../hooks/useWorkspaceUiPersistence.js";
Expand Down Expand Up @@ -198,9 +199,10 @@ function WorkspaceInner() {
closeDeleteDialog: () => setDeleteTarget(null),
});

/** Jump-to-terminal action from the agent sessions dashboard: switches to
* the session's worktree (if not already active) and focuses its terminal tab. */
function handleJumpToSession(session: TerminalInfo) {
/** Jump-to-terminal action from the agent sessions dashboard (and from
* clicking an agent-session-finished OS notification): switches to the
* session's worktree (if not already active) and focuses its terminal tab. */
function handleJumpToSession(session: { id: string; cwd: string }) {
const wt = worktrees.find((w) => w.path === session.cwd);
// The dashboard already filters to this workspace's worktrees, but guard
// here too — jumping to a session with no matching worktree would leave
Expand All @@ -220,6 +222,8 @@ function WorkspaceInner() {
setSessionsPanelOpen(false);
}

useAgentSessionNotifications({ worktrees, onJump: handleJumpToSession });

// ── Commit diff state ──────────────────────────────────────────────────
const {
selectedCommits,
Expand Down
61 changes: 61 additions & 0 deletions app/src/renderer/settings/NotificationsSection.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { Bell } from 'lucide-react';
import { useEffect, useState } from 'react';
import type { ToastData } from '@sproutgit/ui';
import { loadAgentSessionNotificationsEnabled, saveAgentSessionNotificationsEnabled } from '../agent-session-notification-settings.js';

interface Props {
onToast: (msg: string, variant?: ToastData['variant']) => void;
}

export function NotificationsSection({ onToast }: Props) {
const [enabled, setEnabled] = useState(true);
const [loaded, setLoaded] = useState(false);

useEffect(() => {
void loadAgentSessionNotificationsEnabled()
.then(value => setEnabled(value))
.catch(err => onToast(String(err), 'error'))
.finally(() => setLoaded(true));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

async function toggle() {
const previous = enabled;
const next = !enabled;
setEnabled(next);
try {
await saveAgentSessionNotificationsEnabled(next);
} catch (err) {
setEnabled(previous);
onToast(String(err), 'error');
}
}

return (
<section className="rounded-lg border border-(--sg-border) bg-(--sg-surface) p-5" data-testid="notifications-section">
<h2 className="sg-heading mb-3 text-sm font-semibold text-(--sg-primary) flex items-center gap-1.5">
<Bell size={15} /> Notifications
</h2>
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-xs text-(--sg-text-dim)">Agent session finished</p>
<p className="mt-0.5 text-[11px] text-(--sg-text-faint)">
Show an OS notification when a background worktree&apos;s agent session finishes or goes idle.
</p>
</div>
<label
className="sg-agent-session-notifications-toggle inline-flex shrink-0 cursor-pointer items-center gap-2 text-xs text-(--sg-text-dim)"
data-testid="agent-session-notifications-toggle"
>
<input
type="checkbox"
checked={enabled}
disabled={!loaded}
onChange={() => void toggle()}
/>
{enabled ? 'Enabled' : 'Disabled'}
</label>
</div>
</section>
);
}
7 changes: 5 additions & 2 deletions packages/terminal/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@
".": "./src/index.ts"
},
"scripts": {
"typecheck": "tsgo --noEmit"
"typecheck": "tsgo --noEmit",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@sproutgit/paths": "workspace:*",
Expand All @@ -19,7 +21,8 @@
"devDependencies": {
"@sproutgit/ts-config": "workspace:*",
"@types/node": "^26.1.0",
"typescript": "npm:@typescript/native-preview@7.0.0-dev.20260701.1"
"typescript": "npm:@typescript/native-preview@7.0.0-dev.20260701.1",
"vitest": "^4.1.9"
},
"license": "MIT"
}
Loading
Loading