From 69f4c7e3db05aeb8144994388e260a546f1e0449 Mon Sep 17 00:00:00 2001 From: Andrew Forster Date: Wed, 9 Sep 2026 21:21:27 -0700 Subject: [PATCH 1/6] feat(desktop): show unread completions in Windows taskbar --- .../src/electron/WindowsTaskbarBadge.test.ts | 66 ++++++++++++++++++ .../src/electron/WindowsTaskbarBadge.ts | 54 +++++++++++++++ apps/desktop/src/ipc/DesktopIpcHandlers.ts | 2 + apps/desktop/src/ipc/channels.ts | 1 + apps/desktop/src/ipc/methods/window.ts | 17 +++++ apps/desktop/src/preload.ts | 2 + apps/web/src/components/Sidebar.logic.ts | 6 +- .../DesktopTaskbarUnreadCoordinator.test.ts | 68 +++++++++++++++++++ .../DesktopTaskbarUnreadCoordinator.tsx | 57 ++++++++++++++++ apps/web/src/routes/__root.tsx | 5 ++ packages/contracts/src/ipc.ts | 2 + 11 files changed, 279 insertions(+), 1 deletion(-) create mode 100644 apps/desktop/src/electron/WindowsTaskbarBadge.test.ts create mode 100644 apps/desktop/src/electron/WindowsTaskbarBadge.ts create mode 100644 apps/web/src/components/desktop/DesktopTaskbarUnreadCoordinator.test.ts create mode 100644 apps/web/src/components/desktop/DesktopTaskbarUnreadCoordinator.tsx diff --git a/apps/desktop/src/electron/WindowsTaskbarBadge.test.ts b/apps/desktop/src/electron/WindowsTaskbarBadge.test.ts new file mode 100644 index 000000000000..2e10cc01ff0f --- /dev/null +++ b/apps/desktop/src/electron/WindowsTaskbarBadge.test.ts @@ -0,0 +1,66 @@ +import { assert, beforeEach, describe, it } from "@effect/vitest"; +import { vi } from "vite-plus/test"; + +const { createFromBitmap } = vi.hoisted(() => ({ + createFromBitmap: vi.fn(() => ({ kind: "unread-completion-overlay" })), +})); + +vi.mock("electron", () => ({ + nativeImage: { createFromBitmap }, +})); + +import { setWindowsTaskbarUnreadIndicator } from "./WindowsTaskbarBadge.ts"; + +function makeWindow(destroyed = false) { + return { + isDestroyed: vi.fn(() => destroyed), + setOverlayIcon: vi.fn(), + }; +} + +describe("setWindowsTaskbarUnreadIndicator", () => { + beforeEach(() => { + createFromBitmap.mockClear(); + }); + + it("sets and clears the Windows taskbar overlay", () => { + const window = makeWindow(); + + assert.isTrue(setWindowsTaskbarUnreadIndicator({ platform: "win32", window, visible: true })); + assert.deepEqual(window.setOverlayIcon.mock.calls, [ + [{ kind: "unread-completion-overlay" }, "Completed thread awaiting review"], + ]); + + assert.isTrue(setWindowsTaskbarUnreadIndicator({ platform: "win32", window, visible: false })); + assert.deepEqual(window.setOverlayIcon.mock.calls[1], [null, ""]); + }); + + it("does nothing outside Windows or without a live window", () => { + const window = makeWindow(); + const destroyedWindow = makeWindow(true); + + assert.isFalse(setWindowsTaskbarUnreadIndicator({ platform: "darwin", window, visible: true })); + assert.isFalse(setWindowsTaskbarUnreadIndicator({ platform: "linux", window, visible: true })); + assert.isFalse( + setWindowsTaskbarUnreadIndicator({ platform: "win32", window: null, visible: true }), + ); + assert.isFalse( + setWindowsTaskbarUnreadIndicator({ + platform: "win32", + window: destroyedWindow, + visible: true, + }), + ); + assert.lengthOf(window.setOverlayIcon.mock.calls, 0); + assert.lengthOf(destroyedWindow.setOverlayIcon.mock.calls, 0); + }); + + it("fails soft when Electron rejects the overlay", () => { + const window = makeWindow(); + window.setOverlayIcon.mockImplementation(() => { + throw new Error("overlay failed"); + }); + + assert.isFalse(setWindowsTaskbarUnreadIndicator({ platform: "win32", window, visible: true })); + }); +}); diff --git a/apps/desktop/src/electron/WindowsTaskbarBadge.ts b/apps/desktop/src/electron/WindowsTaskbarBadge.ts new file mode 100644 index 000000000000..f5598305f15a --- /dev/null +++ b/apps/desktop/src/electron/WindowsTaskbarBadge.ts @@ -0,0 +1,54 @@ +import * as Electron from "electron"; + +const OVERLAY_SIZE = 16; +const OVERLAY_DESCRIPTION = "Completed thread awaiting review"; + +let unreadCompletionOverlay: Electron.NativeImage | undefined; + +function getUnreadCompletionOverlay(): Electron.NativeImage { + if (unreadCompletionOverlay !== undefined) { + return unreadCompletionOverlay; + } + + // The Windows bitmap representation is BGRA. Opaque pixels avoid + // platform-specific alpha premultiplication while keeping the icon crisp. + const bitmap = Buffer.alloc(OVERLAY_SIZE * OVERLAY_SIZE * 4); + const center = (OVERLAY_SIZE - 1) / 2; + for (let y = 0; y < OVERLAY_SIZE; y += 1) { + for (let x = 0; x < OVERLAY_SIZE; x += 1) { + const distance = Math.hypot(x - center, y - center); + const offset = (y * OVERLAY_SIZE + x) * 4; + if (distance <= 4.5) { + bitmap.set([129, 185, 16, 255], offset); + } else if (distance <= 6.25) { + bitmap.set([255, 255, 255, 255], offset); + } + } + } + + unreadCompletionOverlay = Electron.nativeImage.createFromBitmap(bitmap, { + width: OVERLAY_SIZE, + height: OVERLAY_SIZE, + }); + return unreadCompletionOverlay; +} + +export function setWindowsTaskbarUnreadIndicator(input: { + readonly platform: NodeJS.Platform; + readonly window: Pick | null; + readonly visible: boolean; +}): boolean { + if (input.platform !== "win32" || input.window === null || input.window.isDestroyed()) { + return false; + } + + try { + input.window.setOverlayIcon( + input.visible ? getUnreadCompletionOverlay() : null, + input.visible ? OVERLAY_DESCRIPTION : "", + ); + return true; + } catch { + return false; + } +} diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index dc3769bb814f..df3524a50870 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -44,6 +44,7 @@ import { pickProjectFavicon, pickThemeFiles, setTheme, + setTaskbarUnreadIndicator, showContextMenu, } from "./methods/window.ts"; import { @@ -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); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 43ecee06c0ca..423d91d7cd47 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -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"; diff --git a/apps/desktop/src/ipc/methods/window.ts b/apps/desktop/src/ipc/methods/window.ts index 61de1361a311..f8096c3ee029 100644 --- a/apps/desktop/src/ipc/methods/window.ts +++ b/apps/desktop/src/ipc/methods/window.ts @@ -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 { setWindowsTaskbarUnreadIndicator } from "../../electron/WindowsTaskbarBadge.ts"; import * as IpcChannels from "../channels.ts"; import * as DesktopIpc from "../DesktopIpc.ts"; import { @@ -268,6 +269,22 @@ export const setTheme = DesktopIpc.makeIpcMethod({ }), }); +export const setTaskbarUnreadIndicator = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.SET_TASKBAR_UNREAD_INDICATOR_CHANNEL, + payload: Schema.Struct({ visible: Schema.Boolean }), + 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 setWindowsTaskbarUnreadIndicator({ + platform: environment.platform, + window: Option.getOrNull(window), + visible: input.visible, + }); + }), +}); + export const showContextMenu = DesktopIpc.makeIpcMethod({ channel: IpcChannels.CONTEXT_MENU_CHANNEL, payload: ContextMenuInput, diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 7da32d7913ae..5d45f8016058 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -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, diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 0150eb473d51..70474979493d 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -611,7 +611,11 @@ export function useThreadJumpHintVisibility(): { }; } -export function hasUnseenCompletion(thread: ThreadStatusInput): boolean { +export function hasUnseenCompletion< + T extends Pick & { + 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; diff --git a/apps/web/src/components/desktop/DesktopTaskbarUnreadCoordinator.test.ts b/apps/web/src/components/desktop/DesktopTaskbarUnreadCoordinator.test.ts new file mode 100644 index 000000000000..429f936faa5a --- /dev/null +++ b/apps/web/src/components/desktop/DesktopTaskbarUnreadCoordinator.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vite-plus/test"; +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models"; +import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; + +import { hasUnseenTaskbarCompletion } from "./DesktopTaskbarUnreadCoordinator"; + +const environmentId = "environment-1" as EnvironmentId; +const threadId = "thread-1" as ThreadId; +const threadKey = `${environmentId}:${threadId}`; + +function completedThread( + overrides: Partial< + Pick + > = {}, +) { + return { + environmentId, + id: threadId, + archivedAt: null, + latestTurn: { + turnId: "turn-1", + state: "completed", + requestedAt: "2026-09-09T12:00:00.000Z", + startedAt: "2026-09-09T12:00:01.000Z", + completedAt: "2026-09-09T12:00:10.000Z", + assistantMessageId: null, + }, + ...overrides, + } as Pick; +} + +describe("hasUnseenTaskbarCompletion", () => { + it("clears after the completed thread is visited", () => { + const threads = [completedThread()]; + + expect( + hasUnseenTaskbarCompletion(threads, { + [threadKey]: "2026-09-09T12:00:05.000Z", + }), + ).toBe(true); + expect( + hasUnseenTaskbarCompletion(threads, { + [threadKey]: "2026-09-09T12:00:10.000Z", + }), + ).toBe(false); + }); + + it("stays visible until every unseen completion is visited", () => { + const secondThreadId = "thread-2" as ThreadId; + const threads = [completedThread(), completedThread({ id: secondThreadId })]; + + expect( + hasUnseenTaskbarCompletion(threads, { + [threadKey]: "2026-09-09T12:00:10.000Z", + [`${environmentId}:${secondThreadId}`]: "2026-09-09T12:00:05.000Z", + }), + ).toBe(true); + }); + + it("ignores archived and never-visited historical threads", () => { + expect( + hasUnseenTaskbarCompletion([completedThread({ archivedAt: "2026-09-09T12:01:00.000Z" })], { + [threadKey]: "2026-09-09T12:00:05.000Z", + }), + ).toBe(false); + expect(hasUnseenTaskbarCompletion([completedThread()], {})).toBe(false); + }); +}); diff --git a/apps/web/src/components/desktop/DesktopTaskbarUnreadCoordinator.tsx b/apps/web/src/components/desktop/DesktopTaskbarUnreadCoordinator.tsx new file mode 100644 index 000000000000..dbc84885bf1c --- /dev/null +++ b/apps/web/src/components/desktop/DesktopTaskbarUnreadCoordinator.tsx @@ -0,0 +1,57 @@ +import { scopedThreadKey } from "@t3tools/client-runtime/environment"; +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models"; +import { useEffect, useMemo } from "react"; + +import { useThreadShells } from "../../state/entities"; +import { useUiStateStore } from "../../uiStateStore"; +import { hasUnseenCompletion } from "../Sidebar.logic"; + +type TaskbarUnreadThread = Pick< + EnvironmentThreadShell, + "archivedAt" | "environmentId" | "id" | "latestTurn" +>; + +export function hasUnseenTaskbarCompletion( + threads: ReadonlyArray, + lastVisitedAtByThreadKey: Readonly>, +): boolean { + return threads.some((thread) => { + if (thread.archivedAt !== null) { + return false; + } + const threadKey = scopedThreadKey({ + environmentId: thread.environmentId, + threadId: thread.id, + }); + return hasUnseenCompletion({ + latestTurn: thread.latestTurn, + lastVisitedAt: lastVisitedAtByThreadKey[threadKey], + }); + }); +} + +export function DesktopTaskbarUnreadCoordinator() { + const threads = useThreadShells(); + const lastVisitedAtByThreadKey = useUiStateStore((state) => state.threadLastVisitedAtById); + const visible = useMemo( + () => hasUnseenTaskbarCompletion(threads, lastVisitedAtByThreadKey), + [lastVisitedAtByThreadKey, threads], + ); + const setIndicator = window.desktopBridge?.setTaskbarUnreadIndicator; + + useEffect(() => { + if (setIndicator === undefined) { + return; + } + void setIndicator({ visible }).catch(() => {}); + }, [setIndicator, visible]); + + useEffect( + () => () => { + void setIndicator?.({ visible: false }).catch(() => {}); + }, + [setIndicator], + ); + + return null; +} diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 23b1e4c58265..60bb0dd3751a 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -23,6 +23,7 @@ import { RelayClientInstallDialog } from "../components/cloud/RelayClientInstall import { SshPasswordPromptDialog } from "../components/desktop/SshPasswordPromptDialog"; import { SnapShotCoordinator } from "../components/desktop/SnapShotCoordinator"; import { DesktopAppActivationCoordinator } from "../components/desktop/DesktopAppActivationCoordinator"; +import { DesktopTaskbarUnreadCoordinator } from "../components/desktop/DesktopTaskbarUnreadCoordinator"; import { ProviderUpdateLaunchNotification } from "../components/ProviderUpdateLaunchNotification"; import { SlowRpcRequestToastCoordinator } from "../components/SlowRpcRequestToastCoordinator"; import { ThemeEditorHost } from "../components/settings/ThemeEditorHost"; @@ -68,6 +69,7 @@ import { import { getDesktopSnapShotBridge } from "../lib/desktopSnapShot"; import { shouldResumeSnapShotSetupOnStartup } from "../lib/snapShotSetupResume"; +import { isElectron } from "../env"; export const Route = createRootRoute({ beforeLoad: async ({ location }) => { @@ -193,6 +195,9 @@ function RootRouteView() { > {primaryEnvironmentAuthenticated ? : null} {primaryEnvironmentAuthenticated ? : null} + {primaryEnvironmentAuthenticated && isElectron ? ( + + ) : null} diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index cd906aecdfce..08891d84efca 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -1291,6 +1291,8 @@ export interface DesktopBridge { */ pickThemeFiles?: () => Promise; setTheme: (theme: DesktopTheme) => Promise; + /** Optional while older desktop shells can host a newer web client. */ + setTaskbarUnreadIndicator?: (input: { visible: boolean }) => Promise; showContextMenu: ( items: readonly ContextMenuItem[], position?: { x: number; y: number }, From ea3ba7d758115eaa8bbe9e4b868fef8fdc558a8a Mon Sep 17 00:00:00 2001 From: Andrew Forster Date: Wed, 9 Sep 2026 22:12:26 -0700 Subject: [PATCH 2/6] fix(desktop): keep background completions unread --- apps/web/src/components/ChatView.tsx | 26 ++---- .../useAcknowledgeThreadCompletion.test.tsx | 90 +++++++++++++++++++ .../chat/useAcknowledgeThreadCompletion.ts | 30 +++++++ 3 files changed, 129 insertions(+), 17 deletions(-) create mode 100644 apps/web/src/components/chat/useAcknowledgeThreadCompletion.test.tsx create mode 100644 apps/web/src/components/chat/useAcknowledgeThreadCompletion.ts diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index f1b5afba94e9..16a1f14b3c4c 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -141,6 +141,7 @@ import { type PendingUserInputDraftAnswer, } from "../pendingUserInput"; import { useUiStateStore } from "../uiStateStore"; +import { useAcknowledgeThreadCompletion } from "./chat/useAcknowledgeThreadCompletion"; import { latestWorkspaceMutationId, useWorkspaceMutationRefresh, @@ -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({ diff --git a/apps/web/src/components/chat/useAcknowledgeThreadCompletion.test.tsx b/apps/web/src/components/chat/useAcknowledgeThreadCompletion.test.tsx new file mode 100644 index 000000000000..354e7e7cbd80 --- /dev/null +++ b/apps/web/src/components/chat/useAcknowledgeThreadCompletion.test.tsx @@ -0,0 +1,90 @@ +import { act } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { useAcknowledgeThreadCompletion } from "./useAcknowledgeThreadCompletion"; + +const threadKey = "environment-1:thread-1"; +const completedAt = "2026-09-10T05:01:06.022Z"; + +let page: EventTarget & { + visibilityState: DocumentVisibilityState; + hasFocus: ReturnType boolean>>; +}; +let browserWindow: EventTarget; +let renderer: ReactTestRenderer | null; + +function CompletionProbe(props: { + acknowledge: (key: string, at: string) => void; + deferWhilePageInactive?: boolean; +}) { + useAcknowledgeThreadCompletion( + threadKey, + completedAt, + props.acknowledge, + props.deferWhilePageInactive ?? true, + ); + return null; +} + +beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + page = Object.assign(new EventTarget(), { + visibilityState: "visible" as DocumentVisibilityState, + hasFocus: vi.fn(() => true), + }); + browserWindow = new EventTarget(); + vi.stubGlobal("document", page); + vi.stubGlobal("window", browserWindow); + renderer = null; +}); + +afterEach(async () => { + await act(async () => renderer?.unmount()); + vi.unstubAllGlobals(); +}); + +describe("useAcknowledgeThreadCompletion", () => { + it("acknowledges a completed thread while the page is being viewed", async () => { + const acknowledge = vi.fn(); + + await act(async () => { + renderer = create(); + }); + + expect(acknowledge).toHaveBeenCalledWith(threadKey, completedAt); + }); + + it("waits to acknowledge a background completion until the window is focused", async () => { + const acknowledge = vi.fn(); + page.visibilityState = "hidden"; + page.hasFocus.mockReturnValue(false); + + await act(async () => { + renderer = create(); + }); + expect(acknowledge).not.toHaveBeenCalled(); + + page.visibilityState = "visible"; + await act(async () => page.dispatchEvent(new Event("visibilitychange"))); + expect(acknowledge).not.toHaveBeenCalled(); + + page.hasFocus.mockReturnValue(true); + await act(async () => browserWindow.dispatchEvent(new Event("focus"))); + expect(acknowledge).toHaveBeenCalledWith(threadKey, completedAt); + }); + + it("preserves immediate acknowledgement outside the desktop app", async () => { + const acknowledge = vi.fn(); + page.visibilityState = "hidden"; + page.hasFocus.mockReturnValue(false); + + await act(async () => { + renderer = create( + , + ); + }); + + expect(acknowledge).toHaveBeenCalledWith(threadKey, completedAt); + }); +}); diff --git a/apps/web/src/components/chat/useAcknowledgeThreadCompletion.ts b/apps/web/src/components/chat/useAcknowledgeThreadCompletion.ts new file mode 100644 index 000000000000..911acb637a55 --- /dev/null +++ b/apps/web/src/components/chat/useAcknowledgeThreadCompletion.ts @@ -0,0 +1,30 @@ +import { useEffect } from "react"; + +export function useAcknowledgeThreadCompletion( + threadKey: string | null, + completedAt: string | null | undefined, + acknowledge: (threadKey: string, completedAt: string) => void, + deferWhilePageInactive: boolean, +) { + useEffect(() => { + if (threadKey === null || !completedAt) return; + + const acknowledgeIfViewed = () => { + if ( + deferWhilePageInactive && + (document.visibilityState !== "visible" || !document.hasFocus()) + ) { + return; + } + acknowledge(threadKey, completedAt); + }; + + acknowledgeIfViewed(); + window.addEventListener("focus", acknowledgeIfViewed); + document.addEventListener("visibilitychange", acknowledgeIfViewed); + return () => { + window.removeEventListener("focus", acknowledgeIfViewed); + document.removeEventListener("visibilitychange", acknowledgeIfViewed); + }; + }, [acknowledge, completedAt, deferWhilePageInactive, threadKey]); +} From 4ad15b6237d9c08ad42be67d2d55631dd3e25d08 Mon Sep 17 00:00:00 2001 From: Andrew Forster Date: Wed, 9 Sep 2026 22:48:42 -0700 Subject: [PATCH 3/6] Improve Windows taskbar completion badges --- .../src/electron/WindowsTaskbarBadge.test.ts | 118 ++++++++++++++++-- .../src/electron/WindowsTaskbarBadge.ts | 65 ++++++---- apps/desktop/src/ipc/methods/window.ts | 8 +- .../DesktopTaskbarUnreadCoordinator.test.ts | 29 +++-- .../DesktopTaskbarUnreadCoordinator.tsx | 75 ++++++++--- packages/contracts/src/ipc.ts | 5 +- 6 files changed, 233 insertions(+), 67 deletions(-) diff --git a/apps/desktop/src/electron/WindowsTaskbarBadge.test.ts b/apps/desktop/src/electron/WindowsTaskbarBadge.test.ts index 2e10cc01ff0f..882d65680e98 100644 --- a/apps/desktop/src/electron/WindowsTaskbarBadge.test.ts +++ b/apps/desktop/src/electron/WindowsTaskbarBadge.test.ts @@ -1,12 +1,13 @@ import { assert, beforeEach, describe, it } from "@effect/vitest"; import { vi } from "vite-plus/test"; -const { createFromBitmap } = vi.hoisted(() => ({ - createFromBitmap: vi.fn(() => ({ kind: "unread-completion-overlay" })), +const { createFromBuffer, overlayImage } = vi.hoisted(() => ({ + createFromBuffer: vi.fn(), + overlayImage: { isEmpty: vi.fn(() => false) }, })); vi.mock("electron", () => ({ - nativeImage: { createFromBitmap }, + nativeImage: { createFromBuffer }, })); import { setWindowsTaskbarUnreadIndicator } from "./WindowsTaskbarBadge.ts"; @@ -20,35 +21,119 @@ function makeWindow(destroyed = false) { describe("setWindowsTaskbarUnreadIndicator", () => { beforeEach(() => { - createFromBitmap.mockClear(); + createFromBuffer.mockReset(); + createFromBuffer.mockReturnValue(overlayImage); + overlayImage.isEmpty.mockReset(); + overlayImage.isEmpty.mockReturnValue(false); }); it("sets and clears the Windows taskbar overlay", () => { const window = makeWindow(); + const badgeDataUrl = "data:image/png;base64,one"; - assert.isTrue(setWindowsTaskbarUnreadIndicator({ platform: "win32", window, visible: true })); - assert.deepEqual(window.setOverlayIcon.mock.calls, [ - [{ kind: "unread-completion-overlay" }, "Completed thread awaiting review"], + assert.isTrue( + setWindowsTaskbarUnreadIndicator({ 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(setWindowsTaskbarUnreadIndicator({ platform: "win32", window, visible: false })); + assert.isTrue( + setWindowsTaskbarUnreadIndicator({ + platform: "win32", + window, + count: 0, + badgeDataUrl: null, + }), + ); assert.deepEqual(window.setOverlayIcon.mock.calls[1], [null, ""]); }); + it("caches decoded overlays", () => { + const window = makeWindow(); + const badgeDataUrl = "data:image/png;base64,two"; + + assert.isTrue( + setWindowsTaskbarUnreadIndicator({ platform: "win32", window, count: 2, badgeDataUrl }), + ); + assert.isTrue( + setWindowsTaskbarUnreadIndicator({ platform: "win32", window, count: 2, badgeDataUrl }), + ); + + assert.equal(createFromBuffer.mock.calls.length, 1); + }); + + it("rejects missing, malformed, and empty badge images", () => { + const window = makeWindow(); + + assert.isFalse( + setWindowsTaskbarUnreadIndicator({ + platform: "win32", + window, + count: 1, + badgeDataUrl: null, + }), + ); + assert.isFalse( + setWindowsTaskbarUnreadIndicator({ + platform: "win32", + window, + count: 1, + badgeDataUrl: "data:image/svg+xml;base64,badge", + }), + ); + + overlayImage.isEmpty.mockReturnValueOnce(true); + assert.isFalse( + setWindowsTaskbarUnreadIndicator({ + platform: "win32", + window, + count: 1, + badgeDataUrl: "data:image/png;base64,empty", + }), + ); + assert.lengthOf(window.setOverlayIcon.mock.calls, 0); + }); + it("does nothing outside Windows or without a live window", () => { const window = makeWindow(); const destroyedWindow = makeWindow(true); + const badgeDataUrl = "data:image/png;base64,platform"; - assert.isFalse(setWindowsTaskbarUnreadIndicator({ platform: "darwin", window, visible: true })); - assert.isFalse(setWindowsTaskbarUnreadIndicator({ platform: "linux", window, visible: true })); assert.isFalse( - setWindowsTaskbarUnreadIndicator({ platform: "win32", window: null, visible: true }), + setWindowsTaskbarUnreadIndicator({ + platform: "darwin", + window, + count: 1, + badgeDataUrl, + }), + ); + assert.isFalse( + setWindowsTaskbarUnreadIndicator({ + platform: "linux", + window, + count: 1, + badgeDataUrl, + }), + ); + assert.isFalse( + setWindowsTaskbarUnreadIndicator({ + platform: "win32", + window: null, + count: 1, + badgeDataUrl, + }), ); assert.isFalse( setWindowsTaskbarUnreadIndicator({ platform: "win32", window: destroyedWindow, - visible: true, + count: 1, + badgeDataUrl, }), ); assert.lengthOf(window.setOverlayIcon.mock.calls, 0); @@ -61,6 +146,13 @@ describe("setWindowsTaskbarUnreadIndicator", () => { throw new Error("overlay failed"); }); - assert.isFalse(setWindowsTaskbarUnreadIndicator({ platform: "win32", window, visible: true })); + assert.isFalse( + setWindowsTaskbarUnreadIndicator({ + platform: "win32", + window, + count: 1, + badgeDataUrl: "data:image/png;base64,failure", + }), + ); }); }); diff --git a/apps/desktop/src/electron/WindowsTaskbarBadge.ts b/apps/desktop/src/electron/WindowsTaskbarBadge.ts index f5598305f15a..6ccb5ba3fea1 100644 --- a/apps/desktop/src/electron/WindowsTaskbarBadge.ts +++ b/apps/desktop/src/electron/WindowsTaskbarBadge.ts @@ -1,51 +1,62 @@ import * as Electron from "electron"; const OVERLAY_SIZE = 16; -const OVERLAY_DESCRIPTION = "Completed thread awaiting review"; +const OVERLAY_SCALE_FACTOR = 4; +const PNG_DATA_URL_PREFIX = "data:image/png;base64,"; -let unreadCompletionOverlay: Electron.NativeImage | undefined; +const overlayByDataUrl = new Map(); -function getUnreadCompletionOverlay(): Electron.NativeImage { - if (unreadCompletionOverlay !== undefined) { - return unreadCompletionOverlay; +function getUnreadCompletionOverlay(dataUrl: string): Electron.NativeImage | null { + const cached = overlayByDataUrl.get(dataUrl); + if (cached !== undefined) { + return cached; } - // The Windows bitmap representation is BGRA. Opaque pixels avoid - // platform-specific alpha premultiplication while keeping the icon crisp. - const bitmap = Buffer.alloc(OVERLAY_SIZE * OVERLAY_SIZE * 4); - const center = (OVERLAY_SIZE - 1) / 2; - for (let y = 0; y < OVERLAY_SIZE; y += 1) { - for (let x = 0; x < OVERLAY_SIZE; x += 1) { - const distance = Math.hypot(x - center, y - center); - const offset = (y * OVERLAY_SIZE + x) * 4; - if (distance <= 4.5) { - bitmap.set([129, 185, 16, 255], offset); - } else if (distance <= 6.25) { - bitmap.set([255, 255, 255, 255], offset); - } - } + 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; } - unreadCompletionOverlay = Electron.nativeImage.createFromBitmap(bitmap, { - width: OVERLAY_SIZE, - height: OVERLAY_SIZE, - }); - return unreadCompletionOverlay; + overlayByDataUrl.set(dataUrl, overlay); + return overlay; } export function setWindowsTaskbarUnreadIndicator(input: { readonly platform: NodeJS.Platform; readonly window: Pick | null; - readonly visible: boolean; + readonly count: number; + readonly badgeDataUrl: string | null; }): boolean { if (input.platform !== "win32" || input.window === null || input.window.isDestroyed()) { return false; } try { + 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( - input.visible ? getUnreadCompletionOverlay() : null, - input.visible ? OVERLAY_DESCRIPTION : "", + overlay, + `${input.count} completed ${input.count === 1 ? "thread" : "threads"} awaiting review`, ); return true; } catch { diff --git a/apps/desktop/src/ipc/methods/window.ts b/apps/desktop/src/ipc/methods/window.ts index f8096c3ee029..b7e5e7dd3851 100644 --- a/apps/desktop/src/ipc/methods/window.ts +++ b/apps/desktop/src/ipc/methods/window.ts @@ -271,7 +271,10 @@ export const setTheme = DesktopIpc.makeIpcMethod({ export const setTaskbarUnreadIndicator = DesktopIpc.makeIpcMethod({ channel: IpcChannels.SET_TASKBAR_UNREAD_INDICATOR_CHANNEL, - payload: Schema.Struct({ visible: Schema.Boolean }), + 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; @@ -280,7 +283,8 @@ export const setTaskbarUnreadIndicator = DesktopIpc.makeIpcMethod({ return setWindowsTaskbarUnreadIndicator({ platform: environment.platform, window: Option.getOrNull(window), - visible: input.visible, + count: input.count, + badgeDataUrl: input.badgeDataUrl, }); }), }); diff --git a/apps/web/src/components/desktop/DesktopTaskbarUnreadCoordinator.test.ts b/apps/web/src/components/desktop/DesktopTaskbarUnreadCoordinator.test.ts index 429f936faa5a..f8a3e20b4613 100644 --- a/apps/web/src/components/desktop/DesktopTaskbarUnreadCoordinator.test.ts +++ b/apps/web/src/components/desktop/DesktopTaskbarUnreadCoordinator.test.ts @@ -2,7 +2,10 @@ import { describe, expect, it } from "vite-plus/test"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models"; import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; -import { hasUnseenTaskbarCompletion } from "./DesktopTaskbarUnreadCoordinator"; +import { + countUnseenTaskbarCompletions, + getTaskbarBadgeLabel, +} from "./DesktopTaskbarUnreadCoordinator"; const environmentId = "environment-1" as EnvironmentId; const threadId = "thread-1" as ThreadId; @@ -30,19 +33,25 @@ function completedThread( } describe("hasUnseenTaskbarCompletion", () => { + it("shows one digit and caps larger counts at 9+", () => { + expect(getTaskbarBadgeLabel(1)).toBe("1"); + expect(getTaskbarBadgeLabel(9)).toBe("9"); + expect(getTaskbarBadgeLabel(10)).toBe("9+"); + }); + it("clears after the completed thread is visited", () => { const threads = [completedThread()]; expect( - hasUnseenTaskbarCompletion(threads, { + countUnseenTaskbarCompletions(threads, { [threadKey]: "2026-09-09T12:00:05.000Z", }), - ).toBe(true); + ).toBe(1); expect( - hasUnseenTaskbarCompletion(threads, { + countUnseenTaskbarCompletions(threads, { [threadKey]: "2026-09-09T12:00:10.000Z", }), - ).toBe(false); + ).toBe(0); }); it("stays visible until every unseen completion is visited", () => { @@ -50,19 +59,19 @@ describe("hasUnseenTaskbarCompletion", () => { const threads = [completedThread(), completedThread({ id: secondThreadId })]; expect( - hasUnseenTaskbarCompletion(threads, { + countUnseenTaskbarCompletions(threads, { [threadKey]: "2026-09-09T12:00:10.000Z", [`${environmentId}:${secondThreadId}`]: "2026-09-09T12:00:05.000Z", }), - ).toBe(true); + ).toBe(1); }); it("ignores archived and never-visited historical threads", () => { expect( - hasUnseenTaskbarCompletion([completedThread({ archivedAt: "2026-09-09T12:01:00.000Z" })], { + countUnseenTaskbarCompletions([completedThread({ archivedAt: "2026-09-09T12:01:00.000Z" })], { [threadKey]: "2026-09-09T12:00:05.000Z", }), - ).toBe(false); - expect(hasUnseenTaskbarCompletion([completedThread()], {})).toBe(false); + ).toBe(0); + expect(countUnseenTaskbarCompletions([completedThread()], {})).toBe(0); }); }); diff --git a/apps/web/src/components/desktop/DesktopTaskbarUnreadCoordinator.tsx b/apps/web/src/components/desktop/DesktopTaskbarUnreadCoordinator.tsx index dbc84885bf1c..ad53fea1cfe2 100644 --- a/apps/web/src/components/desktop/DesktopTaskbarUnreadCoordinator.tsx +++ b/apps/web/src/components/desktop/DesktopTaskbarUnreadCoordinator.tsx @@ -11,30 +11,74 @@ type TaskbarUnreadThread = Pick< "archivedAt" | "environmentId" | "id" | "latestTurn" >; -export function hasUnseenTaskbarCompletion( +const BADGE_RENDER_SIZE = 64; +const MAX_VISIBLE_COUNT = 9; +const badgeDataUrlByLabel = new Map(); + +export function getTaskbarBadgeLabel(count: number): string { + return count > MAX_VISIBLE_COUNT ? `${MAX_VISIBLE_COUNT}+` : String(count); +} + +function createTaskbarBadgeDataUrl(count: number): string | null { + const label = getTaskbarBadgeLabel(count); + const cached = badgeDataUrlByLabel.get(label); + if (cached !== undefined) { + return cached; + } + + const canvas = document.createElement("canvas"); + canvas.width = BADGE_RENDER_SIZE; + canvas.height = BADGE_RENDER_SIZE; + const context = canvas.getContext("2d"); + if (context === null) { + return null; + } + + context.beginPath(); + context.arc(32, 32, 24, 0, Math.PI * 2); + context.fillStyle = "#e5484d"; + context.fill(); + + context.fillStyle = "#ffffff"; + context.font = `600 ${label.length === 1 ? 34 : 25}px "Segoe UI Variable Text", "Segoe UI", sans-serif`; + context.textAlign = "center"; + context.textBaseline = "middle"; + context.fillText(label, 32, 33); + + const dataUrl = canvas.toDataURL("image/png"); + badgeDataUrlByLabel.set(label, dataUrl); + return dataUrl; +} + +export function countUnseenTaskbarCompletions( threads: ReadonlyArray, lastVisitedAtByThreadKey: Readonly>, -): boolean { - return threads.some((thread) => { +): number { + return threads.reduce((count, thread) => { if (thread.archivedAt !== null) { - return false; + return count; } const threadKey = scopedThreadKey({ environmentId: thread.environmentId, threadId: thread.id, }); - return hasUnseenCompletion({ - latestTurn: thread.latestTurn, - lastVisitedAt: lastVisitedAtByThreadKey[threadKey], - }); - }); + return ( + count + + Number( + hasUnseenCompletion({ + latestTurn: thread.latestTurn, + lastVisitedAt: lastVisitedAtByThreadKey[threadKey], + }), + ) + ); + }, 0); } export function DesktopTaskbarUnreadCoordinator() { const threads = useThreadShells(); const lastVisitedAtByThreadKey = useUiStateStore((state) => state.threadLastVisitedAtById); - const visible = useMemo( - () => hasUnseenTaskbarCompletion(threads, lastVisitedAtByThreadKey), + const count = useMemo( + () => countUnseenTaskbarCompletions(threads, lastVisitedAtByThreadKey), [lastVisitedAtByThreadKey, threads], ); const setIndicator = window.desktopBridge?.setTaskbarUnreadIndicator; @@ -43,12 +87,15 @@ export function DesktopTaskbarUnreadCoordinator() { if (setIndicator === undefined) { return; } - void setIndicator({ visible }).catch(() => {}); - }, [setIndicator, visible]); + void setIndicator({ + count, + badgeDataUrl: count > 0 ? createTaskbarBadgeDataUrl(count) : null, + }).catch(() => {}); + }, [count, setIndicator]); useEffect( () => () => { - void setIndicator?.({ visible: false }).catch(() => {}); + void setIndicator?.({ count: 0, badgeDataUrl: null }).catch(() => {}); }, [setIndicator], ); diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 08891d84efca..93250e5b2f19 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -1292,7 +1292,10 @@ export interface DesktopBridge { pickThemeFiles?: () => Promise; setTheme: (theme: DesktopTheme) => Promise; /** Optional while older desktop shells can host a newer web client. */ - setTaskbarUnreadIndicator?: (input: { visible: boolean }) => Promise; + setTaskbarUnreadIndicator?: (input: { + count: number; + badgeDataUrl: string | null; + }) => Promise; showContextMenu: ( items: readonly ContextMenuItem[], position?: { x: number; y: number }, From c5bd2faa7fabc3aa03b89ba77888ade2ebedbc10 Mon Sep 17 00:00:00 2001 From: Andrew Forster Date: Thu, 10 Sep 2026 00:21:51 -0700 Subject: [PATCH 4/6] feat(desktop): add macOS completion badges --- ...dge.test.ts => DesktopUnreadBadge.test.ts} | 80 ++++++++++++------- ...sTaskbarBadge.ts => DesktopUnreadBadge.ts} | 14 ++-- apps/desktop/src/ipc/methods/window.ts | 4 +- .../DesktopTaskbarUnreadCoordinator.tsx | 8 +- 4 files changed, 65 insertions(+), 41 deletions(-) rename apps/desktop/src/electron/{WindowsTaskbarBadge.test.ts => DesktopUnreadBadge.test.ts} (60%) rename apps/desktop/src/electron/{WindowsTaskbarBadge.ts => DesktopUnreadBadge.ts} (85%) diff --git a/apps/desktop/src/electron/WindowsTaskbarBadge.test.ts b/apps/desktop/src/electron/DesktopUnreadBadge.test.ts similarity index 60% rename from apps/desktop/src/electron/WindowsTaskbarBadge.test.ts rename to apps/desktop/src/electron/DesktopUnreadBadge.test.ts index 882d65680e98..e66738c400b3 100644 --- a/apps/desktop/src/electron/WindowsTaskbarBadge.test.ts +++ b/apps/desktop/src/electron/DesktopUnreadBadge.test.ts @@ -1,16 +1,18 @@ import { assert, beforeEach, describe, it } from "@effect/vitest"; import { vi } from "vite-plus/test"; -const { createFromBuffer, overlayImage } = vi.hoisted(() => ({ +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 { setWindowsTaskbarUnreadIndicator } from "./WindowsTaskbarBadge.ts"; +import { setDesktopUnreadBadge } from "./DesktopUnreadBadge.ts"; function makeWindow(destroyed = false) { return { @@ -19,21 +21,21 @@ function makeWindow(destroyed = false) { }; } -describe("setWindowsTaskbarUnreadIndicator", () => { +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( - setWindowsTaskbarUnreadIndicator({ platform: "win32", window, count: 1, badgeDataUrl }), - ); + assert.isTrue(setDesktopUnreadBadge({ platform: "win32", window, count: 1, badgeDataUrl })); assert.deepEqual(createFromBuffer.mock.calls, [ [Buffer.from("one", "base64"), { width: 64, height: 64, scaleFactor: 4 }], ]); @@ -43,7 +45,7 @@ describe("setWindowsTaskbarUnreadIndicator", () => { ]); assert.isTrue( - setWindowsTaskbarUnreadIndicator({ + setDesktopUnreadBadge({ platform: "win32", window, count: 0, @@ -53,25 +55,41 @@ describe("setWindowsTaskbarUnreadIndicator", () => { assert.deepEqual(window.setOverlayIcon.mock.calls[1], [null, ""]); }); - it("caches decoded overlays", () => { - const window = makeWindow(); - const badgeDataUrl = "data:image/png;base64,two"; - + it("uses the native badge count on macOS", () => { assert.isTrue( - setWindowsTaskbarUnreadIndicator({ platform: "win32", window, count: 2, badgeDataUrl }), + setDesktopUnreadBadge({ platform: "darwin", window: null, count: 2, badgeDataUrl: null }), ); assert.isTrue( - setWindowsTaskbarUnreadIndicator({ platform: "win32", window, count: 2, badgeDataUrl }), + 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 badge images", () => { + it("rejects missing, malformed, and empty Windows badge images", () => { const window = makeWindow(); assert.isFalse( - setWindowsTaskbarUnreadIndicator({ + setDesktopUnreadBadge({ platform: "win32", window, count: 1, @@ -79,7 +97,7 @@ describe("setWindowsTaskbarUnreadIndicator", () => { }), ); assert.isFalse( - setWindowsTaskbarUnreadIndicator({ + setDesktopUnreadBadge({ platform: "win32", window, count: 1, @@ -89,7 +107,7 @@ describe("setWindowsTaskbarUnreadIndicator", () => { overlayImage.isEmpty.mockReturnValueOnce(true); assert.isFalse( - setWindowsTaskbarUnreadIndicator({ + setDesktopUnreadBadge({ platform: "win32", window, count: 1, @@ -99,21 +117,13 @@ describe("setWindowsTaskbarUnreadIndicator", () => { assert.lengthOf(window.setOverlayIcon.mock.calls, 0); }); - it("does nothing outside Windows or without a live window", () => { + 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( - setWindowsTaskbarUnreadIndicator({ - platform: "darwin", - window, - count: 1, - badgeDataUrl, - }), - ); - assert.isFalse( - setWindowsTaskbarUnreadIndicator({ + setDesktopUnreadBadge({ platform: "linux", window, count: 1, @@ -121,7 +131,7 @@ describe("setWindowsTaskbarUnreadIndicator", () => { }), ); assert.isFalse( - setWindowsTaskbarUnreadIndicator({ + setDesktopUnreadBadge({ platform: "win32", window: null, count: 1, @@ -129,7 +139,7 @@ describe("setWindowsTaskbarUnreadIndicator", () => { }), ); assert.isFalse( - setWindowsTaskbarUnreadIndicator({ + setDesktopUnreadBadge({ platform: "win32", window: destroyedWindow, count: 1, @@ -138,21 +148,29 @@ describe("setWindowsTaskbarUnreadIndicator", () => { ); 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 the overlay", () => { + it("fails soft when Electron rejects a badge update", () => { const window = makeWindow(); window.setOverlayIcon.mockImplementation(() => { throw new Error("overlay failed"); }); assert.isFalse( - setWindowsTaskbarUnreadIndicator({ + 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 }), + ); }); }); diff --git a/apps/desktop/src/electron/WindowsTaskbarBadge.ts b/apps/desktop/src/electron/DesktopUnreadBadge.ts similarity index 85% rename from apps/desktop/src/electron/WindowsTaskbarBadge.ts rename to apps/desktop/src/electron/DesktopUnreadBadge.ts index 6ccb5ba3fea1..5e171061b280 100644 --- a/apps/desktop/src/electron/WindowsTaskbarBadge.ts +++ b/apps/desktop/src/electron/DesktopUnreadBadge.ts @@ -32,17 +32,21 @@ function getUnreadCompletionOverlay(dataUrl: string): Electron.NativeImage | nul return overlay; } -export function setWindowsTaskbarUnreadIndicator(input: { +export function setDesktopUnreadBadge(input: { readonly platform: NodeJS.Platform; readonly window: Pick | null; readonly count: number; readonly badgeDataUrl: string | null; }): boolean { - if (input.platform !== "win32" || input.window === null || input.window.isDestroyed()) { - return false; - } - try { + if (input.platform === "darwin") { + return Electron.app.setBadgeCount(input.count); + } + + if (input.platform !== "win32" || input.window === null || input.window.isDestroyed()) { + return false; + } + if (input.count === 0) { input.window.setOverlayIcon(null, ""); return true; diff --git a/apps/desktop/src/ipc/methods/window.ts b/apps/desktop/src/ipc/methods/window.ts index b7e5e7dd3851..5eaf6b672aac 100644 --- a/apps/desktop/src/ipc/methods/window.ts +++ b/apps/desktop/src/ipc/methods/window.ts @@ -34,7 +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 { setWindowsTaskbarUnreadIndicator } from "../../electron/WindowsTaskbarBadge.ts"; +import { setDesktopUnreadBadge } from "../../electron/DesktopUnreadBadge.ts"; import * as IpcChannels from "../channels.ts"; import * as DesktopIpc from "../DesktopIpc.ts"; import { @@ -280,7 +280,7 @@ export const setTaskbarUnreadIndicator = DesktopIpc.makeIpcMethod({ const environment = yield* DesktopEnvironment.DesktopEnvironment; const electronWindow = yield* ElectronWindow.ElectronWindow; const window = yield* electronWindow.main; - return setWindowsTaskbarUnreadIndicator({ + return setDesktopUnreadBadge({ platform: environment.platform, window: Option.getOrNull(window), count: input.count, diff --git a/apps/web/src/components/desktop/DesktopTaskbarUnreadCoordinator.tsx b/apps/web/src/components/desktop/DesktopTaskbarUnreadCoordinator.tsx index ad53fea1cfe2..76bb4c14414a 100644 --- a/apps/web/src/components/desktop/DesktopTaskbarUnreadCoordinator.tsx +++ b/apps/web/src/components/desktop/DesktopTaskbarUnreadCoordinator.tsx @@ -81,7 +81,9 @@ export function DesktopTaskbarUnreadCoordinator() { () => countUnseenTaskbarCompletions(threads, lastVisitedAtByThreadKey), [lastVisitedAtByThreadKey, threads], ); - const setIndicator = window.desktopBridge?.setTaskbarUnreadIndicator; + const desktopBridge = window.desktopBridge; + const setIndicator = desktopBridge?.setTaskbarUnreadIndicator; + const needsOverlayImage = desktopBridge?.getClientPlatform?.() === "win32"; useEffect(() => { if (setIndicator === undefined) { @@ -89,9 +91,9 @@ export function DesktopTaskbarUnreadCoordinator() { } void setIndicator({ count, - badgeDataUrl: count > 0 ? createTaskbarBadgeDataUrl(count) : null, + badgeDataUrl: count > 0 && needsOverlayImage ? createTaskbarBadgeDataUrl(count) : null, }).catch(() => {}); - }, [count, setIndicator]); + }, [count, needsOverlayImage, setIndicator]); useEffect( () => () => { From 6bf86b88a2c3f3dd35351cc8b4f4be60094359d0 Mon Sep 17 00:00:00 2001 From: Andrew Forster Date: Fri, 11 Sep 2026 16:53:15 -0700 Subject: [PATCH 5/6] fix(desktop): log unread badge update failures --- apps/desktop/src/electron/DesktopUnreadBadge.test.ts | 11 +++++++++++ apps/desktop/src/electron/DesktopUnreadBadge.ts | 4 +++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/electron/DesktopUnreadBadge.test.ts b/apps/desktop/src/electron/DesktopUnreadBadge.test.ts index 4b269aa64a4c..dfc97e4ce890 100644 --- a/apps/desktop/src/electron/DesktopUnreadBadge.test.ts +++ b/apps/desktop/src/electron/DesktopUnreadBadge.test.ts @@ -1,4 +1,5 @@ import { assert, beforeEach, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; import { vi } from "vite-plus/test"; const { createFromBuffer, overlayImage, setBadgeCount } = vi.hoisted(() => ({ @@ -12,6 +13,11 @@ vi.mock("electron", () => ({ nativeImage: { createFromBuffer }, })); +vi.mock("effect/Effect", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, logWarning: vi.fn(() => actual.void) }; +}); + import { setDesktopUnreadBadge } from "./DesktopUnreadBadge.ts"; function makeWindow(destroyed = false) { @@ -156,6 +162,7 @@ describe("setDesktopUnreadBadge", () => { }); it("fails soft when Electron rejects a badge update", () => { + const warn = vi.mocked(Effect.logWarning); const window = makeWindow(); window.setOverlayIcon.mockImplementation(() => { throw new Error("overlay failed"); @@ -176,5 +183,9 @@ describe("setDesktopUnreadBadge", () => { assert.isFalse( setDesktopUnreadBadge({ platform: "darwin", window: null, count: 1, badgeDataUrl: null }), ); + assert.deepEqual(warn.mock.calls, [ + ["Failed to update desktop unread badge", new Error("overlay failed")], + ["Failed to update desktop unread badge", new Error("badge failed")], + ]); }); }); diff --git a/apps/desktop/src/electron/DesktopUnreadBadge.ts b/apps/desktop/src/electron/DesktopUnreadBadge.ts index e8e4a4c5e690..aeed15fd647f 100644 --- a/apps/desktop/src/electron/DesktopUnreadBadge.ts +++ b/apps/desktop/src/electron/DesktopUnreadBadge.ts @@ -1,4 +1,5 @@ import * as Electron from "electron"; +import * as Effect from "effect/Effect"; const OVERLAY_SIZE = 16; const OVERLAY_SCALE_FACTOR = 4; @@ -64,7 +65,8 @@ export function setDesktopUnreadBadge(input: { `${input.count} completed ${input.count === 1 ? "thread" : "threads"} awaiting review`, ); return true; - } catch { + } catch (error) { + Effect.runSync(Effect.logWarning("Failed to update desktop unread badge", error)); return false; } } From 4e51482fdccecde39fc85ed1627e3a8a7f73539d Mon Sep 17 00:00:00 2001 From: Andrew Forster Date: Sat, 12 Sep 2026 14:46:15 -0700 Subject: [PATCH 6/6] feat(desktop): show unread badge on Linux --- .../src/electron/DesktopUnreadBadge.test.ts | 16 +++++++++++----- apps/desktop/src/electron/DesktopUnreadBadge.ts | 2 +- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/electron/DesktopUnreadBadge.test.ts b/apps/desktop/src/electron/DesktopUnreadBadge.test.ts index dfc97e4ce890..6e1383be5cb0 100644 --- a/apps/desktop/src/electron/DesktopUnreadBadge.test.ts +++ b/apps/desktop/src/electron/DesktopUnreadBadge.test.ts @@ -61,23 +61,29 @@ describe("setDesktopUnreadBadge", () => { assert.deepEqual(window.setOverlayIcon.mock.calls[1], [null, ""]); }); - it("uses the native badge count on macOS", () => { + it("uses the native badge count on macOS and Linux", () => { assert.isTrue( setDesktopUnreadBadge({ platform: "darwin", window: null, count: 2, badgeDataUrl: null }), ); assert.isTrue( setDesktopUnreadBadge({ platform: "darwin", window: null, count: 0, badgeDataUrl: null }), ); + assert.isTrue( + setDesktopUnreadBadge({ platform: "linux", window: null, count: 3, badgeDataUrl: null }), + ); + assert.isTrue( + setDesktopUnreadBadge({ platform: "linux", window: null, count: 0, badgeDataUrl: null }), + ); - assert.deepEqual(setBadgeCount.mock.calls, [[2], [0]]); + assert.deepEqual(setBadgeCount.mock.calls, [[2], [0], [3], [0]]); assert.lengthOf(createFromBuffer.mock.calls, 0); }); - it("returns the native result when macOS cannot show badge counts", () => { + it("returns the native result when the platform cannot show badge counts", () => { setBadgeCount.mockReturnValueOnce(false); assert.isFalse( - setDesktopUnreadBadge({ platform: "darwin", window: null, count: 1, badgeDataUrl: null }), + setDesktopUnreadBadge({ platform: "linux", window: null, count: 1, badgeDataUrl: null }), ); }); @@ -134,7 +140,7 @@ describe("setDesktopUnreadBadge", () => { assert.isFalse( setDesktopUnreadBadge({ - platform: "linux", + platform: "freebsd", window, count: 1, badgeDataUrl, diff --git a/apps/desktop/src/electron/DesktopUnreadBadge.ts b/apps/desktop/src/electron/DesktopUnreadBadge.ts index aeed15fd647f..537c097bbb5c 100644 --- a/apps/desktop/src/electron/DesktopUnreadBadge.ts +++ b/apps/desktop/src/electron/DesktopUnreadBadge.ts @@ -40,7 +40,7 @@ export function setDesktopUnreadBadge(input: { readonly badgeDataUrl: string | null; }): boolean { try { - if (input.platform === "darwin") { + if (input.platform === "darwin" || input.platform === "linux") { return Electron.app.setBadgeCount(input.count); }