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
2 changes: 2 additions & 0 deletions apps/desktop/src/ipc/DesktopIpcHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {
installUpdate,
setUpdateChannel,
} from "./methods/updates.ts";
import { showNotification } from "./methods/notification.ts";
import {
confirm,
getAppBranding,
Expand Down Expand Up @@ -75,6 +76,7 @@ export const installDesktopIpcHandlers = Effect.gen(function* () {
yield* ipc.handle(setTheme);
yield* ipc.handle(showContextMenu);
yield* ipc.handle(openExternal);
yield* ipc.handle(showNotification);

yield* ipc.handle(getUpdateState);
yield* ipc.handle(setUpdateChannel);
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/ipc/channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,5 @@ export const SET_SERVER_EXPOSURE_MODE_CHANNEL = "desktop:set-server-exposure-mod
export const SET_TAILSCALE_SERVE_ENABLED_CHANNEL = "desktop:set-tailscale-serve-enabled";
export const GET_ADVERTISED_ENDPOINTS_CHANNEL = "desktop:get-advertised-endpoints";
export const SSH_PASSWORD_PROMPT_CANCELLED_RESULT = "ssh-password-prompt-cancelled";
export const SHOW_NOTIFICATION_CHANNEL = "desktop:show-notification";
export const NOTIFICATION_NAVIGATE_CHANNEL = "desktop:notification-navigate";
46 changes: 46 additions & 0 deletions apps/desktop/src/ipc/methods/notification.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { DesktopNotificationPayloadSchema } from "@t3tools/contracts";
import * as Electron from "electron";
import * as Effect from "effect/Effect";
import * as Schema from "effect/Schema";

import * as IpcChannels from "../channels.ts";
import { makeIpcMethod } from "../DesktopIpc.ts";

export const showNotification = makeIpcMethod({
channel: IpcChannels.SHOW_NOTIFICATION_CHANNEL,
payload: DesktopNotificationPayloadSchema,
result: Schema.Void,
handler: Effect.fn("desktop.ipc.notification.show")(function* (payload) {
// Skip if any window is currently focused — user is already looking at the app
const focused = Electron.BrowserWindow.getFocusedWindow();
if (focused && !focused.isDestroyed()) {
return;
}

// macOS uses the app bundle's .icns as the notification icon automatically;
// setting `icon` here would add a secondary content image on the right instead.
const notification = new Electron.Notification({
title: payload.title,
body: payload.body,
});

const navigatePayload = {
environmentId: payload.threadEnvironmentId,
threadId: payload.threadId,
};

// The click handler fires asynchronously after IPC resolution so raw
// Electron APIs are used here instead of the Effect-wrapped services.
notification.on("click", () => {
const win = Electron.BrowserWindow.getAllWindows()[0];
if (!win || win.isDestroyed()) return;
if (win.isMinimized()) win.restore();
if (!win.isVisible()) win.show();
Electron.app.focus({ steal: true });
win.focus();
win.webContents.send(IpcChannels.NOTIFICATION_NAVIGATE_CHANNEL, navigatePayload);
});

notification.show();
}),

Check warning on line 45 in apps/desktop/src/ipc/methods/notification.ts

View workflow job for this annotation

GitHub Actions / Format, Lint, Typecheck, Test, Browser Test, Build

eslint(require-yield)

This generator function does not have `yield`
});
13 changes: 13 additions & 0 deletions apps/desktop/src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,4 +124,17 @@ contextBridge.exposeInMainWorld("desktopBridge", {
ipcRenderer.removeListener(IpcChannels.UPDATE_STATE_CHANNEL, wrappedListener);
};
},
showDesktopNotification: (payload) =>
ipcRenderer.invoke(IpcChannels.SHOW_NOTIFICATION_CHANNEL, payload),
onNotificationNavigate: (listener) => {
const wrappedListener = (_event: Electron.IpcRendererEvent, payload: unknown) => {
if (typeof payload !== "object" || payload === null) return;
listener(payload as Parameters<typeof listener>[0]);
};

ipcRenderer.on(IpcChannels.NOTIFICATION_NAVIGATE_CHANNEL, wrappedListener);
return () => {
ipcRenderer.removeListener(IpcChannels.NOTIFICATION_NAVIGATE_CHANNEL, wrappedListener);
};
},
} satisfies DesktopBridge);
3 changes: 3 additions & 0 deletions apps/desktop/src/settings/DesktopClientSettings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ const clientSettings: ClientSettings = {
sidebarThreadSortOrder: "created_at",
sidebarThreadPreviewCount: 6,
timestampFormat: "24-hour",
desktopNotifyOnTurnCompleted: false,
desktopNotifyOnInputNeeded: false,
desktopNotifyOnError: false,
};

const decodeClientSettingsJson = Schema.decodeEffect(Schema.fromJsonString(ClientSettingsSchema));
Expand Down
21 changes: 21 additions & 0 deletions apps/web/src/components/AppSidebarLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,27 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) {
};
}, [navigate]);

