From a4b3a4ac2f2819176d09664cf65f1145c84ea484 Mon Sep 17 00:00:00 2001 From: Noah Gregory Date: Thu, 12 Mar 2026 05:38:32 -0400 Subject: [PATCH 01/14] feat: initial addition of tray icon --- apps/desktop/package.json | 3 +- apps/desktop/src/main.ts | 13 +++++- apps/desktop/src/tray.ts | 87 +++++++++++++++++++++++++++++++++++++++ bun.lock | 1 + 4 files changed, 102 insertions(+), 2 deletions(-) create mode 100644 apps/desktop/src/tray.ts diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 0754c0d1c..f139f0f12 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -16,7 +16,8 @@ "dependencies": { "effect": "catalog:", "electron": "40.6.0", - "electron-updater": "^6.6.2" + "electron-updater": "^6.6.2", + "sharp": "^0.34.5" }, "devDependencies": { "@t3tools/contracts": "workspace:*", diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 460684929..3abfbef12 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -15,7 +15,7 @@ import { protocol, shell, } from "electron"; -import type { MenuItemConstructorOptions } from "electron"; +import type { MenuItemConstructorOptions, Tray } from "electron"; import * as Effect from "effect/Effect"; import type { DesktopTheme, @@ -43,6 +43,7 @@ import { reduceDesktopUpdateStateOnUpdateAvailable, } from "./updateMachine"; import { isArm64HostRunningIntelBuild, resolveDesktopRuntimeInfo } from "./runtimeArch"; +import { createTray } from "./tray"; fixPath(); @@ -79,6 +80,7 @@ const DESKTOP_UPDATE_ALLOW_PRERELEASE = false; type DesktopUpdateErrorContext = DesktopUpdateState["errorContext"]; let mainWindow: BrowserWindow | null = null; +let tray: Tray | null = null; let backendProcess: ChildProcess.ChildProcess | null = null; let backendPort = 0; let backendAuthToken = ""; @@ -658,6 +660,11 @@ function resolveIconPath(ext: "ico" | "icns" | "png"): string | null { return resolveResourcePath(`icon.${ext}`); } +async function configureTray(): Promise { + // TODO: Add a context menu to the tray + tray = await createTray(Menu.buildFromTemplate([])); +} + /** * Resolve the Electron userData directory path. * @@ -1330,6 +1337,8 @@ async function bootstrap(): Promise { writeDesktopLogHeader("bootstrap backend start requested"); mainWindow = createWindow(); writeDesktopLogHeader("bootstrap main window created"); + await configureTray(); + writeDesktopLogHeader("bootstrap tray created"); } app.on("before-quit", () => { @@ -1337,6 +1346,8 @@ app.on("before-quit", () => { writeDesktopLogHeader("before-quit received"); clearUpdatePollTimer(); stopBackend(); + tray?.destroy(); + tray = null; restoreStdIoCapture?.(); }); diff --git a/apps/desktop/src/tray.ts b/apps/desktop/src/tray.ts new file mode 100644 index 000000000..8223a0b28 --- /dev/null +++ b/apps/desktop/src/tray.ts @@ -0,0 +1,87 @@ +import sharp from "sharp"; +import { nativeImage, app, Tray, type Menu } from "electron"; + +// Stolen from the T3Wordmark component in the web app +const T3_WORDMARK_VIEW_BOX = "15.5309 37 94.3941 56.96"; +const T3_WORDMARK_PATH_STRING = + "M33.4509 93V47.56H15.5309V37H64.3309V47.56H46.4109V93H33.4509ZM86.7253 93.96C82.832 93.96 78.9653 93.4533 75.1253 92.44C71.2853 91.3733 68.032 89.88 65.3653 87.96L70.4053 78.04C72.5386 79.5867 75.0186 80.8133 77.8453 81.72C80.672 82.6267 83.5253 83.08 86.4053 83.08C89.6586 83.08 92.2186 82.44 94.0853 81.16C95.952 79.88 96.8853 78.12 96.8853 75.88C96.8853 73.7467 96.0586 72.0667 94.4053 70.84C92.752 69.6133 90.0853 69 86.4053 69H80.4853V60.44L96.0853 42.76L97.5253 47.4H68.1653V37H107.365V45.4L91.8453 63.08L85.2853 59.32H89.0453C95.9253 59.32 101.125 60.8667 104.645 63.96C108.165 67.0533 109.925 71.0267 109.925 75.88C109.925 79.0267 109.099 81.9867 107.445 84.76C105.792 87.48 103.259 89.6933 99.8453 91.4C96.432 93.1067 92.0586 93.96 86.7253 93.96Z"; + +const T3_TRAY_IMAGE_OPTICAL_Y_OFFSET_1X = 1.6; // vertically centering the wordmark looks weird, so we offset it slightly +const TRAY_SIZE_1X = 16; + +/** + * Rasterizes an SVG to a square template image. + * @see: https://developer.apple.com/documentation/appkit/nsimage/istemplate + * @param viewBox The viewBox of the SVG to rasterize + * @param path The path of the SVG to rasterize + * @param size The size of the resulting image + * @param opticalYOffset The optical Y offset of the resulting image + * @returns The resulting image as a PNG buffer + */ +async function rasterizeSvgToSquareTemplateImage( + viewBox: string, + path: string, + size: number, + opticalYOffset: number, +) { + // Template images "should consist of only black and clear colors" (see above-linked documentation). + const svg = ``; + return await sharp(Buffer.from(svg), { + density: 2000, + }) + .resize({ + width: size, + height: size, + fit: "contain", + position: "centre", + background: { r: 0, g: 0, b: 0, alpha: 0 }, + }) + .extend({ + top: opticalYOffset, + bottom: 0, + left: 0, + right: 0, + background: { r: 0, g: 0, b: 0, alpha: 0 }, + }) + .extract({ left: 0, top: 0, width: size, height: size }) + .png() + .toBuffer(); +} + +async function createTrayTemplateImage() { + const rasterizeT3Wordmark = async (size: number) => { + const opticalYOffset = Math.max(0, Math.round((T3_TRAY_IMAGE_OPTICAL_Y_OFFSET_1X * size) / TRAY_SIZE_1X)); + return await rasterizeSvgToSquareTemplateImage( + T3_WORDMARK_VIEW_BOX, + T3_WORDMARK_PATH_STRING, + size, + opticalYOffset, + ); + }; + const image = nativeImage.createEmpty(); + const addRepresentation = async (scaleFactor: number, size: number) => { + image.addRepresentation({ + scaleFactor: scaleFactor, + width: size, + height: size, + buffer: await rasterizeT3Wordmark(size), + }); + }; + await addRepresentation(1, TRAY_SIZE_1X); + await addRepresentation(2, TRAY_SIZE_1X * 2); + image.setTemplateImage(true); + return image; +} + +async function createTray(contextMenu: Menu): Promise { + // macOS only (for now) + if (process.platform !== "darwin") return null; + + const image = await createTrayTemplateImage(); + const tray = new Tray(image); + tray.setToolTip(app.getName()); + tray.setContextMenu(contextMenu); + return tray; +} + +export { createTray }; diff --git a/bun.lock b/bun.lock index b8e36149f..be05753d7 100644 --- a/bun.lock +++ b/bun.lock @@ -19,6 +19,7 @@ "effect": "catalog:", "electron": "40.6.0", "electron-updater": "^6.6.2", + "sharp": "^0.34.5", }, "devDependencies": { "@t3tools/contracts": "workspace:*", From 0721355835cc4141089d7fca1da1b5262956cf0f Mon Sep 17 00:00:00 2001 From: Noah Gregory Date: Thu, 12 Mar 2026 05:59:41 -0400 Subject: [PATCH 02/14] fix: fix formatting issues --- apps/desktop/src/tray.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/tray.ts b/apps/desktop/src/tray.ts index 8223a0b28..a4a857c8f 100644 --- a/apps/desktop/src/tray.ts +++ b/apps/desktop/src/tray.ts @@ -50,7 +50,10 @@ async function rasterizeSvgToSquareTemplateImage( async function createTrayTemplateImage() { const rasterizeT3Wordmark = async (size: number) => { - const opticalYOffset = Math.max(0, Math.round((T3_TRAY_IMAGE_OPTICAL_Y_OFFSET_1X * size) / TRAY_SIZE_1X)); + const opticalYOffset = Math.max( + 0, + Math.round((T3_TRAY_IMAGE_OPTICAL_Y_OFFSET_1X * size) / TRAY_SIZE_1X), + ); return await rasterizeSvgToSquareTemplateImage( T3_WORDMARK_VIEW_BOX, T3_WORDMARK_PATH_STRING, From 0f0be1e3db263b58c5e5da5039ed441050a1cf01 Mon Sep 17 00:00:00 2001 From: Noah Gregory Date: Thu, 12 Mar 2026 13:40:57 -0400 Subject: [PATCH 03/14] refactor: move entire tray implementation `tray.ts` --- apps/desktop/src/main.ts | 13 +++---------- apps/desktop/src/tray.ts | 31 ++++++++++++++++++++++++------- 2 files changed, 27 insertions(+), 17 deletions(-) diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 3abfbef12..db7353957 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -15,7 +15,7 @@ import { protocol, shell, } from "electron"; -import type { MenuItemConstructorOptions, Tray } from "electron"; +import type { MenuItemConstructorOptions } from "electron"; import * as Effect from "effect/Effect"; import type { DesktopTheme, @@ -43,7 +43,7 @@ import { reduceDesktopUpdateStateOnUpdateAvailable, } from "./updateMachine"; import { isArm64HostRunningIntelBuild, resolveDesktopRuntimeInfo } from "./runtimeArch"; -import { createTray } from "./tray"; +import { configureTray, setTrayEnabled } from "./tray"; fixPath(); @@ -80,7 +80,6 @@ const DESKTOP_UPDATE_ALLOW_PRERELEASE = false; type DesktopUpdateErrorContext = DesktopUpdateState["errorContext"]; let mainWindow: BrowserWindow | null = null; -let tray: Tray | null = null; let backendProcess: ChildProcess.ChildProcess | null = null; let backendPort = 0; let backendAuthToken = ""; @@ -660,11 +659,6 @@ function resolveIconPath(ext: "ico" | "icns" | "png"): string | null { return resolveResourcePath(`icon.${ext}`); } -async function configureTray(): Promise { - // TODO: Add a context menu to the tray - tray = await createTray(Menu.buildFromTemplate([])); -} - /** * Resolve the Electron userData directory path. * @@ -1346,8 +1340,7 @@ app.on("before-quit", () => { writeDesktopLogHeader("before-quit received"); clearUpdatePollTimer(); stopBackend(); - tray?.destroy(); - tray = null; + setTrayEnabled(false); restoreStdIoCapture?.(); }); diff --git a/apps/desktop/src/tray.ts b/apps/desktop/src/tray.ts index a4a857c8f..3b414430b 100644 --- a/apps/desktop/src/tray.ts +++ b/apps/desktop/src/tray.ts @@ -1,5 +1,5 @@ import sharp from "sharp"; -import { nativeImage, app, Tray, type Menu } from "electron"; +import { nativeImage, app, Tray, Menu } from "electron"; // Stolen from the T3Wordmark component in the web app const T3_WORDMARK_VIEW_BOX = "15.5309 37 94.3941 56.96"; @@ -76,15 +76,32 @@ async function createTrayTemplateImage() { return image; } -async function createTray(contextMenu: Menu): Promise { +let tray: Tray | null = null; + +async function createTray(contextMenu: Menu): Promise { // macOS only (for now) - if (process.platform !== "darwin") return null; + if (process.platform !== "darwin") tray = null; const image = await createTrayTemplateImage(); - const tray = new Tray(image); - tray.setToolTip(app.getName()); - tray.setContextMenu(contextMenu); - return tray; + const newTray = new Tray(image); + newTray.setToolTip(app.getName()); + newTray.setContextMenu(contextMenu); + tray = newTray; +} + +export async function configureTray(): Promise { + // TODO: Add a context menu to the tray + await createTray(Menu.buildFromTemplate([])); +} + +export async function setTrayEnabled(enabled: boolean): Promise { + if (enabled) { + if (tray && !tray.isDestroyed()) return; + await createTray(Menu.buildFromTemplate([])); + } else { + if (tray?.isDestroyed()) tray.destroy(); + tray = null; + } } export { createTray }; From e20701b6e03e671b8d58517fcbcb2dadd1b86c87 Mon Sep 17 00:00:00 2001 From: Noah Gregory Date: Thu, 12 Mar 2026 13:51:06 -0400 Subject: [PATCH 04/14] fix: fix inverted boolean logic --- apps/desktop/src/tray.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/tray.ts b/apps/desktop/src/tray.ts index 3b414430b..28f67b19a 100644 --- a/apps/desktop/src/tray.ts +++ b/apps/desktop/src/tray.ts @@ -99,7 +99,7 @@ export async function setTrayEnabled(enabled: boolean): Promise { if (tray && !tray.isDestroyed()) return; await createTray(Menu.buildFromTemplate([])); } else { - if (tray?.isDestroyed()) tray.destroy(); + if (tray?.isDestroyed() == false) tray.destroy(); tray = null; } } From d7b9077867e319d829941738b49f654af966b788 Mon Sep 17 00:00:00 2001 From: Noah Gregory Date: Thu, 12 Mar 2026 15:30:39 -0400 Subject: [PATCH 05/14] fix: don't duplicate tray configuration work --- apps/desktop/src/tray.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/tray.ts b/apps/desktop/src/tray.ts index 28f67b19a..26f6e4f91 100644 --- a/apps/desktop/src/tray.ts +++ b/apps/desktop/src/tray.ts @@ -97,7 +97,7 @@ export async function configureTray(): Promise { export async function setTrayEnabled(enabled: boolean): Promise { if (enabled) { if (tray && !tray.isDestroyed()) return; - await createTray(Menu.buildFromTemplate([])); + await configureTray(); } else { if (tray?.isDestroyed() == false) tray.destroy(); tray = null; From 0cee999a81467c5bfd9f264d6c0178b7526ca137 Mon Sep 17 00:00:00 2001 From: Noah Gregory Date: Thu, 12 Mar 2026 15:32:33 -0400 Subject: [PATCH 06/14] fix: use consistent export syntax in `tray.ts` --- apps/desktop/src/tray.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/tray.ts b/apps/desktop/src/tray.ts index 26f6e4f91..c94900157 100644 --- a/apps/desktop/src/tray.ts +++ b/apps/desktop/src/tray.ts @@ -89,12 +89,12 @@ async function createTray(contextMenu: Menu): Promise { tray = newTray; } -export async function configureTray(): Promise { +async function configureTray(): Promise { // TODO: Add a context menu to the tray await createTray(Menu.buildFromTemplate([])); } -export async function setTrayEnabled(enabled: boolean): Promise { +async function setTrayEnabled(enabled: boolean): Promise { if (enabled) { if (tray && !tray.isDestroyed()) return; await configureTray(); @@ -104,4 +104,4 @@ export async function setTrayEnabled(enabled: boolean): Promise { } } -export { createTray }; +export { createTray, configureTray, setTrayEnabled }; From 2ee4a63f648d845999e97c7740359ce463c836aa Mon Sep 17 00:00:00 2001 From: Noah Gregory Date: Thu, 12 Mar 2026 15:33:35 -0400 Subject: [PATCH 07/14] feat: set up tray enablement ipc --- apps/desktop/src/main.ts | 6 +++--- apps/desktop/src/preload.ts | 2 ++ apps/desktop/src/tray.ts | 11 +++++++++-- packages/contracts/src/ipc.ts | 1 + 4 files changed, 15 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index db7353957..70fb130cc 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -43,7 +43,7 @@ import { reduceDesktopUpdateStateOnUpdateAvailable, } from "./updateMachine"; import { isArm64HostRunningIntelBuild, resolveDesktopRuntimeInfo } from "./runtimeArch"; -import { configureTray, setTrayEnabled } from "./tray"; +import { setupTrayIpcHandlers, setTrayEnabled } from "./tray"; fixPath(); @@ -1331,8 +1331,8 @@ async function bootstrap(): Promise { writeDesktopLogHeader("bootstrap backend start requested"); mainWindow = createWindow(); writeDesktopLogHeader("bootstrap main window created"); - await configureTray(); - writeDesktopLogHeader("bootstrap tray created"); + setupTrayIpcHandlers(); + writeDesktopLogHeader("bootstrap tray ipc handlers registered"); } app.on("before-quit", () => { diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 1e1bb3bd8..61f3a79b1 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -11,6 +11,7 @@ const UPDATE_STATE_CHANNEL = "desktop:update-state"; const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state"; const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download"; const UPDATE_INSTALL_CHANNEL = "desktop:update-install"; +const SET_TRAY_ENABLED_CHANNEL = "desktop:set-tray-enabled"; const wsUrl = process.env.T3CODE_DESKTOP_WS_URL ?? null; contextBridge.exposeInMainWorld("desktopBridge", { @@ -45,4 +46,5 @@ contextBridge.exposeInMainWorld("desktopBridge", { ipcRenderer.removeListener(UPDATE_STATE_CHANNEL, wrappedListener); }; }, + setTrayEnabled: (enabled: boolean) => ipcRenderer.invoke(SET_TRAY_ENABLED_CHANNEL, enabled), } satisfies DesktopBridge); diff --git a/apps/desktop/src/tray.ts b/apps/desktop/src/tray.ts index c94900157..704df78f2 100644 --- a/apps/desktop/src/tray.ts +++ b/apps/desktop/src/tray.ts @@ -1,5 +1,5 @@ import sharp from "sharp"; -import { nativeImage, app, Tray, Menu } from "electron"; +import { nativeImage, app, ipcMain, Tray, Menu } from "electron"; // Stolen from the T3Wordmark component in the web app const T3_WORDMARK_VIEW_BOX = "15.5309 37 94.3941 56.96"; @@ -89,6 +89,13 @@ async function createTray(contextMenu: Menu): Promise { tray = newTray; } +function setupTrayIpcHandlers(): void { + const SET_TRAY_ENABLED_CHANNEL = "desktop:set-tray-enabled"; + ipcMain.handle(SET_TRAY_ENABLED_CHANNEL, async (_event, enabled: boolean) => { + await setTrayEnabled(enabled); + }); +} + async function configureTray(): Promise { // TODO: Add a context menu to the tray await createTray(Menu.buildFromTemplate([])); @@ -104,4 +111,4 @@ async function setTrayEnabled(enabled: boolean): Promise { } } -export { createTray, configureTray, setTrayEnabled }; +export { setupTrayIpcHandlers, setTrayEnabled }; diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index b9127fb17..5e705563a 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -109,6 +109,7 @@ export interface DesktopBridge { downloadUpdate: () => Promise; installUpdate: () => Promise; onUpdateState: (listener: (state: DesktopUpdateState) => void) => () => void; + setTrayEnabled: (enabled: boolean) => Promise; } export interface NativeApi { From 34ed93ab669cdaee029c927880131d3bfba9085f Mon Sep 17 00:00:00 2001 From: Noah Gregory Date: Thu, 12 Mar 2026 15:34:32 -0400 Subject: [PATCH 08/14] feat: add setting to toggle tray icon --- apps/web/src/appSettings.ts | 1 + apps/web/src/hooks/useTray.ts | 26 ++++++++++++++++++++++++++ apps/web/src/routes/__root.tsx | 19 +++++++++++++++++++ apps/web/src/routes/_chat.settings.tsx | 26 ++++++++++++++++++++++++++ 4 files changed, 72 insertions(+) create mode 100644 apps/web/src/hooks/useTray.ts diff --git a/apps/web/src/appSettings.ts b/apps/web/src/appSettings.ts index 18e76d2f9..7f5d0d01d 100644 --- a/apps/web/src/appSettings.ts +++ b/apps/web/src/appSettings.ts @@ -24,6 +24,7 @@ const AppSettingsSchema = Schema.Struct({ defaultThreadEnvMode: Schema.Literals(["local", "worktree"]).pipe( Schema.withConstructorDefault(() => Option.some("local")), ), + showTrayIcon: Schema.Boolean.pipe(Schema.withConstructorDefault(() => Option.some(false))), confirmThreadDelete: Schema.Boolean.pipe(Schema.withConstructorDefault(() => Option.some(true))), enableAssistantStreaming: Schema.Boolean.pipe( Schema.withConstructorDefault(() => Option.some(false)), diff --git a/apps/web/src/hooks/useTray.ts b/apps/web/src/hooks/useTray.ts new file mode 100644 index 000000000..9c7a9aeb3 --- /dev/null +++ b/apps/web/src/hooks/useTray.ts @@ -0,0 +1,26 @@ +import { useCallback } from "react"; +import { useAppSettings } from "~/appSettings"; +import { isElectron } from "~/env"; + +type TrayState = [boolean, (enabled: boolean) => void]; + +export function useTray(): TrayState { + if (!isElectron) return [false, () => {}]; + const bridge = window.desktopBridge; + if (!bridge) return [false, () => {}]; + + const { settings, updateSettings } = useAppSettings(); + + const setEnabledOverBridge = useCallback((enabled: boolean) => { + bridge + .setTrayEnabled(enabled) + .then(() => { + updateSettings({ showTrayIcon: enabled }); + }) + .catch(() => { + // Do nothing + }); + }, []); + + return [settings.showTrayIcon, setEnabledOverBridge]; +} diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 34f9c4b82..767faa1ce 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -10,6 +10,7 @@ import { useEffect, useRef } from "react"; import { QueryClient, useQueryClient } from "@tanstack/react-query"; import { Throttler } from "@tanstack/react-pacer"; +import { useAppSettings } from "../appSettings"; import { APP_DISPLAY_NAME } from "../branding"; import { Button } from "../components/ui/button"; import { AnchoredToastProvider, ToastProvider, toastManager } from "../components/ui/toast"; @@ -24,6 +25,7 @@ import { onServerConfigUpdated, onServerWelcome } from "../wsNativeApi"; import { providerQueryKeys } from "../lib/providerReactQuery"; import { projectQueryKeys } from "../lib/projectReactQuery"; import { collectActiveTerminalThreadIds } from "../lib/terminalStateCleanup"; +import { isElectron } from "../env"; export const Route = createRootRouteWithContext<{ queryClient: QueryClient; @@ -53,6 +55,7 @@ function RootRouteView() { + @@ -325,3 +328,19 @@ function DesktopProjectBootstrap() { // Desktop hydration runs through EventRouter project + orchestration sync. return null; } + +function DesktopTrayBootstrap() { + const { settings } = useAppSettings(); + + useEffect(() => { + if (!isElectron) return; + const bridge = window.desktopBridge; + if (!bridge) return; + + void bridge.setTrayEnabled(settings.showTrayIcon).catch(() => { + // Keep the persisted setting as the source of truth and retry on the next change. + }); + }, [settings.showTrayIcon]); + + return null; +} diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index b4afcdefa..d56420e20 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -21,6 +21,7 @@ import { import { Switch } from "../components/ui/switch"; import { APP_VERSION } from "../branding"; import { SidebarInset } from "~/components/ui/sidebar"; +import { useTray } from "~/hooks/useTray"; const THEME_OPTIONS = [ { @@ -107,6 +108,8 @@ function SettingsRouteView() { Partial> >({}); + const [isTrayEnabled, setTrayEnabled] = useTray(); + const codexBinaryPath = settings.codexBinaryPath; const codexHomePath = settings.codexHomePath; const keybindingsConfigPath = serverConfigQuery.data?.keybindingsConfigPath ?? null; @@ -666,6 +669,29 @@ function SettingsRouteView() { ) : null} + {isElectron ? ( +
+
+

Tray

+

+ Control the system tray icon and context menu. +

+
+
+
+

Show tray icon

+

+ Show a system tray icon in the notification area. +

+
+ setTrayEnabled(Boolean(checked))} + aria-label="Show tray icon" + /> +
+
+ ) : null}

