Skip to content
Closed
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
69f4c7e
feat(desktop): show unread completions in Windows taskbar
Andrew-Forster Sep 10, 2026
ea3ba7d
fix(desktop): keep background completions unread
Andrew-Forster Sep 10, 2026
4ad15b6
Improve Windows taskbar completion badges
Andrew-Forster Sep 10, 2026
c5bd2fa
feat(desktop): add macOS completion badges
Andrew-Forster Sep 10, 2026
f122ca6
Merge branch 'main' into andrew/windows-taskbar-unread-badge
Andrew-Forster Sep 11, 2026
a7ffbaa
Merge main and clear stale completion badges
Andrew-Forster Sep 11, 2026
6bf86b8
fix(desktop): log unread badge update failures
Andrew-Forster Sep 11, 2026
5ca4fec
Merge branch 'main' into andrew/windows-taskbar-unread-badge
Andrew-Forster Sep 12, 2026
e66ce09
Merge branch 'main' into andrew/windows-taskbar-unread-badge
Andrew-Forster Sep 12, 2026
18058e1
Merge branch 'main' into andrew/windows-taskbar-unread-badge
Andrew-Forster Sep 12, 2026
f1a64d6
Merge branch 'main' into andrew/windows-taskbar-unread-badge
Andrew-Forster Sep 12, 2026
271e01b
Merge branch 'main' into andrew/windows-taskbar-unread-badge
Andrew-Forster Sep 12, 2026
77362f5
Merge remote-tracking branch 'upstream/main' into andrew/windows-task…
Andrew-Forster Sep 12, 2026
4e51482
feat(desktop): show unread badge on Linux
Andrew-Forster Sep 12, 2026
a76e60c
Merge branch 'main' into andrew/windows-taskbar-unread-badge
Andrew-Forster Sep 12, 2026
b55553a
Merge branch 'main' into andrew/windows-taskbar-unread-badge
Andrew-Forster Sep 13, 2026
be54231
Merge branch 'main' into andrew/windows-taskbar-unread-badge
Andrew-Forster Sep 13, 2026
a79fd67
Merge branch 'main' into andrew/windows-taskbar-unread-badge
Andrew-Forster Sep 13, 2026
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
176 changes: 176 additions & 0 deletions apps/desktop/src/electron/DesktopUnreadBadge.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
import { assert, beforeEach, describe, it } from "@effect/vitest";
import { vi } from "vite-plus/test";

const { createFromBuffer, overlayImage, setBadgeCount } = vi.hoisted(() => ({
createFromBuffer: vi.fn(),
overlayImage: { isEmpty: vi.fn(() => false) },
setBadgeCount: vi.fn(() => true),
}));

vi.mock("electron", () => ({
app: { setBadgeCount },
nativeImage: { createFromBuffer },
}));

import { setDesktopUnreadBadge } from "./DesktopUnreadBadge.ts";

function makeWindow(destroyed = false) {
return {
isDestroyed: vi.fn(() => destroyed),
setOverlayIcon: vi.fn(),
};
}