useEffect(() => {
const onNotificationNavigate = window.desktopBridge?.onNotificationNavigate;
if (typeof onNotificationNavigate !== "function") {
return;
}

const unsubscribe = onNotificationNavigate((payload) => {
void navigate({
to: "/$environmentId/$threadId",
params: {
environmentId: payload.environmentId,
threadId: payload.threadId,
},
});
});

return () => {
unsubscribe?.();
};
}, [navigate]);

return (
<SidebarProvider className="h-dvh! min-h-0!" defaultOpen>
<Sidebar
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/components/settings/SettingsPanels.browser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -465,6 +465,8 @@ const createDesktopBridgeStub = (overrides?: {
.fn()
.mockResolvedValue({ accepted: false, completed: false, state: idleUpdateState }),
onUpdateState: () => () => {},
showDesktopNotification: vi.fn().mockResolvedValue(undefined),
onNotificationNavigate: () => () => {},
};
};

Expand Down
84 changes: 84 additions & 0 deletions apps/web/src/components/settings/SettingsPanels.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -891,6 +891,90 @@ export function GeneralSettingsPanel() {
/>
</SettingsSection>

{isElectron ? (
<SettingsSection title="Desktop Notifications">
<SettingsRow
title="Turn completed"
description="Notify when an agent finishes a turn."
resetAction={
settings.desktopNotifyOnTurnCompleted !==
DEFAULT_UNIFIED_SETTINGS.desktopNotifyOnTurnCompleted ? (
<SettingResetButton
label="turn completed notification"
onClick={() =>
updateSettings({
desktopNotifyOnTurnCompleted:
DEFAULT_UNIFIED_SETTINGS.desktopNotifyOnTurnCompleted,
})
}
/>
) : null
}
control={
<Switch
checked={settings.desktopNotifyOnTurnCompleted}
onCheckedChange={(checked) =>
updateSettings({ desktopNotifyOnTurnCompleted: Boolean(checked) })
}
aria-label="Notify on turn completed"
/>
}
/>
<SettingsRow
title="Input needed"
description="Notify when the agent is waiting for your approval or response."
resetAction={
settings.desktopNotifyOnInputNeeded !==
DEFAULT_UNIFIED_SETTINGS.desktopNotifyOnInputNeeded ? (
<SettingResetButton
label="input needed notification"
onClick={() =>
updateSettings({
desktopNotifyOnInputNeeded:
DEFAULT_UNIFIED_SETTINGS.desktopNotifyOnInputNeeded,
})
}
/>
) : null
}
control={
<Switch
checked={settings.desktopNotifyOnInputNeeded}
onCheckedChange={(checked) =>
updateSettings({ desktopNotifyOnInputNeeded: Boolean(checked) })
}
aria-label="Notify on input needed"
/>
}
/>
<SettingsRow
title="Error"
description="Notify when an agent session encounters an error."
resetAction={
settings.desktopNotifyOnError !== DEFAULT_UNIFIED_SETTINGS.desktopNotifyOnError ? (
<SettingResetButton
label="error notification"
onClick={() =>
updateSettings({
desktopNotifyOnError: DEFAULT_UNIFIED_SETTINGS.desktopNotifyOnError,
})
}
/>
) : null
}
control={
<Switch
checked={settings.desktopNotifyOnError}
onCheckedChange={(checked) =>
updateSettings({ desktopNotifyOnError: Boolean(checked) })
}
aria-label="Notify on error"
/>
}
/>
</SettingsSection>
) : null}

<SettingsSection title="About">
{isElectron || HOSTED_APP_CHANNEL ? (
<AboutVersionSection />
Expand Down
89 changes: 89 additions & 0 deletions apps/web/src/desktopNotifications.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import type { ClientSettings, EnvironmentId, ThreadId } from "@t3tools/contracts";

import { getClientSettings } from "~/hooks/useSettings";
import type { SidebarThreadSummary } from "./types";

const lastNotificationByThread = new Map<string, number>();
const NOTIFICATION_COOLDOWN_MS = 5_000;

export function maybeFireDesktopNotification(
environmentId: EnvironmentId,
threadId: ThreadId,
previous: SidebarThreadSummary | undefined,
next: SidebarThreadSummary,
): void {
const bridge = window.desktopBridge;

Check failure on line 15 in apps/web/src/desktopNotifications.ts

View workflow job for this annotation

GitHub Actions / Format, Lint, Typecheck, Test, Browser Test, Build

src/environments/runtime/service.threadSubscriptions.test.ts > retainThreadDetailSubscription > keeps non-idle thread detail subscriptions attached until the thread becomes idle

ReferenceError: window is not defined ❯ maybeFireDesktopNotification src/desktopNotifications.ts:15:18 ❯ Object.applyShellEvent src/environments/runtime/service.ts:1079:11 ❯ src/environments/runtime/service.threadSubscriptions.test.ts:310:21
if (!bridge?.showDesktopNotification) return;

const settings = getClientSettings();
if (
!settings.desktopNotifyOnTurnCompleted &&
!settings.desktopNotifyOnInputNeeded &&
!settings.desktopNotifyOnError
) {
return;
}

const key = `${environmentId}:${threadId}`;
const now = Date.now();
const last = lastNotificationByThread.get(key);
if (last !== undefined && now - last < NOTIFICATION_COOLDOWN_MS) return;

const notification = deriveNotification(previous, next, settings);
if (!notification) return;

lastNotificationByThread.set(key, now);

void bridge.showDesktopNotification({
title: notification.title,
body: notification.body,
threadEnvironmentId: environmentId,
threadId,
});
}

function deriveNotification(
previous: SidebarThreadSummary | undefined,
next: SidebarThreadSummary,
settings: ClientSettings,
): { title: string; body: string } | null {
const threadLabel = next.title || "Thread";

if (
settings.desktopNotifyOnError &&
next.session?.status === "error" &&
previous?.session?.status !== "error"
) {
return { title: "Error", body: threadLabel };
}

if (
settings.desktopNotifyOnInputNeeded &&
next.hasPendingApprovals &&
!previous?.hasPendingApprovals
) {
return { title: "Approval needed", body: threadLabel };
}

if (
settings.desktopNotifyOnInputNeeded &&
next.hasPendingUserInput &&
!previous?.hasPendingUserInput
) {
return { title: "Input needed", body: threadLabel };
}

if (settings.desktopNotifyOnTurnCompleted) {
const prevTurnState = previous?.latestTurn?.state;
const nextTurnState = next.latestTurn?.state;
if (
nextTurnState === "completed" &&
prevTurnState !== "completed" &&
prevTurnState !== undefined
) {
return { title: "Turn completed", body: threadLabel };
}
}

return null;
}
18 changes: 17 additions & 1 deletion apps/web/src/environments/runtime/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
} from "~/composerDraftStore";
import { ensureLocalApi } from "~/localApi";
import { collectActiveTerminalThreadIds } from "~/lib/terminalStateCleanup";
import { maybeFireDesktopNotification } from "~/desktopNotifications";
import { deriveOrchestrationBatchEffects } from "~/orchestrationEventEffects";
import { projectQueryKeys } from "~/lib/projectReactQuery";
import { providerQueryKeys } from "~/lib/providerReactQuery";
Expand Down Expand Up @@ -1048,7 +1049,11 @@ function applyShellEvent(event: OrchestrationShellStreamEvent, environmentId: En
? event.threadId
: null;
const threadRef = threadId ? scopeThreadRef(environmentId, threadId) : null;
const previousThread = threadRef ? selectThreadByRef(useStore.getState(), threadRef) : undefined;
const previousStoreState = useStore.getState();
const previousThread = threadRef ? selectThreadByRef(previousStoreState, threadRef) : undefined;
const previousSidebarSummary = threadRef
? selectSidebarThreadSummaryByRef(previousStoreState, threadRef)
: undefined;

useStore.getState().applyShellEvent(event, environmentId);
markAppliedProjectionEvent(environmentId, event.sequence);
Expand All @@ -1068,6 +1073,17 @@ function applyShellEvent(event: OrchestrationShellStreamEvent, environmentId: En
}
reconcileThreadDetailSubscriptionEvictionForThread(environmentId, event.thread.id);
evictIdleThreadDetailSubscriptionsToCapacity();
if (threadRef) {
const nextSidebarSummary = selectSidebarThreadSummaryByRef(useStore.getState(), threadRef);
if (nextSidebarSummary) {
maybeFireDesktopNotification(
environmentId,
event.thread.id,
previousSidebarSummary,
nextSidebarSummary,
);
}
}
return;
case "thread-removed":
if (threadRef) {
Expand Down
8 changes: 8 additions & 0 deletions apps/web/src/localApi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,8 @@ function makeDesktopBridge(overrides: Partial<DesktopBridge> = {}): DesktopBridg
throw new Error("installUpdate not implemented in test");
},
onUpdateState: () => () => undefined,
showDesktopNotification: async () => undefined,
onNotificationNavigate: () => () => undefined,
...overrides,
};
}
Expand Down Expand Up @@ -615,6 +617,9 @@ describe("wsApi", () => {
sidebarThreadSortOrder: "created_at" as const,
sidebarThreadPreviewCount: 6,
timestampFormat: "24-hour" as const,
desktopNotifyOnTurnCompleted: false,
desktopNotifyOnInputNeeded: false,
desktopNotifyOnError: false,
};
const getClientSettings = vi.fn().mockResolvedValue({
...clientSettings,
Expand Down Expand Up @@ -678,6 +683,9 @@ describe("wsApi", () => {
sidebarThreadSortOrder: "created_at" as const,
sidebarThreadPreviewCount: 6,
timestampFormat: "24-hour" as const,
desktopNotifyOnTurnCompleted: false,
desktopNotifyOnInputNeeded: false,
desktopNotifyOnError: false,
};

await api.persistence.setClientSettings(clientSettings);
Expand Down
Loading
Loading