About

From 5318e0c72ca7c48d4de957aa063fa3cdaffe0d50 Mon Sep 17 00:00:00 2001 From: Noah Gregory Date: Thu, 12 Mar 2026 19:53:59 -0400 Subject: [PATCH 09/14] feat: add thread menu items --- apps/desktop/src/main.ts | 4 ++ apps/desktop/src/preload.ts | 19 ++++- apps/desktop/src/tray.ts | 126 +++++++++++++++++++++++++++++++-- apps/web/src/routes/__root.tsx | 52 +++++++++++++- packages/contracts/src/ipc.ts | 20 ++++++ 5 files changed, 214 insertions(+), 7 deletions(-) diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 70fb130cc..08e89fc8d 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -1393,3 +1393,7 @@ if (process.platform !== "win32") { app.quit(); }); } + +export function getMainWindow(): BrowserWindow | null { + return mainWindow; +} \ No newline at end of file diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 61f3a79b1..1f7b1f0d6 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -1,5 +1,5 @@ import { contextBridge, ipcRenderer } from "electron"; -import type { DesktopBridge } from "@t3tools/contracts"; +import type { DesktopBridge, DesktopTrayState } from "@t3tools/contracts"; const PICK_FOLDER_CHANNEL = "desktop:pick-folder"; const CONFIRM_CHANNEL = "desktop:confirm"; @@ -12,6 +12,9 @@ const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state"; const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download"; const UPDATE_INSTALL_CHANNEL = "desktop:update-install"; const SET_TRAY_ENABLED_CHANNEL = "desktop:set-tray-enabled"; +const GET_TRAY_STATE_CHANNEL = "desktop:get-tray-state"; +const UPDATE_TRAY_STATE_CHANNEL = "desktop:update-tray-state"; +const TRAY_MESSAGE_CHANNEL = "desktop:tray-message"; const wsUrl = process.env.T3CODE_DESKTOP_WS_URL ?? null; contextBridge.exposeInMainWorld("desktopBridge", { @@ -47,4 +50,18 @@ contextBridge.exposeInMainWorld("desktopBridge", { }; }, setTrayEnabled: (enabled: boolean) => ipcRenderer.invoke(SET_TRAY_ENABLED_CHANNEL, enabled), + getTrayState: () => ipcRenderer.invoke(GET_TRAY_STATE_CHANNEL), + updateTrayState: (state: Partial) => + ipcRenderer.invoke(UPDATE_TRAY_STATE_CHANNEL, state), + onTrayMessage: (listener) => { + const wrappedListener = (_event: Electron.IpcRendererEvent, message: unknown) => { + if (typeof message !== "object" || message === null) return; + listener(message as Parameters[0]); + }; + + ipcRenderer.on(TRAY_MESSAGE_CHANNEL, wrappedListener); + return () => { + ipcRenderer.removeListener(TRAY_MESSAGE_CHANNEL, wrappedListener); + }; + }, } satisfies DesktopBridge); diff --git a/apps/desktop/src/tray.ts b/apps/desktop/src/tray.ts index 704df78f2..a4020e7db 100644 --- a/apps/desktop/src/tray.ts +++ b/apps/desktop/src/tray.ts @@ -1,5 +1,11 @@ import sharp from "sharp"; -import { nativeImage, app, ipcMain, Tray, Menu } from "electron"; +import { + nativeImage, app, ipcMain, Tray, Menu, + type MenuItemConstructorOptions, + type BrowserWindow, +} from "electron"; +import type { DesktopTrayState, DesktopTrayMessage, ThreadId } from "@t3tools/contracts"; +import { getMainWindow } from "./main"; // Stolen from the T3Wordmark component in the web app const T3_WORDMARK_VIEW_BOX = "15.5309 37 94.3941 56.96"; @@ -78,33 +84,145 @@ async function createTrayTemplateImage() { let tray: Tray | null = null; -async function createTray(contextMenu: Menu): Promise { +async function createTray(): Promise { // macOS only (for now) if (process.platform !== "darwin") tray = null; const image = await createTrayTemplateImage(); const newTray = new Tray(image); newTray.setToolTip(app.getName()); - newTray.setContextMenu(contextMenu); tray = newTray; } +let trayState: DesktopTrayState = { + threads: [], +}; + +// TODO: Maybe move this to a utils file? +function truncateGraphemes(value: string, maxLength: number): string { + const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" }); + const graphemes = Array.from(segmenter.segment(value), (segment) => segment.segment); + + if (graphemes.length <= maxLength) { + return value; + } + + return `${graphemes.slice(0, maxLength).join("")}...`; +} + +const MAX_THREAD_NAME_LENGTH = 20; +const MAX_THREADS_IN_CONTEXT_MENU = 3; +const MAX_VIEW_MORE_THREADS = 5; +function buildTrayContextMenu(): Menu { + const sortedThreads = trayState.threads.sort( + (a, b) => b.lastUpdated - a.lastUpdated, + ); + const topLevelThreads = sortedThreads.slice(0, MAX_THREADS_IN_CONTEXT_MENU); + const viewMoreThreads = sortedThreads.slice( + MAX_THREADS_IN_CONTEXT_MENU, + MAX_THREADS_IN_CONTEXT_MENU + MAX_VIEW_MORE_THREADS, + ); + function buildThreadMenuItem( + thread: DesktopTrayState["threads"][number], + ): MenuItemConstructorOptions { + return { + // TODO: This isn't accessible to screen readers! + label: `${thread.needsAttention ? "ยท" : ""} ${truncateGraphemes(thread.name, MAX_THREAD_NAME_LENGTH)}`, + click: () => { + const mainWindow = getMainWindow(); + if (!mainWindow) return; + sendTrayMessage({ type: "thread-click", threadId: thread.id as ThreadId }, mainWindow); + mainWindow.focus(); + }, + }; + } + const menuItemConstructors: MenuItemConstructorOptions[] = [ + ...topLevelThreads.map(buildThreadMenuItem), + { + type: "submenu", + label: `View More (${viewMoreThreads.length})`, + submenu: viewMoreThreads.map(buildThreadMenuItem), + }, + ]; + const menu = Menu.buildFromTemplate(menuItemConstructors); + return menu; +} + +function updateTray(): void { + if (!tray) return; + tray.setContextMenu(buildTrayContextMenu()); + const threadsNeedingAttention = trayState.threads.filter( + (thread) => thread.needsAttention, + ).length; + if (threadsNeedingAttention > 0) { + tray.setTitle(`(${threadsNeedingAttention} unread)`); + // TODO: Do we want an icon variant as well? + } else { + tray.setTitle(""); // Clear the title + } +} + +async function getTrayState(): Promise { + return trayState; +} + +function isSameThread( + a: DesktopTrayState["threads"][number], + b: DesktopTrayState["threads"][number], +): boolean { + return a.id === b.id; +} + +// TODO: This probably doesn't have the best performance! +function mergeThreads(threads: DesktopTrayState["threads"]): DesktopTrayState["threads"] { + return threads.reduce((acc, thread) => { + const existingThread = acc.find((t) => isSameThread(t, thread)); + if (existingThread) { + return acc.map((t) => (isSameThread(t, thread) ? { ...t, ...thread } : t)); + } + return [...acc, thread]; + }, []); +} + +async function updateTrayState(state: Partial): Promise { + trayState = { + ...trayState, + ...state, + threads: mergeThreads([...trayState.threads, ...(state.threads ?? [])]), + }; + updateTray(); +} + function setupTrayIpcHandlers(): void { const SET_TRAY_ENABLED_CHANNEL = "desktop:set-tray-enabled"; ipcMain.handle(SET_TRAY_ENABLED_CHANNEL, async (_event, enabled: boolean) => { await setTrayEnabled(enabled); }); + const GET_TRAY_STATE_CHANNEL = "desktop:get-tray-state"; + ipcMain.handle(GET_TRAY_STATE_CHANNEL, async (_event) => { + return await getTrayState(); + }); + const UPDATE_TRAY_STATE_CHANNEL = "desktop:update-tray-state"; + ipcMain.handle(UPDATE_TRAY_STATE_CHANNEL, async (_event, state: DesktopTrayState) => { + await updateTrayState(state); + }); +} + +function sendTrayMessage(message: DesktopTrayMessage, window: BrowserWindow): void { + const TRAY_MESSAGE_CHANNEL = "desktop:tray-message"; + window.webContents.send(TRAY_MESSAGE_CHANNEL, message); } async function configureTray(): Promise { // TODO: Add a context menu to the tray - await createTray(Menu.buildFromTemplate([])); + await createTray(); } async function setTrayEnabled(enabled: boolean): Promise { if (enabled) { if (tray && !tray.isDestroyed()) return; await configureTray(); + updateTray(); } else { if (tray?.isDestroyed() == false) tray.destroy(); tray = null; diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 767faa1ce..0a1f8cf07 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -1,4 +1,4 @@ -import { ThreadId } from "@t3tools/contracts"; +import { ThreadId, type DesktopTrayMessage } from "@t3tools/contracts"; import { Outlet, createRootRouteWithContext, @@ -6,7 +6,7 @@ import { useNavigate, useRouterState, } from "@tanstack/react-router"; -import { useEffect, useRef } from "react"; +import { useEffect, useRef, useCallback, useMemo } from "react"; import { QueryClient, useQueryClient } from "@tanstack/react-query"; import { Throttler } from "@tanstack/react-pacer"; @@ -26,6 +26,7 @@ import { providerQueryKeys } from "../lib/providerReactQuery"; import { projectQueryKeys } from "../lib/projectReactQuery"; import { collectActiveTerminalThreadIds } from "../lib/terminalStateCleanup"; import { isElectron } from "../env"; +import { useThreadSelectionStore } from "~/threadSelectionStore"; export const Route = createRootRouteWithContext<{ queryClient: QueryClient; @@ -332,15 +333,62 @@ function DesktopProjectBootstrap() { function DesktopTrayBootstrap() { const { settings } = useAppSettings(); + const navigate = useNavigate(); + const setSelectionAnchor = useThreadSelectionStore((s) => s.setAnchor); + + const onTrayMessage = useCallback( + (message: DesktopTrayMessage) => { + if (message.type === "thread-click") { + setSelectionAnchor(message.threadId); + void navigate({ + to: "/$threadId", + params: { threadId: message.threadId }, + }); + } + }, + [navigate], + ); + useEffect(() => { if (!isElectron) return; const bridge = window.desktopBridge; if (!bridge) return; + if (!settings.showTrayIcon) return; + const unsubscribe = bridge.onTrayMessage(onTrayMessage); + return () => { + unsubscribe(); + }; + }, [onTrayMessage, settings.showTrayIcon]); + useEffect(() => { + if (!isElectron) return; + const bridge = window.desktopBridge; + if (!bridge) return; void bridge.setTrayEnabled(settings.showTrayIcon).catch(() => { // Keep the persisted setting as the source of truth and retry on the next change. }); }, [settings.showTrayIcon]); + const threads = useStore((store) => store.threads); + useEffect(() => { + if (!isElectron) return; + const bridge = window.desktopBridge; + if (!bridge) return; + if (!settings.showTrayIcon) return; + bridge.updateTrayState({ + threads: threads.map((thread) => { + const lastVisitedAt = thread.lastVisitedAt ? Date.parse(thread.lastVisitedAt) : NaN; + const latestTurnCompletedAt = thread.latestTurn?.completedAt ? Date.parse(thread.latestTurn.completedAt) : NaN; + console.log(thread.id, latestTurnCompletedAt, lastVisitedAt); + return { + id: thread.id, + name: thread.title, + lastUpdated: latestTurnCompletedAt, + needsAttention: latestTurnCompletedAt > lastVisitedAt, + }; + }), + }); + }, [threads, settings.showTrayIcon]); + return null; } diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 5e705563a..a71c2023a 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -46,6 +46,23 @@ import type { OrchestrationReadModel, } from "./orchestration"; import { EditorId } from "./editor"; +import { ThreadId } from "./baseSchemas"; + +export interface DesktopTrayState { + threads: { + id: string; + name: string; + lastUpdated: number; + needsAttention: boolean; + }[]; +} + +interface DesktopTrayThreadMessage { + type: "thread-click"; + threadId: ThreadId; +} + +export type DesktopTrayMessage = DesktopTrayThreadMessage; // TODO: Add more message types as needed export interface ContextMenuItem { id: T; @@ -110,6 +127,9 @@ export interface DesktopBridge { installUpdate: () => Promise; onUpdateState: (listener: (state: DesktopUpdateState) => void) => () => void; setTrayEnabled: (enabled: boolean) => Promise; + getTrayState: () => Promise; + updateTrayState: (state: Partial) => Promise; + onTrayMessage: (listener: (message: DesktopTrayMessage) => void) => () => void; } export interface NativeApi { From f58d8ae8f6644bfdeaa0dc5f9809fa64853af4ee Mon Sep 17 00:00:00 2001 From: Noah Gregory Date: Thu, 12 Mar 2026 19:54:48 -0400 Subject: [PATCH 10/14] fix: format files --- apps/desktop/src/main.ts | 2 +- apps/desktop/src/tray.ts | 10 ++++++---- apps/web/src/routes/__root.tsx | 4 +++- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 08e89fc8d..0dc70ea30 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -1396,4 +1396,4 @@ if (process.platform !== "win32") { export function getMainWindow(): BrowserWindow | null { return mainWindow; -} \ No newline at end of file +} diff --git a/apps/desktop/src/tray.ts b/apps/desktop/src/tray.ts index a4020e7db..9f3d18d56 100644 --- a/apps/desktop/src/tray.ts +++ b/apps/desktop/src/tray.ts @@ -1,6 +1,10 @@ import sharp from "sharp"; import { - nativeImage, app, ipcMain, Tray, Menu, + nativeImage, + app, + ipcMain, + Tray, + Menu, type MenuItemConstructorOptions, type BrowserWindow, } from "electron"; @@ -114,9 +118,7 @@ const MAX_THREAD_NAME_LENGTH = 20; const MAX_THREADS_IN_CONTEXT_MENU = 3; const MAX_VIEW_MORE_THREADS = 5; function buildTrayContextMenu(): Menu { - const sortedThreads = trayState.threads.sort( - (a, b) => b.lastUpdated - a.lastUpdated, - ); + const sortedThreads = trayState.threads.sort((a, b) => b.lastUpdated - a.lastUpdated); const topLevelThreads = sortedThreads.slice(0, MAX_THREADS_IN_CONTEXT_MENU); const viewMoreThreads = sortedThreads.slice( MAX_THREADS_IN_CONTEXT_MENU, diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 0a1f8cf07..01f703ec6 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -378,7 +378,9 @@ function DesktopTrayBootstrap() { bridge.updateTrayState({ threads: threads.map((thread) => { const lastVisitedAt = thread.lastVisitedAt ? Date.parse(thread.lastVisitedAt) : NaN; - const latestTurnCompletedAt = thread.latestTurn?.completedAt ? Date.parse(thread.latestTurn.completedAt) : NaN; + const latestTurnCompletedAt = thread.latestTurn?.completedAt + ? Date.parse(thread.latestTurn.completedAt) + : NaN; console.log(thread.id, latestTurnCompletedAt, lastVisitedAt); return { id: thread.id, From f1e03ec087bb9f5f5ae89a88a2aed997a27f0b1f Mon Sep 17 00:00:00 2001 From: Noah Gregory Date: Fri, 13 Mar 2026 03:14:13 -0400 Subject: [PATCH 11/14] refactor: rename `useTray` hook to `useTrayEnabled` --- apps/web/src/hooks/{useTray.ts => useTrayEnabled.ts} | 4 ++-- apps/web/src/routes/_chat.settings.tsx | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) rename apps/web/src/hooks/{useTray.ts => useTrayEnabled.ts} (84%) diff --git a/apps/web/src/hooks/useTray.ts b/apps/web/src/hooks/useTrayEnabled.ts similarity index 84% rename from apps/web/src/hooks/useTray.ts rename to apps/web/src/hooks/useTrayEnabled.ts index 9c7a9aeb3..60cf770f9 100644 --- a/apps/web/src/hooks/useTray.ts +++ b/apps/web/src/hooks/useTrayEnabled.ts @@ -2,9 +2,9 @@ import { useCallback } from "react"; import { useAppSettings } from "~/appSettings"; import { isElectron } from "~/env"; -type TrayState = [boolean, (enabled: boolean) => void]; +type TrayEnabledState = [boolean, (enabled: boolean) => void]; -export function useTray(): TrayState { +export function useTrayEnabled(): TrayEnabledState { if (!isElectron) return [false, () => {}]; const bridge = window.desktopBridge; if (!bridge) return [false, () => {}]; diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index d56420e20..799c17330 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -21,7 +21,7 @@ import { import { Switch } from "../components/ui/switch"; import { APP_VERSION } from "../branding"; import { SidebarInset } from "~/components/ui/sidebar"; -import { useTray } from "~/hooks/useTray"; +import { useTrayEnabled } from "~/hooks/useTrayEnabled"; const THEME_OPTIONS = [ { @@ -108,7 +108,7 @@ function SettingsRouteView() { Partial> >({}); - const [isTrayEnabled, setTrayEnabled] = useTray(); + const [isTrayEnabled, setTrayEnabled] = useTrayEnabled(); const codexBinaryPath = settings.codexBinaryPath; const codexHomePath = settings.codexHomePath; From 19ddf6893e39a0a95b71ff6443be16e7b8b53806 Mon Sep 17 00:00:00 2001 From: Noah Gregory Date: Fri, 13 Mar 2026 03:26:37 -0400 Subject: [PATCH 12/14] refactor: make tray state setter hook and move from update pattern to set pattern --- apps/desktop/src/preload.ts | 6 +++--- apps/desktop/src/tray.ts | 32 +++++----------------------- apps/web/src/hooks/useTrayState.ts | 34 ++++++++++++++++++++++++++++++ apps/web/src/routes/__root.tsx | 9 ++++++-- packages/contracts/src/ipc.ts | 2 +- 5 files changed, 50 insertions(+), 33 deletions(-) create mode 100644 apps/web/src/hooks/useTrayState.ts diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 1f7b1f0d6..2f1b34a38 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -13,7 +13,7 @@ const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download"; const UPDATE_INSTALL_CHANNEL = "desktop:update-install"; const SET_TRAY_ENABLED_CHANNEL = "desktop:set-tray-enabled"; const GET_TRAY_STATE_CHANNEL = "desktop:get-tray-state"; -const UPDATE_TRAY_STATE_CHANNEL = "desktop:update-tray-state"; +const SET_TRAY_STATE_CHANNEL = "desktop:set-tray-state"; const TRAY_MESSAGE_CHANNEL = "desktop:tray-message"; const wsUrl = process.env.T3CODE_DESKTOP_WS_URL ?? null; @@ -51,8 +51,8 @@ contextBridge.exposeInMainWorld("desktopBridge", { }, setTrayEnabled: (enabled: boolean) => ipcRenderer.invoke(SET_TRAY_ENABLED_CHANNEL, enabled), getTrayState: () => ipcRenderer.invoke(GET_TRAY_STATE_CHANNEL), - updateTrayState: (state: Partial) => - ipcRenderer.invoke(UPDATE_TRAY_STATE_CHANNEL, state), + setTrayState: (state: DesktopTrayState) => + ipcRenderer.invoke(SET_TRAY_STATE_CHANNEL, state), onTrayMessage: (listener) => { const wrappedListener = (_event: Electron.IpcRendererEvent, message: unknown) => { if (typeof message !== "object" || message === null) return; diff --git a/apps/desktop/src/tray.ts b/apps/desktop/src/tray.ts index 9f3d18d56..e7e3cb41e 100644 --- a/apps/desktop/src/tray.ts +++ b/apps/desktop/src/tray.ts @@ -168,30 +168,8 @@ async function getTrayState(): Promise { return trayState; } -function isSameThread( - a: DesktopTrayState["threads"][number], - b: DesktopTrayState["threads"][number], -): boolean { - return a.id === b.id; -} - -// TODO: This probably doesn't have the best performance! -function mergeThreads(threads: DesktopTrayState["threads"]): DesktopTrayState["threads"] { - return threads.reduce((acc, thread) => { - const existingThread = acc.find((t) => isSameThread(t, thread)); - if (existingThread) { - return acc.map((t) => (isSameThread(t, thread) ? { ...t, ...thread } : t)); - } - return [...acc, thread]; - }, []); -} - -async function updateTrayState(state: Partial): Promise { - trayState = { - ...trayState, - ...state, - threads: mergeThreads([...trayState.threads, ...(state.threads ?? [])]), - }; +async function setTrayState(state: DesktopTrayState): Promise { + trayState = state; updateTray(); } @@ -204,9 +182,9 @@ function setupTrayIpcHandlers(): void { ipcMain.handle(GET_TRAY_STATE_CHANNEL, async (_event) => { return await getTrayState(); }); - const UPDATE_TRAY_STATE_CHANNEL = "desktop:update-tray-state"; - ipcMain.handle(UPDATE_TRAY_STATE_CHANNEL, async (_event, state: DesktopTrayState) => { - await updateTrayState(state); + const SET_TRAY_STATE_CHANNEL = "desktop:set-tray-state"; + ipcMain.handle(SET_TRAY_STATE_CHANNEL, async (_event, state: DesktopTrayState) => { + await setTrayState(state); }); } diff --git a/apps/web/src/hooks/useTrayState.ts b/apps/web/src/hooks/useTrayState.ts new file mode 100644 index 000000000..0a589cba2 --- /dev/null +++ b/apps/web/src/hooks/useTrayState.ts @@ -0,0 +1,34 @@ +import { useState, useEffect, useCallback } from "react"; +import { isElectron } from "~/env"; +import type { DesktopTrayState } from "@t3tools/contracts"; + +const EMPTY_TRAY_STATE: DesktopTrayState = { + threads: [], +}; +type TrayState = [DesktopTrayState, (state: DesktopTrayState) => void]; + +export function useTrayState(): TrayState { + if (!isElectron) return [EMPTY_TRAY_STATE, () => {}]; + const bridge = window.desktopBridge; + if (!bridge) return [EMPTY_TRAY_STATE, () => {}]; + + const [localTrayState, setLocalTrayState] = useState(EMPTY_TRAY_STATE); + + useEffect(() => { + void bridge.getTrayState().then((state) => { + setLocalTrayState(state); + }).catch(() => { + // Do nothing + }); + }, [setLocalTrayState]); + + const setTrayStateOverBridge = useCallback((state: DesktopTrayState) => { + bridge.setTrayState(state).then(() => { + setLocalTrayState(state); + }).catch(() => { + // Do nothing + }); + }, [setLocalTrayState]); + + return [localTrayState, setTrayStateOverBridge]; +} \ No newline at end of file diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 01f703ec6..eaf787d53 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -27,6 +27,7 @@ import { projectQueryKeys } from "../lib/projectReactQuery"; import { collectActiveTerminalThreadIds } from "../lib/terminalStateCleanup"; import { isElectron } from "../env"; import { useThreadSelectionStore } from "~/threadSelectionStore"; +import { useTrayState } from "~/hooks/useTrayState"; export const Route = createRootRouteWithContext<{ queryClient: QueryClient; @@ -370,12 +371,16 @@ function DesktopTrayBootstrap() { }, [settings.showTrayIcon]); const threads = useStore((store) => store.threads); + + const [trayState, setTrayState] = useTrayState(); + useEffect(() => { if (!isElectron) return; const bridge = window.desktopBridge; if (!bridge) return; if (!settings.showTrayIcon) return; - bridge.updateTrayState({ + setTrayState({ + ...trayState, threads: threads.map((thread) => { const lastVisitedAt = thread.lastVisitedAt ? Date.parse(thread.lastVisitedAt) : NaN; const latestTurnCompletedAt = thread.latestTurn?.completedAt @@ -390,7 +395,7 @@ function DesktopTrayBootstrap() { }; }), }); - }, [threads, settings.showTrayIcon]); + }, [threads, settings.showTrayIcon, setTrayState]); return null; } diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index a71c2023a..8e646988e 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -128,7 +128,7 @@ export interface DesktopBridge { onUpdateState: (listener: (state: DesktopUpdateState) => void) => () => void; setTrayEnabled: (enabled: boolean) => Promise; getTrayState: () => Promise; - updateTrayState: (state: Partial) => Promise; + setTrayState: (state: DesktopTrayState) => Promise; onTrayMessage: (listener: (message: DesktopTrayMessage) => void) => () => void; } From 8a7c828fef6606644928d49177013731d5d74b80 Mon Sep 17 00:00:00 2001 From: Noah Gregory Date: Fri, 13 Mar 2026 03:28:41 -0400 Subject: [PATCH 13/14] fix: remove leftover `console.log` --- apps/web/src/routes/__root.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index eaf787d53..87dfd356d 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -386,7 +386,6 @@ function DesktopTrayBootstrap() { const latestTurnCompletedAt = thread.latestTurn?.completedAt ? Date.parse(thread.latestTurn.completedAt) : NaN; - console.log(thread.id, latestTurnCompletedAt, lastVisitedAt); return { id: thread.id, name: thread.title, From 19f69c534d575216fc110f348dafa4ab46730d39 Mon Sep 17 00:00:00 2001 From: Noah Gregory Date: Fri, 13 Mar 2026 03:43:10 -0400 Subject: [PATCH 14/14] fix: format files as needed --- apps/desktop/src/preload.ts | 3 +-- apps/web/src/hooks/useTrayState.ts | 35 +++++++++++++++++++----------- 2 files changed, 23 insertions(+), 15 deletions(-) diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 2f1b34a38..d83efed8d 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -51,8 +51,7 @@ contextBridge.exposeInMainWorld("desktopBridge", { }, setTrayEnabled: (enabled: boolean) => ipcRenderer.invoke(SET_TRAY_ENABLED_CHANNEL, enabled), getTrayState: () => ipcRenderer.invoke(GET_TRAY_STATE_CHANNEL), - setTrayState: (state: DesktopTrayState) => - ipcRenderer.invoke(SET_TRAY_STATE_CHANNEL, state), + setTrayState: (state: DesktopTrayState) => ipcRenderer.invoke(SET_TRAY_STATE_CHANNEL, state), onTrayMessage: (listener) => { const wrappedListener = (_event: Electron.IpcRendererEvent, message: unknown) => { if (typeof message !== "object" || message === null) return; diff --git a/apps/web/src/hooks/useTrayState.ts b/apps/web/src/hooks/useTrayState.ts index 0a589cba2..9dbbe1e03 100644 --- a/apps/web/src/hooks/useTrayState.ts +++ b/apps/web/src/hooks/useTrayState.ts @@ -15,20 +15,29 @@ export function useTrayState(): TrayState { const [localTrayState, setLocalTrayState] = useState(EMPTY_TRAY_STATE); useEffect(() => { - void bridge.getTrayState().then((state) => { - setLocalTrayState(state); - }).catch(() => { - // Do nothing - }); + void bridge + .getTrayState() + .then((state) => { + setLocalTrayState(state); + }) + .catch(() => { + // Do nothing + }); }, [setLocalTrayState]); - const setTrayStateOverBridge = useCallback((state: DesktopTrayState) => { - bridge.setTrayState(state).then(() => { - setLocalTrayState(state); - }).catch(() => { - // Do nothing - }); - }, [setLocalTrayState]); + const setTrayStateOverBridge = useCallback( + (state: DesktopTrayState) => { + bridge + .setTrayState(state) + .then(() => { + setLocalTrayState(state); + }) + .catch(() => { + // Do nothing + }); + }, + [setLocalTrayState], + ); return [localTrayState, setTrayStateOverBridge]; -} \ No newline at end of file +}