describe("setDesktopUnreadBadge", () => {
beforeEach(() => {
createFromBuffer.mockReset();
createFromBuffer.mockReturnValue(overlayImage);
overlayImage.isEmpty.mockReset();
overlayImage.isEmpty.mockReturnValue(false);
setBadgeCount.mockReset();
setBadgeCount.mockReturnValue(true);
});

it("sets and clears the Windows taskbar overlay", () => {
const window = makeWindow();
const badgeDataUrl = "data:image/png;base64,one";

assert.isTrue(setDesktopUnreadBadge({ platform: "win32", window, count: 1, badgeDataUrl }));
assert.deepEqual(createFromBuffer.mock.calls, [
[Buffer.from("one", "base64"), { width: 64, height: 64, scaleFactor: 4 }],
]);
assert.deepEqual(window.setOverlayIcon.mock.calls[0], [
overlayImage,
"1 completed thread awaiting review",
]);

assert.isTrue(
setDesktopUnreadBadge({
platform: "win32",
window,
count: 0,
badgeDataUrl: null,
}),
);
assert.deepEqual(window.setOverlayIcon.mock.calls[1], [null, ""]);
});

it("uses the native badge count on macOS", () => {
assert.isTrue(
setDesktopUnreadBadge({ platform: "darwin", window: null, count: 2, badgeDataUrl: null }),
);
assert.isTrue(
setDesktopUnreadBadge({ platform: "darwin", window: null, count: 0, badgeDataUrl: null }),
);

assert.deepEqual(setBadgeCount.mock.calls, [[2], [0]]);
assert.lengthOf(createFromBuffer.mock.calls, 0);
});

it("returns the native result when macOS cannot show badge counts", () => {
setBadgeCount.mockReturnValueOnce(false);

assert.isFalse(
setDesktopUnreadBadge({ platform: "darwin", window: null, count: 1, badgeDataUrl: null }),
);
});

it("caches decoded Windows overlays", () => {
const window = makeWindow();
const badgeDataUrl = "data:image/png;base64,two";

assert.isTrue(setDesktopUnreadBadge({ platform: "win32", window, count: 2, badgeDataUrl }));
assert.isTrue(setDesktopUnreadBadge({ platform: "win32", window, count: 2, badgeDataUrl }));

assert.equal(createFromBuffer.mock.calls.length, 1);
});

it("rejects missing, malformed, and empty Windows badge images", () => {
const window = makeWindow();

assert.isFalse(
setDesktopUnreadBadge({
platform: "win32",
window,
count: 1,
badgeDataUrl: null,
}),
);
assert.isFalse(
setDesktopUnreadBadge({
platform: "win32",
window,
count: 1,
badgeDataUrl: "data:image/svg+xml;base64,badge",
}),
);

overlayImage.isEmpty.mockReturnValueOnce(true);
assert.isFalse(
setDesktopUnreadBadge({
platform: "win32",
window,
count: 1,
badgeDataUrl: "data:image/png;base64,empty",
}),
);
assert.lengthOf(window.setOverlayIcon.mock.calls, 0);
});

it("does nothing on unsupported platforms or without a live Windows window", () => {
const window = makeWindow();
const destroyedWindow = makeWindow(true);
const badgeDataUrl = "data:image/png;base64,platform";

assert.isFalse(
setDesktopUnreadBadge({
platform: "linux",
window,
count: 1,
badgeDataUrl,
}),
);
assert.isFalse(
setDesktopUnreadBadge({
platform: "win32",
window: null,
count: 1,
badgeDataUrl,
}),
);
assert.isFalse(
setDesktopUnreadBadge({
platform: "win32",
window: destroyedWindow,
count: 1,
badgeDataUrl,
}),
);
assert.lengthOf(window.setOverlayIcon.mock.calls, 0);
assert.lengthOf(destroyedWindow.setOverlayIcon.mock.calls, 0);
assert.lengthOf(setBadgeCount.mock.calls, 0);
});

it("fails soft when Electron rejects a badge update", () => {
const window = makeWindow();
window.setOverlayIcon.mockImplementation(() => {
throw new Error("overlay failed");
});

assert.isFalse(
setDesktopUnreadBadge({
platform: "win32",
window,
count: 1,
badgeDataUrl: "data:image/png;base64,failure",
}),
);

setBadgeCount.mockImplementationOnce(() => {
throw new Error("badge failed");
});
assert.isFalse(
setDesktopUnreadBadge({ platform: "darwin", window: null, count: 1, badgeDataUrl: null }),
);
});
});
69 changes: 69 additions & 0 deletions apps/desktop/src/electron/DesktopUnreadBadge.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import * as Electron from "electron";

const OVERLAY_SIZE = 16;
const OVERLAY_SCALE_FACTOR = 4;
const PNG_DATA_URL_PREFIX = "data:image/png;base64,";

const overlayByDataUrl = new Map<string, Electron.NativeImage>();

