From 01f49ef1cfe4b29c044fd4766fe79f621e3dd5ad Mon Sep 17 00:00:00 2001 From: David Hill Date: Wed, 30 Sep 2026 12:58:00 -0600 Subject: [PATCH] feat(desktop): add menu-bar tray companion --- bun.lock | 28 ++ package.json | 2 + packages/app/package.json | 1 + packages/app/src/desktop.ts | 5 +- .../src/runtime/i18n/desktop-native.test.ts | 6 + .../app/src/runtime/i18n/desktop-native.ts | 34 +++ packages/app/src/runtime/i18n/en.ts | 3 + .../app/src/runtime/platform/platform.tsx | 3 + packages/app/src/settings/general/general.tsx | 24 ++ packages/app/src/shell/layout/helpers.ts | 9 +- .../src/shell/layout/project-avatar-source.ts | 8 + packages/cli/src/commands/handlers/default.ts | 35 ++- packages/cli/src/services/terminal-tray.ts | 139 ++++++++++ packages/cli/test/terminal-tray.test.ts | 90 +++++++ packages/desktop/README.md | 101 +++++++ packages/desktop/electron.vite.config.test.ts | 1 + packages/desktop/package.json | 4 +- packages/desktop/scripts/dev-live.ts | 19 ++ packages/desktop/scripts/dev-terminal-tray.ts | 51 ++++ packages/desktop/scripts/dev.ts | 4 +- packages/desktop/src/main/desktop.ts | 5 +- packages/desktop/src/main/env.d.ts | 2 + packages/desktop/src/main/index.ts | 3 +- packages/desktop/src/main/ipc-handlers/app.ts | 10 + .../desktop/src/main/ipc-handlers/menu.ts | 6 + packages/desktop/src/main/ipc.ts | 87 ++++++- .../main/lifecycle/desktop-initialization.ts | 3 + packages/desktop/src/main/lifecycle/index.ts | 28 +- .../src/main/native/menu-queue.test.ts | 54 ++++ .../desktop/src/main/native/menu-queue.ts | 27 ++ packages/desktop/src/main/native/menu.ts | 8 +- .../desktop/src/main/native/translations.ts | 5 + .../src/main/native/tray-icon-art.test.ts | 141 ++++++++++ .../desktop/src/main/native/tray-icon-art.ts | 99 +++++++ .../desktop/src/main/native/tray-icons.ts | 69 +++++ .../src/main/native/tray-label.test.ts | 17 ++ .../desktop/src/main/native/tray-label.ts | 22 ++ .../desktop/src/main/native/tray-menu.test.ts | 192 ++++++++++++++ packages/desktop/src/main/native/tray-menu.ts | 106 ++++++++ .../src/main/native/tray-sessions.test.ts | 246 ++++++++++++++++++ .../desktop/src/main/native/tray-sessions.ts | 150 +++++++++++ .../src/main/native/tray-shortcut.test.ts | 48 ++++ .../desktop/src/main/native/tray-shortcut.ts | 18 ++ .../desktop/src/main/native/tray-tabs.test.ts | 60 +++++ packages/desktop/src/main/native/tray-tabs.ts | 38 +++ packages/desktop/src/main/native/tray.ts | 145 +++++++++++ .../src/main/service/background-service.ts | 7 + packages/desktop/src/main/storage/keys.ts | 3 +- .../desktop/src/main/terminal-tray-entry.ts | 7 + packages/desktop/src/main/terminal-tray.ts | 184 +++++++++++++ packages/desktop/src/renderer/api-types.ts | 8 +- packages/desktop/src/renderer/api.ts | 6 +- packages/desktop/src/renderer/desktop-app.tsx | 68 ++++- .../desktop/src/renderer/platform/index.ts | 2 + .../desktop/src/renderer/platform/menu.ts | 5 +- .../src/renderer/platform/tray-avatar.test.ts | 32 +++ .../src/renderer/platform/tray-avatar.ts | 18 ++ .../src/renderer/platform/tray-tabs.test.ts | 15 ++ .../src/renderer/platform/tray-tabs.ts | 9 + packages/desktop/src/shared/ipc-rpc/app.ts | 12 + packages/desktop/src/shared/ipc-rpc/events.ts | 3 +- packages/desktop/src/shared/ipc-rpc/menu.ts | 4 +- packages/desktop/src/shared/menu-command.ts | 8 + packages/desktop/src/shared/tray-avatar.ts | 12 + packages/schema/src/terminal-tray.ts | 39 +++ packages/tui/src/app.tsx | 39 ++- packages/tui/src/component/dialog-config.tsx | 9 + packages/tui/src/config/index.tsx | 13 +- packages/tui/src/context/session-tabs.tsx | 1 + packages/tui/test/config-v2.test.tsx | 11 + packages/ui/package.json | 1 + packages/ui/src/icons/icon/icon.tsx | 14 +- packages/ui/src/icons/icon/shell-icons.ts | 23 ++ 73 files changed, 2648 insertions(+), 61 deletions(-) create mode 100644 packages/app/src/shell/layout/project-avatar-source.ts create mode 100644 packages/cli/src/services/terminal-tray.ts create mode 100644 packages/cli/test/terminal-tray.test.ts create mode 100644 packages/desktop/scripts/dev-live.ts create mode 100644 packages/desktop/scripts/dev-terminal-tray.ts create mode 100644 packages/desktop/src/main/native/menu-queue.test.ts create mode 100644 packages/desktop/src/main/native/menu-queue.ts create mode 100644 packages/desktop/src/main/native/tray-icon-art.test.ts create mode 100644 packages/desktop/src/main/native/tray-icon-art.ts create mode 100644 packages/desktop/src/main/native/tray-icons.ts create mode 100644 packages/desktop/src/main/native/tray-label.test.ts create mode 100644 packages/desktop/src/main/native/tray-label.ts create mode 100644 packages/desktop/src/main/native/tray-menu.test.ts create mode 100644 packages/desktop/src/main/native/tray-menu.ts create mode 100644 packages/desktop/src/main/native/tray-sessions.test.ts create mode 100644 packages/desktop/src/main/native/tray-sessions.ts create mode 100644 packages/desktop/src/main/native/tray-shortcut.test.ts create mode 100644 packages/desktop/src/main/native/tray-shortcut.ts create mode 100644 packages/desktop/src/main/native/tray-tabs.test.ts create mode 100644 packages/desktop/src/main/native/tray-tabs.ts create mode 100644 packages/desktop/src/main/native/tray.ts create mode 100644 packages/desktop/src/main/terminal-tray-entry.ts create mode 100644 packages/desktop/src/main/terminal-tray.ts create mode 100644 packages/desktop/src/renderer/platform/tray-avatar.test.ts create mode 100644 packages/desktop/src/renderer/platform/tray-avatar.ts create mode 100644 packages/desktop/src/renderer/platform/tray-tabs.test.ts create mode 100644 packages/desktop/src/renderer/platform/tray-tabs.ts create mode 100644 packages/desktop/src/shared/menu-command.ts create mode 100644 packages/desktop/src/shared/tray-avatar.ts create mode 100644 packages/schema/src/terminal-tray.ts create mode 100644 packages/ui/src/icons/icon/shell-icons.ts diff --git a/bun.lock b/bun.lock index 03335639dcb..96472dfdbe4 100644 --- a/bun.lock +++ b/bun.lock @@ -412,11 +412,13 @@ "name": "@opencode/desktop", "version": "2.0.20", "dependencies": { + "@resvg/resvg-js": "2.6.2", "@zip.js/zip.js": "2.7.62", "electron-context-menu": "5.0.0", "electron-log": "^5", "electron-updater": "6.8.9", "lighthouse": "13.4.1", + "string-width": "catalog:", }, "devDependencies": { "@brendonovich/vite-plugin-opencode": "0.1.3", @@ -2637,6 +2639,32 @@ "@remix-run/router": ["@remix-run/router@1.9.0", "", {}, "sha512-bV63itrKBC0zdT27qYm6SDZHlkXwFL1xMBuhkn+X7l0+IIhNaH5wuuvZKp6eKhCD4KFhujhfhCT1YxXW6esUIA=="], + "@resvg/resvg-js": ["@resvg/resvg-js@2.6.2", "", { "optionalDependencies": { "@resvg/resvg-js-android-arm-eabi": "2.6.2", "@resvg/resvg-js-android-arm64": "2.6.2", "@resvg/resvg-js-darwin-arm64": "2.6.2", "@resvg/resvg-js-darwin-x64": "2.6.2", "@resvg/resvg-js-linux-arm-gnueabihf": "2.6.2", "@resvg/resvg-js-linux-arm64-gnu": "2.6.2", "@resvg/resvg-js-linux-arm64-musl": "2.6.2", "@resvg/resvg-js-linux-x64-gnu": "2.6.2", "@resvg/resvg-js-linux-x64-musl": "2.6.2", "@resvg/resvg-js-win32-arm64-msvc": "2.6.2", "@resvg/resvg-js-win32-ia32-msvc": "2.6.2", "@resvg/resvg-js-win32-x64-msvc": "2.6.2" } }, "sha512-xBaJish5OeGmniDj9cW5PRa/PtmuVU3ziqrbr5xJj901ZDN4TosrVaNZpEiLZAxdfnhAe7uQ7QFWfjPe9d9K2Q=="], + + "@resvg/resvg-js-android-arm-eabi": ["@resvg/resvg-js-android-arm-eabi@2.6.2", "", { "os": "android", "cpu": "arm" }, "sha512-FrJibrAk6v29eabIPgcTUMPXiEz8ssrAk7TXxsiZzww9UTQ1Z5KAbFJs+Z0Ez+VZTYgnE5IQJqBcoSiMebtPHA=="], + + "@resvg/resvg-js-android-arm64": ["@resvg/resvg-js-android-arm64@2.6.2", "", { "os": "android", "cpu": "arm64" }, "sha512-VcOKezEhm2VqzXpcIJoITuvUS/fcjIw5NA/w3tjzWyzmvoCdd+QXIqy3FBGulWdClvp4g+IfUemigrkLThSjAQ=="], + + "@resvg/resvg-js-darwin-arm64": ["@resvg/resvg-js-darwin-arm64@2.6.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-nmok2LnAd6nLUKI16aEB9ydMC6Lidiiq2m1nEBDR1LaaP7FGs4AJ90qDraxX+CWlVuRlvNjyYJTNv8qFjtL9+A=="], + + "@resvg/resvg-js-darwin-x64": ["@resvg/resvg-js-darwin-x64@2.6.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-GInyZLjgWDfsVT6+SHxQVRwNzV0AuA1uqGsOAW+0th56J7Nh6bHHKXHBWzUrihxMetcFDmQMAX1tZ1fZDYSRsw=="], + + "@resvg/resvg-js-linux-arm-gnueabihf": ["@resvg/resvg-js-linux-arm-gnueabihf@2.6.2", "", { "os": "linux", "cpu": "arm" }, "sha512-YIV3u/R9zJbpqTTNwTZM5/ocWetDKGsro0SWp70eGEM9eV2MerWyBRZnQIgzU3YBnSBQ1RcxRZvY/UxwESfZIw=="], + + "@resvg/resvg-js-linux-arm64-gnu": ["@resvg/resvg-js-linux-arm64-gnu@2.6.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-zc2BlJSim7YR4FZDQ8OUoJg5holYzdiYMeobb9pJuGDidGL9KZUv7SbiD4E8oZogtYY42UZEap7dqkkYuA91pg=="], + + "@resvg/resvg-js-linux-arm64-musl": ["@resvg/resvg-js-linux-arm64-musl@2.6.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-3h3dLPWNgSsD4lQBJPb4f+kvdOSJHa5PjTYVsWHxLUzH4IFTJUAnmuWpw4KqyQ3NA5QCyhw4TWgxk3jRkQxEKg=="], + + "@resvg/resvg-js-linux-x64-gnu": ["@resvg/resvg-js-linux-x64-gnu@2.6.2", "", { "os": "linux", "cpu": "x64" }, "sha512-IVUe+ckIerA7xMZ50duAZzwf1U7khQe2E0QpUxu5MBJNao5RqC0zwV/Zm965vw6D3gGFUl7j4m+oJjubBVoftw=="], + + "@resvg/resvg-js-linux-x64-musl": ["@resvg/resvg-js-linux-x64-musl@2.6.2", "", { "os": "linux", "cpu": "x64" }, "sha512-UOf83vqTzoYQO9SZ0fPl2ZIFtNIz/Rr/y+7X8XRX1ZnBYsQ/tTb+cj9TE+KHOdmlTFBxhYzVkP2lRByCzqi4jQ=="], + + "@resvg/resvg-js-win32-arm64-msvc": ["@resvg/resvg-js-win32-arm64-msvc@2.6.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-7C/RSgCa+7vqZ7qAbItfiaAWhyRSoD4l4BQAbVDqRRsRgY+S+hgS3in0Rxr7IorKUpGE69X48q6/nOAuTJQxeQ=="], + + "@resvg/resvg-js-win32-ia32-msvc": ["@resvg/resvg-js-win32-ia32-msvc@2.6.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-har4aPAlvjnLcil40AC77YDIk6loMawuJwFINEM7n0pZviwMkMvjb2W5ZirsNOZY4aDbo5tLx0wNMREp5Brk+w=="], + + "@resvg/resvg-js-win32-x64-msvc": ["@resvg/resvg-js-win32-x64-msvc@2.6.2", "", { "os": "win32", "cpu": "x64" }, "sha512-ZXtYhtUr5SSaBrUDq7DiyjOFJqBVL/dOBN7N/qmi/pO0IgiWW/f/ue3nbvu9joWE5aAKDoIzy/CxsY0suwGosQ=="], + "@rolldown/binding-android-arm-eabi": ["@rolldown/binding-android-arm-eabi@1.2.5", "", { "os": "android", "cpu": "arm" }, "sha512-DLe/i+l8ynIBY7XEQ191TeZvCoowIGa18R+dIV30GW7DiOtp74i/xX8hs8GUjW5ARV7VZuie3d6AumSmCwbeRA=="], "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.5", "", { "os": "android", "cpu": "arm64" }, "sha512-zXcwKlQApYAOELHd8PwKDFkagYF9Wy4e0RJ+0qnzl9Pjnpj75TEG8ufv40p2J7kCEfwZAsNiuzRIyNNMWT38ig=="], diff --git a/package.json b/package.json index 278aa8a6c84..b5a99dab892 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,8 @@ "dev:vite": "bun run --cwd packages/cli --conditions=browser dev/vite.ts", "dev:vite:live": "sh -c 'OPENCODE_TUI_CHANNEL=dev OPENCODE_PASSWORD=\"$(opencode service get password)\" exec bun run dev:vite \"$@\" --server \"$(opencode service status)\"' --", "dev:desktop": "bun --cwd packages/desktop dev", + "dev:tray": "bun --cwd packages/desktop dev --tray", + "dev:tray:live": "bun --cwd packages/desktop ./scripts/dev-live.ts", "dev:web": "bun --cwd packages/app dev", "dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev", "dev:stats": "bun sst shell --stage=production -- bun run --cwd packages/stats/app dev", diff --git a/packages/app/package.json b/packages/app/package.json index 5cc956072a9..bde4f3fdcd3 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -7,6 +7,7 @@ ".": "./src/index.ts", "./desktop": "./src/desktop.ts", "./desktop-menu": "./src/shell/commands/desktop-menu.ts", + "./project-avatar-source": "./src/shell/layout/project-avatar-source.ts", "./i18n/desktop-native": "./src/runtime/i18n/desktop-native.ts", "./updater": "./src/shell/updates/types.ts", "./wsl/types": "./src/servers/wsl/types.ts", diff --git a/packages/app/src/desktop.ts b/packages/app/src/desktop.ts index 31d607ac9ce..634e931a2a0 100644 --- a/packages/app/src/desktop.ts +++ b/packages/app/src/desktop.ts @@ -1,6 +1,8 @@ export { AppBaseProviders, AppInterface, preloadRoute } from "./app" export { useCommand } from "./shell/commands/command" -export { currentRoute, type LayoutRoute, useCurrentRoute } from "./shell/state/layout" +export { currentRoute, getProjectAvatarVariant, type LayoutRoute, useCurrentRoute } from "./shell/state/layout" +export { displayName, projectForSession } from "./shell/layout/helpers" +export { useServerCtx } from "./runtime/server/runtime" export { loadLocaleDict, normalizeLocale, type Locale, useLanguage } from "./runtime/i18n/language" export { type FatalRendererErrorLog, type Platform, PlatformProvider } from "./runtime/platform/platform" export type { @@ -17,6 +19,7 @@ export type { export { ServerConnection, useServers } from "./runtime/server/registry" export { useGlobal } from "./runtime/server/runtime" export { useTabs } from "./shell/tabs/tabs" +export { sessionHref } from "./shell/routes/session" export { createDraftStore } from "./runtime/persistence/drafts" export { createNamespaceStorage, type NamespaceStorage } from "./runtime/persistence/namespace" export { flushPersisted } from "./runtime/persistence/persist" diff --git a/packages/app/src/runtime/i18n/desktop-native.test.ts b/packages/app/src/runtime/i18n/desktop-native.test.ts index cb2ffb32a46..a7377d1e9c0 100644 --- a/packages/app/src/runtime/i18n/desktop-native.test.ts +++ b/packages/app/src/runtime/i18n/desktop-native.test.ts @@ -9,10 +9,16 @@ import { detectDesktopNativeLocale, DESKTOP_NATIVE_MAX_PAYLOAD_BYTES, formatDesktopNativeMessage, + formatDesktopNativeCost, parseDesktopNativeBundle, } from "./desktop-native" describe("desktop native translations", () => { + test("formats session costs as USD using the active native locale", () => { + expect(formatDesktopNativeCost("en", 0)).toBe("$0.00") + expect(formatDesktopNativeCost("en", 12.34)).toBe("$12.34") + expect(formatDesktopNativeCost("de", 12.34)).toContain("12,34") + }) test("uses native language names independent of the active locale", () => { expect(DESKTOP_NATIVE_LOCALES.map((locale) => DESKTOP_NATIVE_LABELS[locale])).toEqual([ "English", diff --git a/packages/app/src/runtime/i18n/desktop-native.ts b/packages/app/src/runtime/i18n/desktop-native.ts index ead24f7f7df..378c4eedddb 100644 --- a/packages/app/src/runtime/i18n/desktop-native.ts +++ b/packages/app/src/runtime/i18n/desktop-native.ts @@ -275,6 +275,36 @@ export const DESKTOP_NATIVE_ENGLISH = { "desktop.menu.reportBug": "Report a Bug", "desktop.menu.ariaLabel": "OpenCode menu", + "desktop.tray.open": "Open OpenCode", + "desktop.tray.newAgent": "New Agent…", + "desktop.tray.quit": "Quit", + "desktop.tray.docs": "Docs", + "desktop.tray.summary": "Working: {{working}} · Needs you: {{attention}}", + "desktop.tray.loading": "Loading sessions…", + "desktop.tray.starting": "Starting OpenCode…", + "desktop.tray.offline": "Unable to refresh sessions", + "desktop.tray.stale": "Showing the last available update", + "desktop.tray.empty": "No active sessions", + "desktop.tray.more": "View all sessions…", + "desktop.tray.untitled": "Untitled session", + "desktop.tray.group.attention": "Needs you", + "desktop.tray.group.working": "Working", + "desktop.tray.group.active": "Active sessions", + "desktop.tray.status.question": "Question waiting", + "desktop.tray.status.permission": "Permission needed", + "desktop.tray.status.working": "Working", + "desktop.tray.status.failed": "Failed", + "desktop.tray.status.interrupted": "Stopped", + "desktop.tray.status.idle": "Idle", + "desktop.tray.context.percent": "Context {{percent}}%", + "desktop.tray.context.tokens": "Context tokens {{tokens}}", + "desktop.tray.context.unknown": "No context", + "desktop.tray.session.detail": "{{project}} · {{context}} · {{cost}}", + "desktop.tray.tooltip.cost": "Cost {{cost}}", + "desktop.tray.tooltip.local": "Local", + "desktop.tray.tooltip.worktree": "Worktree · {{name}}", + "desktop.tray.tooltip.placementUnknown": "Location unavailable", + "desktop.updater.dialog.checkFailed.message": "Update check failed.", "desktop.updater.dialog.checkFailed.title": "Update Error", "desktop.updater.dialog.upToDate.message": "You're up to date.", @@ -375,3 +405,7 @@ export function formatDesktopNativeMessage(message: string, params?: Record setKeepScreenActive?(enabled: boolean): Promise + /** Show the bundled menu bar/system tray companion (desktop only). */ + getTrayEnabled?(): Promise + setTrayEnabled?(enabled: boolean): Promise /** Run a desktop-only menu action from the app chrome */ runDesktopMenuAction?(action: DesktopMenuAction): Promise | void diff --git a/packages/app/src/settings/general/general.tsx b/packages/app/src/settings/general/general.tsx index 9a63b508db4..2faafd6c8ff 100644 --- a/packages/app/src/settings/general/general.tsx +++ b/packages/app/src/settings/general/general.tsx @@ -342,6 +342,15 @@ export const SettingsGeneral: Component = () => { void update.catch(() => setPinchZoom(!checked)) } + const [tray, { mutate: setTray }] = createResource( + () => desktop() && platform.getTrayEnabled, + (get) => get(), + ) + const onTrayChange = (checked: boolean) => { + setTray(checked) + void platform.setTrayEnabled?.(checked).catch(() => setTray(!checked)) + } + const GeneralSection = () => (

{language.t("settings.general.section.general")}

@@ -376,6 +385,21 @@ export const SettingsGeneral: Component = () => {
+ + +
+ +
+
+
{ working return status() } -const OPENCODE_PROJECT_ID = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750" - -export function getProjectAvatarSource(id?: string, icon?: { color?: string; url?: string; override?: string }) { - if (id === OPENCODE_PROJECT_ID) return "https://opencode.ai/favicon.svg" - if (icon?.override) return icon.override - if (icon?.color) return undefined - return icon?.url -} +export { getProjectAvatarSource } from "./project-avatar-source" export function projectForSession( session: SessionInfo, diff --git a/packages/app/src/shell/layout/project-avatar-source.ts b/packages/app/src/shell/layout/project-avatar-source.ts new file mode 100644 index 00000000000..f783d33adfe --- /dev/null +++ b/packages/app/src/shell/layout/project-avatar-source.ts @@ -0,0 +1,8 @@ +const OPENCODE_PROJECT_ID = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750" + +export function getProjectAvatarSource(id?: string, icon?: { color?: string; url?: string; override?: string }) { + if (id === OPENCODE_PROJECT_ID) return "https://opencode.ai/favicon.svg" + if (icon?.override) return icon.override + if (icon?.color) return undefined + return icon?.url +} diff --git a/packages/cli/src/commands/handlers/default.ts b/packages/cli/src/commands/handlers/default.ts index 0ba72099a76..37c1431ed70 100644 --- a/packages/cli/src/commands/handlers/default.ts +++ b/packages/cli/src/commands/handlers/default.ts @@ -9,7 +9,7 @@ import { ServerConnection } from "../../services/server-connection" import { Updater } from "../../services/updater" import { UpdatePreflight } from "../../services/update-preflight" import { Npm } from "@opencode/util/npm" -import { OPENCODE_ARTIFACT, OPENCODE_CHANNEL, OPENCODE_VERSION } from "../../version" +import { OPENCODE_ARTIFACT, OPENCODE_CHANNEL, OPENCODE_LOCAL, OPENCODE_VERSION } from "../../version" import { Env } from "../../env" import { Service } from "@opencode/client/effect/service" import { OpenCode } from "@opencode/client/promise" @@ -56,7 +56,10 @@ export default Runtime.handler(Commands, (input) => session !== undefined && (yield* Effect.tryPromise({ try: () => - findSession(OpenCode.make({ baseUrl: server.endpoint.url, headers: Service.headers(server.endpoint) }), session), + findSession( + OpenCode.make({ baseUrl: server.endpoint.url, headers: Service.headers(server.endpoint) }), + session, + ), catch: (cause) => new Error(errorMessage(cause)), })) !== undefined const updater = yield* Updater.Service @@ -77,6 +80,27 @@ export default Runtime.handler(Commands, (input) => const runFork = Effect.runForkWith(context) const runPromise = Effect.runPromiseWith(context) const service = server.service + const global = yield* Global.Service + const { createTerminalTray, focusTerminal, hasDesktop, launchTerminalTray } = yield* Effect.promise( + () => import("../../services/terminal-tray"), + ) + const tray = hasDesktop(process.env, process.platform, !!process.stdout.isTTY) + ? yield* Effect.tryPromise(() => + createTerminalTray({ + endpoint: server.endpoint, + directory: global.state + "/terminal-tray", + enabled: false, + launch: (file) => launchTerminalTray(file, OPENCODE_LOCAL, global.log), + focus: focusTerminal, + log: (message, error) => { + runFork(Effect.logWarning(message, { error })) + }, + }), + ).pipe( + Effect.catch((error) => Effect.logWarning("Terminal tray unavailable", { error }).pipe(Effect.as(undefined))), + ) + : undefined + if (tray) yield* Effect.addFinalizer(() => Effect.promise(() => tray.close())) yield* run({ app: { name: process.env.OPENCODE_CLIENT ?? OPENCODE_ARTIFACT, @@ -87,7 +111,11 @@ export default Runtime.handler(Commands, (input) => endpoint: server.endpoint, service: service ? { - reconnect: (signal) => runServicePromise(service.reconnect(), { signal }), + reconnect: async (signal) => { + const endpoint = await runServicePromise(service.reconnect(), { signal }) + tray?.endpoint(endpoint) + return endpoint + }, restart: () => runServicePromise(service.restart()), } : undefined, @@ -99,6 +127,7 @@ export default Runtime.handler(Commands, (input) => prompt: Option.getOrUndefined(input.prompt), auto: input.auto || input.yolo || input.dangerouslySkipPermissions, }, + tray, config: { path: config.path, get: () => runPromise(config.get()), diff --git a/packages/cli/src/services/terminal-tray.ts b/packages/cli/src/services/terminal-tray.ts new file mode 100644 index 00000000000..2e6f53ff5a4 --- /dev/null +++ b/packages/cli/src/services/terminal-tray.ts @@ -0,0 +1,139 @@ +import { TerminalTray } from "@opencode/schema/terminal-tray" +import type { Endpoint } from "@opencode/client/service" +import { Option, Schema } from "effect" +import { createServer } from "node:http" +import { randomUUID } from "node:crypto" +import { mkdir, open, rm, writeFile, access } from "node:fs/promises" +import { join, dirname } from "node:path" +import { fileURLToPath } from "node:url" +import { spawn, execFile } from "node:child_process" + +const decodeCommand = Schema.decodeUnknownOption(Schema.fromJsonString(TerminalTray.Command)) + +export function hasDesktop(env: NodeJS.ProcessEnv, platform: string, tty: boolean) { + if (!tty || env.OPENCODE_TRAY === "0" || env.OPENCODE_TRAY === "false") return false + if (env.SSH_CONNECTION || env.SSH_TTY) return false + return platform === "darwin" || platform === "win32" || !!(env.DISPLAY || env.WAYLAND_DISPLAY) +} + +export async function createTerminalTray(input: { + endpoint: Endpoint + directory: string + enabled: boolean + launch: (file: string) => Promise + focus: () => void + log: (message: string, error: unknown) => void +}) { + const token = randomUUID() + const state = { + value: { enabled: false, focused: true, sessions: [] } as TerminalTray.State, + endpoint: input.endpoint, + } + const listeners = new Set<(command: TerminalTray.Command) => void>() + const pending: TerminalTray.Command[] = [] + const server = createServer((request, response) => { + if (request.headers.authorization !== `Bearer ${token}`) { + response.writeHead(401).end() + return + } + if (request.method === "GET" && request.url === "/state") { + response.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" }) + response.end(JSON.stringify({ ...state.value, server: state.endpoint } satisfies TerminalTray.Snapshot)) + return + } + if (request.method !== "POST" || request.url !== "/command") { + response.writeHead(404).end() + return + } + const chunks: Buffer[] = [] + let size = 0 + request.on("data", (chunk: Buffer) => { + size += chunk.length + if (size > 16_384) { + response.writeHead(413).end() + request.destroy() + return + } + chunks.push(chunk) + }) + request.on("end", () => { + const command = Option.getOrUndefined(decodeCommand(Buffer.concat(chunks).toString("utf8"))) + if (!command) { + response.writeHead(400).end() + return + } + input.focus() + if (!listeners.size) pending.push(command) + listeners.forEach((listener) => listener(command)) + response.writeHead(204).end() + }) + }) + await new Promise((resolve, reject) => { + server.once("error", reject) + server.listen(0, "127.0.0.1", () => resolve()) + }) + const address = server.address() + if (!address || typeof address === "string") throw new Error("Terminal tray bridge did not bind a TCP port") + const connection: TerminalTray.Connection = { id: randomUUID(), url: `http://127.0.0.1:${address.port}`, token } + await mkdir(input.directory, { recursive: true, mode: 0o700 }) + const file = join(input.directory, `${connection.id}.json`) + await writeFile(file, JSON.stringify(connection), { mode: 0o600 }) + const update = (value: TerminalTray.State) => { + const launch = value.enabled && !state.value.enabled + state.value = value + if (launch) void input.launch(file).catch((error) => input.log("Could not launch the menu-bar companion", error)) + } + update({ enabled: input.enabled, focused: true, sessions: [] }) + return { + connection, + update, + endpoint(endpoint: Endpoint) { + state.endpoint = endpoint + }, + subscribe(listener: (command: TerminalTray.Command) => void) { + listeners.add(listener) + pending.splice(0).forEach(listener) + return () => { + listeners.delete(listener) + } + }, + async close() { + state.value = { enabled: false, focused: false, sessions: [] } + server.closeAllConnections() + await new Promise((resolve) => server.close(() => resolve())) + await rm(file, { force: true }) + }, + } +} + +export async function launchTerminalTray(file: string, local: boolean, logDirectory: string) { + const override = process.env.OPENCODE_TRAY_EXECUTABLE + const script = fileURLToPath(new URL("../../../desktop/scripts/dev-terminal-tray.ts", import.meta.url)) + const executable = + override ?? + (local + ? process.execPath + : join(dirname(process.execPath), "opencode-tray" + (process.platform === "win32" ? ".exe" : ""))) + await access(override || !local ? executable : script) + await mkdir(logDirectory, { recursive: true }) + const log = await open(join(logDirectory, "terminal-tray.log"), "a", 0o600) + const child = spawn(executable, override || !local ? [`--terminal-tray=${file}`] : [script, file], { + cwd: local && !override ? dirname(dirname(script)) : undefined, + env: process.env, + stdio: ["ignore", log.fd, log.fd], + detached: true, + windowsHide: true, + }) + await new Promise((resolve, reject) => { + child.once("spawn", resolve) + child.once("error", reject) + }).finally(() => log.close()) + child.unref() +} + +export function focusTerminal() { + if (process.platform !== "darwin") return + const bundle = process.env.__CFBundleIdentifier + if (!bundle || !/^[a-zA-Z0-9.-]+$/.test(bundle)) return + execFile("osascript", ["-e", `tell application id "${bundle}" to activate`], () => {}) +} diff --git a/packages/cli/test/terminal-tray.test.ts b/packages/cli/test/terminal-tray.test.ts new file mode 100644 index 00000000000..2f34b3d19df --- /dev/null +++ b/packages/cli/test/terminal-tray.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, test } from "bun:test" +import { mkdtemp, readFile, rm, stat } from "node:fs/promises" +import { join } from "node:path" +import { tmpdir } from "node:os" +import { createTerminalTray, hasDesktop } from "../src/services/terminal-tray" +import type { TerminalTray } from "@opencode/schema/terminal-tray" + +describe("terminal tray bridge", () => { + test("enables the companion only for local graphical interactive terminals", () => { + expect(hasDesktop({}, "darwin", true)).toBe(true) + expect(hasDesktop({}, "win32", true)).toBe(true) + expect(hasDesktop({ DISPLAY: ":0" }, "linux", true)).toBe(true) + expect(hasDesktop({}, "linux", true)).toBe(false) + expect(hasDesktop({ SSH_CONNECTION: "remote" }, "darwin", true)).toBe(false) + expect(hasDesktop({ OPENCODE_TRAY: "false" }, "darwin", true)).toBe(false) + expect(hasDesktop({}, "darwin", false)).toBe(false) + }) + + test("authenticates the bridge, publishes tabs, routes actions, and cleans up", async () => { + const directory = await mkdtemp(join(tmpdir(), "opencode-terminal-tray-")) + const launched: string[] = [] + const focused: boolean[] = [] + const commands: TerminalTray.Command[] = [] + const bridge = await createTerminalTray({ + endpoint: { + url: "http://127.0.0.1:4096", + auth: { type: "basic", username: "opencode", password: "test-password" }, + }, + directory, + enabled: false, + launch: async (file) => { + launched.push(file) + }, + focus: () => { + focused.push(true) + }, + log: (message) => { + throw new Error(message) + }, + }) + try { + expect((await fetch(`${bridge.connection.url}/state`)).status).toBe(401) + const headers = { authorization: `Bearer ${bridge.connection.token}` } + bridge.update({ enabled: true, focused: true, sessions: ["ses_one"], current: "ses_one" }) + expect(launched).toHaveLength(1) + expect(JSON.parse(await readFile(launched[0], "utf8"))).toEqual(bridge.connection) + expect((await stat(launched[0])).mode & 0o777).toBe(0o600) + const state = await fetch(`${bridge.connection.url}/state`, { headers }).then((response) => response.json()) + expect(state.sessions).toEqual(["ses_one"]) + expect(state.server.url).toBe("http://127.0.0.1:4096") + expect( + ( + await fetch(`${bridge.connection.url}/command`, { + method: "POST", + headers, + body: JSON.stringify({ type: "new" }), + }) + ).status, + ).toBe(204) + const unsubscribe = bridge.subscribe((command) => commands.push(command)) + expect(commands).toEqual([{ type: "new" }]) + await fetch(`${bridge.connection.url}/command`, { + method: "POST", + headers, + body: JSON.stringify({ type: "session", sessionID: "ses_one" }), + }) + expect(commands.at(-1)).toEqual({ type: "session", sessionID: "ses_one" }) + expect(focused).toHaveLength(2) + expect( + ( + await fetch(`${bridge.connection.url}/command`, { + method: "POST", + headers, + body: JSON.stringify({ type: "invalid" }), + }) + ).status, + ).toBe(400) + bridge.update({ enabled: true, focused: true, sessions: ["ses_one"] }) + expect(launched).toHaveLength(1) + bridge.update({ enabled: false, focused: true, sessions: ["ses_one"] }) + bridge.update({ enabled: true, focused: true, sessions: ["ses_one"] }) + expect(launched).toHaveLength(2) + unsubscribe() + } finally { + await bridge.close() + await rm(directory, { recursive: true, force: true }) + } + await expect(fetch(`${bridge.connection.url}/state`)).rejects.toThrow() + }) +}) diff --git a/packages/desktop/README.md b/packages/desktop/README.md index 0d49419d095..6903e4d0c73 100644 --- a/packages/desktop/README.md +++ b/packages/desktop/README.md @@ -9,6 +9,107 @@ bun install bun dev ``` +### Terminal app with the menu-bar companion + +From the repository root, run: + +```bash +bun run dev +``` + +The local V2 terminal app starts its service as usual, then automatically launches the bundled Electron tray entrypoint. +No desktop window or separately started server is required. New Agent and Settings act on the terminal app; session rows +select the corresponding TUI session. On macOS the companion also activates the originating terminal application when +its bundle identifier is available. Multiple terminal clients share one companion and their open sessions are combined. +The companion exits after the last terminal client exits; quitting the companion itself does not stop the TUI or server. + +Opt out through **Open settings → Terminal → Menu bar / system tray**, or set `"tray": { "enabled": false }` in `cli.json`. +`OPENCODE_TRAY=0 bun run dev` disables the companion for one invocation. SSH, headless Linux, and non-interactive commands +do not launch it. The local launcher needs the normal workspace dependencies (`bun install`) and builds only the Electron +main process, not the desktop renderer. Launcher output is written to `terminal-tray.log` in the OpenCode log directory. + +Packaged CLI distributions can supply an `opencode-tray` executable beside the CLI or set `OPENCODE_TRAY_EXECUTABLE` to +the companion launcher. The automatic workspace build path is specific to local source development. + +### Menu bar / system tray scaffold + +The desktop bundle includes a native tray companion, enabled by default. It offers **New Agent…** +(opens a new session draft), **Settings**, **Docs**, and **Quit**. Closing the last window keeps +the companion running; Quit exits the desktop process, not the shared OpenCode background service. +The icon appears as soon as Electron is ready, before shell-environment discovery and service initialization. Its startup +menu remains available while the full session menu loads, and the saved opt-out is still respected. + +Above those actions, the tray shows the local-service sessions open in tabs across this desktop app's windows. It groups +them into **Needs you** and **Active sessions**, with titles, project directory names, and the last measured +context usage. Clicking a session focuses the window that already owns its tab. Duplicate tabs across windows appear once; +closed tabs, blank drafts, history-only sessions, and terminal-only sessions are excluded. All open session tabs are included, +not just the eight most recent sessions. Tab changes trigger a refresh; status and context refresh every ten seconds while +enabled. An open menu keeps its rows stable until dismissed. Connection failures are labeled and retain the last available +summary, but closing a tab still removes it. +Working sessions keep their existing position in Active sessions; their favicon is replaced with a simple working dot. + +Context is the latest measured assistant step (not cumulative session tokens or task progress). If the model's context +limit is unavailable, the tray shows token usage instead; sessions without a recent measurement say **No context**. +The session's total recorded cost in USD follows the context, formatted using the active locale (for example, `Context 52% · $0.12`). +On macOS 14.4+, each session uses one native menu item with a title and secondary label. Both lines share the native hover +highlight and click target, with compact system spacing and default styling. Transparent padding below each session icon +positions the visible favicon alongside the title without changing its size, horizontal padding, or shared hover target. +Platforms without native secondary labels +retain separate title/detail rows and an invisible alignment spacer. Menu icons have an extra four-point trailing gutter. +Footer shortcuts use the operating system's separate, subdued shortcut column: +**Cmd/Ctrl+N** for New Agent, **Cmd/Ctrl+,** for Settings, and **Cmd/Ctrl+Q** for Quit. Docs has no shortcut. + +**Cmd+Option+O** opens the tray globally on macOS (**Ctrl+Alt+O** on Windows/Linux), even while another app is focused. +It is registered when the tray icon appears and released when the tray is disabled or the app quits. If another application +already owns the combination, the tray remains usable by mouse and a shortcut-registration warning is written to the log. + +Session titles are single-line and ellipsized. The project name on the detail line gets the remaining text budget after +context and cost, so those values stay visible. Full titles and directories remain available in the session tooltip. +Tooltips identify **Local** or **Worktree · name** using the server-resolved checkout root, followed by the full session +directory. Usage reads **Context 52%** and **Cost $0.12**. If placement resolution fails, the tooltip says **Location unavailable**. + +Session icons use the project's favicon/override, or its colored initial when no image is available. They use +the same resolved workspace icon and theme colors published by their desktop tab, including the avatar gradient and border. +Project images remain full-color rather than template images. Working sessions use a static dot because native menus do +not reliably repaint animation frames. Finished sessions with a completion timestamp newer than their viewed timestamp +show a blue corner dot on the restored favicon, until the client acknowledges viewing that completion. Questions and +permission requests also add an attention dot. The menu-bar icon is badged whenever any listed session has one of these +notifications. State refreshes on the normal polling cycle; an open native menu keeps its current layout until reopened. +Footer actions reuse the app's SVG artwork, +with book and exit icons for Docs and Quit. SVG favicons are rasterized for native menus without opening a renderer window. + +From the repository root: + +```bash +bun run dev:tray # Start with only the menu bar / tray icon +bun run dev:desktop # Start the usual desktop window and the icon +bun run dev:tray:live # Use the installed opencode2 service for this desktop's tabs without replacing it +``` + +Or run `bun dev --tray` from `packages/desktop`. Packaged applications accept `--tray` as well. A tray-only launch respects +the saved opt-out and exits if the companion is disabled. Launch the desktop normally to enable it again under +**Settings → Preferences → Display → Menu bar / system tray**. The preference is shared between windows and persists +across launches. Development uses the dev app's settings, separate from the production app. + +Manual checks: + +- Start tray-only: the icon should appear without a desktop window. +- With no desktop windows open, the menu should say No active sessions. Open Settings or New Agent to restore a window. +- Verify that session rows appear above the actions and clicking one opens that exact session. +- Open and close session tabs: only open tabs should appear. Test multiple windows and a session open in both. +- Trigger a question or permission request: the session should move into Needs you on the next refresh. +- Choose New Agent or Settings: a window should open and perform the action after loading. +- Minimize that window and choose a session or Settings: it should restore and focus. +- Close all windows: the icon should remain available. Repeat New Agent and Settings. +- Open docs: the V2 documentation should open in your browser. +- Disable the tray in Settings: the icon should disappear immediately. Enable it again to restore it. +- Quit from the tray: the app and icon should exit. A subsequent launch should respect the saved preference. + +Regular `dev:tray` uses the isolated desktop development server. `dev:tray:live` uses the client library to discover a +running service (no `opencode2` executable required), then attaches without electing or replacing it. These desktop-only +commands show desktop tabs; use `bun run dev` for the terminal companion. Selecting a TUI session updates the owning TUI, +but selecting a particular emulator tab/window among multiple Ghostty windows is not portable and remains host-dependent. + ## Build Run the `build` script to build the app's JS assets, then `package` to diff --git a/packages/desktop/electron.vite.config.test.ts b/packages/desktop/electron.vite.config.test.ts index c2a29baeea9..4dd8e36c6a5 100644 --- a/packages/desktop/electron.vite.config.test.ts +++ b/packages/desktop/electron.vite.config.test.ts @@ -99,6 +99,7 @@ test("bundles one Effect runtime and Drizzle while keeping native dependencies e expect(new Set(effect.map((id) => id.split("/node_modules/effect/")[0])).size).toBe(1) expect(new Set(effect).size).toBe(effect.length) expect(imports).toContain("electron") + expect(imports).toContain("@resvg/resvg-js") expect(imports).toContain("node:sqlite") expect(chunks.some((chunk) => chunk.dynamicImports.includes("@zip.js/zip.js"))).toBe(true) expect(imports).toContain(`@lydell/node-pty-${process.platform}-${process.arch}`) diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 768a6d4452e..e2aa910b54e 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -25,11 +25,13 @@ }, "main": "./out/main/index.js", "dependencies": { + "@resvg/resvg-js": "2.6.2", "@zip.js/zip.js": "2.7.62", "electron-context-menu": "5.0.0", "electron-log": "^5", "electron-updater": "6.8.9", - "lighthouse": "13.4.1" + "lighthouse": "13.4.1", + "string-width": "catalog:" }, "devDependencies": { "@brendonovich/vite-plugin-opencode": "0.1.3", diff --git a/packages/desktop/scripts/dev-live.ts b/packages/desktop/scripts/dev-live.ts new file mode 100644 index 00000000000..18916137df4 --- /dev/null +++ b/packages/desktop/scripts/dev-live.ts @@ -0,0 +1,19 @@ +import { Service } from "@opencode/client/service" +import { homedir } from "node:os" +import { join } from "node:path" + +const endpoints = await Promise.all([ + Service.discover(), + Service.discover({ + file: join(process.env.XDG_STATE_HOME ?? join(homedir(), ".local", "state"), "opencode", "service-local.json"), + }), +]) +const endpoint = endpoints.find((endpoint) => endpoint !== undefined) +if (!endpoint) + throw new Error( + "No running OpenCode service found. Use `bun run dev` from the repository root to start the terminal app with its tray companion.", + ) +process.env.OPENCODE_DESKTOP_SERVER_URL = endpoint.url +process.env.OPENCODE_DESKTOP_SERVER_PASSWORD = endpoint.auth?.password ?? "" +process.argv.push("--tray") +await import("./dev") diff --git a/packages/desktop/scripts/dev-terminal-tray.ts b/packages/desktop/scripts/dev-terminal-tray.ts new file mode 100644 index 00000000000..e886bfd2c85 --- /dev/null +++ b/packages/desktop/scripts/dev-terminal-tray.ts @@ -0,0 +1,51 @@ +import { join } from "node:path" +import { fileURLToPath } from "node:url" +import { spawn } from "node:child_process" + +const root = fileURLToPath(new URL("..", import.meta.url)) +const file = process.argv[2] +if (!file) throw new Error("A terminal tray connection file is required") +process.env.OPENCODE_CHANNEL = "local" + +const install = Bun.spawn([process.execPath, "run", "install-electron"], { + cwd: root, + stdout: "inherit", + stderr: "inherit", +}) +if (await install.exited) throw new Error("Could not prepare Electron") + +const { loadConfigFromFile, MainConfigFactory } = await import("electron-vite") +const { build } = await import("vite") +const result = await loadConfigFromFile( + { command: "build", mode: "development" }, + join(root, "electron.vite.config.ts"), +) +if (!result.config.main) throw new Error("Missing Electron main configuration") +const config = await new MainConfigFactory( + result.config.main, + { configFile: false, mode: "development" }, + { root }, +).build() +config.build = { + ...config.build, + outDir: join(root, "out/terminal-tray"), + emptyOutDir: false, + rolldownOptions: { + ...config.build?.rolldownOptions, + input: { index: join(root, "src/main/terminal-tray-entry.ts") }, + }, +} +await build(config) +const { default: electron } = await import("electron") +const child = spawn(electron, [join(root, "out/terminal-tray/index.js"), `--terminal-tray=${file}`], { + cwd: root, + env: process.env, + detached: true, + stdio: "ignore", + windowsHide: true, +}) +await new Promise((resolve, reject) => { + child.once("spawn", resolve) + child.once("error", reject) +}) +child.unref() diff --git a/packages/desktop/scripts/dev.ts b/packages/desktop/scripts/dev.ts index d7e3f53f19a..d6a3680b3ad 100644 --- a/packages/desktop/scripts/dev.ts +++ b/packages/desktop/scripts/dev.ts @@ -29,13 +29,15 @@ async function prepareDesktop() { function selectOptions(): DevOptions { const args = process.argv.slice(2) + const tray = args.indexOf("--tray") + if (tray >= 0) process.env.OPENCODE_DESKTOP_TRAY_ONLY = "1" const build = args.indexOf("--build-server") const download = args.indexOf("--download-server") if (build >= 0 && download >= 0) { throw new Error("--build-server and --download-server cannot be used together") } if (download >= 0 && !args[download + 1]) throw new Error("--download-server requires a version") - const consumed = new Set([build, download, download >= 0 ? download + 1 : -1]) + const consumed = new Set([build, download, download >= 0 ? download + 1 : -1, tray]) return { server: download >= 0 ? { type: "download", version: args[download + 1] } : { type: "build" }, electron: args.filter((_, index) => !consumed.has(index)), diff --git a/packages/desktop/src/main/desktop.ts b/packages/desktop/src/main/desktop.ts index 96841bbea7a..940d37a2ef9 100644 --- a/packages/desktop/src/main/desktop.ts +++ b/packages/desktop/src/main/desktop.ts @@ -10,6 +10,7 @@ import { BackgroundService } from "./service/background-service" import { DesktopCli } from "./service/desktop-cli" import { UpdaterLive } from "./updater/live" import { marks } from "./lifecycle/marks" +import { getTrayEnabled, trayOnly } from "./native/tray" // Everything above has been loaded and evaluated by now; the layers start below. marks.bundle = Date.now() @@ -19,7 +20,8 @@ const runIpc = Effect.fn("Desktop.runIpc")(function* () { marks.layers = Date.now() yield* Effect.logInfo("layers ready", { marks }) const ipc = yield* Ipc.registerIpcHandlers - if (lifecycle.restoreWindows().length) ipc.installMenu() + ipc.installMenu() + if (!trayOnly()) lifecycle.restoreWindows() // The first window's renderer now has its IPC port and is hydrating its stores over it. The crash // reporter (spawns a process) and the context menu (a dependency tree) are not worth answering late. yield* Effect.sleep("500 millis") @@ -29,6 +31,7 @@ const runIpc = Effect.fn("Desktop.runIpc")(function* () { yield* Effect.callback((resume) => { const quit = () => resume(Effect.void) app.once("will-quit", quit) + if (trayOnly() && !getTrayEnabled()) app.quit() return Effect.sync(() => app.off("will-quit", quit)) }) }) diff --git a/packages/desktop/src/main/env.d.ts b/packages/desktop/src/main/env.d.ts index 353119cf067..baf65b39149 100644 --- a/packages/desktop/src/main/env.d.ts +++ b/packages/desktop/src/main/env.d.ts @@ -1,3 +1,5 @@ +/// + interface ImportMetaEnv { readonly OPENCODE_CHANNEL: string readonly OPENCODE_VERSION?: string diff --git a/packages/desktop/src/main/index.ts b/packages/desktop/src/main/index.ts index 9a1d0e2b4a9..f6c10be8851 100644 --- a/packages/desktop/src/main/index.ts +++ b/packages/desktop/src/main/index.ts @@ -20,7 +20,8 @@ if (acquireApplicationLock()) { void app.whenReady().then(async () => { marks.ready = Date.now() registerStorageSnapshotHandler() - createEarlyWindow() + // Tray-only launches (see native/tray.ts trayOnly) start without a window. + if (!process.argv.includes("--tray") && process.env.OPENCODE_DESKTOP_TRAY_ONLY !== "1") createEarlyWindow() marks.window = Date.now() startSidecarProbe() // The window's renderer is already loading. Its HTML and preloaded chunks are served from this diff --git a/packages/desktop/src/main/ipc-handlers/app.ts b/packages/desktop/src/main/ipc-handlers/app.ts index 5674c980e59..af051df15bc 100644 --- a/packages/desktop/src/main/ipc-handlers/app.ts +++ b/packages/desktop/src/main/ipc-handlers/app.ts @@ -22,6 +22,8 @@ import { DesktopStorage } from "../storage" import { createPairing } from "../service/pairing" import { getLastFocusedWindow, setBackgroundColor } from "../windows" import { sender } from "./context" +import { getTrayEnabled, setTrayEnabled, updateTray } from "../native/tray" +import { trayTabs } from "../native/tray-tabs" export const appHandlers = AppRpcs.toLayer( Effect.gen(function* () { @@ -37,6 +39,13 @@ export const appHandlers = AppRpcs.toLayer( const pairing = createPairing() const runFork = Effect.runForkWith(yield* Effect.context()) return AppRpcs.of({ + AppGetTrayEnabled: () => Effect.sync(getTrayEnabled), + AppSetTrayEnabled: ({ enabled }) => Effect.sync(() => setTrayEnabled(enabled)), + AppSetTrayTabs: ({ sessionIDs, avatars }, context) => + Effect.sync(() => { + const win = BrowserWindow.fromWebContents(sender(handoff, context)) + if (win && !win.isDestroyed()) trayTabs.set(win.id, sessionIDs, avatars) + }), AppAwaitInitialization: () => background.connection.pipe(Effect.map(SidecarCredentials.ready)), AppReconnectService: () => background.reconnect.pipe(Effect.map(SidecarCredentials.ready)), AppConsumeInitialDeepLinks: () => Effect.sync(lifecycle.consumeInitialDeepLinks), @@ -62,6 +71,7 @@ export const appHandlers = AppRpcs.toLayer( const bundle = parseDesktopNativeBundle(value) if (!bundle) throw new Error("Invalid native translation bundle") if (!setNativeTranslations(bundle)) return + updateTray() createMenu({ trigger: (id) => { const win = getLastFocusedWindow() diff --git a/packages/desktop/src/main/ipc-handlers/menu.ts b/packages/desktop/src/main/ipc-handlers/menu.ts index 0f484169685..91c9c9ed941 100644 --- a/packages/desktop/src/main/ipc-handlers/menu.ts +++ b/packages/desktop/src/main/ipc-handlers/menu.ts @@ -6,6 +6,7 @@ import { ApplicationLifecycle } from "../lifecycle" import { runDesktopMenuAction } from "../native/menu-actions" import { Updater } from "../updater" import { sender } from "./context" +import { menuCommands } from "../native/menu" export const menuHandlers = MenuRpcs.toLayer( Effect.gen(function* () { @@ -14,6 +15,11 @@ export const menuHandlers = MenuRpcs.toLayer( const updater = yield* Updater.Service const runFork = Effect.runForkWith(yield* Effect.context()) return MenuRpcs.of({ + MenuReady: (_, context) => + Effect.sync(() => { + const win = BrowserWindow.fromWebContents(sender(handoff, context)) + return win ? menuCommands.ready(win) : [] + }), MenuRunAction: ({ action }, context) => Effect.sync(() => runDesktopMenuAction(BrowserWindow.fromWebContents(sender(handoff, context)), action, { diff --git a/packages/desktop/src/main/ipc.ts b/packages/desktop/src/main/ipc.ts index 718902eb7f8..a4b8358c667 100644 --- a/packages/desktop/src/main/ipc.ts +++ b/packages/desktop/src/main/ipc.ts @@ -1,7 +1,7 @@ export * as Ipc from "./ipc" import { app, BrowserWindow, MessageChannelMain } from "electron" -import { Effect, Layer } from "effect" +import { Effect, Layer, Queue } from "effect" import { RpcServer } from "effect/unstable/rpc" import { DesktopRpcs } from "../shared/ipc-rpc" import { DragCancelEvent, IpcTransportPort } from "../shared/ipc-transport" @@ -19,7 +19,11 @@ import { Ssh } from "./ssh/service" import { IpcPortHandoff, IpcServerProtocolLive } from "./ipc-transport" import { ApplicationLifecycle } from "./lifecycle" import { showCliInstaller } from "./native/install-cli" -import { createMenu, sendMenuCommand } from "./native/menu" +import { createMenu, menuCommands, sendMenuCommand } from "./native/menu" +import { getTrayEnabled, setTraySessions, startTray, syncTrayTabs } from "./native/tray" +import { trayTabs } from "./native/tray-tabs" +import { loadTraySessions } from "./native/tray-sessions" +import { BackgroundService } from "./service/background-service" import { DesktopCli } from "./service/desktop-cli" import { Updater } from "./updater" import { getLastFocusedWindow } from "./windows" @@ -48,6 +52,7 @@ export const registerIpcHandlers = Effect.gen(function* () { const lifecycle = yield* ApplicationLifecycle.Service const desktopCli = yield* DesktopCli.Service const updater = yield* Updater.Service + const background = yield* BackgroundService.Service const runFork = Effect.runForkWith(yield* Effect.context()) const menu = { trigger: (id: string) => { @@ -61,8 +66,23 @@ export const registerIpcHandlers = Effect.gen(function* () { relaunch: lifecycle.relaunch, } const wire = (_event: Electron.Event, win: BrowserWindow) => { - win.webContents.on("before-input-event", (_event, input) => { - if (input.type !== "keyDown" || input.key !== "Escape") return + win.once("closed", () => trayTabs.remove(win.id)) + win.webContents.on("did-start-loading", () => menuCommands.reset(win)) + win.webContents.on("before-input-event", (event, input) => { + if (input.type !== "keyDown") return + if ( + process.platform !== "darwin" && + input.key.toLowerCase() === "q" && + input.control && + !input.meta && + !input.alt && + !input.shift + ) { + event.preventDefault() + app.quit() + return + } + if (input.key !== "Escape") return win.webContents.send(DragCancelEvent) }) const post = () => { @@ -80,6 +100,65 @@ export const registerIpcHandlers = Effect.gen(function* () { BrowserWindow.getAllWindows().forEach((win) => wire({} as Electron.Event, win)) }) yield* Effect.addFinalizer(() => Effect.sync(() => app.off("browser-window-created", wire))) + const { createTrayIcons } = yield* Effect.promise(() => import("./native/tray-icons")) + const icons = yield* Effect.promise(createTrayIcons) + const stopTray = startTray( + { + open: () => menuCommands.trigger(lifecycle.showWindow(), { type: "home" }), + trigger: (id) => sendMenuCommand(lifecycle.showWindow(), id), + session: (sessionID) => { + const owner = trayTabs.owner(sessionID, getLastFocusedWindow()?.id) + const win = owner === undefined ? undefined : BrowserWindow.fromId(owner) + if (!win) return + if (win.isMinimized()) win.restore() + win.show() + win.focus() + menuCommands.trigger(win, { type: "session", sessionID }) + }, + docs: () => menu.openExternal("https://opencode.ai/v2/docs/"), + quit: () => app.quit(), + }, + icons, + ) + yield* Effect.addFinalizer(() => Effect.sync(stopTray)) + const refresh = yield* Queue.sliding(1) + const unsubscribe = trayTabs.subscribe(() => { + syncTrayTabs() + Queue.offerUnsafe(refresh, undefined) + }) + yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)) + yield* Queue.offer(refresh, undefined) + yield* Effect.gen(function* () { + yield* Queue.take(refresh).pipe(Effect.timeoutOption("10 seconds")) + if (!getTrayEnabled()) return + const sessionIDs = trayTabs.sessions() + if (!sessionIDs.length) { + syncTrayTabs() + return + } + const connection = yield* background.connection + const { OpenCode } = yield* Effect.promise(() => import("@opencode/client")) + const client = OpenCode.make({ + baseUrl: connection.url, + headers: connection.password + ? { authorization: `Basic ${Buffer.from(`opencode:${connection.password}`).toString("base64")}` } + : undefined, + }) + const snapshot = yield* Effect.tryPromise((signal) => + loadTraySessions(client, sessionIDs, AbortSignal.any([signal, AbortSignal.timeout(15_000)])), + ) + yield* Effect.promise((signal) => + icons.prepare( + snapshot.sessions.map((session) => ({ ...session, avatar: trayTabs.avatar(session.id) })), + AbortSignal.any([signal, AbortSignal.timeout(5_000)]), + ), + ) + setTraySessions(snapshot) + }).pipe( + Effect.catch(() => Effect.sync(() => setTraySessions())), + Effect.forever, + Effect.forkScoped, + ) return { installMenu: () => createMenu(menu), } diff --git a/packages/desktop/src/main/lifecycle/desktop-initialization.ts b/packages/desktop/src/main/lifecycle/desktop-initialization.ts index 1784208b5ea..9be854a2ca3 100644 --- a/packages/desktop/src/main/lifecycle/desktop-initialization.ts +++ b/packages/desktop/src/main/lifecycle/desktop-initialization.ts @@ -3,6 +3,7 @@ export * as DesktopInitialization from "./desktop-initialization" import { app } from "electron" import { Context, Effect, Layer } from "effect" import { DesktopLogging } from "../native/logging" +import { initializeTray } from "../native/tray" import { getStore } from "../storage/store" import { marks } from "./marks" import { @@ -30,6 +31,8 @@ export const layer = Layer.effect( prepareApplicationEnvironment.pipe(Effect.andThen(loadProxyEnvironment), Effect.andThen(logging.startNetwork)), ) yield* Effect.promise(() => app.whenReady()) + const stopTray = initializeTray() + yield* Effect.addFinalizer(() => Effect.sync(stopTray)) yield* prepareDesktop marks.init = Date.now() return Service.of({ diff --git a/packages/desktop/src/main/lifecycle/index.ts b/packages/desktop/src/main/lifecycle/index.ts index 24a856a197e..f21d1e99f1f 100644 --- a/packages/desktop/src/main/lifecycle/index.ts +++ b/packages/desktop/src/main/lifecycle/index.ts @@ -13,6 +13,7 @@ import { marks } from "./marks" import { initializeFirstLaunchOnboarding } from "./onboarding" import { Shutdown } from "./shutdown" import { consoleReturnWindow } from "./deep-link" +import { hasTray } from "../native/tray" export interface Interface { readonly relaunch: () => void @@ -20,6 +21,7 @@ export interface Interface { readonly consumeInitialDeepLinks: () => string[] readonly createWindow: () => BrowserWindow readonly restoreWindows: () => BrowserWindow[] + readonly showWindow: () => BrowserWindow } export class Service extends Context.Service()("opencode/desktop/ApplicationLifecycle") {} @@ -32,6 +34,14 @@ const runtime = Layer.effect( const windows = yield* makeMainWindows() const createWindow = windows.create const restoreWindows = windows.restore + const showWindow = () => { + const existing = getLastFocusedWindow() + if (!existing) return restoreWindows()[0] ?? createWindow() + if (existing.isMinimized()) existing.restore() + existing.show() + existing.focus() + return existing + } const pendingDeepLinks: string[] = [] let shutdownReady = false const prepareToRestart = shutdown.run.pipe(Effect.ensuring(Effect.sync(() => (shutdownReady = true)))) @@ -72,7 +82,8 @@ const runtime = Layer.effect( runFork(Effect.logInfo("deep link received via second-instance", { urls })) focusWindow(emitDeepLinks(urls) ?? null) } - if (!urls.length) focusWindow(getLastFocusedWindow()) + if (urls.length || argv.includes("--tray")) return + showWindow() } const openUrl = (event: Event, url: string) => { event.preventDefault() @@ -106,13 +117,12 @@ const runtime = Layer.effect( runFork(prepareToRestart.pipe(Effect.ensuring(Effect.sync(() => app.quit())))) } const windowAllClosed = () => { - if (process.platform !== "darwin") app.quit() + if (process.platform !== "darwin" && !hasTray()) app.quit() } const activate = () => { if (BrowserWindow.getAllWindows().length === 0) restoreWindows() } const resetRelaunchHandler = setRelaunchHandler(relaunch) - let windowsWired = false app.on("second-instance", secondInstance) app.on("open-url", openUrl) @@ -120,6 +130,8 @@ const runtime = Layer.effect( app.on("will-quit", willQuit) app.on("child-process-gone", childProcessGone) app.on("render-process-gone", renderProcessGone) + app.on("window-all-closed", windowAllClosed) + app.on("activate", activate) process.on("SIGINT", signal) process.on("SIGTERM", signal) yield* Effect.addFinalizer(() => @@ -143,14 +155,8 @@ const runtime = Layer.effect( prepareToRestart, consumeInitialDeepLinks: () => pendingDeepLinks.splice(0), createWindow, - restoreWindows: () => { - if (!windowsWired) { - windowsWired = true - app.on("window-all-closed", windowAllClosed) - app.on("activate", activate) - } - return restoreWindows() - }, + showWindow, + restoreWindows, }) }), ) diff --git a/packages/desktop/src/main/native/menu-queue.test.ts b/packages/desktop/src/main/native/menu-queue.test.ts new file mode 100644 index 00000000000..571879273a4 --- /dev/null +++ b/packages/desktop/src/main/native/menu-queue.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, test } from "bun:test" +import { createMenuQueue } from "./menu-queue" +import type { MenuCommand } from "../../shared/menu-command" + +describe("native menu command handoff", () => { + test("queues session navigation alongside ordinary commands on a cold window", () => { + const delivered: MenuCommand[] = [] + const queue = createMenuQueue((_, command) => delivered.push(command)) + const win = {} + queue.trigger(win, { type: "session", sessionID: "ses_from_terminal" }) + queue.reset(win) + expect(queue.ready(win)).toEqual([{ type: "session", sessionID: "ses_from_terminal" }]) + queue.trigger(win, { type: "command", id: "settings.open" }) + expect(delivered).toEqual([{ type: "command", id: "settings.open" }]) + }) + test("holds commands until a newly opened window registers its commands", () => { + const delivered: string[] = [] + const queue = createMenuQueue(() => delivered.push("unexpected")) + const win = {} + queue.trigger(win, "tab.new") + // Loading can begin after the native click creates the window. + queue.reset(win) + queue.trigger(win, "settings.open") + expect(delivered).toEqual([]) + expect(queue.ready(win)).toEqual(["tab.new", "settings.open"]) + expect(queue.ready(win)).toEqual([]) + }) + + test("delivers immediately to a ready window and waits again after reload", () => { + const delivered: string[] = [] + const queue = createMenuQueue((_, id) => delivered.push(id)) + const win = {} + queue.ready(win) + queue.trigger(win, "settings.open") + expect(delivered).toEqual(["settings.open"]) + queue.reset(win) + queue.trigger(win, "tab.new") + expect(delivered).toEqual(["settings.open"]) + expect(queue.ready(win)).toEqual(["tab.new"]) + }) + + test("keeps commands isolated between windows", () => { + const delivered: string[] = [] + const queue = createMenuQueue((_, id) => delivered.push(id)) + const first = {} + const second = {} + queue.ready(first) + queue.trigger(first, "tab.new") + queue.trigger(second, "settings.open") + expect(delivered).toEqual(["tab.new"]) + expect(queue.ready(first)).toEqual([]) + expect(queue.ready(second)).toEqual(["settings.open"]) + }) +}) diff --git a/packages/desktop/src/main/native/menu-queue.ts b/packages/desktop/src/main/native/menu-queue.ts new file mode 100644 index 00000000000..3ead84af4fc --- /dev/null +++ b/packages/desktop/src/main/native/menu-queue.ts @@ -0,0 +1,27 @@ +// Native menu actions can create a window before its command registry exists. +// Hold those actions until the renderer explicitly claims them, not just until +// did-finish-load (which precedes application initialization). +export function createMenuQueue( + send: (win: Window, command: Command) => void, +) { + const windows = new WeakMap() + return { + reset(win: Window) { + windows.set(win, { ready: false, pending: windows.get(win)?.pending ?? [] }) + }, + trigger(win: Window, id: Command) { + const state = windows.get(win) + if (state?.ready) return send(win, id) + if (state) { + state.pending.push(id) + return + } + windows.set(win, { ready: false, pending: [id] }) + }, + ready(win: Window) { + const state = windows.get(win) + windows.set(win, { ready: true, pending: [] }) + return state?.pending ?? [] + }, + } +} diff --git a/packages/desktop/src/main/native/menu.ts b/packages/desktop/src/main/native/menu.ts index b572a8aad37..50e8501f61c 100644 --- a/packages/desktop/src/main/native/menu.ts +++ b/packages/desktop/src/main/native/menu.ts @@ -12,6 +12,12 @@ import { emitIpcEvent } from "../ipc-events" import { CHANNEL, UPDATER_ENABLED } from "../constants" import { runDesktopMenuAction } from "./menu-actions" import { nativeT } from "./translations" +import { createMenuQueue } from "./menu-queue" +import type { MenuCommand } from "../../shared/menu-command" + +export const menuCommands = createMenuQueue((win, command) => { + emitIpcEvent(win.webContents, new MenuCommandTriggered({ command })) +}) type Deps = { trigger: (id: string) => void @@ -39,7 +45,7 @@ export function createMenu(deps: Deps) { } export function sendMenuCommand(win: BrowserWindow, id: string) { - emitIpcEvent(win.webContents, new MenuCommandTriggered({ id })) + menuCommands.trigger(win, { type: "command", id }) } function nativeItem(entry: DesktopMenuEntry, deps: Deps): MenuItemConstructorOptions { diff --git a/packages/desktop/src/main/native/translations.ts b/packages/desktop/src/main/native/translations.ts index 5d20bc24eee..90cf4d47544 100644 --- a/packages/desktop/src/main/native/translations.ts +++ b/packages/desktop/src/main/native/translations.ts @@ -2,6 +2,7 @@ import { DESKTOP_NATIVE_ENGLISH, DESKTOP_NATIVE_KEYS, formatDesktopNativeMessage, + formatDesktopNativeCost, type DesktopNativeBundle, type DesktopNativeKey, } from "@opencode/app/i18n/desktop-native" @@ -22,3 +23,7 @@ export function setNativeTranslations(next: DesktopNativeBundle) { export function nativeT(key: DesktopNativeKey, params?: Record) { return formatDesktopNativeMessage(bundle.messages[key], params) } + +export function nativeCost(value: number) { + return formatDesktopNativeCost(bundle.locale, value) +} diff --git a/packages/desktop/src/main/native/tray-icon-art.test.ts b/packages/desktop/src/main/native/tray-icon-art.test.ts new file mode 100644 index 00000000000..4cccf5698d3 --- /dev/null +++ b/packages/desktop/src/main/native/tray-icon-art.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, test } from "bun:test" +import { Resvg } from "@resvg/resvg-js" +import { shellIcons } from "@opencode/ui/icons/shell" +import { getProjectAvatarSource } from "@opencode/app/project-avatar-source" +import { trayActionSvg, trayAppSvg, trayMenuIconSvg, traySessionSvg, trayStartupPixels } from "./tray-icon-art" +import { traySessionNotification, type TraySession } from "./tray-sessions" + +const session: TraySession = { + id: "session", + directory: "/code/project", + status: "idle", + cost: 0, + project: { id: "project", name: "Dashboard", icon: { color: "blue" } }, +} + +function render(svg: string) { + return new Resvg(svg, { fitTo: { mode: "width", value: 36 }, font: { loadSystemFonts: false } }).render() +} + +describe("tray icon artwork", () => { + test("renders the desktop's resolved avatar colors instead of the fallback palette", () => { + const avatar = { + name: "Workspace", + background: "#123456", + border: "#654321", + foreground: "#fefefe", + highlight: "#ffffff29", + accent: "#ff8800", + } + const artwork = traySessionSvg({ ...session, avatar, status: "permission" }, "black") + expect(artwork).toContain('fill="#123456"') + expect(artwork).toContain('stroke="#654321"') + expect(artwork).toContain('fill="#fefefe"') + expect(artwork).toContain('fill="#ff8800"') + expect(artwork).toContain("linearGradient") + expect(artwork).not.toContain('fill="#3250df"') + const image = render(artwork) + expect([...image.pixels.subarray((6 * 36 + 30) * 4, (6 * 36 + 30) * 4 + 4)]).toEqual([255, 136, 0, 255]) + }) + test("top-aligns session artwork using bottom padding without moving or resizing its pixels", () => { + const artwork = traySessionSvg({ ...session, status: "question" }, "black") + const options = { fitTo: { mode: "zoom" as const, value: 2 }, font: { loadSystemFonts: false } } + const normal = new Resvg(trayMenuIconSvg(artwork), options).render() + const twoLines = new Resvg(trayMenuIconSvg(artwork, true), options).render() + expect([twoLines.width, twoLines.height]).toEqual([44, 60]) + expect(twoLines.pixels.subarray(0, normal.pixels.length)).toEqual(normal.pixels) + expect(twoLines.pixels.subarray(normal.pixels.length).every((value) => value === 0)).toBe(true) + }) + test("centers the Quit artwork on the same axis as the other action icons", () => { + const image = new Resvg(trayActionSvg("quit", "black"), { + fitTo: { mode: "zoom", value: 8 }, + font: { loadSystemFonts: false }, + }).render() + const columns = Array.from({ length: image.width * image.height }, (_, index) => index) + .filter((index) => image.pixels[index * 4 + 3] > 128) + .map((index) => index % image.width) + expect((Math.min(...columns) + Math.max(...columns) + 1) / 2).toBe(image.width / 2) + }) + test("adds a four-point icon-to-label gutter without shrinking the artwork", () => { + const artwork = trayActionSvg("settings", "black") + const image = new Resvg(trayMenuIconSvg(artwork), { + fitTo: { mode: "zoom", value: 2 }, + font: { loadSystemFonts: false }, + }).render() + const original = render(artwork) + expect([image.width, image.height]).toEqual([44, 36]) + Array.from({ length: 36 }, (_, y) => y).forEach((y) => { + expect(image.pixels.subarray(y * 44 * 4, (y * 44 + 36) * 4)).toEqual( + original.pixels.subarray(y * 36 * 4, (y + 1) * 36 * 4), + ) + expect(image.pixels.subarray((y * 44 + 36) * 4, (y + 1) * 44 * 4).every((value) => value === 0)).toBe(true) + }) + }) + test("reserves the same icon width for the detail line without drawing an icon", () => { + const image = new Resvg(trayMenuIconSvg(""), { fitTo: { mode: "zoom", value: 2 } }).render() + expect([image.width, image.height]).toEqual([44, 36]) + expect(image.pixels.every((value) => value === 0)).toBe(true) + }) + test("the immediate startup bitmap matches the fully initialized mark", () => { + expect(trayStartupPixels()).toEqual(render(trayAppSvg(false, "black")).pixels) + expect(trayStartupPixels(255)).toEqual(render(trayAppSvg(false, "white")).pixels) + }) + test("reuses the app's exact new-session and settings SVG paths", () => { + expect(trayActionSvg("newAgent", "black")).toContain(shellIcons.edit.body) + expect(trayActionSvg("settings", "black")).toContain(shellIcons["settings-gear"].body) + for (const action of ["newAgent", "settings", "docs", "quit", "more"] as const) { + const image = render(trayActionSvg(action, "black")) + expect([image.width, image.height]).toEqual([36, 36]) + expect(image.pixels.some((value, index) => index % 4 === 3 && value > 0)).toBe(true) + } + }) + + test("puts the attention dot on the app mark and clears it when attention ends", () => { + const normal = render(trayAppSvg(false, "black")) + const attention = render(trayAppSvg(true, "black")) + const offset = (6 * 36 + 30) * 4 + expect([...attention.pixels.subarray(offset, offset + 4)]).toEqual([59, 92, 246, 255]) + expect(normal.pixels[offset + 3]).toBe(0) + expect(trayAppSvg(false, "white")).not.toContain("#3b5cf6") + expect(trayAppSvg(true, "white")).toContain('fill="white"') + }) + + test("uses project artwork for idle and attention states, a simple dot while running", () => { + const favicon = `data:image/png;base64,${render(trayActionSvg("docs", "red")).asPng().toString("base64")}` + expect(traySessionSvg(session, "black", favicon)).toContain(favicon) + expect(traySessionSvg(session, "black", favicon)).not.toContain("#3b5cf6") + for (const status of ["question", "permission"] as const) { + const svg = traySessionSvg({ ...session, status }, "black", favicon) + expect(svg).toContain(favicon) + expect(svg).toContain("#3b5cf6") + expect(svg).not.toContain('') + expect(busy).not.toContain(favicon) + expect(busy).not.toContain("#3b5cf6") + expect(render(busy).pixels.some((value, index) => index % 4 === 3 && value > 0)).toBe(true) + }) + + test("restores the favicon with a blue corner dot for an unviewed completion", () => { + const favicon = `data:image/png;base64,${render(trayActionSvg("docs", "red")).asPng().toString("base64")}` + const finished = { ...session, unread: true } + const artwork = traySessionSvg(finished, "black", favicon) + expect(artwork).toContain(favicon) + const image = render(artwork) + expect([...image.pixels.subarray((6 * 36 + 30) * 4, (6 * 36 + 30) * 4 + 4)]).toEqual([59, 92, 246, 255]) + expect(traySessionNotification(finished)).toBe(true) + expect(traySessionNotification({ ...finished, unread: false })).toBe(false) + expect(traySessionNotification({ ...finished, status: "working" })).toBe(false) + expect(traySessionSvg({ ...finished, unread: false }, "black", favicon)).not.toContain("#3b5cf6") + }) + + test("keeps project overrides, color-only avatars, and the OpenCode favicon consistent with the app", () => { + expect(getProjectAvatarSource("project", { url: "icon.png", override: "custom.png" })).toBe("custom.png") + expect(getProjectAvatarSource("project", { url: "icon.png", color: "blue" })).toBeUndefined() + expect(getProjectAvatarSource("4b0ea68d7af9a6031a7ffda7ad66e0cb83315750")).toBe("https://opencode.ai/favicon.svg") + expect(traySessionSvg(session, "black")).toContain('fill="#3250df"') + expect(traySessionSvg({ ...session, project: { id: "project", name: "" } }, "black")).toContain("<") + expect(traySessionSvg({ ...session, project: { id: "project", name: "مشروع" } }, "black")).toContain("م") + }) +}) diff --git a/packages/desktop/src/main/native/tray-icon-art.ts b/packages/desktop/src/main/native/tray-icon-art.ts new file mode 100644 index 00000000000..488681f4ebc --- /dev/null +++ b/packages/desktop/src/main/native/tray-icon-art.ts @@ -0,0 +1,99 @@ +import { shellIcons } from "@opencode/ui/icons/shell" +import { traySessionNotification, type TraySession } from "./tray-sessions" + +export type TrayActionIcon = "newAgent" | "settings" | "docs" | "quit" | "more" + +export function trayMenuIconSvg(artwork: string, twoLines = false) { + // Native menus measure the image's full width. Leave a trailing gutter + // without scaling the artwork or changing the menu-bar icon's size. + // For a title + sublabel, transparent space below the artwork offsets it + // toward the first line while AppKit centers the complete image canvas. + const height = twoLines ? 30 : 18 + return `${artwork}` +} + +export function trayStartupPixels(foreground = 0) { + // The initial mark needs no SVG renderer, fonts, assets, or server connection. + const pixels = Buffer.alloc(36 * 36 * 4) + Array.from({ length: 36 * 36 }, (_, index) => index).forEach((index) => { + const x = index % 36 + const y = Math.floor(index / 36) + if (x < 6 || x >= 30 || y < 3 || y >= 32) return + if (x >= 12 && x < 24 && y >= 9 && y < 26) return + pixels.fill(foreground, index * 4, index * 4 + 3) + pixels[index * 4 + 3] = 255 + }) + return pixels +} + +// Fallbacks while a desktop window has not published its resolved theme colors. +const avatarColors: Record = { + orange: "#ee7330", + yellow: "#e7af36", + cyan: "#0096b8", + mint: "#0096b8", + green: "#2eaf5a", + lime: "#2eaf5a", + red: "#d92e3c", + pink: "#e4429e", + blue: "#3250df", + purple: "#623be2", + gray: "#5c5c5c", +} + +export function trayActionSvg(action: TrayActionIcon, foreground: string) { + const name = { + newAgent: "edit", + settings: "settings-gear", + docs: "book-open", + quit: "log-out", + more: "grid-plus", + } as const + const icon = shellIcons[name[action]] + return svg( + `${icon.body}`, + ) +} + +export function trayAppSvg(attention: boolean, foreground: string) { + // Preserve the 18-point tray icon and mark dimensions chosen in the scaffold. + return svg(badge(``, attention)) +} + +export function traySessionSvg(session: TraySession, foreground: string, image?: string) { + if (session.status === "working") { + return svg(``) + } + const name = + session.avatar?.name || + session.project?.name || + session.directory.replaceAll("\\", "/").split("/").filter(Boolean).at(-1) || + "" + const initial = + new Intl.Segmenter(undefined, { granularity: "grapheme" }).segment(name)[Symbol.iterator]().next().value?.segment ?? + "" + const color = avatarColors[session.project?.icon?.color ?? "gray"] ?? avatarColors.gray + const background = escapeXml(session.avatar?.background || color) + const border = escapeXml(session.avatar?.border || color) + const text = escapeXml(session.avatar?.foreground || "white") + const highlight = escapeXml(session.avatar?.highlight || "#ffffff29") + const surface = `` + const body = image + ? `${surface}` + : `${surface}${escapeXml(initial.toLocaleUpperCase())}` + return svg(badge(body, traySessionNotification(session), session.avatar?.accent)) +} + +function svg(body: string) { + return `${body}` +} + +function badge(body: string, attention: boolean, accent = "#3b5cf6") { + if (!attention) return body + // A transparent clearance ring keeps the app's accent dot distinct on any menu background. + return `${body}` +} + +function escapeXml(value: string) { + return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """) +} diff --git a/packages/desktop/src/main/native/tray-icons.ts b/packages/desktop/src/main/native/tray-icons.ts new file mode 100644 index 00000000000..2764516f540 --- /dev/null +++ b/packages/desktop/src/main/native/tray-icons.ts @@ -0,0 +1,69 @@ +import { nativeImage, nativeTheme } from "electron" +import type { NativeImage } from "electron" +import { getProjectAvatarSource } from "@opencode/app/project-avatar-source" +import { trayActionSvg, trayAppSvg, trayMenuIconSvg, traySessionSvg, type TrayActionIcon } from "./tray-icon-art" +import type { TraySession } from "./tray-sessions" + +export type TrayIcons = Awaited> + +export async function createTrayIcons() { + const { Resvg } = await import("@resvg/resvg-js") + const images = new Map() + const cache = new Map() + const foreground = () => (nativeTheme.shouldUseDarkColors ? "#ffffff" : "#202020") + const render = (svg: string, template: boolean, text = false) => { + const key = `${template}:${svg}` + const existing = cache.get(key) + if (existing) return existing + const png = new Resvg(svg, { fitTo: { mode: "zoom", value: 2 }, font: { loadSystemFonts: text } }).render().asPng() + const image = nativeImage.createFromBuffer(png, { scaleFactor: 2 }) + image.setTemplateImage(process.platform === "darwin" && template) + if (cache.size >= 128) cache.clear() + cache.set(key, image) + return image + } + return { + app: (attention: boolean) => render(trayAppSvg(attention, foreground()), !attention), + action: (action: TrayActionIcon) => render(trayMenuIconSvg(trayActionSvg(action, foreground())), true), + spacer: () => render(trayMenuIconSvg(""), false), + session: (session: TraySession, twoLines = false) => { + const image = images.get(source(session) ?? "") + return render( + trayMenuIconSvg(traySessionSvg(session, foreground(), image), twoLines), + session.status === "working", + !image && session.status !== "working", + ) + }, + async prepare(sessions: TraySession[], signal: AbortSignal) { + const sources = new Set(sessions.flatMap((session) => source(session) ?? [])) + images.forEach((_, source) => { + if (!sources.has(source)) images.delete(source) + }) + await Promise.all( + [...sources] + .filter((source) => !images.has(source)) + .map(async (source) => { + // Project icons are images, never local file paths or authenticated API requests. + if (!/^(https?:\/\/|data:image\/)/i.test(source)) return + const image = await fetch(source, { signal }) + .then(async (response) => { + if (!response.ok) return + const data = Buffer.from(await response.arrayBuffer()) + const bitmap = nativeImage.createFromBuffer(data) + if (!bitmap.isEmpty()) return bitmap.resize({ width: 36 }).toDataURL() + const png = new Resvg(data, { fitTo: { mode: "width", value: 36 }, font: { loadSystemFonts: false } }) + .render() + .asPng() + return `data:image/png;base64,${png.toString("base64")}` + }) + .catch(() => undefined) + if (image) images.set(source, image) + }), + ) + }, + } + + function source(session: TraySession) { + return session.avatar ? session.avatar.source : getProjectAvatarSource(session.project?.id, session.project?.icon) + } +} diff --git a/packages/desktop/src/main/native/tray-label.test.ts b/packages/desktop/src/main/native/tray-label.test.ts new file mode 100644 index 00000000000..08292cb3ffa --- /dev/null +++ b/packages/desktop/src/main/native/tray-label.test.ts @@ -0,0 +1,17 @@ +import { expect, test } from "bun:test" +import { trayLabel, trayLabelWidth } from "./tray-label" + +test("normalizes line breaks and whitespace before truncating", () => { + expect(trayLabel(" Fix\r\n login\t redirects\u2028now ")).toBe("Fix login redirects now") + expect(trayLabel("abcdefghijklmnop", 10)).toBe("abcdefghi…") + expect(trayLabel("short", 10)).toBe("short") +}) + +test("keeps Unicode graphemes intact and budgets for wide characters", () => { + expect(trayLabel("项目名称项目名称", 7)).toBe("项目名…") + expect(trayLabel("👩‍💻👩‍💻👩‍💻", 5)).toBe("👩‍💻👩‍💻…") + expect(trayLabel("e\u0301e\u0301e\u0301e\u0301", 3)).toBe("e\u0301e\u0301…") + expect(trayLabelWidth(trayLabel("项目名称".repeat(20)))).toBeLessThanOrEqual(44) + expect(trayLabel("long", 1)).toBe("…") + expect(trayLabel("long", 0)).toBe("") +}) diff --git a/packages/desktop/src/main/native/tray-label.ts b/packages/desktop/src/main/native/tray-label.ts new file mode 100644 index 00000000000..c3da528153b --- /dev/null +++ b/packages/desktop/src/main/native/tray-label.ts @@ -0,0 +1,22 @@ +import stringWidth from "string-width" + +export const TRAY_LABEL_WIDTH = 44 +const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" }) + +export function trayLabelWidth(value: string) { + return stringWidth(value) +} + +export function trayLabel(value: string, width = TRAY_LABEL_WIDTH) { + const line = value.replace(/\s+/gu, " ").trim() + if (stringWidth(line) <= width) return line + if (width <= 0) return "" + const prefix = Array.from(segmenter.segment(line)).reduce( + (result, item) => { + const length = result.width + stringWidth(item.segment) + return { width: length, text: length <= width - 1 ? result.text + item.segment : result.text } + }, + { width: 0, text: "" }, + ) + return `${prefix.text.trimEnd()}…` +} diff --git a/packages/desktop/src/main/native/tray-menu.test.ts b/packages/desktop/src/main/native/tray-menu.test.ts new file mode 100644 index 00000000000..e026c83b0e9 --- /dev/null +++ b/packages/desktop/src/main/native/tray-menu.test.ts @@ -0,0 +1,192 @@ +import { describe, expect, test } from "bun:test" +import { trayMenu } from "./tray-menu" +import type { TraySessions } from "./tray-sessions" +import { TRAY_LABEL_WIDTH, trayLabelWidth } from "./tray-label" + +const empty: TraySessions = { state: "ready", sessions: [], working: 0, attention: 0, more: false } + +describe("tray menu", () => { + test("requests top-aligned icons only for native two-line items", () => { + const actions = { open() {}, trigger() {}, docs() {}, quit() {}, session() {} } + const snapshot: TraySessions = { + ...empty, + sessions: [{ id: "session", directory: "/repo", status: "idle", cost: 0 }], + } + const icons = { + action: (name: string) => `${name}.png`, + session: (_session: unknown, twoLines: boolean) => (twoLines ? "top-aligned.png" : "normal.png"), + spacer: () => "transparent.png", + } + const native = trayMenu(actions, snapshot, icons, true) + const fallback = trayMenu(actions, snapshot, icons, false) + expect(native.find((item) => "id" in item && item.id === "session:session")?.icon).toBe("top-aligned.png") + expect(fallback.find((item) => "id" in item && item.id === "session:session")?.icon).toBe("normal.png") + }) + test("truncates titles and project names while keeping context and cost on one line", () => { + const actions = { open() {}, trigger() {}, docs() {}, quit() {}, session() {} } + const title = "A very long session title\n".repeat(6) + const directory = `/code/${"项目名称".repeat(20)}` + const menu = trayMenu(actions, { + ...empty, + sessions: [{ id: "long", title, directory, status: "idle", context: 52, cost: 12.34 }], + }) + const index = menu.findIndex((item) => "id" in item && item.id === "session:long") + const session = menu[index] + const detail = "sublabel" in session ? session.sublabel : undefined + expect(session.label?.endsWith("…")).toBe(true) + expect(session.label).not.toMatch(/[\r\n\u2028\u2029]/u) + expect(trayLabelWidth(session.label ?? "")).toBeLessThanOrEqual(TRAY_LABEL_WIDTH) + expect(detail).toContain("… · Context 52% · $12.34") + expect(trayLabelWidth(detail ?? "")).toBeLessThanOrEqual(TRAY_LABEL_WIDTH) + expect("toolTip" in session && session.toolTip).toContain(title) + expect("toolTip" in session && session.toolTip).toContain(directory) + }) + test("keeps View all sessions text-only", () => { + const actions = { open() {}, trigger() {}, docs() {}, quit() {}, session() {} } + const menu = trayMenu( + actions, + { ...empty, more: true }, + { + action: (name) => `${name}.png`, + session: (item) => `${item.id}.png`, + spacer: () => "transparent.png", + }, + ) + const item = menu.find((item) => item.label === "View all sessions…") + expect(item).toBeDefined() + expect(item && "icon" in item).toBe(false) + }) + test("places an icon before every session and footer action", () => { + const actions = { open() {}, trigger() {}, docs() {}, quit() {}, session() {} } + const menu = trayMenu( + actions, + { ...empty, sessions: [{ id: "session", directory: "/repo", status: "idle", cost: 0 }] }, + { + action: (name) => `${name}.png`, + session: (item) => `${item.id}.png`, + spacer: () => "transparent.png", + }, + ) + expect(menu.filter((item) => item.click).map((item) => item.icon)).toEqual([ + "session.png", + "newAgent.png", + "settings.png", + "docs.png", + "quit.png", + ]) + expect(menu.some((item) => item.label === "repo · No context · $0.00")).toBe(false) + }) + test("provides real app actions without starting model execution", () => { + const actions: string[] = [] + const menu = trayMenu( + { + open: () => actions.push("open"), + trigger: (id) => actions.push(id), + docs: () => actions.push("docs"), + quit: () => actions.push("quit"), + session: (id) => actions.push(id), + }, + empty, + ) + expect(menu.flatMap((item) => item.label ?? [])).toEqual([ + "No active sessions", + "New Agent…", + "Settings", + "Docs", + "Quit", + ]) + menu.forEach((item) => item.click?.()) + expect(actions).toEqual(["tab.new", "settings.open", "docs", "quit"]) + }) + + test("shows the grouped summary before actions and opens the exact session", () => { + const opened: string[] = [] + const actions = { open() {}, trigger() {}, docs() {}, quit() {}, session: (id: string) => opened.push(id) } + const snapshot: TraySessions = { + ...empty, + attention: 1, + working: 1, + sessions: [ + { + id: "question", + title: "Fix login", + directory: "/code/dashboard", + status: "question", + context: 52, + cost: 0.12, + placement: { type: "worktree", directory: "/code/worktrees/login-fix" }, + }, + { id: "working", title: "CSV export", directory: "C:\\code\\api", status: "working", tokens: 1024, cost: 1.25 }, + { + id: "idle", + directory: "/code/cli", + status: "idle", + cost: 0, + placement: { type: "local", directory: "/code/cli" }, + }, + ], + } + const menu = trayMenu(actions, snapshot) + expect(menu.flatMap((item) => item.label ?? []).slice(0, 5)).toEqual([ + "Needs you", + "Fix login", + "Active sessions", + "CSV export", + "Untitled session", + ]) + expect(menu.some((item) => item.label === "Working")).toBe(false) + const session = menu.find((item) => "id" in item && item.id === "session:question") + expect(session && "sublabel" in session && session.sublabel).toBe("dashboard · Context 52% · $0.12") + expect(session && "toolTip" in session && session.toolTip).toContain("Worktree · login-fix\n/code/dashboard") + expect(session && "toolTip" in session && session.toolTip).toContain("Context 52%\nCost $0.12") + expect(session && "toolTip" in session && session.toolTip).not.toContain("Context:") + session?.click?.() + expect(opened).toEqual(["question"]) + expect(menu.some((item) => item.label === "dashboard · Context 52% · $0.12")).toBe(false) + const local = menu.find((item) => "id" in item && item.id === "session:idle") + expect(local && "toolTip" in local && local.toolTip).toContain("Local\n/code/cli") + }) + + test("retains aligned details where native sublabels are unavailable", () => { + const actions = { open() {}, trigger() {}, docs() {}, quit() {}, session() {} } + const menu = trayMenu( + actions, + { ...empty, sessions: [{ id: "session", directory: "/repo", status: "idle", cost: 0 }] }, + { + action: (name) => `${name}.png`, + session: () => "project.png", + spacer: () => "transparent.png", + }, + false, + ) + expect(menu.find((item) => item.label === "repo · No context · $0.00")?.icon).toBe("transparent.png") + }) + + test("uses the native shortcut column instead of adding shortcuts to action labels", () => { + const actions = { open() {}, trigger() {}, docs() {}, quit() {}, session() {} } + const menu = trayMenu(actions, empty) + expect( + menu + .filter((item) => item.click) + .map((item) => [item.label, "accelerator" in item ? item.accelerator : undefined]), + ).toEqual([ + ["New Agent…", "CommandOrControl+N"], + ["Settings", "CommandOrControl+,"], + ["Docs", undefined], + ["Quit", "CommandOrControl+Q"], + ]) + }) + + test("does not claim stale sessions are live after a connection failure", () => { + const actions = { open() {}, trigger() {}, docs() {}, quit() {}, session() {} } + const menu = trayMenu(actions, { + ...empty, + state: "offline", + sessions: [{ id: "old", title: "Cached session", directory: "/repo", status: "working", cost: 0 }], + }) + expect(menu[0].label).toBe("Unable to refresh sessions") + expect(menu[1].label).toBe("Showing the last available update") + expect(menu.some((item) => item.label === "New Agent…")).toBe(true) + expect(trayMenu(actions, { ...empty, state: "loading" })[0].label).toBe("Loading sessions…") + }) +}) diff --git a/packages/desktop/src/main/native/tray-menu.ts b/packages/desktop/src/main/native/tray-menu.ts new file mode 100644 index 00000000000..e636fda43f5 --- /dev/null +++ b/packages/desktop/src/main/native/tray-menu.ts @@ -0,0 +1,106 @@ +import type { MenuItemConstructorOptions } from "electron" +import { nativeCost, nativeT } from "./translations" +import { traySessionGroup, type TraySession, type TraySessions } from "./tray-sessions" +import type { TrayActionIcon } from "./tray-icon-art" +import { TRAY_LABEL_WIDTH, trayLabel, trayLabelWidth } from "./tray-label" + +type TrayMenuIcons = { + action: (action: TrayActionIcon) => MenuItemConstructorOptions["icon"] + session: (session: TraySession, twoLines: boolean) => MenuItemConstructorOptions["icon"] + spacer: () => MenuItemConstructorOptions["icon"] +} + +export type TrayActions = { + open: () => void + trigger: (id: string) => void + docs: () => void + quit: () => void + session: (id: string) => void +} + +export function trayMenu(actions: TrayActions, snapshot: TraySessions, icons?: TrayMenuIcons, sublabels = true) { + const items = snapshot.sessions.flatMap((session, index) => { + const group = traySessionGroup(session.status) + const previous = snapshot.sessions[index - 1] + const project = session.directory.replaceAll("\\", "/").split("/").filter(Boolean).at(-1) ?? session.directory + const context = + session.context !== undefined + ? nativeT("desktop.tray.context.percent", { percent: session.context }) + : session.tokens !== undefined + ? nativeT("desktop.tray.context.tokens", { tokens: session.tokens }) + : nativeT("desktop.tray.context.unknown") + const cost = nativeCost(session.cost) + const placement = !session.placement + ? nativeT("desktop.tray.tooltip.placementUnknown") + : session.placement.type === "local" + ? nativeT("desktop.tray.tooltip.local") + : nativeT("desktop.tray.tooltip.worktree", { + name: + session.placement.directory.replaceAll("\\", "/").split("/").filter(Boolean).at(-1) ?? + session.placement.directory, + }) + const remaining = + TRAY_LABEL_WIDTH - trayLabelWidth(nativeT("desktop.tray.session.detail", { project: "", context, cost })) + const detail = nativeT("desktop.tray.session.detail", { + project: trayLabel(project, remaining), + context, + cost, + }) + return [ + ...(!previous || traySessionGroup(previous.status) !== group + ? [{ label: nativeT(`desktop.tray.group.${group}`), enabled: false }] + : []), + { + id: `session:${session.id}`, + icon: icons?.session(session, sublabels), + label: trayLabel(session.title ?? "") || nativeT("desktop.tray.untitled"), + sublabel: sublabels ? detail : undefined, + toolTip: [ + session.title, + placement, + session.directory, + session.detail, + session.model, + context, + nativeT("desktop.tray.tooltip.cost", { cost }), + ] + .filter(Boolean) + .join("\n"), + click: () => actions.session(session.id), + }, + // macOS sublabels belong to the same native item, including its hover + // highlight and click target. Retain detail rows on platforms without them. + ...(sublabels ? [] : [{ label: detail, icon: icons?.spacer(), enabled: false }]), + ] + }) + return [ + ...(snapshot.state !== "ready" ? [{ label: nativeT(`desktop.tray.${snapshot.state}`), enabled: false }] : []), + ...(snapshot.state === "offline" && snapshot.sessions.length + ? [{ label: nativeT("desktop.tray.stale"), enabled: false }] + : []), + ...items, + ...(snapshot.state === "ready" && !items.length ? [{ label: nativeT("desktop.tray.empty"), enabled: false }] : []), + ...(snapshot.more ? [{ label: nativeT("desktop.tray.more"), click: actions.open }] : []), + { type: "separator" }, + { + label: nativeT("desktop.tray.newAgent"), + icon: icons?.action("newAgent"), + accelerator: "CommandOrControl+N", + click: () => actions.trigger("tab.new"), + }, + { + label: nativeT("desktop.menu.settings"), + icon: icons?.action("settings"), + accelerator: "CommandOrControl+,", + click: () => actions.trigger("settings.open"), + }, + { label: nativeT("desktop.tray.docs"), icon: icons?.action("docs"), click: actions.docs }, + { type: "separator" }, + { + label: nativeT("desktop.tray.quit"), + icon: icons?.action("quit"), + accelerator: "CommandOrControl+Q", + click: actions.quit, + }, + ] satisfies MenuItemConstructorOptions[] +} diff --git a/packages/desktop/src/main/native/tray-sessions.test.ts b/packages/desktop/src/main/native/tray-sessions.test.ts new file mode 100644 index 00000000000..2f48537d142 --- /dev/null +++ b/packages/desktop/src/main/native/tray-sessions.test.ts @@ -0,0 +1,246 @@ +import { describe, expect, test } from "bun:test" +import { OpenCode, type SessionInfo, type SessionMessageAssistant } from "@opencode/client" +import { loadTraySessions } from "./tray-sessions" + +const tokens = { input: 900, output: 100, reasoning: 10, cache: { read: 50, write: 40 } } + +function session(id: string, updated: number, extra: Partial = {}): SessionInfo { + return { + id, + title: id, + projectID: "project", + location: { directory: "/code/project/src" }, + cost: 4.56, + tokens: { ...tokens, input: 999_999 }, + time: { created: 1, updated }, + ...extra, + } +} + +// Exercise the generated client's real HTTP decoding as well as the tray's +// inventory logic, without running a provider or creating durable sessions. +function fixture( + input: { + many?: boolean + detailsUnavailable?: boolean + offline?: boolean + worktree?: boolean + placementUnavailable?: boolean + finished?: number + viewed?: number + running?: boolean + } = {}, +) { + const requests: string[] = [] + const inventory = input.many + ? Array.from({ length: 12 }, (_, index) => session(`recent-${index}`, 100 - index)) + : [ + session("idle", 100, { outcome: "succeeded" }), + session("failed", 90, { outcome: "failed" }), + session("permission", 80), + session("question", 70), + session("archived", 60, { time: { created: 1, updated: 60, archived: 70 } }), + ] + const message: SessionMessageAssistant = { + id: "message", + type: "assistant", + time: { created: 10, completed: 20 }, + agent: "build", + model: { providerID: "provider", id: "model" }, + content: [], + tokens, + } + const server = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + fetch(request) { + const url = new URL(request.url) + requests.push(url.pathname) + if (input.offline) return new Response("Unavailable", { status: 503 }) + if (url.pathname === "/api/project") + return Response.json([ + { + id: "project", + canonical: "/code/another-clone", + name: "Dashboard", + icon: { url: "data:image/png;base64,fixture" }, + }, + ]) + if (url.pathname === "/api/location") { + if (input.placementUnavailable) return new Response("Unavailable", { status: 503 }) + return Response.json({ + directory: "/code/project/src", + project: { + id: "project", + directory: "/code/project", + canonical: input.worktree ? "/code/main" : "/code/project", + }, + }) + } + if (url.pathname === "/api/session") return Response.json({ data: inventory, cursor: {} }) + if (url.pathname === "/api/session/active") + return Response.json({ + data: { + older: { type: "running" }, + permission: { type: "running" }, + ...(input.running ? { idle: { type: "running" } } : {}), + }, + }) + if (url.pathname === "/api/session/older") + return Response.json({ data: session("older", 2, { parentID: "parent" }) }) + if (url.pathname === "/api/session/permission") return Response.json({ data: session("permission", 3) }) + const found = inventory.find((item) => url.pathname === `/api/session/${item.id}`) + if (found) + return Response.json({ + data: { ...found, time: { ...found.time, idle: input.finished, viewed: input.viewed } }, + }) + if (url.pathname.endsWith("/form")) + return Response.json({ + data: url.pathname.includes("/question/") + ? [{ id: "form", sessionID: "question", title: "Which approach?", fields: [] }] + : [], + }) + if (url.pathname.endsWith("/permission")) + return Response.json({ + data: url.pathname.includes("/permission/") + ? [{ id: "permission", sessionID: "permission", action: "shell", resources: [] }] + : [], + }) + if (url.pathname.endsWith("/message")) { + if (input.detailsUnavailable) return new Response("Unavailable", { status: 503 }) + expect(url.searchParams.get("order")).toBe("desc") + expect(url.searchParams.get("limit")).toBe("10") + return Response.json({ + data: [message, { ...message, id: "older-message", tokens: { ...tokens, input: 1 } }], + cursor: {}, + }) + } + if (url.pathname === "/api/model") + return Response.json({ + location: {}, + data: [{ id: "model", providerID: "provider", name: "Example Model", limit: { context: 2000 } }], + }) + return new Response("Not found", { status: 404 }) + }, + }) + return { + client: OpenCode.make({ baseUrl: server.url.toString() }), + requests, + [Symbol.dispose]() { + server.stop(true) + }, + } +} + +describe("tray session inventory", () => { + test.each([ + { finished: undefined, viewed: undefined, unread: false }, + { finished: 20, viewed: undefined, unread: true }, + { finished: 20, viewed: 10, unread: true }, + { finished: 20, viewed: 20, unread: false }, + { finished: 20, viewed: 30, unread: false }, + { finished: 20, viewed: 10, running: true, unread: false }, + ])("uses the completion/viewed watermarks for unread state: %j", async (input) => { + using server = fixture(input) + const result = await loadTraySessions(server.client, ["idle"], AbortSignal.timeout(5000)) + expect(result.sessions[0].unread).toBe(input.unread) + }) + + test("clears the completion badge on viewing and badges a later completion again", async () => { + const state: { finished: number; viewed?: number } = { finished: 20 } + using server = fixture(state) + const load = () => loadTraySessions(server.client, ["idle"], AbortSignal.timeout(5000)) + expect((await load()).sessions[0].unread).toBe(true) + state.viewed = 20 + expect((await load()).sessions[0].unread).toBe(false) + state.finished = 30 + expect((await load()).sessions[0].unread).toBe(true) + }) + test("identifies worktrees by their resolved checkout root, including nested session directories", async () => { + using server = fixture({ worktree: true }) + const result = await loadTraySessions(server.client, ["idle"], AbortSignal.timeout(5000)) + expect(result.sessions[0].directory).toBe("/code/project/src") + expect(result.sessions[0].placement).toEqual({ type: "worktree", directory: "/code/project" }) + }) + + test("does not guess Local when placement cannot be resolved", async () => { + using server = fixture({ placementUnavailable: true }) + const result = await loadTraySessions(server.client, ["idle"], AbortSignal.timeout(5000)) + expect(result.state).toBe("ready") + expect(result.sessions[0].placement).toBeUndefined() + }) + test("loads open tabs only and prioritizes pending input over activity", async () => { + using server = fixture() + const result = await loadTraySessions( + server.client, + ["idle", "failed", "permission", "question", "older", "archived"], + AbortSignal.timeout(5000), + ) + expect(result.state).toBe("ready") + expect(result.sessions.map((item) => [item.id, item.status])).toEqual([ + ["permission", "permission"], + ["question", "question"], + ["idle", "idle"], + ["failed", "failed"], + ["older", "working"], + ]) + expect(result.attention).toBe(2) + expect(result.working).toBe(1) + expect(result.sessions[0].detail).toBe("shell") + expect(result.sessions[1].detail).toBe("Which approach?") + expect(result.sessions[0].tokens).toBe(1100) + expect(result.sessions[0].cost).toBe(4.56) + expect(result.sessions[0].placement).toEqual({ type: "local", directory: "/code/project" }) + expect(result.sessions[0].context).toBe(55) + expect(result.sessions[0].model).toBe("Example Model") + expect(result.sessions[0].project).toMatchObject({ + id: "project", + name: "Dashboard", + icon: { url: "data:image/png;base64,fixture" }, + }) + expect(server.requests.filter((path) => path === "/api/model")).toHaveLength(1) + expect(server.requests.filter((path) => path === "/api/location")).toHaveLength(1) + expect(server.requests.some((path) => path.includes("/archived/"))).toBe(false) + expect(server.requests).not.toContain("/api/session") + }) + + test("shows all open tabs, preserving their order within the active group", async () => { + using server = fixture({ many: true }) + const ids = Array.from({ length: 12 }, (_, index) => `recent-${11 - index}`) + const result = await loadTraySessions(server.client, ids, AbortSignal.timeout(5000)) + expect(result.sessions.map((item) => item.id)).toEqual(ids) + expect(result.more).toBe(false) + expect(result.working).toBe(0) + expect(server.requests).not.toContain("/api/session/older") + expect(server.requests).not.toContain("/api/session/permission") + }) + + test("does not fetch history or running sessions when there are no open tabs", async () => { + using server = fixture() + const result = await loadTraySessions(server.client, [], AbortSignal.timeout(5000)) + expect(result.sessions).toEqual([]) + expect(result.state).toBe("ready") + expect(server.requests).toEqual([]) + }) + + test("deduplicates sessions open in multiple windows", async () => { + using server = fixture() + const result = await loadTraySessions(server.client, ["idle", "idle"], AbortSignal.timeout(5000)) + expect(result.sessions.map((item) => item.id)).toEqual(["idle"]) + expect(server.requests.filter((path) => path === "/api/session/idle")).toHaveLength(1) + }) + + test("keeps status when optional usage is unavailable and does not invent a percentage", async () => { + using server = fixture({ detailsUnavailable: true }) + const result = await loadTraySessions(server.client, ["permission"], AbortSignal.timeout(5000)) + expect(result.sessions[0].status).toBe("permission") + expect(result.sessions[0].tokens).toBeUndefined() + expect(result.sessions[0].cost).toBe(4.56) + expect(result.sessions[0].context).toBeUndefined() + }) + + test("fails the refresh instead of turning a disconnected server into an empty list", async () => { + using server = fixture({ offline: true }) + await expect(loadTraySessions(server.client, ["idle"], AbortSignal.timeout(5000))).rejects.toThrow() + }) +}) diff --git a/packages/desktop/src/main/native/tray-sessions.ts b/packages/desktop/src/main/native/tray-sessions.ts new file mode 100644 index 00000000000..9933522716b --- /dev/null +++ b/packages/desktop/src/main/native/tray-sessions.ts @@ -0,0 +1,150 @@ +import type { LocationPublicInfo, OpenCode, ModelInfo, ProjectIcon, SessionInfo } from "@opencode/client" +import type { TrayAvatar } from "../../shared/tray-avatar" + +export type TraySession = { + id: string + title?: string + directory: string + cost: number + status: "question" | "permission" | "working" | "failed" | "interrupted" | "idle" + detail?: string + tokens?: number + context?: number + model?: string + project?: { id: string; name?: string; icon?: ProjectIcon } + placement?: { type: "local" | "worktree"; directory: string } + avatar?: TrayAvatar + unread?: boolean +} + +export type TraySessions = { + state: "loading" | "ready" | "offline" + sessions: TraySession[] + working: number + attention: number + more: boolean +} + +export function traySessionGroup(status: TraySession["status"]) { + if (status === "question" || status === "permission") return "attention" + return "active" +} + +export function traySessionNotification(session: TraySession) { + return ( + session.status === "question" || + session.status === "permission" || + (session.status !== "working" && !!session.unread) + ) +} + +export async function loadTraySessions( + client: ReturnType, + sessionIDs: readonly string[], + signal: AbortSignal, +): Promise { + if (!sessionIDs.length) return { state: "ready", sessions: [], working: 0, attention: 0, more: false } + const options = { signal } + const [tabs, active, projects] = await Promise.all([ + Promise.all([...new Set(sessionIDs)].map((sessionID) => client.session.get({ sessionID }, options))), + client.session.active(options), + client.project.list(options).catch(() => []), + ]) + const sessions = tabs.filter((session) => !session.time.archived) + const byProject = new Map(projects.map((project) => [project.id, project])) + const roots = new Map>() + const rows = await Promise.all( + sessions.map(async (session) => { + const key = JSON.stringify(session.location) + const root = + roots.get(key) ?? + client.location.get({ location: { directory: session.location.directory } }, options).catch(() => undefined) + roots.set(key, root) + const [forms, permissions, current] = await Promise.all([ + client.session.form.list({ sessionID: session.id }, options), + client.permission.list({ sessionID: session.id }, options), + root, + ]) + const status: TraySession["status"] = forms.length + ? "question" + : permissions.length + ? "permission" + : active[session.id] + ? "working" + : session.outcome === "failed" + ? "failed" + : session.outcome === "interrupted" + ? "interrupted" + : "idle" + return { + session, + row: { + id: session.id, + title: session.title, + directory: session.location.directory, + cost: session.cost, + status, + unread: + !active[session.id] && + session.time.idle !== undefined && + (session.time.viewed === undefined || session.time.idle > session.time.viewed), + detail: forms[0]?.title ?? permissions[0]?.action, + project: byProject.get(session.projectID), + // Resolve the checkout root, not the session's subdirectory or the + // project-list canonical path (which may belong to another clone). + placement: current + ? { + type: current.project.directory === current.project.canonical ? "local" : "worktree", + directory: current.project.directory, + } + : undefined, + } satisfies TraySession, + } + }), + ) + const priority = { attention: 0, active: 1 } + rows.sort((a, b) => priority[traySessionGroup(a.row.status)] - priority[traySessionGroup(b.row.status)]) + const catalogs = new Map>() + return { + state: "ready", + working: rows.filter((item) => item.row.status === "working").length, + attention: rows.filter((item) => traySessionGroup(item.row.status) === "attention").length, + more: false, + sessions: await Promise.all( + rows.map(async ({ session, row }): Promise => { + const messages = await client.message + .list({ sessionID: session.id, order: "desc", limit: 10 }, options) + .catch(() => undefined) + const message = messages?.data.find((item) => item.type === "assistant" && item.tokens) + if (message?.type !== "assistant" || !message.tokens) return row + const key = JSON.stringify(session.location) + const catalog = catalogs.get(key) ?? models(session) + catalogs.set(key, catalog) + const model = (await catalog).find( + (item) => item.id === message.model.id && item.providerID === message.model.providerID, + ) + // Match the context indicator in the app: use the most recent measured + // assistant step, never the session's cumulative usage counters. + const tokens = + message.tokens.input + + message.tokens.output + + message.tokens.reasoning + + message.tokens.cache.read + + message.tokens.cache.write + return { + ...row, + tokens, + context: model?.limit.context ? Math.round((tokens / model.limit.context) * 100) : undefined, + model: model?.name ?? message.model.id, + } + }), + ), + } + + function models(session: SessionInfo) { + return client.model + .list({ location: { directory: session.location.directory } }, options) + .then((result) => result.data) + .catch(() => []) + } +} diff --git a/packages/desktop/src/main/native/tray-shortcut.test.ts b/packages/desktop/src/main/native/tray-shortcut.test.ts new file mode 100644 index 00000000000..dcf742d0d46 --- /dev/null +++ b/packages/desktop/src/main/native/tray-shortcut.test.ts @@ -0,0 +1,48 @@ +import { expect, test } from "bun:test" +import { registerTrayShortcut, TRAY_SHORTCUT } from "./tray-shortcut" + +test("registers Command/Control+Alt+O and invokes the tray opener", () => { + const callbacks = new Map void>() + const opened: string[] = [] + const released: string[] = [] + const shortcut = registerTrayShortcut( + { + register(key, callback) { + callbacks.set(key, callback) + return true + }, + unregister(key) { + released.push(key) + callbacks.delete(key) + }, + }, + () => { + opened.push("tray") + }, + ) + expect(TRAY_SHORTCUT).toBe("CommandOrControl+Alt+O") + expect(shortcut.registered).toBe(true) + callbacks.get(TRAY_SHORTCUT)?.() + expect(opened).toEqual(["tray"]) + shortcut.dispose() + shortcut.dispose() + expect(released).toEqual([TRAY_SHORTCUT]) + expect(shortcut.registered).toBe(false) + expect(callbacks.size).toBe(0) +}) + +test("does not unregister a shortcut it could not acquire", () => { + const released: string[] = [] + const shortcut = registerTrayShortcut( + { + register: () => false, + unregister: (key) => { + released.push(key) + }, + }, + () => {}, + ) + expect(shortcut.registered).toBe(false) + shortcut.dispose() + expect(released).toEqual([]) +}) diff --git a/packages/desktop/src/main/native/tray-shortcut.ts b/packages/desktop/src/main/native/tray-shortcut.ts new file mode 100644 index 00000000000..65fb2eac48d --- /dev/null +++ b/packages/desktop/src/main/native/tray-shortcut.ts @@ -0,0 +1,18 @@ +export const TRAY_SHORTCUT = "CommandOrControl+Alt+O" + +export function registerTrayShortcut( + registry: { register: (key: string, callback: () => void) => boolean; unregister: (key: string) => void }, + open: () => void, +) { + const state = { registered: registry.register(TRAY_SHORTCUT, open) } + return { + get registered() { + return state.registered + }, + dispose() { + if (!state.registered) return + registry.unregister(TRAY_SHORTCUT) + state.registered = false + }, + } +} diff --git a/packages/desktop/src/main/native/tray-tabs.test.ts b/packages/desktop/src/main/native/tray-tabs.test.ts new file mode 100644 index 00000000000..5b7cc019a04 --- /dev/null +++ b/packages/desktop/src/main/native/tray-tabs.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, test } from "bun:test" +import { createTrayTabs } from "./tray-tabs" +import type { TrayAvatar } from "../../shared/tray-avatar" + +describe("tray tab inventory", () => { + test("publishes avatar changes even when the same tabs remain open", () => { + const tabs = createTrayTabs() + const avatar: TrayAvatar = { + name: "Project", + background: "#263fa9", + border: "#7698fd", + foreground: "#fff", + highlight: "#ffffff29", + accent: "#3b5cf6", + } + const changes: string[] = [] + tabs.subscribe(() => changes.push(tabs.avatar("a")?.background ?? "none")) + tabs.set(1, ["a"], { a: avatar }) + tabs.set(1, ["a"], { a: { ...avatar } }) + tabs.set(1, ["a"], { a: { ...avatar, background: "#3250df" } }) + expect(changes).toEqual(["#263fa9", "#3250df"]) + tabs.remove(1) + expect(tabs.avatar("a")).toBeUndefined() + }) + test("deduplicates open sessions across windows and preserves tab order", () => { + const tabs = createTrayTabs() + tabs.set(1, ["b", "a", "b"]) + tabs.set(2, ["a", "c"]) + expect(tabs.sessions()).toEqual(["b", "a", "c"]) + expect(tabs.owner("a", 2)).toBe(2) + expect(tabs.owner("b", 2)).toBe(1) + }) + + test("removes closed tabs and windows without removing a tab still open elsewhere", () => { + const tabs = createTrayTabs() + tabs.set(1, ["a", "b"]) + tabs.set(2, ["b", "c"]) + tabs.set(1, ["b"]) + expect(tabs.sessions()).toEqual(["b", "c"]) + expect(tabs.owner("a")).toBeUndefined() + tabs.remove(1) + expect(tabs.sessions()).toEqual(["b", "c"]) + tabs.remove(2) + expect(tabs.sessions()).toEqual([]) + }) + + test("notifies only when the inventory changes and releases subscribers", () => { + const tabs = createTrayTabs() + const changes: string[][] = [] + const unsubscribe = tabs.subscribe(() => changes.push(tabs.sessions())) + tabs.set(1, ["a"]) + tabs.set(1, ["a"]) + tabs.set(1, ["b"]) + tabs.remove(1) + tabs.remove(1) + unsubscribe() + tabs.set(2, ["c"]) + expect(changes).toEqual([["a"], ["b"], []]) + }) +}) diff --git a/packages/desktop/src/main/native/tray-tabs.ts b/packages/desktop/src/main/native/tray-tabs.ts new file mode 100644 index 00000000000..be83953fef6 --- /dev/null +++ b/packages/desktop/src/main/native/tray-tabs.ts @@ -0,0 +1,38 @@ +import type { TrayAvatar } from "../../shared/tray-avatar" + +// Tabs belong to client windows, not to the server's session history. +export function createTrayTabs() { + const windows = new Map }>() + const listeners = new Set<() => void>() + return { + set(windowID: number, sessionIDs: readonly string[], avatars: Record = {}) { + const next = { ids: [...new Set(sessionIDs)], avatars } + const previous = windows.get(windowID) + if (JSON.stringify(previous) === JSON.stringify(next)) return + windows.set(windowID, next) + listeners.forEach((listener) => listener()) + }, + remove(windowID: number) { + if (windows.delete(windowID)) listeners.forEach((listener) => listener()) + }, + sessions() { + return [...new Set([...windows.values()].flatMap((window) => window.ids))] + }, + owner(sessionID: string, preferred?: number) { + if (preferred !== undefined && windows.get(preferred)?.ids.includes(sessionID)) return preferred + return [...windows].find(([, window]) => window.ids.includes(sessionID))?.[0] + }, + avatar(sessionID: string) { + return [...windows.values()].find((window) => window.ids.includes(sessionID) && window.avatars[sessionID]) + ?.avatars[sessionID] + }, + subscribe(listener: () => void) { + listeners.add(listener) + return () => { + listeners.delete(listener) + } + }, + } +} + +export const trayTabs = createTrayTabs() diff --git a/packages/desktop/src/main/native/tray.ts b/packages/desktop/src/main/native/tray.ts new file mode 100644 index 00000000000..b3450b13173 --- /dev/null +++ b/packages/desktop/src/main/native/tray.ts @@ -0,0 +1,145 @@ +import { globalShortcut, Menu, nativeImage, nativeTheme, Tray } from "electron" +import type { NativeImage } from "electron" +import log from "electron-log/main.js" +import { TRAY_ENABLED_KEY } from "../storage/keys" +import { getStore } from "../storage/store" +import { nativeT } from "./translations" +import { trayMenu, type TrayActions } from "./tray-menu" +import { traySessionNotification, type TraySessions } from "./tray-sessions" +import type { TrayIcons } from "./tray-icons" +import { trayTabs } from "./tray-tabs" +import { trayStartupPixels } from "./tray-icon-art" +import { registerTrayShortcut, TRAY_SHORTCUT } from "./tray-shortcut" + +let tray: Tray | undefined +let actions: TrayActions | undefined +let icons: TrayIcons | undefined +let snapshot: TraySessions = { state: "loading", sessions: [], working: 0, attention: 0, more: false } +let menuOpen = false +let shortcut: ReturnType | undefined + +export function initializeTray() { + if (tray || !getTrayEnabled()) return () => {} + const image = nativeImage.createFromBitmap(trayStartupPixels(nativeTheme.shouldUseDarkColors ? 255 : 32), { + width: 36, + height: 36, + scaleFactor: 2, + }) + image.setTemplateImage(process.platform === "darwin") + tray = createTray(image) + tray.setToolTip(nativeT("desktop.menu.app")) + const menu = Menu.buildFromTemplate([ + { label: nativeT("desktop.tray.starting"), enabled: false }, + { type: "separator" }, + { label: nativeT("desktop.tray.quit"), role: "quit" }, + ]) + menu.on("menu-will-show", () => { + menuOpen = true + }) + menu.on("menu-will-close", () => { + menuOpen = false + }) + tray.setContextMenu(menu) + return destroyTray +} + +export function setTraySessions(next?: TraySessions) { + const current = next ?? { ...snapshot, state: "offline" as const } + const tabs = new Set(trayTabs.sessions()) + const sessions = current.sessions + .filter((session) => tabs.has(session.id)) + .map((session) => ({ ...session, avatar: trayTabs.avatar(session.id) })) + // Closing a tab takes effect immediately, including when an older poll is + // still in flight. It must not put a closed session back into the menu. + snapshot = { + ...current, + state: tabs.size ? current.state : "ready", + sessions, + working: sessions.filter((session) => session.status === "working").length, + attention: sessions.filter((session) => session.status === "question" || session.status === "permission").length, + } + if (icons) tray?.setImage(icons.app(snapshot.sessions.some(traySessionNotification))) + // Don't move rows underneath the pointer while a native menu is open. + if (!menuOpen) updateTray() +} + +export function syncTrayTabs() { + setTraySessions(snapshot) +} + +export function getTrayEnabled() { + return getStore().get(TRAY_ENABLED_KEY) !== false +} + +export function hasTray() { + return tray !== undefined +} + +export function setTrayEnabled(enabled: boolean) { + getStore().set(TRAY_ENABLED_KEY, enabled) + updateTray() +} + +export function startTray(next: TrayActions, artwork: TrayIcons) { + actions = next + icons = artwork + updateTray() + nativeTheme.on("updated", updateTray) + return () => { + nativeTheme.off("updated", updateTray) + destroyTray() + actions = undefined + icons = undefined + menuOpen = false + } +} + +export function updateTray() { + if (!actions || !icons) return + if (!getTrayEnabled()) { + destroyTray() + return + } + if (!tray) tray = createTray(icons.app(snapshot.sessions.some(traySessionNotification))) + tray.setImage(icons.app(snapshot.sessions.some(traySessionNotification))) + tray.setToolTip( + snapshot.state === "ready" + ? nativeT("desktop.tray.summary", { working: snapshot.working, attention: snapshot.attention }) + : nativeT(`desktop.tray.${snapshot.state}`), + ) + const version = process.platform === "darwin" ? process.getSystemVersion().split(".").map(Number) : [] + const sublabels = process.platform === "darwin" && (version[0] > 14 || (version[0] === 14 && version[1] >= 4)) + const menu = Menu.buildFromTemplate(trayMenu(actions, snapshot, icons, sublabels)) + menu.on("menu-will-show", () => { + menuOpen = true + }) + menu.on("menu-will-close", () => { + menuOpen = false + // Replacing a menu during its close event can swallow its selected action. + setImmediate(() => updateTray()) + }) + tray.setContextMenu(menu) +} + +export function trayOnly() { + return process.argv.includes("--tray") || process.env.OPENCODE_DESKTOP_TRAY_ONLY === "1" +} + +function createTray(image: NativeImage) { + const item = new Tray(image) + if (process.platform === "win32") item.on("click", () => item.popUpContextMenu()) + shortcut = registerTrayShortcut(globalShortcut, () => { + if (!menuOpen) item.popUpContextMenu() + }) + if (!shortcut.registered) + log.warn("Unable to register tray shortcut; it may be in use by another application", { shortcut: TRAY_SHORTCUT }) + return item +} + +function destroyTray() { + shortcut?.dispose() + shortcut = undefined + tray?.destroy() + tray = undefined + menuOpen = false +} diff --git a/packages/desktop/src/main/service/background-service.ts b/packages/desktop/src/main/service/background-service.ts index 56b7fdd44c1..825bf23d169 100644 --- a/packages/desktop/src/main/service/background-service.ts +++ b/packages/desktop/src/main/service/background-service.ts @@ -28,6 +28,13 @@ export const layer = Layer.effect( ) const connect = Effect.fn("BackgroundService.connect")(function* (mode: "initial" | "reconnect") { + // Explicit development connections never elect or replace a managed server. + if (!app.isPackaged && process.env.OPENCODE_DESKTOP_SERVER_URL) { + return { + url: new URL(process.env.OPENCODE_DESKTOP_SERVER_URL).origin, + password: process.env.OPENCODE_DESKTOP_SERVER_PASSWORD ?? null, + } + } yield* Effect.logInfo("starting v2 background service") const path = yield* Path.Path const desktopCli = yield* DesktopCli.Service diff --git a/packages/desktop/src/main/storage/keys.ts b/packages/desktop/src/main/storage/keys.ts index 5604a7f189d..936cc7986b5 100644 --- a/packages/desktop/src/main/storage/keys.ts +++ b/packages/desktop/src/main/storage/keys.ts @@ -6,4 +6,5 @@ export const PINCH_ZOOM_ENABLED_KEY = "pinchZoomEnabled" export const KEEP_SCREEN_ACTIVE_KEY = "keepScreenActive" export const BACKGROUND_COLOR_KEY = "backgroundColor" export const WINDOW_IDS_KEY = "windowIds" -export const BUNDLED_CLI_VERSION_KEY = "bundledCliVersion" +export const BUNDLED_CLI_VERSION_KEY = "bundledCliVersion" +export const TRAY_ENABLED_KEY = "trayEnabled" diff --git a/packages/desktop/src/main/terminal-tray-entry.ts b/packages/desktop/src/main/terminal-tray-entry.ts new file mode 100644 index 00000000000..b81371e14f1 --- /dev/null +++ b/packages/desktop/src/main/terminal-tray-entry.ts @@ -0,0 +1,7 @@ +import { app } from "electron" +import { runTerminalTray } from "./terminal-tray" + +void runTerminalTray().catch(() => { + console.error("Could not start terminal tray companion") + app.exit(1) +}) diff --git a/packages/desktop/src/main/terminal-tray.ts b/packages/desktop/src/main/terminal-tray.ts new file mode 100644 index 00000000000..3c1318775fb --- /dev/null +++ b/packages/desktop/src/main/terminal-tray.ts @@ -0,0 +1,184 @@ +import { app, Menu, shell } from "electron" +import { OpenCode } from "@opencode/client" +import { Service } from "@opencode/client/service" +import { TerminalTray } from "@opencode/schema/terminal-tray" +import { Schema } from "effect" +import { readFile, mkdir } from "node:fs/promises" +import { join } from "node:path" +import { createTrayIcons } from "./native/tray-icons" +import { getTrayEnabled, initializeTray, setTrayEnabled, setTraySessions, startTray } from "./native/tray" +import { loadTraySessions, type TraySession } from "./native/tray-sessions" +import { trayTabs } from "./native/tray-tabs" + +const decodeConnection = Schema.decodeUnknownSync(Schema.fromJsonString(TerminalTray.Connection)) +const decodeSnapshot = Schema.decodeUnknownSync(TerminalTray.Snapshot) + +type Terminal = { + owner: number + connection: TerminalTray.Connection + snapshot?: TerminalTray.Snapshot + rows: TraySession[] + loaded: number + signature?: string + changed: number + offline: boolean +} + +export async function runTerminalTray() { + app.setName("OpenCode") + app.setPath( + "userData", + process.env.OPENCODE_DESKTOP_TEST_ROOT + ? join(process.env.OPENCODE_DESKTOP_TEST_ROOT, "terminal-tray") + : join(app.getPath("appData"), app.isPackaged ? "ai.opencode.terminal-tray" : "ai.opencode.terminal-tray.dev"), + ) + await mkdir(app.getPath("userData"), { recursive: true }) + if (!app.requestSingleInstanceLock()) { + app.quit() + return + } + const terminals = new Map() + const controller = new AbortController() + const runtime = { owner: -1, timer: undefined as ReturnType | undefined } + const attach = async (argv: string[]) => { + const file = argv.find((arg) => arg.startsWith("--terminal-tray="))?.slice("--terminal-tray=".length) + if (!file) return + const connection = decodeConnection(await readFile(file, "utf8")) + if (terminals.has(connection.id)) return + terminals.set(connection.id, { + owner: runtime.owner--, + connection, + rows: [], + loaded: 0, + changed: Date.now(), + offline: false, + }) + } + app.on("second-instance", (_event, argv) => { + void attach(argv).catch(() => console.warn("Could not attach terminal tray connection")) + }) + await attach(process.argv) + if (!terminals.size) { + app.quit() + return + } + await app.whenReady() + if (process.platform === "darwin") app.dock?.hide() + Menu.setApplicationMenu(null) + // The terminal's tray.enabled preference owns opt-out in this mode. + setTrayEnabled(true) + const stopInitial = initializeTray() + const icons = await createTrayIcons() + const active = () => + [...terminals.values()] + .filter((terminal) => terminal.snapshot?.enabled) + .sort((a, b) => Number(b.snapshot?.focused) - Number(a.snapshot?.focused) || b.changed - a.changed) + const send = (command: TerminalTray.Command, terminal = active()[0]) => { + if (!terminal) return + void fetch(`${terminal.connection.url}/command`, { + method: "POST", + headers: { authorization: `Bearer ${terminal.connection.token}`, "content-type": "application/json" }, + body: JSON.stringify(command), + signal: AbortSignal.any([controller.signal, AbortSignal.timeout(3000)]), + }).catch(() => console.warn("Could not deliver terminal tray action")) + } + const stop = startTray( + { + open: () => send({ type: "focus" }), + trigger: (id) => send({ type: id === "tab.new" ? "new" : "settings" }), + session: (key) => { + const terminal = active().find((terminal) => terminal.rows.some((row) => row.id === key)) + if (!terminal?.snapshot) return + send({ type: "session", sessionID: key.slice(terminal.snapshot.server.url.length + 1) }, terminal) + }, + docs: () => { + void shell.openExternal("https://opencode.ai/v2/docs/") + }, + quit: () => app.quit(), + }, + icons, + ) + app.on("before-quit", () => { + controller.abort() + clearTimeout(runtime.timer) + stop() + stopInitial() + }) + const poll = async () => { + await Promise.all( + [...terminals].map(async ([id, terminal]) => { + const snapshot = await fetch(`${terminal.connection.url}/state`, { + headers: { authorization: `Bearer ${terminal.connection.token}` }, + signal: AbortSignal.any([controller.signal, AbortSignal.timeout(3000)]), + }) + .then(async (response) => { + if (!response.ok) throw new Error("Terminal unavailable") + return decodeSnapshot(await response.json()) + }) + .catch(() => undefined) + if (!snapshot) { + terminals.delete(id) + trayTabs.remove(terminal.owner) + return + } + if (snapshot.current !== terminal.snapshot?.current || snapshot.focused !== terminal.snapshot?.focused) + terminal.changed = Date.now() + terminal.snapshot = snapshot + const keys = snapshot.enabled ? snapshot.sessions.map((id) => `${snapshot.server.url}\n${id}`) : [] + trayTabs.set(terminal.owner, keys) + terminal.rows = terminal.rows.filter((row) => keys.includes(row.id)) + if (!snapshot.enabled) return + const signature = JSON.stringify([snapshot.server, snapshot.sessions]) + if (signature === terminal.signature && Date.now() - terminal.loaded < 10_000) return + terminal.signature = signature + terminal.loaded = Date.now() + const client = OpenCode.make({ baseUrl: snapshot.server.url, headers: Service.headers(snapshot.server) }) + const result = await loadTraySessions( + client, + snapshot.sessions, + AbortSignal.any([controller.signal, AbortSignal.timeout(10_000)]), + ).catch(() => undefined) + terminal.offline = !result + if (result) + terminal.rows = result.sessions.map((session) => ({ + ...session, + id: `${snapshot.server.url}\n${session.id}`, + })) + }), + ) + if (controller.signal.aborted) return + if (!terminals.size) { + app.quit() + return + } + const enabled = active() + if (!enabled.length) { + app.quit() + return + } + if (getTrayEnabled() !== !!enabled.length) setTrayEnabled(!!enabled.length) + const sessions = [...new Map(enabled.flatMap((terminal) => terminal.rows).map((row) => [row.id, row])).values()] + sessions.sort( + (a, b) => + Number(b.status === "permission" || b.status === "question") - + Number(a.status === "permission" || a.status === "question"), + ) + await icons.prepare(sessions, AbortSignal.any([controller.signal, AbortSignal.timeout(3000)])) + if (controller.signal.aborted) return + setTraySessions({ + state: enabled.some((terminal) => terminal.offline) ? "offline" : "ready", + sessions, + attention: 0, + working: 0, + more: false, + }) + } + const refresh = () => { + void poll() + .catch(() => console.warn("Could not refresh terminal tray")) + .finally(() => { + if (!controller.signal.aborted) runtime.timer = setTimeout(refresh, 1000) + }) + } + refresh() +} diff --git a/packages/desktop/src/renderer/api-types.ts b/packages/desktop/src/renderer/api-types.ts index 2c1a7efed88..aebce69fc6c 100644 --- a/packages/desktop/src/renderer/api-types.ts +++ b/packages/desktop/src/renderer/api-types.ts @@ -7,6 +7,8 @@ import type { SshPlatform } from "@opencode/app/ssh" import type { Browser } from "@opencode/plugin-browser/rpc" import type { BrowserPaneRequest } from "../shared/ipc-rpc/browser" import type { WindowBootstrap } from "../shared/window-bootstrap" +import type { MenuCommand } from "../shared/menu-command" +import type { TrayAvatar } from "../shared/tray-avatar" import type { ClipboardImage, DirectoryPickerOptions, @@ -59,7 +61,11 @@ export type ElectronAPI = { getWindowID(): string getWindowBootstrap(): WindowBootstrap themeReady(): Promise - onMenuCommand(cb: (id: string) => void): () => void + onMenuCommand(cb: (command: MenuCommand) => void): () => void + menuReady(): Promise + getTrayEnabled(): Promise + setTrayEnabled(enabled: boolean): Promise + setTrayTabs(sessionIDs: string[], avatars?: Record): Promise onDeepLink(cb: (urls: string[]) => void): () => void openDirectoryPicker(opts?: DirectoryPickerOptions): Promise openFilePicker(opts?: FilePickerOptions): Promise diff --git a/packages/desktop/src/renderer/api.ts b/packages/desktop/src/renderer/api.ts index 5eaaea16490..0b650ce12de 100644 --- a/packages/desktop/src/renderer/api.ts +++ b/packages/desktop/src/renderer/api.ts @@ -126,7 +126,11 @@ export const api: ElectronAPI = { getWindowID: () => window.electron.windowID, getWindowBootstrap: () => window.electron.bootstrap, themeReady: () => invoke("WindowThemeReady"), - onMenuCommand: (cb) => listen("MenuCommandTriggered", (event) => cb(event.id)), + onMenuCommand: (cb) => listen("MenuCommandTriggered", (event) => cb(event.command)), + menuReady: () => invoke("MenuReady").then(mutable), + getTrayEnabled: () => invoke("AppGetTrayEnabled"), + setTrayEnabled: (enabled) => invoke("AppSetTrayEnabled", { enabled }), + setTrayTabs: (sessionIDs, avatars) => invoke("AppSetTrayTabs", { sessionIDs, avatars }), onDeepLink: (cb) => listen("DeepLinksOpened", (event) => cb(mutable(event.urls))), openDirectoryPicker: (opts) => invoke("FilesOpenDirectoryPicker", { options: opts }).then(mutable), diff --git a/packages/desktop/src/renderer/desktop-app.tsx b/packages/desktop/src/renderer/desktop-app.tsx index a0182b77064..8c4e11af64d 100644 --- a/packages/desktop/src/renderer/desktop-app.tsx +++ b/packages/desktop/src/renderer/desktop-app.tsx @@ -5,12 +5,18 @@ import { AppBaseProviders, AppInterface, currentRoute, + displayName, + getProjectAvatarVariant, PlatformProvider, preloadRoute, + projectForSession, ServerConnection, + sessionHref, useCommand, useCurrentRoute, useLanguage, + useServerCtx, + useServers, useTabs, useWslServers, useSsh, @@ -19,12 +25,17 @@ import { } from "@opencode/app/desktop" import { useTheme } from "@opencode/ui/theme/context" import type { BaseRouterProps } from "@solidjs/router" +import { useNavigate } from "@solidjs/router" import { createEffect, createMemo, createResource, lazy, Show, Suspense } from "solid-js" import { createStore } from "solid-js/store" import type { ElectronAPI } from "./api-types" +import type { MenuCommand } from "../shared/menu-command" import { DesktopFirstLaunchOnboarding } from "./onboarding" import { createDesktopPlatform } from "./platform" import { bindDesktopMenu } from "./platform/menu" +import { traySessionIDs } from "./platform/tray-tabs" +import { trayAvatar } from "./platform/tray-avatar" +import { getProjectAvatarSource } from "@opencode/app/project-avatar-source" import { createSidecarResolver, initializationData, sidecarHttp } from "./startup/initialization" import { preloadStoredLocale } from "./startup/locale" import { LoadingSplash } from "./startup/splash" @@ -132,7 +143,7 @@ export function DesktopApp(props: { api: ElectronAPI; updater: UpdaterPlatform; pending={firstLaunch() ?? false} onReady={() => setStartup("onboardingReady", true)} /> - + {(server) => } @@ -195,11 +206,58 @@ function DesktopStartupReady(props: { return null } -function DesktopEffects(props: { api: ElectronAPI }) { - const command = useCommand() - bindDesktopMenu((id) => command.trigger(id)) +function DesktopEffects(props: { api: ElectronAPI; ready: boolean }) { + const tabs = useTabs() const theme = useTheme() - + const servers = useServers() + const server = useServerCtx(() => servers.list.find(ServerConnection.builtin)) + createEffect(() => { + if (!tabs.ready()) return + theme.themeId() + theme.mode() + const ids = traySessionIDs(tabs.store) + const ctx = server() + const style = getComputedStyle(document.documentElement) + const avatars = Object.fromEntries( + ids.flatMap((id) => { + const session = ctx?.data.session.get(id) + if (!session) return [] + const project = projectForSession(session, ctx?.projects.list() ?? []) + const avatar = trayAvatar( + { + name: displayName(project ?? { worktree: session.location.directory }), + source: getProjectAvatarSource(project?.id, project?.icon), + variant: getProjectAvatarVariant(project?.icon?.color), + }, + style, + ) + return avatar ? [[id, avatar] as const] : [] + }), + ) + void props.api.setTrayTabs(ids, avatars).catch(console.error) + }) + const command = useCommand() + const navigate = useNavigate() + const dispatch = (action: MenuCommand) => { + if (action.type === "command") return command.trigger(action.id) + if (action.type === "home") return navigate("/") + navigate(sessionHref(ServerConnection.Key.make("sidecar"), action.sessionID)) + } + bindDesktopMenu(dispatch) + const menu = { ready: false } + createEffect(() => { + if ( + !props.ready || + menu.ready || + !["tab.new", "settings.open"].every((id) => command.options.some((entry) => entry.id === id)) + ) + return + menu.ready = true + void props.api + .menuReady() + .then((commands) => commands.forEach(dispatch)) + .catch(console.error) + }) createEffect(() => { theme.themeId() theme.mode() diff --git a/packages/desktop/src/renderer/platform/index.ts b/packages/desktop/src/renderer/platform/index.ts index 3b5cd1bb2ee..ff412a34224 100644 --- a/packages/desktop/src/renderer/platform/index.ts +++ b/packages/desktop/src/renderer/platform/index.ts @@ -96,6 +96,8 @@ export function createDesktopPlatform( webviewZoom, windowFullscreen, getPinchZoomEnabled: () => api.getPinchZoomEnabled(), + getTrayEnabled: () => api.getTrayEnabled(), + setTrayEnabled: (enabled) => api.setTrayEnabled(enabled), setPinchZoomEnabled, getKeepScreenActive: () => api.getKeepScreenActive(), setKeepScreenActive: (enabled) => api.setKeepScreenActive(enabled), diff --git a/packages/desktop/src/renderer/platform/menu.ts b/packages/desktop/src/renderer/platform/menu.ts index 8edddf3c588..bd52d2d34f3 100644 --- a/packages/desktop/src/renderer/platform/menu.ts +++ b/packages/desktop/src/renderer/platform/menu.ts @@ -1,14 +1,15 @@ import type { Platform } from "@opencode/app/desktop" import type { ElectronAPI } from "../api-types" import { resetZoom, zoomIn, zoomOut } from "../window/zoom" +import type { MenuCommand } from "../../shared/menu-command" -let trigger: ((id: string) => void) | null = null +let trigger: ((command: MenuCommand) => void) | null = null export function startDesktopMenu(api: ElectronAPI) { api.onMenuCommand((id) => trigger?.(id)) } -export function bindDesktopMenu(next: (id: string) => void) { +export function bindDesktopMenu(next: (command: MenuCommand) => void) { trigger = next } diff --git a/packages/desktop/src/renderer/platform/tray-avatar.test.ts b/packages/desktop/src/renderer/platform/tray-avatar.test.ts new file mode 100644 index 00000000000..2afd516a162 --- /dev/null +++ b/packages/desktop/src/renderer/platform/tray-avatar.test.ts @@ -0,0 +1,32 @@ +import { expect, test } from "bun:test" +import { trayAvatar } from "./tray-avatar" + +test("uses resolved desktop colors and preserves a workspace-specific favicon", () => { + const values: Record = { + "--v2-avatar-bg-blue": " #263fa9ff ", + "--v2-avatar-border-blue": "#7698fdff", + "--v2-avatar-fg": "#ffffffff", + "--v2-alpha-light-16": "rgba(255, 255, 255, 0.16)", + "--v2-background-bg-accent": "#3b5cf6ff", + } + expect( + trayAvatar( + { name: "Workspace", source: "data:image/png;base64,workspace", variant: "blue" }, + { + getPropertyValue: (name) => values[name] ?? "", + }, + ), + ).toEqual({ + name: "Workspace", + source: "data:image/png;base64,workspace", + background: "#263fa9ff", + border: "#7698fdff", + foreground: "#ffffffff", + highlight: "rgba(255, 255, 255, 0.16)", + accent: "#3b5cf6ff", + }) +}) + +test("waits for theme styles rather than publishing an empty palette", () => { + expect(trayAvatar({ name: "Project", variant: "blue" }, { getPropertyValue: () => "" })).toBeUndefined() +}) diff --git a/packages/desktop/src/renderer/platform/tray-avatar.ts b/packages/desktop/src/renderer/platform/tray-avatar.ts new file mode 100644 index 00000000000..c40b3d82714 --- /dev/null +++ b/packages/desktop/src/renderer/platform/tray-avatar.ts @@ -0,0 +1,18 @@ +import type { TrayAvatar } from "../../shared/tray-avatar" + +export function trayAvatar( + project: { name: string; source?: string; variant: string }, + style: Pick, +): TrayAvatar | undefined { + const background = style.getPropertyValue(`--v2-avatar-bg-${project.variant}`).trim() + if (!background) return + return { + name: project.name, + source: project.source, + background, + border: style.getPropertyValue(`--v2-avatar-border-${project.variant}`).trim(), + foreground: style.getPropertyValue("--v2-avatar-fg").trim(), + highlight: style.getPropertyValue("--v2-alpha-light-16").trim(), + accent: style.getPropertyValue("--v2-background-bg-accent").trim(), + } +} diff --git a/packages/desktop/src/renderer/platform/tray-tabs.test.ts b/packages/desktop/src/renderer/platform/tray-tabs.test.ts new file mode 100644 index 00000000000..67e64bc7334 --- /dev/null +++ b/packages/desktop/src/renderer/platform/tray-tabs.test.ts @@ -0,0 +1,15 @@ +import { expect, test } from "bun:test" +import { traySessionIDs } from "./tray-tabs" + +test("publishes local session tabs, not drafts or sessions on another server", () => { + expect( + traySessionIDs([ + { type: "session", server: "sidecar", sessionId: "open" }, + { type: "session", server: "sidecar", sessionId: "open" }, + { type: "draft", server: "sidecar" }, + { type: "session", server: "https://remote.example", sessionId: "remote" }, + { type: "session", server: "wsl:Ubuntu", sessionId: "wsl" }, + { type: "session", server: "sidecar", sessionId: "another" }, + ]), + ).toEqual(["open", "another"]) +}) diff --git a/packages/desktop/src/renderer/platform/tray-tabs.ts b/packages/desktop/src/renderer/platform/tray-tabs.ts new file mode 100644 index 00000000000..0f6b74f306c --- /dev/null +++ b/packages/desktop/src/renderer/platform/tray-tabs.ts @@ -0,0 +1,9 @@ +type Tab = { type: "session"; server: string; sessionId: string } | { type: "draft"; server: string } + +export function traySessionIDs(tabs: readonly Tab[]) { + // Drafts have no durable Session yet. The tray currently monitors the base + // service, so remote/WSL tab IDs must never be looked up on that service. + return [ + ...new Set(tabs.flatMap((tab) => (tab.type === "session" && tab.server === "sidecar" ? [tab.sessionId] : []))), + ] +} diff --git a/packages/desktop/src/shared/ipc-rpc/app.ts b/packages/desktop/src/shared/ipc-rpc/app.ts index 1f9b62bdf9c..566dc8d7b43 100644 --- a/packages/desktop/src/shared/ipc-rpc/app.ts +++ b/packages/desktop/src/shared/ipc-rpc/app.ts @@ -1,5 +1,6 @@ import { Schema } from "effect" import { Rpc, RpcGroup } from "effect/unstable/rpc" +import { TrayAvatar } from "../tray-avatar" const ServerReadyData = Schema.Struct({ url: Schema.String, @@ -64,6 +65,14 @@ export const AppSetKeepScreenActive = Rpc.make("AppSetKeepScreenActive", { payload: { enabled: Schema.Boolean }, error: Schema.String, }) +export const AppGetTrayEnabled = Rpc.make("AppGetTrayEnabled", { success: Schema.Boolean }) +export const AppSetTrayEnabled = Rpc.make("AppSetTrayEnabled", { payload: { enabled: Schema.Boolean } }) +export const AppSetTrayTabs = Rpc.make("AppSetTrayTabs", { + payload: { + sessionIDs: Schema.Array(Schema.String), + avatars: Schema.optionalKey(Schema.Record(Schema.String, TrayAvatar)), + }, +}) export const AppRpcs = RpcGroup.make( AppAwaitInitialization, AppReconnectService, @@ -84,4 +93,7 @@ export const AppRpcs = RpcGroup.make( AppPairCode, AppGetKeepScreenActive, AppSetKeepScreenActive, + AppGetTrayEnabled, + AppSetTrayEnabled, + AppSetTrayTabs, ) diff --git a/packages/desktop/src/shared/ipc-rpc/events.ts b/packages/desktop/src/shared/ipc-rpc/events.ts index daebcfe3d85..18b39ed287a 100644 --- a/packages/desktop/src/shared/ipc-rpc/events.ts +++ b/packages/desktop/src/shared/ipc-rpc/events.ts @@ -3,6 +3,7 @@ import { Rpc, RpcGroup } from "effect/unstable/rpc" import { BrowserPaneCaptureRpc, BrowserPaneEventSchema, BrowserPaneRpc } from "./browser" import { UpdaterStateSchema } from "./updater" import { WslServersEventSchema } from "./wsl" +import { MenuCommand } from "../menu-command" import { SshState } from "@opencode/app/ssh" export class SshChanged extends Schema.TaggedClass()("SshChanged", { state: SshState }) {} @@ -17,7 +18,7 @@ export class DeepLinksOpened extends Schema.TaggedClass()("Deep }) {} export class MenuCommandTriggered extends Schema.TaggedClass()("MenuCommandTriggered", { - id: Schema.String, + command: MenuCommand, }) {} export class UpdaterStateChanged extends Schema.TaggedClass()("UpdaterStateChanged", { diff --git a/packages/desktop/src/shared/ipc-rpc/menu.ts b/packages/desktop/src/shared/ipc-rpc/menu.ts index c64d8e9bcfb..46f4f3c2e86 100644 --- a/packages/desktop/src/shared/ipc-rpc/menu.ts +++ b/packages/desktop/src/shared/ipc-rpc/menu.ts @@ -1,5 +1,6 @@ import { Schema } from "effect" import { Rpc, RpcGroup } from "effect/unstable/rpc" +import { MenuCommand } from "../menu-command" const DesktopMenuAction = Schema.Literals([ "app.checkForUpdates", @@ -27,4 +28,5 @@ const DesktopMenuAction = Schema.Literals([ export const MenuRunAction = Rpc.make("MenuRunAction", { payload: { action: DesktopMenuAction }, }) -export const MenuRpcs = RpcGroup.make(MenuRunAction) +export const MenuReady = Rpc.make("MenuReady", { success: Schema.Array(MenuCommand) }) +export const MenuRpcs = RpcGroup.make(MenuRunAction, MenuReady) diff --git a/packages/desktop/src/shared/menu-command.ts b/packages/desktop/src/shared/menu-command.ts new file mode 100644 index 00000000000..d663627e479 --- /dev/null +++ b/packages/desktop/src/shared/menu-command.ts @@ -0,0 +1,8 @@ +import { Schema } from "effect" + +export const MenuCommand = Schema.Union([ + Schema.Struct({ type: Schema.Literal("command"), id: Schema.String }), + Schema.Struct({ type: Schema.Literal("home") }), + Schema.Struct({ type: Schema.Literal("session"), sessionID: Schema.String }), +]) +export type MenuCommand = typeof MenuCommand.Type diff --git a/packages/desktop/src/shared/tray-avatar.ts b/packages/desktop/src/shared/tray-avatar.ts new file mode 100644 index 00000000000..0acf016bd20 --- /dev/null +++ b/packages/desktop/src/shared/tray-avatar.ts @@ -0,0 +1,12 @@ +import { Schema } from "effect" + +export const TrayAvatar = Schema.Struct({ + name: Schema.String, + source: Schema.optionalKey(Schema.String), + background: Schema.String, + border: Schema.String, + foreground: Schema.String, + highlight: Schema.String, + accent: Schema.String, +}) +export type TrayAvatar = typeof TrayAvatar.Type diff --git a/packages/schema/src/terminal-tray.ts b/packages/schema/src/terminal-tray.ts new file mode 100644 index 00000000000..8d8fb6e4260 --- /dev/null +++ b/packages/schema/src/terminal-tray.ts @@ -0,0 +1,39 @@ +export * as TerminalTray from "./terminal-tray.js" + +import { Schema } from "effect" + +export const Connection = Schema.Struct({ + id: Schema.String, + url: Schema.String.check(Schema.isPattern(/^http:\/\/127\.0\.0\.1:\d+$/)), + token: Schema.String, +}).annotate({ identifier: "TerminalTray.Connection" }) +export interface Connection extends Schema.Schema.Type {} + +export const State = Schema.Struct({ + enabled: Schema.Boolean, + focused: Schema.Boolean, + sessions: Schema.Array(Schema.String), + current: Schema.optionalKey(Schema.String), +}).annotate({ identifier: "TerminalTray.State" }) +export interface State extends Schema.Schema.Type {} + +export const Snapshot = Schema.Struct({ + ...State.fields, + server: Schema.Struct({ + url: Schema.String, + auth: Schema.optionalKey( + Schema.Struct({ + type: Schema.Literal("basic"), + username: Schema.String, + password: Schema.String, + }), + ), + }), +}).annotate({ identifier: "TerminalTray.Snapshot" }) +export interface Snapshot extends Schema.Schema.Type {} + +export const Command = Schema.Union([ + Schema.Struct({ type: Schema.Literal("session"), sessionID: Schema.String }), + Schema.Struct({ type: Schema.Literals(["new", "settings", "focus"]) }), +]).annotate({ identifier: "TerminalTray.Command" }) +export type Command = typeof Command.Type diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index efb195b8663..01feac818e8 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -103,6 +103,7 @@ import { SessionTerminalsProvider } from "./context/session-terminals" import { PanelProvider, usePanel } from "./context/panel" import { SessionFrame } from "./component/session-frame" import { createTuiClipboard } from "./clipboard" +import type { TerminalTray } from "@opencode/schema/terminal-tray" registerOpencodeSpinner() @@ -199,6 +200,10 @@ export type TuiInput = { | undefined > log?: LogSink + tray?: { + update: (state: TerminalTray.State) => void + subscribe: (listener: (command: TerminalTray.Command) => void) => () => void + } } export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { @@ -403,7 +408,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { packages={input.packages} directories={pluginDirectories} > - + @@ -456,7 +461,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { }) }) -function App() { +function App(props: { tray?: TuiInput["tray"] }) { const log = useLog({ component: "app" }) const app = useTuiApp() const startup = useTuiStartup() @@ -493,6 +498,36 @@ function App() { renderer.off(CliRenderEvents.FRAME, afterFrame) if (paletteTimer) clearTimeout(paletteTimer) }) + createEffect(() => { + const current = sessionTabs.current() + props.tray?.update({ + enabled: config.data.tray?.enabled !== false, + focused: sessionTabs.focused(), + sessions: sessionTabs.enabled() + ? sessionTabs + .tabs() + .map((tab) => tab.sessionID) + .filter((id) => id !== "dummy") + : current && current !== "dummy" + ? [current] + : [], + current, + }) + }) + onMount(() => { + if (!props.tray) return + onCleanup( + props.tray.subscribe((command) => { + if (command.type === "session") { + dialog.clear() + route.navigate({ type: "session", sessionID: command.sessionID }) + return + } + if (command.type === "new") keymap.dispatch("session.new") + if (command.type === "settings") keymap.dispatch("opencode.settings") + }), + ) + }) createEffect(() => { if (client.connection.status() !== "connected") return if (route.data.type !== "session") return diff --git a/packages/tui/src/component/dialog-config.tsx b/packages/tui/src/component/dialog-config.tsx index de20ca66960..4678a5d24c2 100644 --- a/packages/tui/src/component/dialog-config.tsx +++ b/packages/tui/src/component/dialog-config.tsx @@ -289,6 +289,15 @@ export const settings: Setting[] = [ format: (value) => `${Math.round(Number(value) * 100)}%`, keywords: ["sound volume", "audio volume"], }, + { + title: "Menu bar / system tray", + category: "Terminal", + path: ["tray", "enabled"], + default: true, + values: [false, true], + labels: ["off", "on"], + keywords: ["tray", "companion", "menu bar"], + }, { title: "Window title", category: "Terminal", diff --git a/packages/tui/src/config/index.tsx b/packages/tui/src/config/index.tsx index ebdb76688f9..998f47f0fc3 100644 --- a/packages/tui/src/config/index.tsx +++ b/packages/tui/src/config/index.tsx @@ -67,6 +67,13 @@ export const Cursor = Schema.Struct({ }).annotate({ description: "Terminal cursor settings" }) export const Info = Schema.Struct({ + tray: Schema.optional( + Schema.Struct({ + enabled: Schema.optional(Schema.Boolean).annotate({ + description: "Show the menu-bar/system-tray companion while the terminal app is open", + }), + }), + ).annotate({ description: "Native tray companion settings" }), theme: Schema.optional( Schema.Struct({ name: Schema.optional(Schema.String).annotate({ description: "Theme name" }), @@ -159,7 +166,8 @@ export const Info = Schema.Struct({ description: "Group related transcript items automatically or render each item separately", }), verbosity: Schema.optional(Schema.Literals(["low", "medium", "high"])).annotate({ - description: "Transcript detail level: low summarizes each run of tools and thoughts, high opens exploration and instruction groups", + description: + "Transcript detail level: low summarizes each run of tools and thoughts, high opens exploration and instruction groups", }), image_preview: Schema.optional(Schema.Boolean).annotate({ description: "Show user attachment and tool-result images in the session transcript", @@ -283,8 +291,7 @@ export function resolve( input: Info, options: { terminalSuspend: boolean; environment?: Readonly> }, ): Resolved { - const tabsMode = - input.tabs?.mode ?? (input.tabs?.enabled === undefined ? "auto" : input.tabs.enabled ? "on" : "off") + const tabsMode = input.tabs?.mode ?? (input.tabs?.enabled === undefined ? "auto" : input.tabs.enabled ? "on" : "off") const keybinds: TuiKeybind.KeybindOverrides = { ...input.keybinds } if (!options.terminalSuspend) { keybinds["terminal.suspend"] = "none" diff --git a/packages/tui/src/context/session-tabs.tsx b/packages/tui/src/context/session-tabs.tsx index 7b7f9f8c1c3..d3a79a08c97 100644 --- a/packages/tui/src/context/session-tabs.tsx +++ b/packages/tui/src/context/session-tabs.tsx @@ -351,6 +351,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp return { enabled, + focused: () => focused() !== false, tabs() { return state().tabs }, diff --git a/packages/tui/test/config-v2.test.tsx b/packages/tui/test/config-v2.test.tsx index ae59c8f7bca..75435a7eba4 100644 --- a/packages/tui/test/config-v2.test.tsx +++ b/packages/tui/test/config-v2.test.tsx @@ -9,6 +9,17 @@ import { CommandMap, Definitions } from "../src/config/v1/keybind" const decodeInfo = Schema.decodeUnknownSync(Info) +test("supports opting out of the default-on terminal tray companion", () => { + expect(decodeInfo({ tray: { enabled: false } })).toEqual({ tray: { enabled: false } }) + expect(resolve({ tray: { enabled: false } }, { terminalSuspend: true }).tray?.enabled).toBe(false) + expect(settings.find((setting) => setting.path.join(".") === "tray.enabled")).toMatchObject({ + category: "Terminal", + default: true, + values: [false, true], + }) + expect(() => decodeInfo({ tray: { enabled: "off" } })).toThrow() +}) + test("validates the explicit diff source defaults", () => { for (const source of ["branch", "committed", "working", "turn"] as const) { expect(decodeInfo({ diffs: { source } })).toEqual({ diffs: { source } }) diff --git a/packages/ui/package.json b/packages/ui/package.json index 7729286f61a..2b49ac7f756 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -46,6 +46,7 @@ "./field": "./src/forms/field/field.tsx", "./file-tree.css": "./src/styles/file-tree.css", "./icon": "./src/icons/icon/icon.tsx", + "./icons/shell": "./src/icons/icon/shell-icons.ts", "./icon-button": "./src/actions/icon-button/icon-button.tsx", "./inline-input": "./src/forms/inline-input/inline-input.tsx", "./keybind": "./src/data-display/keybind/keybind.tsx", diff --git a/packages/ui/src/icons/icon/icon.tsx b/packages/ui/src/icons/icon/icon.tsx index 9b7d6ad44d1..6c9ee0739f9 100644 --- a/packages/ui/src/icons/icon/icon.tsx +++ b/packages/ui/src/icons/icon/icon.tsx @@ -1,5 +1,6 @@ import { onMount, type ComponentProps, splitProps } from "solid-js" import { additionalIcons, additionalIconViewBox } from "./additional-icons" +import { shellIcons } from "./shell-icons" import "./icon.css" // Consumers center the SVG viewport, so each icon must center its artwork within its viewBox. @@ -16,14 +17,11 @@ const icons = { viewBox: "0 0 16 16", body: ``, }, + ...shellIcons, flask: { viewBox: "0 0 16 16", body: ``, }, - edit: { - viewBox: "0 0 16 16", - body: ``, - }, "folder-add-left": { viewBox: "0 0 16 16", body: ``, @@ -40,10 +38,6 @@ const icons = { viewBox: "0 0 16 16", body: ``, }, - "grid-plus": { - viewBox: "0 0 16 16", - body: ``, - }, help: { viewBox: "0 0 16 16", body: ``, @@ -84,10 +78,6 @@ const icons = { viewBox: "0 0 16 16", body: ``, }, - "settings-gear": { - viewBox: "0 0 16 16", - body: ``, - }, "chevron-down": { viewBox: "0 0 16 16", body: ``, diff --git a/packages/ui/src/icons/icon/shell-icons.ts b/packages/ui/src/icons/icon/shell-icons.ts new file mode 100644 index 00000000000..3dad65095f1 --- /dev/null +++ b/packages/ui/src/icons/icon/shell-icons.ts @@ -0,0 +1,23 @@ +// Shared SVG artwork for web controls and native desktop menus. +export const shellIcons = { + "grid-plus": { + viewBox: "0 0 16 16", + body: ``, + }, + edit: { + viewBox: "0 0 16 16", + body: ``, + }, + "settings-gear": { + viewBox: "0 0 16 16", + body: ``, + }, + "book-open": { + viewBox: "0 0 16 16", + body: ``, + }, + "log-out": { + viewBox: "0 0 16 16", + body: ``, + }, +}