From a984bd0042f8a014829287782356d8e62cea8503 Mon Sep 17 00:00:00 2001 From: Noah Gregory Date: Thu, 12 Mar 2026 05:38:32 -0400 Subject: [PATCH 01/27] 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 0754c0d1c8..f139f0f12f 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 4606849295..3abfbef121 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 0000000000..8223a0b285 --- /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 b8e36149f7..be05753d72 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 64538f2f4300dd8d5530e1da539c3e41d03f85c1 Mon Sep 17 00:00:00 2001 From: Noah Gregory Date: Thu, 12 Mar 2026 05:59:41 -0400 Subject: [PATCH 02/27] 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 8223a0b285..a4a857c8f2 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 f9112f0d0367781f49fcb353067602eff7f6e44a Mon Sep 17 00:00:00 2001 From: Noah Gregory Date: Thu, 12 Mar 2026 13:40:57 -0400 Subject: [PATCH 03/27] 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 3abfbef121..db73539578 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 a4a857c8f2..3b414430b8 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 beb08f3113bca92770607278c5f771fbb061e510 Mon Sep 17 00:00:00 2001 From: Noah Gregory Date: Thu, 12 Mar 2026 13:51:06 -0400 Subject: [PATCH 04/27] 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 3b414430b8..28f67b19ad 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 9549570c5720845907ce2bc2dcdc657bd4f0679f Mon Sep 17 00:00:00 2001 From: Noah Gregory Date: Thu, 12 Mar 2026 15:30:39 -0400 Subject: [PATCH 05/27] 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 28f67b19ad..26f6e4f91a 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 fcd4f0014ab7db911e67e41ada68c26096bfe807 Mon Sep 17 00:00:00 2001 From: Noah Gregory Date: Thu, 12 Mar 2026 15:32:33 -0400 Subject: [PATCH 06/27] 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 26f6e4f91a..c94900157c 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 3441ef1243d57df7f57e035166faebfa505c8642 Mon Sep 17 00:00:00 2001 From: Noah Gregory Date: Thu, 12 Mar 2026 15:33:35 -0400 Subject: [PATCH 07/27] 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 db73539578..70fb130ccf 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 1e1bb3bd8e..61f3a79b17 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 c94900157c..704df78f21 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 b9127fb176..5e705563a3 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 82bce0e378e48bdc4ae092ee12df1c2f4c40cd35 Mon Sep 17 00:00:00 2001 From: Noah Gregory Date: Thu, 12 Mar 2026 15:34:32 -0400 Subject: [PATCH 08/27] 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 18e76d2f92..7f5d0d01df 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 0000000000..9c7a9aeb3c --- /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 34f9c4b82f..767faa1ced 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 b4afcdefa1..d56420e20d 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 1a4f31ccfdc5e6c49439b8d28de0a2f692ea7164 Mon Sep 17 00:00:00 2001 From: Noah Gregory Date: Thu, 12 Mar 2026 19:53:59 -0400 Subject: [PATCH 09/27] 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 70fb130ccf..08e89fc8d4 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 61f3a79b17..1f7b1f0d61 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 704df78f21..a4020e7dbc 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 767faa1ced..0a1f8cf070 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 5e705563a3..a71c2023a8 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 6169a61fae1a63407ae911612287a13d8cea9d89 Mon Sep 17 00:00:00 2001 From: Noah Gregory Date: Thu, 12 Mar 2026 19:54:48 -0400 Subject: [PATCH 10/27] 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 08e89fc8d4..0dc70ea308 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 a4020e7dbc..9f3d18d569 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 0a1f8cf070..01f703ec65 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 867d8f868baf4bc55d2eb32554b1d0cf62212325 Mon Sep 17 00:00:00 2001 From: Noah Gregory Date: Fri, 13 Mar 2026 03:14:13 -0400 Subject: [PATCH 11/27] 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 9c7a9aeb3c..60cf770f94 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 d56420e20d..799c173308 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 2d63b25882b2547c1976b35012a029f93467d345 Mon Sep 17 00:00:00 2001 From: Noah Gregory Date: Fri, 13 Mar 2026 03:26:37 -0400 Subject: [PATCH 12/27] 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 1f7b1f0d61..2f1b34a389 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 9f3d18d569..e7e3cb41e9 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 0000000000..0a589cba24 --- /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 01f703ec65..eaf787d53f 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 a71c2023a8..8e646988e7 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 14786ba541ffa7b54c5290c6551bca90a225db89 Mon Sep 17 00:00:00 2001 From: Noah Gregory Date: Fri, 13 Mar 2026 03:28:41 -0400 Subject: [PATCH 13/27] 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 eaf787d53f..87dfd356d3 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 0c2ca9602e90ee1cb48d9108c3f808a47ce1037c Mon Sep 17 00:00:00 2001 From: Noah Gregory Date: Fri, 13 Mar 2026 03:43:10 -0400 Subject: [PATCH 14/27] 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 2f1b34a389..d83efed8d6 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 0a589cba24..9dbbe1e030 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 +} From b6de8bd18e4eb32ea31f17e14d64cff6d49c476a Mon Sep 17 00:00:00 2001 From: Noah Gregory Date: Sun, 15 Mar 2026 00:35:11 -0400 Subject: [PATCH 15/27] fix: use `toSorted` instead of `sorted` --- 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 e7e3cb41e9..b6d97f7efb 100644 --- a/apps/desktop/src/tray.ts +++ b/apps/desktop/src/tray.ts @@ -118,7 +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.toSorted((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, From e5d7fd33aee57510d495f5f2b3361c59b0bf5863 Mon Sep 17 00:00:00 2001 From: Noah Gregory Date: Sun, 15 Mar 2026 00:36:00 -0400 Subject: [PATCH 16/27] fix: increase thread name truncation length in tray menu --- 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 b6d97f7efb..a025d06a6c 100644 --- a/apps/desktop/src/tray.ts +++ b/apps/desktop/src/tray.ts @@ -114,7 +114,7 @@ function truncateGraphemes(value: string, maxLength: number): string { return `${graphemes.slice(0, maxLength).join("")}...`; } -const MAX_THREAD_NAME_LENGTH = 20; +const MAX_THREAD_NAME_LENGTH = 40; const MAX_THREADS_IN_CONTEXT_MENU = 3; const MAX_VIEW_MORE_THREADS = 5; function buildTrayContextMenu(): Menu { From 01156debba41eb15ee8f940c8283fc870e7197e6 Mon Sep 17 00:00:00 2001 From: Noah Gregory Date: Sun, 15 Mar 2026 00:46:46 -0400 Subject: [PATCH 17/27] fix: remove old todo --- apps/desktop/src/tray.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/desktop/src/tray.ts b/apps/desktop/src/tray.ts index a025d06a6c..fac17a5b08 100644 --- a/apps/desktop/src/tray.ts +++ b/apps/desktop/src/tray.ts @@ -194,7 +194,6 @@ function sendTrayMessage(message: DesktopTrayMessage, window: BrowserWindow): vo } async function configureTray(): Promise { - // TODO: Add a context menu to the tray await createTray(); } From a5f2731c4035444d4e9804aa16314d7d0a3fe414 Mon Sep 17 00:00:00 2001 From: Noah Gregory Date: Sun, 15 Mar 2026 01:01:36 -0400 Subject: [PATCH 18/27] fix: address linter concerns --- apps/web/src/hooks/useTrayEnabled.ts | 23 +++++++++++++---------- apps/web/src/hooks/useTrayState.ts | 4 ++-- apps/web/src/routes/__root.tsx | 2 +- 3 files changed, 16 insertions(+), 13 deletions(-) diff --git a/apps/web/src/hooks/useTrayEnabled.ts b/apps/web/src/hooks/useTrayEnabled.ts index 60cf770f94..cad160f244 100644 --- a/apps/web/src/hooks/useTrayEnabled.ts +++ b/apps/web/src/hooks/useTrayEnabled.ts @@ -11,16 +11,19 @@ export function useTrayEnabled(): TrayEnabledState { const { settings, updateSettings } = useAppSettings(); - const setEnabledOverBridge = useCallback((enabled: boolean) => { - bridge - .setTrayEnabled(enabled) - .then(() => { - updateSettings({ showTrayIcon: enabled }); - }) - .catch(() => { - // Do nothing - }); - }, []); + const setEnabledOverBridge = useCallback( + (enabled: boolean) => { + bridge + .setTrayEnabled(enabled) + .then(() => { + updateSettings({ showTrayIcon: enabled }); + }) + .catch(() => { + // Do nothing + }); + }, + [bridge, updateSettings], + ); return [settings.showTrayIcon, setEnabledOverBridge]; } diff --git a/apps/web/src/hooks/useTrayState.ts b/apps/web/src/hooks/useTrayState.ts index 9dbbe1e030..9d05b498f1 100644 --- a/apps/web/src/hooks/useTrayState.ts +++ b/apps/web/src/hooks/useTrayState.ts @@ -23,7 +23,7 @@ export function useTrayState(): TrayState { .catch(() => { // Do nothing }); - }, [setLocalTrayState]); + }, [setLocalTrayState, bridge]); const setTrayStateOverBridge = useCallback( (state: DesktopTrayState) => { @@ -36,7 +36,7 @@ export function useTrayState(): TrayState { // Do nothing }); }, - [setLocalTrayState], + [setLocalTrayState, bridge], ); return [localTrayState, setTrayStateOverBridge]; diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 87dfd356d3..df205c37b5 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -6,7 +6,7 @@ import { useNavigate, useRouterState, } from "@tanstack/react-router"; -import { useEffect, useRef, useCallback, useMemo } from "react"; +import { useEffect, useRef, useCallback } from "react"; import { QueryClient, useQueryClient } from "@tanstack/react-query"; import { Throttler } from "@tanstack/react-pacer"; From 7fa0ec22a434556f3bfb8e53513dea94612894d2 Mon Sep 17 00:00:00 2001 From: Noah Gregory Date: Sun, 15 Mar 2026 01:05:25 -0400 Subject: [PATCH 19/27] fix: address additional linter concerns --- apps/web/src/routes/__root.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index df205c37b5..4c56aea01e 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -347,7 +347,7 @@ function DesktopTrayBootstrap() { }); } }, - [navigate], + [navigate, setSelectionAnchor], ); useEffect(() => { @@ -394,7 +394,7 @@ function DesktopTrayBootstrap() { }; }), }); - }, [threads, settings.showTrayIcon, setTrayState]); + }, [threads, settings.showTrayIcon, trayState, setTrayState]); return null; } From a0c944d2ef872c124c8d88bc059fb2b657a714bf Mon Sep 17 00:00:00 2001 From: Noah Gregory Date: Sun, 15 Mar 2026 01:55:54 -0400 Subject: [PATCH 20/27] fix: actually fail early on non-macOS platforms --- apps/desktop/src/tray.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/tray.ts b/apps/desktop/src/tray.ts index fac17a5b08..26fc3c8677 100644 --- a/apps/desktop/src/tray.ts +++ b/apps/desktop/src/tray.ts @@ -90,8 +90,10 @@ let tray: Tray | null = null; async function createTray(): Promise { // macOS only (for now) - if (process.platform !== "darwin") tray = null; - + if (process.platform !== "darwin") { + tray = null; + return; + } const image = await createTrayTemplateImage(); const newTray = new Tray(image); newTray.setToolTip(app.getName()); From 10a9876498cd1985dea15f2840baa8882d7e62ec Mon Sep 17 00:00:00 2001 From: Noah Gregory Date: Sun, 15 Mar 2026 02:01:50 -0400 Subject: [PATCH 21/27] fix: create main window from tray if it doesn't exist --- apps/desktop/src/main.ts | 5 ++++- apps/desktop/src/tray.ts | 6 +++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 0dc70ea308..9c174012bb 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -1394,6 +1394,9 @@ if (process.platform !== "win32") { }); } -export function getMainWindow(): BrowserWindow | null { +export function getMainWindow(createIfNeeded: boolean = false): BrowserWindow | null { + if (createIfNeeded && mainWindow === null) { + mainWindow = createWindow(); + } return mainWindow; } diff --git a/apps/desktop/src/tray.ts b/apps/desktop/src/tray.ts index 26fc3c8677..7fdc10784d 100644 --- a/apps/desktop/src/tray.ts +++ b/apps/desktop/src/tray.ts @@ -134,7 +134,11 @@ function buildTrayContextMenu(): Menu { label: `${thread.needsAttention ? "·" : ""} ${truncateGraphemes(thread.name, MAX_THREAD_NAME_LENGTH)}`, click: () => { const mainWindow = getMainWindow(); - if (!mainWindow) return; + if (!mainWindow) { + console.error("[tray] Failed to get (or create) main window"); + return; + } + // TODO: Wait to ensure the window is ready? sendTrayMessage({ type: "thread-click", threadId: thread.id as ThreadId }, mainWindow); mainWindow.focus(); }, From 9d0d67fa0db43a7d6b6905bd92a4600a34531edf Mon Sep 17 00:00:00 2001 From: Noah Gregory Date: Sun, 15 Mar 2026 02:15:43 -0400 Subject: [PATCH 22/27] fix: don't rely on old thread state --- apps/desktop/src/tray.ts | 32 ++++++++++++++++++++++++++++++++ apps/web/src/routes/__root.tsx | 15 ++++++--------- 2 files changed, 38 insertions(+), 9 deletions(-) diff --git a/apps/desktop/src/tray.ts b/apps/desktop/src/tray.ts index 7fdc10784d..9bd18debf3 100644 --- a/apps/desktop/src/tray.ts +++ b/apps/desktop/src/tray.ts @@ -104,6 +104,34 @@ let trayState: DesktopTrayState = { threads: [], }; +function areTrayThreadsEqual( + previous: DesktopTrayState["threads"], + next: DesktopTrayState["threads"], +): boolean { + if (previous.length !== next.length) { + return false; + } + + for (let index = 0; index < previous.length; index += 1) { + const previousThread = previous[index]; + const nextThread = next[index]; + if ( + previousThread?.id !== nextThread?.id || + previousThread?.name !== nextThread?.name || + previousThread?.lastUpdated !== nextThread?.lastUpdated || + previousThread?.needsAttention !== nextThread?.needsAttention + ) { + return false; + } + } + + return true; +} + +function isTrayStateEqual(previous: DesktopTrayState, next: DesktopTrayState): boolean { + return areTrayThreadsEqual(previous.threads, next.threads); +} + // TODO: Maybe move this to a utils file? function truncateGraphemes(value: string, maxLength: number): string { const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" }); @@ -175,6 +203,10 @@ async function getTrayState(): Promise { } async function setTrayState(state: DesktopTrayState): Promise { + // Avoid updating the tray if the state is the same + if (isTrayStateEqual(trayState, state)) { + return; + } trayState = state; updateTray(); } diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 4c56aea01e..3a3b8c08ff 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -1,4 +1,4 @@ -import { ThreadId, type DesktopTrayMessage } from "@t3tools/contracts"; +import { ThreadId, type DesktopTrayMessage, type DesktopTrayState } from "@t3tools/contracts"; import { Outlet, createRootRouteWithContext, @@ -372,15 +372,11 @@ function DesktopTrayBootstrap() { const threads = useStore((store) => store.threads); - const [trayState, setTrayState] = useTrayState(); + const [, setTrayState] = useTrayState(); useEffect(() => { - if (!isElectron) return; - const bridge = window.desktopBridge; - if (!bridge) return; if (!settings.showTrayIcon) return; - setTrayState({ - ...trayState, + const nextTrayState: DesktopTrayState = { threads: threads.map((thread) => { const lastVisitedAt = thread.lastVisitedAt ? Date.parse(thread.lastVisitedAt) : NaN; const latestTurnCompletedAt = thread.latestTurn?.completedAt @@ -393,8 +389,9 @@ function DesktopTrayBootstrap() { needsAttention: latestTurnCompletedAt > lastVisitedAt, }; }), - }); - }, [threads, settings.showTrayIcon, trayState, setTrayState]); + }; + setTrayState(nextTrayState); + }, [threads, settings.showTrayIcon, setTrayState]); return null; } From 053e7f6d350ef46acd719d007973183a2d872ba4 Mon Sep 17 00:00:00 2001 From: Noah Gregory Date: Sun, 15 Mar 2026 02:23:45 -0400 Subject: [PATCH 23/27] fix: add state updater to `useTrayState` --- apps/web/src/hooks/useTrayState.ts | 40 +++++++++++++++++++----------- apps/web/src/routes/__root.tsx | 8 +++--- 2 files changed, 30 insertions(+), 18 deletions(-) diff --git a/apps/web/src/hooks/useTrayState.ts b/apps/web/src/hooks/useTrayState.ts index 9d05b498f1..bd185ad1fe 100644 --- a/apps/web/src/hooks/useTrayState.ts +++ b/apps/web/src/hooks/useTrayState.ts @@ -1,11 +1,12 @@ -import { useState, useEffect, useCallback } from "react"; +import { useState, useEffect, useCallback, useRef } from "react"; import { isElectron } from "~/env"; import type { DesktopTrayState } from "@t3tools/contracts"; const EMPTY_TRAY_STATE: DesktopTrayState = { threads: [], }; -type TrayState = [DesktopTrayState, (state: DesktopTrayState) => void]; +type SetTrayStateAction = DesktopTrayState | ((previous: DesktopTrayState) => DesktopTrayState); +type TrayState = [DesktopTrayState, (action: SetTrayStateAction) => void]; export function useTrayState(): TrayState { if (!isElectron) return [EMPTY_TRAY_STATE, () => {}]; @@ -13,30 +14,41 @@ export function useTrayState(): TrayState { if (!bridge) return [EMPTY_TRAY_STATE, () => {}]; const [localTrayState, setLocalTrayState] = useState(EMPTY_TRAY_STATE); + const localTrayStateRef = useRef(localTrayState); + + const syncLocalTrayState = useCallback((state: DesktopTrayState) => { + localTrayStateRef.current = state; + setLocalTrayState(state); + }, []); useEffect(() => { void bridge .getTrayState() .then((state) => { - setLocalTrayState(state); + syncLocalTrayState(state); }) .catch(() => { // Do nothing }); - }, [setLocalTrayState, bridge]); + }, [bridge, syncLocalTrayState]); const setTrayStateOverBridge = useCallback( - (state: DesktopTrayState) => { - bridge - .setTrayState(state) - .then(() => { - setLocalTrayState(state); - }) - .catch(() => { - // Do nothing - }); + (action: SetTrayStateAction) => { + const nextState = typeof action === "function" ? action(localTrayStateRef.current) : action; + + syncLocalTrayState(nextState); + bridge.setTrayState(nextState).catch(() => { + void bridge + .getTrayState() + .then((state) => { + syncLocalTrayState(state); + }) + .catch(() => { + // Do nothing + }); + }); }, - [setLocalTrayState, bridge], + [bridge, syncLocalTrayState], ); return [localTrayState, setTrayStateOverBridge]; diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 3a3b8c08ff..3c04f641f3 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -1,4 +1,4 @@ -import { ThreadId, type DesktopTrayMessage, type DesktopTrayState } from "@t3tools/contracts"; +import { ThreadId, type DesktopTrayMessage } from "@t3tools/contracts"; import { Outlet, createRootRouteWithContext, @@ -376,7 +376,8 @@ function DesktopTrayBootstrap() { useEffect(() => { if (!settings.showTrayIcon) return; - const nextTrayState: DesktopTrayState = { + setTrayState((previous) => ({ + ...previous, threads: threads.map((thread) => { const lastVisitedAt = thread.lastVisitedAt ? Date.parse(thread.lastVisitedAt) : NaN; const latestTurnCompletedAt = thread.latestTurn?.completedAt @@ -389,8 +390,7 @@ function DesktopTrayBootstrap() { needsAttention: latestTurnCompletedAt > lastVisitedAt, }; }), - }; - setTrayState(nextTrayState); + })); }, [threads, settings.showTrayIcon, setTrayState]); return null; From 8c1cd5b0c21385287aae71b61dd00c745eacf69e Mon Sep 17 00:00:00 2001 From: Noah Gregory Date: Sun, 15 Mar 2026 02:26:26 -0400 Subject: [PATCH 24/27] fix: actually create main window on thread click from tray --- 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 9bd18debf3..4e5077d20e 100644 --- a/apps/desktop/src/tray.ts +++ b/apps/desktop/src/tray.ts @@ -161,7 +161,7 @@ function buildTrayContextMenu(): Menu { // TODO: This isn't accessible to screen readers! label: `${thread.needsAttention ? "·" : ""} ${truncateGraphemes(thread.name, MAX_THREAD_NAME_LENGTH)}`, click: () => { - const mainWindow = getMainWindow(); + const mainWindow = getMainWindow(true); if (!mainWindow) { console.error("[tray] Failed to get (or create) main window"); return; From 4ab4f1bbc7c6e67e13059b6576adb7829504ef5a Mon Sep 17 00:00:00 2001 From: Noah Gregory Date: Sun, 15 Mar 2026 02:49:21 -0400 Subject: [PATCH 25/27] feat: allow for tray menu to re-open main window --- apps/desktop/src/main.ts | 3 ++- apps/desktop/src/preload.ts | 3 +++ apps/desktop/src/tray.ts | 40 +++++++++++++++++++++++++++------- apps/web/src/routes/__root.tsx | 19 ++++++++++++++-- packages/contracts/src/ipc.ts | 1 + 5 files changed, 55 insertions(+), 11 deletions(-) diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 9c174012bb..4129f9ec2b 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 { setupTrayIpcHandlers, setTrayEnabled } from "./tray"; +import { setupTrayIpcHandlers, setTrayEnabled, setReadyToHandleTrayMessages } from "./tray"; fixPath(); @@ -1370,6 +1370,7 @@ app.on("window-all-closed", () => { if (process.platform !== "darwin") { app.quit(); } + setReadyToHandleTrayMessages(false); }); if (process.platform !== "win32") { diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index d83efed8d6..38e1c2fbed 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -14,6 +14,7 @@ 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 SET_TRAY_STATE_CHANNEL = "desktop:set-tray-state"; +const SET_READY_TO_HANDLE_TRAY_MESSAGES_CHANNEL = "desktop:set-ready-to-handle-tray-messages"; const TRAY_MESSAGE_CHANNEL = "desktop:tray-message"; const wsUrl = process.env.T3CODE_DESKTOP_WS_URL ?? null; @@ -52,6 +53,8 @@ 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), + setReadyToHandleTrayMessages: (ready: boolean) => + ipcRenderer.invoke(SET_READY_TO_HANDLE_TRAY_MESSAGES_CHANNEL, ready), 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 4e5077d20e..b10785175a 100644 --- a/apps/desktop/src/tray.ts +++ b/apps/desktop/src/tray.ts @@ -160,13 +160,13 @@ function buildTrayContextMenu(): Menu { return { // TODO: This isn't accessible to screen readers! label: `${thread.needsAttention ? "·" : ""} ${truncateGraphemes(thread.name, MAX_THREAD_NAME_LENGTH)}`, - click: () => { + click: async () => { const mainWindow = getMainWindow(true); if (!mainWindow) { console.error("[tray] Failed to get (or create) main window"); return; } - // TODO: Wait to ensure the window is ready? + await waitForWindowReady(); sendTrayMessage({ type: "thread-click", threadId: thread.id as ThreadId }, mainWindow); mainWindow.focus(); }, @@ -211,6 +211,31 @@ async function setTrayState(state: DesktopTrayState): Promise { updateTray(); } +let readyToHandleTrayMessages = false; + +function setReadyToHandleTrayMessages(ready: boolean): void { + readyToHandleTrayMessages = ready; +} + +const WAIT_FOR_WINDOW_READY_TIMEOUT_MS = 250; +async function waitForWindowReady( + timeoutMs: number = WAIT_FOR_WINDOW_READY_TIMEOUT_MS, +): Promise { + const startTime = Date.now(); + // oxlint-disable-next-line no-unmodified-loop-condition + while (!readyToHandleTrayMessages && Date.now() - startTime < timeoutMs) { + await new Promise((resolve) => setTimeout(resolve, 10)); + } + if (!readyToHandleTrayMessages) { + throw new Error("Window not ready to handle tray messages"); + } +} + +function sendTrayMessage(message: DesktopTrayMessage, window: BrowserWindow): void { + const TRAY_MESSAGE_CHANNEL = "desktop:tray-message"; + window.webContents.send(TRAY_MESSAGE_CHANNEL, message); +} + function setupTrayIpcHandlers(): void { const SET_TRAY_ENABLED_CHANNEL = "desktop:set-tray-enabled"; ipcMain.handle(SET_TRAY_ENABLED_CHANNEL, async (_event, enabled: boolean) => { @@ -224,11 +249,10 @@ function setupTrayIpcHandlers(): void { ipcMain.handle(SET_TRAY_STATE_CHANNEL, async (_event, state: DesktopTrayState) => { await setTrayState(state); }); -} - -function sendTrayMessage(message: DesktopTrayMessage, window: BrowserWindow): void { - const TRAY_MESSAGE_CHANNEL = "desktop:tray-message"; - window.webContents.send(TRAY_MESSAGE_CHANNEL, message); + const SET_READY_TO_HANDLE_TRAY_MESSAGES_CHANNEL = "desktop:set-ready-to-handle-tray-messages"; + ipcMain.handle(SET_READY_TO_HANDLE_TRAY_MESSAGES_CHANNEL, async (_event, ready: boolean) => { + setReadyToHandleTrayMessages(ready); + }); } async function configureTray(): Promise { @@ -246,4 +270,4 @@ async function setTrayEnabled(enabled: boolean): Promise { } } -export { setupTrayIpcHandlers, setTrayEnabled }; +export { setupTrayIpcHandlers, setTrayEnabled, setReadyToHandleTrayMessages }; diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 3c04f641f3..9bff7c2b09 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -350,6 +350,23 @@ function DesktopTrayBootstrap() { [navigate, setSelectionAnchor], ); + const threads = useStore((store) => store.threads); + + useEffect(() => { + if (!isElectron) return; + const bridge = window.desktopBridge; + if (!bridge) return; + if (threads.length === 0) return; + void bridge.setReadyToHandleTrayMessages(true).catch(() => { + // Do nothing + }); + return () => { + void bridge.setReadyToHandleTrayMessages(false).catch(() => { + // Do nothing + }); + }; + }, [threads]); + useEffect(() => { if (!isElectron) return; const bridge = window.desktopBridge; @@ -370,8 +387,6 @@ function DesktopTrayBootstrap() { }); }, [settings.showTrayIcon]); - const threads = useStore((store) => store.threads); - const [, setTrayState] = useTrayState(); useEffect(() => { diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 8e646988e7..8bad2becbf 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -129,6 +129,7 @@ export interface DesktopBridge { setTrayEnabled: (enabled: boolean) => Promise; getTrayState: () => Promise; setTrayState: (state: DesktopTrayState) => Promise; + setReadyToHandleTrayMessages: (ready: boolean) => Promise; onTrayMessage: (listener: (message: DesktopTrayMessage) => void) => () => void; } From a1bc2ed2f890bde11d96022683996171e6f162c5 Mon Sep 17 00:00:00 2001 From: Noah Gregory Date: Sun, 15 Mar 2026 03:06:18 -0400 Subject: [PATCH 26/27] style: re-order hooks --- apps/web/src/routes/__root.tsx | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 9bff7c2b09..c7ea9c2ad4 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -350,23 +350,6 @@ function DesktopTrayBootstrap() { [navigate, setSelectionAnchor], ); - const threads = useStore((store) => store.threads); - - useEffect(() => { - if (!isElectron) return; - const bridge = window.desktopBridge; - if (!bridge) return; - if (threads.length === 0) return; - void bridge.setReadyToHandleTrayMessages(true).catch(() => { - // Do nothing - }); - return () => { - void bridge.setReadyToHandleTrayMessages(false).catch(() => { - // Do nothing - }); - }; - }, [threads]); - useEffect(() => { if (!isElectron) return; const bridge = window.desktopBridge; @@ -387,6 +370,23 @@ function DesktopTrayBootstrap() { }); }, [settings.showTrayIcon]); + const threads = useStore((store) => store.threads); + + useEffect(() => { + if (!isElectron) return; + const bridge = window.desktopBridge; + if (!bridge) return; + if (threads.length === 0) return; + void bridge.setReadyToHandleTrayMessages(true).catch(() => { + // Do nothing + }); + return () => { + void bridge.setReadyToHandleTrayMessages(false).catch(() => { + // Do nothing + }); + }; + }, [threads]); + const [, setTrayState] = useTrayState(); useEffect(() => { From f388328435db32d123167b102d4fd273967b22fe Mon Sep 17 00:00:00 2001 From: Noah Gregory Date: Sun, 15 Mar 2026 03:30:41 -0400 Subject: [PATCH 27/27] feat: add new thread in project action from tray --- apps/desktop/src/tray.ts | 65 ++++++++++++++++++++++++++---- apps/web/src/hooks/useTrayState.ts | 1 + apps/web/src/routes/__root.tsx | 35 ++++++++++++---- packages/contracts/src/ipc.ts | 13 +++++- 4 files changed, 97 insertions(+), 17 deletions(-) diff --git a/apps/desktop/src/tray.ts b/apps/desktop/src/tray.ts index b10785175a..aa463a20f6 100644 --- a/apps/desktop/src/tray.ts +++ b/apps/desktop/src/tray.ts @@ -8,7 +8,7 @@ import { type MenuItemConstructorOptions, type BrowserWindow, } from "electron"; -import type { DesktopTrayState, DesktopTrayMessage, ThreadId } from "@t3tools/contracts"; +import type { DesktopTrayState, DesktopTrayMessage, ThreadId, ProjectId } from "@t3tools/contracts"; import { getMainWindow } from "./main"; // Stolen from the T3Wordmark component in the web app @@ -102,6 +102,7 @@ async function createTray(): Promise { let trayState: DesktopTrayState = { threads: [], + projects: [], }; function areTrayThreadsEqual( @@ -128,8 +129,28 @@ function areTrayThreadsEqual( return true; } +function areTrayProjectsEqual( + previous: DesktopTrayState["projects"], + next: DesktopTrayState["projects"], +): boolean { + if (previous.length !== next.length) { + return false; + } + for (let index = 0; index < previous.length; index += 1) { + const previousProject = previous[index]; + const nextProject = next[index]; + if (previousProject?.id !== nextProject?.id || previousProject?.name !== nextProject?.name) { + return false; + } + } + return true; +} + function isTrayStateEqual(previous: DesktopTrayState, next: DesktopTrayState): boolean { - return areTrayThreadsEqual(previous.threads, next.threads); + return ( + areTrayThreadsEqual(previous.threads, next.threads) && + areTrayProjectsEqual(previous.projects, next.projects) + ); } // TODO: Maybe move this to a utils file? @@ -144,6 +165,16 @@ function truncateGraphemes(value: string, maxLength: number): string { return `${graphemes.slice(0, maxLength).join("")}...`; } +async function getMainWindowAndWaitForReady(): Promise { + const mainWindow = getMainWindow(true); + if (!mainWindow) { + console.error("[tray] Failed to get (or create) main window"); + return null; + } + await waitForWindowReady(); + return mainWindow; +} + const MAX_THREAD_NAME_LENGTH = 40; const MAX_THREADS_IN_CONTEXT_MENU = 3; const MAX_VIEW_MORE_THREADS = 5; @@ -161,18 +192,36 @@ function buildTrayContextMenu(): Menu { // TODO: This isn't accessible to screen readers! label: `${thread.needsAttention ? "·" : ""} ${truncateGraphemes(thread.name, MAX_THREAD_NAME_LENGTH)}`, click: async () => { - const mainWindow = getMainWindow(true); - if (!mainWindow) { - console.error("[tray] Failed to get (or create) main window"); - return; - } - await waitForWindowReady(); + const mainWindow = await getMainWindowAndWaitForReady(); + if (!mainWindow) return; sendTrayMessage({ type: "thread-click", threadId: thread.id as ThreadId }, mainWindow); mainWindow.focus(); }, }; } + function buildNewThreadInProjectMenuItem( + project: DesktopTrayState["projects"][number], + ): MenuItemConstructorOptions { + return { + label: project.name, + click: async () => { + const mainWindow = await getMainWindowAndWaitForReady(); + if (!mainWindow) return; + sendTrayMessage( + { type: "new-thread-in-project-click", projectId: project.id as ProjectId }, + mainWindow, + ); + mainWindow.focus(); + }, + }; + } const menuItemConstructors: MenuItemConstructorOptions[] = [ + { + type: "submenu", + label: "New thread in...", + submenu: trayState.projects.map(buildNewThreadInProjectMenuItem), + }, + { type: "separator" }, ...topLevelThreads.map(buildThreadMenuItem), { type: "submenu", diff --git a/apps/web/src/hooks/useTrayState.ts b/apps/web/src/hooks/useTrayState.ts index bd185ad1fe..c3a19072d1 100644 --- a/apps/web/src/hooks/useTrayState.ts +++ b/apps/web/src/hooks/useTrayState.ts @@ -4,6 +4,7 @@ import type { DesktopTrayState } from "@t3tools/contracts"; const EMPTY_TRAY_STATE: DesktopTrayState = { threads: [], + projects: [], }; type SetTrayStateAction = DesktopTrayState | ((previous: DesktopTrayState) => DesktopTrayState); type TrayState = [DesktopTrayState, (action: SetTrayStateAction) => void]; diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index c7ea9c2ad4..6d5e156d1c 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -28,6 +28,7 @@ import { collectActiveTerminalThreadIds } from "../lib/terminalStateCleanup"; import { isElectron } from "../env"; import { useThreadSelectionStore } from "~/threadSelectionStore"; import { useTrayState } from "~/hooks/useTrayState"; +import { useHandleNewThread } from "~/hooks/useHandleNewThread"; export const Route = createRootRouteWithContext<{ queryClient: QueryClient; @@ -337,17 +338,24 @@ function DesktopTrayBootstrap() { const navigate = useNavigate(); const setSelectionAnchor = useThreadSelectionStore((s) => s.setAnchor); + const { handleNewThread } = useHandleNewThread(); + const onTrayMessage = useCallback( (message: DesktopTrayMessage) => { - if (message.type === "thread-click") { - setSelectionAnchor(message.threadId); - void navigate({ - to: "/$threadId", - params: { threadId: message.threadId }, - }); + switch (message.type) { + case "thread-click": + setSelectionAnchor(message.threadId); + void navigate({ + to: "/$threadId", + params: { threadId: message.threadId }, + }); + break; + case "new-thread-in-project-click": + void handleNewThread(message.projectId); + break; } }, - [navigate, setSelectionAnchor], + [navigate, setSelectionAnchor, handleNewThread], ); useEffect(() => { @@ -408,5 +416,18 @@ function DesktopTrayBootstrap() { })); }, [threads, settings.showTrayIcon, setTrayState]); + const projects = useStore((store) => store.projects); + + useEffect(() => { + if (!settings.showTrayIcon) return; + setTrayState((previous) => ({ + ...previous, + projects: projects.map((project) => ({ + id: project.id, + name: project.name, + })), + })); + }, [projects, settings.showTrayIcon, setTrayState]); + return null; } diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 8bad2becbf..557fa32754 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -46,7 +46,7 @@ import type { OrchestrationReadModel, } from "./orchestration"; import { EditorId } from "./editor"; -import { ThreadId } from "./baseSchemas"; +import { ProjectId, ThreadId } from "./baseSchemas"; export interface DesktopTrayState { threads: { @@ -55,6 +55,10 @@ export interface DesktopTrayState { lastUpdated: number; needsAttention: boolean; }[]; + projects: { + id: string; + name: string; + }[]; } interface DesktopTrayThreadMessage { @@ -62,7 +66,12 @@ interface DesktopTrayThreadMessage { threadId: ThreadId; } -export type DesktopTrayMessage = DesktopTrayThreadMessage; // TODO: Add more message types as needed +interface DesktopTrayProjectMessage { + type: "new-thread-in-project-click"; + projectId: ProjectId; +} + +export type DesktopTrayMessage = DesktopTrayThreadMessage | DesktopTrayProjectMessage; export interface ContextMenuItem { id: T;