diff --git a/apps/desktop/src/desktopTray.ts b/apps/desktop/src/desktopTray.ts index 7e4fcecdb..84fbd6d4f 100644 --- a/apps/desktop/src/desktopTray.ts +++ b/apps/desktop/src/desktopTray.ts @@ -14,6 +14,7 @@ type Options = { export function createDesktopTray(options: Options) { let tray: Tray | null = null; let quitting = false; + let approvalWaiting = false; const available = () => tray !== null && !tray.isDestroyed(); const invoke = (action: () => Promise) => { if (!quitting) void action().catch(options.reportError); @@ -22,7 +23,14 @@ export function createDesktopTray(options: Options) { if (!available()) return; const zh = options.isChinese(); const quittingLabel = zh ? '正在退出…' : 'Quitting…'; - tray!.setToolTip(quitting ? `PilotDeck — ${quittingLabel}` : 'PilotDeck'); + const approvalLabel = zh ? '等待授权' : 'Waiting for approval'; + tray!.setToolTip( + quitting + ? `PilotDeck — ${quittingLabel}` + : approvalWaiting + ? `PilotDeck — ${approvalLabel}` + : 'PilotDeck', + ); tray!.setContextMenu(options.buildMenu([ { label: zh ? '打开主界面' : 'Open main window', enabled: !quitting, click: () => invoke(options.open) }, { type: 'separator' }, @@ -49,5 +57,6 @@ export function createDesktopTray(options: Options) { } return { available, refreshMenu, dispose, setQuitting(value: boolean) { quitting = value; refreshMenu(); }, + setApprovalWaiting(value: boolean) { if (approvalWaiting !== value) { approvalWaiting = value; refreshMenu(); } }, }; } diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 9160d46f8..0942e1009 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -11,7 +11,7 @@ import { normalizeAppearance, renderLoadingHtml, startupText, type DesktopAppear import { saveAppearancePatch, appearanceImagePath, writeAppearanceImage } from './appearanceStorage'; import { createFilePicker } from './filePicker'; import { ComputerUseController } from './computerUse'; -import { app, BrowserWindow, dialog, ipcMain, Menu, shell, Tray, nativeImage, powerMonitor, nativeTheme, clipboard, screen } from "electron"; +import { app, BrowserWindow, dialog, ipcMain, Menu, Notification, shell, Tray, nativeImage, powerMonitor, nativeTheme, clipboard, screen } from "electron"; import { DebUpdater, MacUpdater, NsisUpdater, RpmUpdater } from "electron-updater"; import { installUpdateDownloadControl } from "./updateDownload"; import { createUpdateController } from "./updates"; @@ -612,6 +612,12 @@ async function createOrShowWindow(): Promise { ); }); lifecycle.attachWindow(recoveryWindow); + // Focusing the window ends the approval-waiting episode: the user is back + // in the app, so the taskbar should stop flashing and the next waiting + // transition may raise a fresh toast. + recoveryWindow.on("focus", () => { + clearApprovalWaitingAlert(); + }); // Windows logoff/shutdown does not go through app's normal quit events. // Respect the OS session end without displaying a quit confirmation. recoveryWindow.on("session-end", () => { @@ -1239,6 +1245,61 @@ ipcMain.handle('pilotdeck:pick-files', (event, request: unknown) => { return pickFiles(mainWindow!, request); }); +// --- Approval-waiting system alerts (issue #691) ----------------------------- +// The renderer asks for these only when the window is unfocused, so a user +// watching the in-app banner never gets a redundant toast. One toast per +// waiting episode; refocusing the window ends it. +let approvalAlertActive = false; + +function logApprovalAlert(message: string, error: unknown): void { + const line = `${new Date().toISOString()} [approval-alert] ${message} ${error instanceof Error ? error.stack ?? error.message : String(error)}\n`; + void fs.promises.appendFile(path.join(app.getPath("logs"), "renderer.log"), line).catch(() => {}); +} + +function presentApprovalWaitingAlert(count: number): void { + if (approvalAlertActive) return; + approvalAlertActive = true; + const zh = readAppearance().language === "zh-CN"; + desktopTray?.setApprovalWaiting(true); + try { + const body = count > 1 + ? zh ? `有 ${count} 项操作等待你的授权。` : `${count} operations are waiting for your approval.` + : zh ? "有操作等待你的授权。" : "An operation is waiting for your approval."; + const notification = new Notification({ title: "PilotDeck", body, silent: false }); + notification.on("click", () => { void createOrShowWindow(); }); + notification.show(); + } catch (error) { + logApprovalAlert("toast failed", error); + } + try { + if (mainWindow && !mainWindow.isDestroyed()) mainWindow.flashFrame(true); + } catch (error) { + logApprovalAlert("flashFrame failed", error); + } +} + +function clearApprovalWaitingAlert(): void { + if (!approvalAlertActive) return; + approvalAlertActive = false; + desktopTray?.setApprovalWaiting(false); + try { + if (mainWindow && !mainWindow.isDestroyed()) mainWindow.flashFrame(false); + } catch (error) { + logApprovalAlert("stop flashing failed", error); + } +} + +ipcMain.handle("pilotdeck:approval-waiting", (_event, detail: unknown) => { + const count = typeof (detail as { count?: unknown } | null)?.count === "number" + && Number.isFinite((detail as { count: number }).count) + ? Math.max(1, Math.floor((detail as { count: number }).count)) + : 1; + presentApprovalWaitingAlert(count); +}); +ipcMain.handle("pilotdeck:approval-clear", () => { + clearApprovalWaitingAlert(); +}); + if (process.platform === "win32") { app.setAppUserModelId(APP_ID); } diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 9c5cbb45c..30ced6d9c 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -49,6 +49,11 @@ contextBridge.exposeInMainWorld("pilotdeckDesktop", { }, retryRuntime: () => ipcRenderer.invoke("pilotdeck:retry-runtime"), openRuntimeLog: () => ipcRenderer.invoke("pilotdeck:open-runtime-log"), + // Approval-waiting alerts (issue #691): the renderer raises a native toast + // and a taskbar flash only when the window is unfocused, and clears them + // once the pending approval queue drains. + notifyApprovalWaiting: (detail: { count: number }) => ipcRenderer.invoke("pilotdeck:approval-waiting", detail), + clearApprovalWaiting: () => ipcRenderer.invoke("pilotdeck:approval-clear"), pickFolder: () => ipcRenderer.invoke("pilotdeck:pick-folder"), pickFiles: (request: FilePickerRequest) => ipcRenderer.invoke('pilotdeck:pick-files', request) as Promise, }); diff --git a/ui/src/components/app-shell/AppShellV2.tsx b/ui/src/components/app-shell/AppShellV2.tsx index 9ae503c26..cd59793ac 100644 --- a/ui/src/components/app-shell/AppShellV2.tsx +++ b/ui/src/components/app-shell/AppShellV2.tsx @@ -31,6 +31,7 @@ import { type SessionProvider, } from '../../types/app'; import { api } from '../../utils/api'; +import { APPROVAL_WAITING_EVENT, type ApprovalWaitingDetail } from '../../utils/approvalAlert'; import { useRejectExternalFileDropOutsideTargets } from '../../utils/externalFileDrop'; import { resolveMarkdownFileHref } from '../chat/utils/resolveMarkdownFileHref'; import type { SessionNavigationOptions } from '../main-content/types/types'; @@ -167,6 +168,22 @@ export default function AppShellV2() { } }), [subscribe, localProcessingSessions, markSessionAsNotProcessing]); + // Sessions with a tool call waiting for user approval. The chat view owns + // the authoritative queue and broadcasts it via `pilotdeck:approval-waiting` + // so the badge survives navigating away from the conversation. + const [awaitingApprovalSessionIds, setAwaitingApprovalSessionIds] = useState>(() => new Set()); + useEffect(() => { + const handler = (event: Event) => { + const detail = (event as CustomEvent).detail; + const sessionIds = Array.isArray(detail?.sessionIds) + ? detail.sessionIds.filter((id): id is string => typeof id === 'string' && id.length > 0) + : []; + setAwaitingApprovalSessionIds(new Set(sessionIds)); + }; + window.addEventListener(APPROVAL_WAITING_EVENT, handler); + return () => window.removeEventListener(APPROVAL_WAITING_EVENT, handler); + }, []); + const misroutedFileFromUrl = useMemo(() => { @@ -691,6 +708,7 @@ export default function AppShellV2() { isMobile={isMobile} processingSessions={processingSessions} unreadSessionIds={unreadSessionIds} + awaitingApprovalSessionIds={awaitingApprovalSessionIds} onSelectProject={handleSelectProject} onSelectSession={handleSelectSession} onStartNewSession={handleStartNewSession} diff --git a/ui/src/components/app-shell/SidebarV2.tsx b/ui/src/components/app-shell/SidebarV2.tsx index 35578297c..4da9d793f 100644 --- a/ui/src/components/app-shell/SidebarV2.tsx +++ b/ui/src/components/app-shell/SidebarV2.tsx @@ -20,6 +20,7 @@ import { Pencil, GitBranch, Trash2, + TriangleAlert, } from 'lucide-react'; import type { AppTab, Project, ProjectSession } from '../../types/app'; import { cn } from '../../lib/utils.js'; @@ -137,7 +138,7 @@ const collectSessionsForProject = (project: Project): FlatSession[] => { .sort((a, b) => b.lastActivity - a.lastActivity); }; -type SessionIndicatorStatus = 'processing' | 'unread' | 'idle'; +type SessionIndicatorStatus = 'awaiting-approval' | 'processing' | 'unread' | 'idle'; export type SidebarV2Props = { projects: Project[]; @@ -150,6 +151,7 @@ export type SidebarV2Props = { isMobile?: boolean; processingSessions?: Set; unreadSessionIds?: Set; + awaitingApprovalSessionIds?: Set; onSelectProject: (project: Project) => void; onSelectSession: (project: Project, sessionId: string) => void; onStartNewSession: (project: Project) => void; @@ -269,6 +271,7 @@ export default function SidebarV2({ isMobile = false, processingSessions, unreadSessionIds, + awaitingApprovalSessionIds, onSelectProject, onSelectSession, onStartNewSession, @@ -768,13 +771,17 @@ export default function SidebarV2({ typeof sessionId === 'string' && sessionId.startsWith('new-session-'); const indicatorStatus: SessionIndicatorStatus = isOptimisticRow ? 'processing' - : processingSessions?.has(sessionId) - ? 'processing' - : unreadSessionIds?.has(sessionId) - ? 'unread' - : 'idle'; + : awaitingApprovalSessionIds?.has(sessionId) + ? 'awaiting-approval' + : processingSessions?.has(sessionId) + ? 'processing' + : unreadSessionIds?.has(sessionId) + ? 'unread' + : 'idle'; const indicatorLabel = - indicatorStatus === 'processing' + indicatorStatus === 'awaiting-approval' + ? t('sidebar:sessions.awaitingApproval', { defaultValue: 'Waiting for approval' }) + : indicatorStatus === 'processing' ? t('sidebar:sessions.processing', { defaultValue: 'Agent is running' }) : indicatorStatus === 'unread' ? t('sidebar:sessions.unread', { defaultValue: 'Unread messages' }) @@ -826,7 +833,8 @@ export default function SidebarV2({ > - {indicatorStatus === 'processing' ?