function getUnreadCompletionOverlay(dataUrl: string): Electron.NativeImage | null {
const cached = overlayByDataUrl.get(dataUrl);
if (cached !== undefined) {
return cached;
}

if (!dataUrl.startsWith(PNG_DATA_URL_PREFIX)) {
return null;
}

const overlay = Electron.nativeImage.createFromBuffer(
Buffer.from(dataUrl.slice(PNG_DATA_URL_PREFIX.length), "base64"),
{
width: OVERLAY_SIZE * OVERLAY_SCALE_FACTOR,
height: OVERLAY_SIZE * OVERLAY_SCALE_FACTOR,
scaleFactor: OVERLAY_SCALE_FACTOR,
},
);
if (overlay.isEmpty()) {
return null;
}

overlayByDataUrl.set(dataUrl, overlay);
return overlay;
}

export function setDesktopUnreadBadge(input: {
readonly platform: NodeJS.Platform;
readonly window: Pick<Electron.BrowserWindow, "isDestroyed" | "setOverlayIcon"> | null;
readonly count: number;
readonly badgeDataUrl: string | null;
}): boolean {
try {
if (input.platform === "darwin") {
return Electron.app.setBadgeCount(input.count);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
Comment thread
Andrew-Forster marked this conversation as resolved.
Outdated

if (input.platform !== "win32" || input.window === null || input.window.isDestroyed()) {
return false;
}

if (input.count === 0) {
input.window.setOverlayIcon(null, "");
return true;
}

const overlay =
input.badgeDataUrl === null ? null : getUnreadCompletionOverlay(input.badgeDataUrl);
if (overlay === null) {
return false;
}

input.window.setOverlayIcon(
overlay,
`${input.count} completed ${input.count === 1 ? "thread" : "threads"} awaiting review`,
);
return true;
} catch {
return false;
Comment thread
Andrew-Forster marked this conversation as resolved.
Outdated
}
}
2 changes: 2 additions & 0 deletions apps/desktop/src/ipc/DesktopIpcHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import {
pickProjectFavicon,
pickThemeFiles,
setTheme,
setTaskbarUnreadIndicator,
showContextMenu,
} from "./methods/window.ts";
import {
Expand Down Expand Up @@ -119,6 +120,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers"
yield* ipc.handle(pickProjectFavicon);
yield* ipc.handle(pickThemeFiles);
yield* ipc.handle(setTheme);
yield* ipc.handle(setTaskbarUnreadIndicator);
yield* ipc.handle(showContextMenu);
yield* ipc.handle(openExternal);
yield* ipc.handle(openSystemSettings);
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/ipc/channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export const PICK_FOLDER_CHANNEL = "desktop:pick-folder";
export const PICK_PROJECT_FAVICON_CHANNEL = "desktop:pick-project-favicon";
export const PICK_THEME_FILES_CHANNEL = "desktop:pick-theme-files";
export const SET_THEME_CHANNEL = "desktop:set-theme";
export const SET_TASKBAR_UNREAD_INDICATOR_CHANNEL = "desktop:set-taskbar-unread-indicator";
export const CONTEXT_MENU_CHANNEL = "desktop:context-menu";
export const OPEN_EXTERNAL_CHANNEL = "desktop:open-external";
export const OPEN_SYSTEM_SETTINGS_CHANNEL = "desktop:open-system-settings";
Expand Down
21 changes: 21 additions & 0 deletions apps/desktop/src/ipc/methods/window.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import * as ElectronMenu from "../../electron/ElectronMenu.ts";
import * as ElectronShell from "../../electron/ElectronShell.ts";
import * as ElectronTheme from "../../electron/ElectronTheme.ts";
import * as ElectronWindow from "../../electron/ElectronWindow.ts";
import { setDesktopUnreadBadge } from "../../electron/DesktopUnreadBadge.ts";
import * as IpcChannels from "../channels.ts";
import * as DesktopIpc from "../DesktopIpc.ts";
import {
Expand Down Expand Up @@ -268,6 +269,26 @@ export const setTheme = DesktopIpc.makeIpcMethod({
}),
});

export const setTaskbarUnreadIndicator = DesktopIpc.makeIpcMethod({
channel: IpcChannels.SET_TASKBAR_UNREAD_INDICATOR_CHANNEL,
payload: Schema.Struct({
count: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
badgeDataUrl: Schema.NullOr(Schema.String.check(Schema.isMaxLength(65_536))),
}),
result: Schema.Boolean,
handler: Effect.fn("desktop.ipc.window.setTaskbarUnreadIndicator")(function* (input) {
const environment = yield* DesktopEnvironment.DesktopEnvironment;
const electronWindow = yield* ElectronWindow.ElectronWindow;
const window = yield* electronWindow.main;
return setDesktopUnreadBadge({
platform: environment.platform,
window: Option.getOrNull(window),
count: input.count,
badgeDataUrl: input.badgeDataUrl,
});
}),
});

export const showContextMenu = DesktopIpc.makeIpcMethod({
channel: IpcChannels.CONTEXT_MENU_CHANNEL,
payload: ContextMenuInput,
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,8 @@ contextBridge.exposeInMainWorld("desktopBridge", {
ipcRenderer.invoke(IpcChannels.PICK_PROJECT_FAVICON_CHANNEL, initialPath),
pickThemeFiles: () => ipcRenderer.invoke(IpcChannels.PICK_THEME_FILES_CHANNEL, undefined),
setTheme: (theme) => ipcRenderer.invoke(IpcChannels.SET_THEME_CHANNEL, theme),
setTaskbarUnreadIndicator: (input) =>
ipcRenderer.invoke(IpcChannels.SET_TASKBAR_UNREAD_INDICATOR_CHANNEL, input),
showContextMenu: (items, position) =>
ipcRenderer.invoke(IpcChannels.CONTEXT_MENU_CHANNEL, {
items,
Expand Down
26 changes: 9 additions & 17 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@ import {
type PendingUserInputDraftAnswer,
} from "../pendingUserInput";
import { useUiStateStore } from "../uiStateStore";
import { useAcknowledgeThreadCompletion } from "./chat/useAcknowledgeThreadCompletion";
import {
latestWorkspaceMutationId,
useWorkspaceMutationRefresh,
Expand Down Expand Up @@ -1976,24 +1977,15 @@ export default function ChatView(props: ChatViewProps) {
const activeRunningTurnId =
(activeThread?.session?.status === "running" ? activeThread.session.activeTurnId : null) ??
(activeLatestTurn?.state === "running" ? activeLatestTurn.turnId : null);
// Reading a finished thread clears the sidebar's Done badge. The visit is
// stamped at the turn's completion time — not now/updatedAt — so it clears
// exactly the completion the user is looking at: a wake or completion that
// lands later still gets its signal (markThreadVisited never moves the
// timestamp backwards).
useEffect(() => {
const completedAt = serverThread?.latestTurn?.completedAt;
if (!serverThread?.id || !completedAt) return;
markThreadVisited(
scopedThreadKey(scopeThreadRef(serverThread.environmentId, serverThread.id)),
completedAt,
);
}, [
markThreadVisited,
serverThread?.environmentId,
serverThread?.id,
const completedThreadKey = serverThread?.id
? scopedThreadKey(scopeThreadRef(serverThread.environmentId, serverThread.id))
: null;
useAcknowledgeThreadCompletion(
completedThreadKey,
serverThread?.latestTurn?.completedAt,
]);
markThreadVisited,
isElectron,
);
useEffect(() => {
setMountedTerminalThreadKeys((currentThreadIds) => {
const nextThreadIds = reconcileMountedTerminalThreadIds({
Expand Down
6 changes: 5 additions & 1 deletion apps/web/src/components/Sidebar.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -611,7 +611,11 @@ export function useThreadJumpHintVisibility(): {
};
}

export function hasUnseenCompletion(thread: ThreadStatusInput): boolean {
export function hasUnseenCompletion<
T extends Pick<SidebarThreadSummary, "latestTurn"> & {
readonly lastVisitedAt?: string | undefined;
},
>(thread: T): boolean {
if (!thread.latestTurn?.completedAt) return false;
const completedAt = Date.parse(thread.latestTurn.completedAt);
if (Number.isNaN(completedAt)) return false;
Expand Down
Loading
Loading