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
11 changes: 10 additions & 1 deletion apps/desktop/src/desktopTray.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>) => {
if (!quitting) void action().catch(options.reportError);
Expand All @@ -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' },
Expand All @@ -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(); } },
};
}
63 changes: 62 additions & 1 deletion apps/desktop/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -612,6 +612,12 @@ async function createOrShowWindow(): Promise<void> {
);
});
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", () => {
Expand Down Expand Up @@ -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);
}
Expand Down
5 changes: 5 additions & 0 deletions apps/desktop/src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<FilePickerResult>,
});
18 changes: 18 additions & 0 deletions ui/src/components/app-shell/AppShellV2.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<Set<string>>(() => new Set());
useEffect(() => {
const handler = (event: Event) => {
const detail = (event as CustomEvent<ApprovalWaitingDetail>).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(() => {
Expand Down Expand Up @@ -691,6 +708,7 @@ export default function AppShellV2() {
isMobile={isMobile}
processingSessions={processingSessions}
unreadSessionIds={unreadSessionIds}
awaitingApprovalSessionIds={awaitingApprovalSessionIds}
onSelectProject={handleSelectProject}
onSelectSession={handleSelectSession}
onStartNewSession={handleStartNewSession}
Expand Down
24 changes: 16 additions & 8 deletions ui/src/components/app-shell/SidebarV2.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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[];
Expand All @@ -150,6 +151,7 @@ export type SidebarV2Props = {
isMobile?: boolean;
processingSessions?: Set<string>;
unreadSessionIds?: Set<string>;
awaitingApprovalSessionIds?: Set<string>;
onSelectProject: (project: Project) => void;
onSelectSession: (project: Project, sessionId: string) => void;
onStartNewSession: (project: Project) => void;
Expand Down Expand Up @@ -269,6 +271,7 @@ export default function SidebarV2({
isMobile = false,
processingSessions,
unreadSessionIds,
awaitingApprovalSessionIds,
onSelectProject,
onSelectSession,
onStartNewSession,
Expand Down Expand Up @@ -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' })
Expand Down Expand Up @@ -826,7 +833,8 @@ export default function SidebarV2({
>
<span className={cn('session-indicator', options.flat && 'session-indicator-flat')}
data-session-indicator={sessionId} data-status={indicatorStatus} aria-label={indicatorLabel} title={indicatorLabel}>
{indicatorStatus === 'processing' ? <Loader2 aria-hidden="true" className="h-3 w-3 animate-spin" />
{indicatorStatus === 'awaiting-approval' ? <TriangleAlert aria-hidden="true" className="h-3 w-3" />
: indicatorStatus === 'processing' ? <Loader2 aria-hidden="true" className="h-3 w-3 animate-spin" />
: options.flat && indicatorStatus === 'idle' ? (
<svg aria-hidden="true" className="icon" fill="none" height="16" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.8" viewBox="0 0 24 24" width="16">
<path d="M22 17a2 2 0 0 1-2 2H6.828a2 2 0 0 0-1.414.586l-2.202 2.202A.71.71 0 0 1 2 21.286V5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2z" />
Expand Down
9 changes: 9 additions & 0 deletions ui/src/components/chat-v2/ChatInterfaceV2.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { useChatProviderState } from '../chat/hooks/useChatProviderState';
import { useChatSessionState } from '../chat/hooks/useChatSessionState';
import { useChatRealtimeHandlers } from '../chat/hooks/useChatRealtimeHandlers';
import { useChatComposerState } from '../chat/hooks/useChatComposerState';
import { useApprovalWaitingAlert } from '../chat/hooks/useApprovalWaitingAlert';
import { useSessionInputQueue } from '../chat/hooks/useSessionInputQueue';
import { isSendingInput } from '../chat/types/queuedInput';
import {
Expand Down Expand Up @@ -450,6 +451,14 @@ function ChatInterfaceV2({
sessionStore,
});

useApprovalWaitingAlert({
pendingPermissionRequests,
fallbackSessionId: currentSessionId ?? selectedSession?.id ?? null,
waitingLabel: t('approvalAlert.documentTitle', {
defaultValue: '⚠ Waiting for approval',
}) as string,
});

useEffect(() => {
if (!isLoading || !canAbortSession) {
setIsAbortPending(false);
Expand Down
Loading