diff --git a/config/scripts/run-electron-vite-dev.mjs b/config/scripts/run-electron-vite-dev.mjs index c3ac1b049a5..41d4a45139d 100644 --- a/config/scripts/run-electron-vite-dev.mjs +++ b/config/scripts/run-electron-vite-dev.mjs @@ -1,6 +1,14 @@ -import { spawn } from 'node:child_process' +import { execFileSync, spawn } from 'node:child_process' import { createHash } from 'node:crypto' -import { chmodSync, mkdirSync, writeFileSync } from 'node:fs' +import { + chmodSync, + cpSync, + existsSync, + mkdirSync, + readFileSync, + rmSync, + writeFileSync +} from 'node:fs' import net from 'node:net' import { createRequire } from 'node:module' import path from 'node:path' @@ -14,6 +22,188 @@ delete process.env.ELECTRON_RUN_AS_NODE const require = createRequire(import.meta.url) const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..') +const STABLE_NAME_FLAG = '--stable-name' +const rawForwardedArgs = process.argv.slice(2) +// Why: keep an escape hatch for tools that key off Electron's stock app name. +// The flag is runner-only and must not leak into Chromium/electron-vite. +const useStableElectronName = + process.env.ORCA_DEV_STABLE_NAME === '1' || rawForwardedArgs.includes(STABLE_NAME_FLAG) +const forwardedRaw = rawForwardedArgs.filter((arg) => arg !== STABLE_NAME_FLAG) +if (useStableElectronName) { + process.env.ORCA_DEV_STABLE_NAME = '1' +} + +function readGitValue(args) { + try { + const value = execFileSync('git', ['-C', repoRoot, ...args], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'] + }).trim() + return value || null + } catch { + return null + } +} + +function lastBranchSegment(value) { + return value.replace(/\\/g, '/').split('/').filter(Boolean).at(-1) ?? value +} + +function formatDevInstanceLabel(branch, worktreeName) { + if (branch && worktreeName) { + if (branch === worktreeName || lastBranchSegment(branch) === worktreeName) { + return worktreeName + } + return `${worktreeName} @ ${branch}` + } + return branch || worktreeName || null +} + +function createBadgeSuffix(seed) { + const n = parseInt(createHash('sha1').update(seed).digest('hex').slice(0, 8), 16) + return (n % 1296).toString(36).toUpperCase().padStart(2, '0') +} + +function createDockBadgeLabel(displayValue, identitySeed) { + const source = lastBranchSegment(displayValue || identitySeed || '') + const words = source.split(/[^a-zA-Z0-9]+/).filter(Boolean) + const prefix = + words.length > 1 + ? words + .slice(0, 2) + .map((word) => word[0]) + .join('') + : (words[0] ?? 'D').slice(0, 2) + const normalizedPrefix = (prefix.replace(/[^a-zA-Z0-9]/g, '').toUpperCase() || 'D').slice(0, 2) + return `${normalizedPrefix}${createBadgeSuffix(identitySeed || displayValue || source)}` +} + +function createDockTitle(branch, label, badgeLabel) { + const title = `Orca Dev${badgeLabel ? ` [${badgeLabel}]` : ''}` + return `${title}: ${branch || label || 'dev'}` +} + +function seedDevInstanceIdentityEnv() { + const branch = + process.env.ORCA_DEV_BRANCH || + readGitValue(['symbolic-ref', '--quiet', '--short', 'HEAD']) || + readGitValue(['rev-parse', '--short', 'HEAD']) + const worktreeName = process.env.ORCA_DEV_WORKTREE_NAME || path.basename(repoRoot) + const label = process.env.ORCA_DEV_INSTANCE_LABEL || formatDevInstanceLabel(branch, worktreeName) + const identitySeed = process.env.ORCA_DEV_INSTANCE_KEY || repoRoot + const badgeLabel = + process.env.ORCA_DEV_DOCK_BADGE_LABEL || + createDockBadgeLabel(worktreeName || branch || label, identitySeed) + const dockTitle = process.env.ORCA_DEV_DOCK_TITLE || createDockTitle(branch, label, badgeLabel) + + process.env.ORCA_DEV_REPO_ROOT ||= repoRoot + process.env.ORCA_DEV_INSTANCE_KEY ||= identitySeed + if (branch) { + process.env.ORCA_DEV_BRANCH ||= branch + } + if (worktreeName) { + process.env.ORCA_DEV_WORKTREE_NAME ||= worktreeName + } + if (label) { + // Why: parallel `pn dev` runs need a stable origin label for window titles, + // Dock badges, and automation sessions without re-running git in Electron. + process.env.ORCA_DEV_INSTANCE_LABEL ||= label + } + if (badgeLabel) { + process.env.ORCA_DEV_DOCK_BADGE_LABEL ||= badgeLabel + } + process.env.ORCA_DEV_DOCK_TITLE ||= dockTitle +} + +function setPlistValue(plistPath, key, value) { + execFileSync('/usr/bin/plutil', ['-replace', key, '-string', value, plistPath]) +} + +function sanitizeBundleIdPart(value) { + return ( + value + .toLowerCase() + .replace(/[^a-z0-9.-]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 80) || 'dev' + ) +} + +function sanitizeMacAppBundleName(value) { + return ( + Array.from(value, (char) => { + const code = char.charCodeAt(0) + return code < 32 || code === 127 || char === ':' || char === '/' || char === '\\' ? '-' : char + }) + .join('') + .replace(/\s+/g, ' ') + .trim() + .slice(0, 120) || 'Orca Dev' + ) +} + +function prepareMacDevElectronApp() { + if (process.platform !== 'darwin') { + return + } + + const sourceAppPath = path.join(repoRoot, 'node_modules', 'electron', 'dist', 'Electron.app') + const electronPackagePath = path.join(repoRoot, 'node_modules', 'electron', 'package.json') + if (!existsSync(sourceAppPath)) { + return + } + + let electronVersion = null + try { + electronVersion = JSON.parse(readFileSync(electronPackagePath, 'utf8')).version ?? null + } catch {} + + const title = process.env.ORCA_DEV_DOCK_TITLE || 'Orca Dev' + const identityKey = process.env.ORCA_DEV_INSTANCE_KEY || repoRoot + const bundleLayoutVersion = 'dock-title-app-filename-v1' + const hash = createHash('sha1') + .update( + `${sourceAppPath}\0${electronVersion ?? ''}\0${title}\0${identityKey}\0${bundleLayoutVersion}` + ) + .digest('hex') + .slice(0, 12) + const distDir = path.join(repoRoot, 'out', 'electron-dev', hash) + // Why: macOS Dock hover uses the bundle's filesystem display name for + // electron-vite's direct binary launch path, even when Info.plist is patched. + const appBundleName = `${sanitizeMacAppBundleName(title)}.app` + const appPath = path.join(distDir, appBundleName) + const markerPath = path.join(distDir, 'orca-dev-electron-app.json') + const bundleId = `com.stablyai.orca.dev.${sanitizeBundleIdPart(hash)}` + const expectedMarker = JSON.stringify( + { title, appBundleName, bundleId, sourceAppPath, electronVersion, bundleLayoutVersion }, + null, + 2 + ) + + if (existsSync(markerPath) && existsSync(appPath)) { + try { + if (readFileSync(markerPath, 'utf8') === expectedMarker) { + process.env.ELECTRON_EXEC_PATH = path.join(appPath, 'Contents', 'MacOS', 'Electron') + return + } + } catch {} + } + + rmSync(distDir, { recursive: true, force: true }) + mkdirSync(distDir, { recursive: true }) + cpSync(sourceAppPath, appPath, { recursive: true }) + + const plistPath = path.join(appPath, 'Contents', 'Info.plist') + setPlistValue(plistPath, 'CFBundleName', title) + setPlistValue(plistPath, 'CFBundleDisplayName', title) + setPlistValue(plistPath, 'CFBundleIdentifier', bundleId) + + // Why no re-sign: dev launches execute the copied Electron binary directly, + // and Electron's framework bundle is ambiguous to codesign when deep-signing + // an already-built distribution. Avoid blocking `pn dev` on local signing. + writeFileSync(markerPath, expectedMarker, 'utf8') + process.env.ELECTRON_EXEC_PATH = path.join(appPath, 'Contents', 'MacOS', 'Electron') +} function getDevUserDataPath() { if (process.env.ORCA_DEV_USER_DATA_PATH) { @@ -72,6 +262,11 @@ if (process.env.ORCA_SKIP_DEV_CLI_PREPARE !== '1') { prepareDevCliWrapper() } +seedDevInstanceIdentityEnv() +if (!useStableElectronName && process.env.ORCA_SKIP_DEV_ELECTRON_APP_PREPARE !== '1') { + prepareMacDevElectronApp() +} + // Why: tests inject a tiny fake CLI here so they can verify Ctrl+C tears down // the full child tree without depending on a real electron-vite install. const electronViteCli = @@ -82,7 +277,6 @@ const electronViteCli = // without manual port juggling. Pick a best-effort deterministic port per // worktree; falls back to a probe sweep if the deterministic pick or its // neighbors are busy (multiple worktrees may share a machine). -const forwardedRaw = process.argv.slice(2) function isPortFree(port) { return new Promise((resolve) => { const srv = net.createServer() @@ -131,6 +325,12 @@ const userPassedPort = forwardedRaw.some( // Why: --help/--version exit immediately; binding a probe socket and printing // a debug-port line would be noise. const isHelpOrVersion = forwardedRaw.some((a) => a === '--help' || a === '-h' || a === '--version') +if (!isHelpOrVersion && process.env.ORCA_DEV_INSTANCE_LABEL) { + const badge = process.env.ORCA_DEV_DOCK_BADGE_LABEL + ? ` [${process.env.ORCA_DEV_DOCK_BADGE_LABEL}]` + : '' + console.error(`[orca-dev] Instance: ${process.env.ORCA_DEV_INSTANCE_LABEL}${badge}`) +} let forwardedExtras = [] if (!userPassedPort && !isHelpOrVersion) { const envPortRaw = process.env.REMOTE_DEBUGGING_PORT diff --git a/package.json b/package.json index 1c8a8adc560..31f1ddcc3b8 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "typecheck:tsc": "tsc --noEmit -p config/tsconfig.node.json --composite false && tsc --noEmit -p config/tsconfig.cli.json --composite false && tsc --noEmit -p config/tsconfig.web.json --composite false", "start": "electron-vite preview", "dev": "node config/scripts/run-electron-vite-dev.mjs", + "dev-stable-name": "node config/scripts/run-electron-vite-dev.mjs --stable-name", "dev:web": "vite --config vite.web.config.ts --host 127.0.0.1", "build:relay": "node config/scripts/build-relay.mjs", "build:computer-macos": "node config/scripts/build-computer-macos.mjs", diff --git a/src/main/dock/unread-badge.test.ts b/src/main/dock/unread-badge.test.ts new file mode 100644 index 00000000000..516f975f483 --- /dev/null +++ b/src/main/dock/unread-badge.test.ts @@ -0,0 +1,48 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +const { setBadgeMock } = vi.hoisted(() => ({ + setBadgeMock: vi.fn() +})) + +vi.mock('electron', () => ({ + app: { + dock: { + setBadge: setBadgeMock + } + } +})) + +describe('unread Dock badge', () => { + const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') + + afterEach(() => { + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform) + } + setBadgeMock.mockReset() + vi.resetModules() + }) + + it('shows the dev identity badge when unread count is zero', async () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' }) + const { setIdleDockBadgeLabel, setUnreadDockBadgeCount } = await import('./unread-badge') + + setIdleDockBadgeLabel('DI') + expect(setBadgeMock).toHaveBeenLastCalledWith('DI') + + setUnreadDockBadgeCount(5) + expect(setBadgeMock).toHaveBeenLastCalledWith('5') + + setUnreadDockBadgeCount(0) + expect(setBadgeMock).toHaveBeenLastCalledWith('DI') + }) + + it('caps unread counts while preserving the idle badge', async () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' }) + const { setIdleDockBadgeLabel, setUnreadDockBadgeCount } = await import('./unread-badge') + + setIdleDockBadgeLabel('DI') + setUnreadDockBadgeCount(104) + expect(setBadgeMock).toHaveBeenLastCalledWith('99+') + }) +}) diff --git a/src/main/dock/unread-badge.ts b/src/main/dock/unread-badge.ts index e9786022817..26e05250ee9 100644 --- a/src/main/dock/unread-badge.ts +++ b/src/main/dock/unread-badge.ts @@ -1,14 +1,32 @@ import { app } from 'electron' +let idleDockBadgeLabel = '' +let unreadCount = 0 + +function applyDockBadge(): void { + if (process.platform !== 'darwin') { + return + } + + const label = + unreadCount === 0 ? idleDockBadgeLabel : unreadCount > 99 ? '99+' : String(unreadCount) + + app.dock?.setBadge(label) +} + +export function setIdleDockBadgeLabel(label: string | null | undefined): void { + idleDockBadgeLabel = label ?? '' + applyDockBadge() +} + export function setUnreadDockBadgeCount(count: number): void { if (process.platform !== 'darwin') { return } - const normalizedCount = Number.isFinite(count) ? Math.max(0, Math.trunc(count)) : 0 - const label = normalizedCount === 0 ? '' : normalizedCount > 99 ? '99+' : String(normalizedCount) + unreadCount = Number.isFinite(count) ? Math.max(0, Math.trunc(count)) : 0 - // Why: unread counts belong on the native Dock tile on macOS. - // Windows/Linux are skipped until we define the right platform behavior. - app.dock?.setBadge(label) + // Why: unread counts own the native badge while active; otherwise dev builds + // keep their worktree badge visible so parallel `pn dev` windows stay distinct. + applyDockBadge() } diff --git a/src/main/index.ts b/src/main/index.ts index 562018b8d43..571878ee471 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -39,6 +39,7 @@ import { patchPackagedProcessPath } from './startup/configure-process' import { startFirstWindowStartupServices } from './startup/first-window-startup-services' +import { getDevInstanceIdentity } from './startup/dev-instance-identity' import { hydrateShellPath, mergePathSegments } from './startup/hydrate-shell-path' import { acquireSingleInstanceLock } from './startup/single-instance-lock' import { RateLimitService } from './rate-limits/service' @@ -66,7 +67,7 @@ import { } from './ipc/pty' import { AgentBrowserBridge } from './browser/agent-browser-bridge' import { browserManager } from './browser/browser-manager' -import { setUnreadDockBadgeCount } from './dock/unread-badge' +import { setIdleDockBadgeLabel, setUnreadDockBadgeCount } from './dock/unread-badge' import { registerFeatureWallFirstAgentTour } from './feature-wall/first-agent-tour' import { AutomationService } from './automations/service' import { AgentAwakeService } from './agent-awake-service' @@ -96,6 +97,7 @@ let watcherShutdownPromise: Promise | null = null let watcherShutdownDone = false let automations: AutomationService | null = null const isServeMode = process.argv.includes('--serve') +const devInstanceIdentity = getDevInstanceIdentity(is.dev) installUncaughtPipeErrorGuard() // Why: propagate the Orca app version into `process.env` so PTY-env @@ -255,7 +257,8 @@ function openMainWindow(): BrowserWindow { onQuitAborted: () => { isQuitting = false }, - deferLoad: true + deferLoad: true, + title: devInstanceIdentity.name }) // Why: telemetry-plan.md§First-launch experience anchors default-on @@ -643,12 +646,13 @@ function driveSyntheticTitleFromHook( } app.whenReady().then(async () => { - electronApp.setAppUserModelId('com.stablyai.orca') - app.setName('Orca') + electronApp.setAppUserModelId(devInstanceIdentity.appUserModelId) + app.setName(devInstanceIdentity.name) if (process.platform === 'darwin' && is.dev) { const dockIcon = nativeImage.createFromPath(devIcon) app.dock?.setIcon(dockIcon) + setIdleDockBadgeLabel(devInstanceIdentity.dockBadgeLabel) } store = new Store() diff --git a/src/main/ipc/app.ts b/src/main/ipc/app.ts index 70e7e9c6ff4..2e0507aea3a 100644 --- a/src/main/ipc/app.ts +++ b/src/main/ipc/app.ts @@ -5,7 +5,10 @@ import path from 'node:path' import { pathToFileURL } from 'node:url' import { promisify } from 'node:util' import { app, ipcMain } from 'electron' +import { is } from '@electron-toolkit/utils' +import type { AppIdentity } from '../../shared/app-identity' import type { FloatingTerminalCwdRequest } from '../../shared/types' +import { getDevInstanceIdentity } from '../startup/dev-instance-identity' import { isPwshAvailable } from '../pwsh' import { isWslAvailable } from '../wsl' import { setUnreadDockBadgeCount } from '../dock/unread-badge' @@ -73,6 +76,19 @@ function resolveDevFeatureWallAssetDir(): string { export function registerAppHandlers(): void { ipcMain.handle('app:getFeatureWallAssetBaseUrl', (): string => getFeatureWallAssetBaseUrl()) + ipcMain.handle('app:getIdentity', (): AppIdentity => { + const identity = getDevInstanceIdentity(is.dev) + return { + name: identity.name, + isDev: identity.isDev, + devLabel: identity.devLabel, + devBranch: identity.devBranch, + devWorktreeName: identity.devWorktreeName, + devRepoRoot: identity.devRepoRoot, + dockBadgeLabel: identity.dockBadgeLabel + } + }) + ipcMain.handle('wsl:isAvailable', (): boolean => isWslAvailable()) ipcMain.handle('pwsh:isAvailable', (): boolean => isPwshAvailable()) diff --git a/src/main/startup/__fixtures__/fake-electron-vite-dev-cli.mjs b/src/main/startup/__fixtures__/fake-electron-vite-dev-cli.mjs index 90528b17916..6f4aca7bae5 100644 --- a/src/main/startup/__fixtures__/fake-electron-vite-dev-cli.mjs +++ b/src/main/startup/__fixtures__/fake-electron-vite-dev-cli.mjs @@ -6,6 +6,7 @@ import { fileURLToPath } from 'node:url' const __dirname = path.dirname(fileURLToPath(import.meta.url)) const grandchildPath = path.join(__dirname, 'electron-vite-dev-grandchild.mjs') const pidFile = process.env.ORCA_DEV_WRAPPER_TEST_PID_FILE +const envFile = process.env.ORCA_DEV_WRAPPER_TEST_ENV_FILE const grandchild = spawn(process.execPath, [grandchildPath], { stdio: 'ignore' @@ -16,4 +17,25 @@ if (!pidFile) { } writeFileSync(pidFile, `${grandchild.pid ?? ''}\n`, 'utf8') +if (envFile) { + writeFileSync( + envFile, + JSON.stringify( + { + args: process.argv.slice(2), + label: process.env.ORCA_DEV_INSTANCE_LABEL ?? null, + branch: process.env.ORCA_DEV_BRANCH ?? null, + worktreeName: process.env.ORCA_DEV_WORKTREE_NAME ?? null, + repoRoot: process.env.ORCA_DEV_REPO_ROOT ?? null, + badgeLabel: process.env.ORCA_DEV_DOCK_BADGE_LABEL ?? null, + dockTitle: process.env.ORCA_DEV_DOCK_TITLE ?? null, + stableName: process.env.ORCA_DEV_STABLE_NAME ?? null, + electronExecPath: process.env.ELECTRON_EXEC_PATH ?? null + }, + null, + 2 + ), + 'utf8' + ) +} setInterval(() => {}, 1000) diff --git a/src/main/startup/dev-instance-identity.test.ts b/src/main/startup/dev-instance-identity.test.ts new file mode 100644 index 00000000000..5a8c74964d5 --- /dev/null +++ b/src/main/startup/dev-instance-identity.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest' +import { createDevDockBadgeLabel, getDevInstanceIdentity } from './dev-instance-identity' + +describe('dev-instance-identity', () => { + it('keeps packaged identity stable', () => { + expect(getDevInstanceIdentity(false, {})).toMatchObject({ + name: 'Orca', + isDev: false, + devLabel: null, + dockBadgeLabel: null, + appUserModelId: 'com.stablyai.orca' + }) + }) + + it('derives a readable dev label from worktree and branch env', () => { + const identity = getDevInstanceIdentity(true, { + ORCA_DEV_REPO_ROOT: '/repo/worktrees/dev-indicator', + ORCA_DEV_WORKTREE_NAME: 'dev-indicator', + ORCA_DEV_BRANCH: 'nwparker/dev-indicator' + }) + + expect(identity).toMatchObject({ + isDev: true, + devLabel: 'dev-indicator', + devBranch: 'nwparker/dev-indicator', + devWorktreeName: 'dev-indicator', + devRepoRoot: '/repo/worktrees/dev-indicator' + }) + expect(identity.name).toMatch(/^Orca Dev \[DI[A-Z0-9]{2}\]: nwparker\/dev-indicator$/) + expect(identity.dockBadgeLabel).toMatch(/^DI[A-Z0-9]{2}$/) + expect(identity.appUserModelId).toMatch(/^com\.stablyai\.orca\.dev\.[a-f0-9]{10}$/) + }) + + it('includes the branch when it differs from the worktree basename', () => { + const identity = getDevInstanceIdentity(true, { + ORCA_DEV_REPO_ROOT: '/repo/worktrees/payment-ui', + ORCA_DEV_WORKTREE_NAME: 'payment-ui', + ORCA_DEV_BRANCH: 'feature/billing-shell' + }) + + expect(identity.devLabel).toBe('payment-ui @ feature/billing-shell') + expect(identity.name).toMatch(/^Orca Dev \[PU[A-Z0-9]{2}\]: feature\/billing-shell$/) + expect(identity.dockBadgeLabel).toMatch(/^PU[A-Z0-9]{2}$/) + }) + + it('allows an explicit label override', () => { + const identity = getDevInstanceIdentity(true, { + ORCA_DEV_INSTANCE_LABEL: 'manual label', + ORCA_DEV_WORKTREE_NAME: 'dev-indicator', + ORCA_DEV_BRANCH: 'feature/other' + }) + + expect(identity.devLabel).toBe('manual label') + expect(identity.name).toMatch(/^Orca Dev \[DI[A-Z0-9]{2}\]: feature\/other$/) + expect(identity.dockBadgeLabel).toMatch(/^DI[A-Z0-9]{2}$/) + }) + + it('creates compact alphanumeric Dock labels with stable collision suffixes', () => { + expect(createDevDockBadgeLabel('dev-indicator', '/repo/a')).toMatch(/^DI[A-Z0-9]{2}$/) + expect(createDevDockBadgeLabel('feature/singleword', '/repo/a')).toMatch(/^SI[A-Z0-9]{2}$/) + expect(createDevDockBadgeLabel('pr-123', '/repo/a')).toMatch(/^P1[A-Z0-9]{2}$/) + expect(createDevDockBadgeLabel('dev-indicator', '/repo/a')).not.toBe( + createDevDockBadgeLabel('dev-indicator', '/repo/b') + ) + }) +}) diff --git a/src/main/startup/dev-instance-identity.ts b/src/main/startup/dev-instance-identity.ts new file mode 100644 index 00000000000..34780c862aa --- /dev/null +++ b/src/main/startup/dev-instance-identity.ts @@ -0,0 +1,113 @@ +import { createHash } from 'crypto' +import path from 'path' +import type { AppIdentity } from '../../shared/app-identity' + +const BASE_APP_NAME = 'Orca' +const BASE_APP_USER_MODEL_ID = 'com.stablyai.orca' +const MAX_LABEL_LENGTH = 80 + +export type DevInstanceIdentity = AppIdentity & { + appUserModelId: string +} + +function cleanEnvValue(value: string | undefined): string | null { + const trimmed = value?.replace(/\s+/g, ' ').trim() + if (!trimmed) { + return null + } + return trimmed.length > MAX_LABEL_LENGTH + ? `${trimmed.slice(0, MAX_LABEL_LENGTH - 3)}...` + : trimmed +} + +function lastPathSegment(value: string): string { + const normalized = value.replace(/\\/g, '/') + return normalized.split('/').filter(Boolean).at(-1) ?? value +} + +function formatLabel(branch: string | null, worktreeName: string | null): string | null { + if (branch && worktreeName) { + if (branch === worktreeName || lastPathSegment(branch) === worktreeName) { + return worktreeName + } + return `${worktreeName} @ ${branch}` + } + return branch ?? worktreeName +} + +function createBadgeSuffix(seed: string): string { + const n = parseInt(createHash('sha1').update(seed).digest('hex').slice(0, 8), 16) + return (n % 1296).toString(36).toUpperCase().padStart(2, '0') +} + +export function createDevDockBadgeLabel( + value: string | null, + identitySeed?: string | null +): string | null { + if (!value) { + return null + } + + const source = lastPathSegment(value) + const words = source.split(/[^a-zA-Z0-9]+/).filter(Boolean) + const label = + words.length > 1 + ? words + .slice(0, 2) + .map((word) => word[0]) + .join('') + : (words[0] ?? source).slice(0, 2) + const prefix = (label.replace(/[^a-zA-Z0-9]/g, '').toUpperCase() || 'D').slice(0, 2) + return `${prefix}${createBadgeSuffix(identitySeed ?? value)}` +} + +function createDevAppUserModelId(identityKey: string | null): string { + if (!identityKey) { + return BASE_APP_USER_MODEL_ID + } + const hash = createHash('sha1').update(identityKey).digest('hex').slice(0, 10) + return `${BASE_APP_USER_MODEL_ID}.dev.${hash}` +} + +export function getDevInstanceIdentity( + isDev: boolean, + env: NodeJS.ProcessEnv = process.env +): DevInstanceIdentity { + if (!isDev) { + return { + name: BASE_APP_NAME, + isDev: false, + devLabel: null, + devBranch: null, + devWorktreeName: null, + devRepoRoot: null, + dockBadgeLabel: null, + appUserModelId: BASE_APP_USER_MODEL_ID + } + } + + const repoRoot = cleanEnvValue(env.ORCA_DEV_REPO_ROOT) + const branch = cleanEnvValue(env.ORCA_DEV_BRANCH) + const worktreeName = + cleanEnvValue(env.ORCA_DEV_WORKTREE_NAME) ?? + cleanEnvValue(path.basename(repoRoot ?? process.cwd())) + const devLabel = cleanEnvValue(env.ORCA_DEV_INSTANCE_LABEL) ?? formatLabel(branch, worktreeName) + const identitySeed = cleanEnvValue(env.ORCA_DEV_INSTANCE_KEY) ?? repoRoot ?? devLabel + const dockBadgeLabel = + cleanEnvValue(env.ORCA_DEV_DOCK_BADGE_LABEL) ?? + createDevDockBadgeLabel(worktreeName ?? branch ?? devLabel, identitySeed) + const dockTitle = + cleanEnvValue(env.ORCA_DEV_DOCK_TITLE) ?? + `${BASE_APP_NAME} Dev${dockBadgeLabel ? ` [${dockBadgeLabel}]` : ''}: ${branch ?? devLabel ?? 'dev'}` + + return { + name: dockTitle, + isDev: true, + devLabel, + devBranch: branch, + devWorktreeName: worktreeName, + devRepoRoot: repoRoot, + dockBadgeLabel, + appUserModelId: createDevAppUserModelId(repoRoot ?? devLabel) + } +} diff --git a/src/main/startup/run-electron-vite-dev.test.ts b/src/main/startup/run-electron-vite-dev.test.ts index e56e8c79c18..68e7a2ae270 100644 --- a/src/main/startup/run-electron-vite-dev.test.ts +++ b/src/main/startup/run-electron-vite-dev.test.ts @@ -64,6 +64,7 @@ describe('run-electron-vite-dev', () => { ...process.env, ORCA_ELECTRON_VITE_CLI: fakeCliPath, ORCA_SKIP_DEV_CLI_PREPARE: '1', + ORCA_SKIP_DEV_ELECTRON_APP_PREPARE: '1', ORCA_DEV_WRAPPER_TEST_PID_FILE: pidFile }, stdio: 'ignore' @@ -100,4 +101,120 @@ describe('run-electron-vite-dev', () => { processesToCleanUp.delete(wrapper.pid!) } ) + + it('forwards dev instance identity to electron-vite', async () => { + const tempDir = mkdtempSync(join(tmpdir(), 'orca-dev-wrapper-')) + const pidFile = join(tempDir, 'grandchild.pid') + const envFile = join(tempDir, 'env.json') + const wrapperPath = resolve('config/scripts/run-electron-vite-dev.mjs') + const fakeCliPath = resolve('src/main/startup/__fixtures__/fake-electron-vite-dev-cli.mjs') + + const wrapper = spawn(process.execPath, [wrapperPath, '--remote-debugging-port=9444'], { + cwd: resolve('.'), + env: { + ...process.env, + ORCA_ELECTRON_VITE_CLI: fakeCliPath, + ORCA_SKIP_DEV_CLI_PREPARE: '1', + ORCA_SKIP_DEV_ELECTRON_APP_PREPARE: '1', + ORCA_DEV_WRAPPER_TEST_PID_FILE: pidFile, + ORCA_DEV_WRAPPER_TEST_ENV_FILE: envFile, + ORCA_DEV_BRANCH: 'feature/billing-shell', + ORCA_DEV_WORKTREE_NAME: 'payment-ui' + }, + stdio: 'ignore' + }) + + expect(wrapper.pid).toBeTypeOf('number') + processesToCleanUp.add(wrapper.pid!) + + await waitFor(() => { + try { + return readFileSync(envFile, 'utf8').trim().length > 0 + } catch { + return false + } + }) + + const grandchildPid = Number.parseInt(readFileSync(pidFile, 'utf8').trim(), 10) + if (Number.isFinite(grandchildPid)) { + processesToCleanUp.add(grandchildPid) + } + + const envSnapshot = JSON.parse(readFileSync(envFile, 'utf8')) as { + args: string[] + label: string + branch: string + worktreeName: string + repoRoot: string + badgeLabel: string + dockTitle: string + stableName: string | null + electronExecPath: string | null + } + expect(envSnapshot.args).toContain('--remote-debugging-port=9444') + expect(envSnapshot.label).toBe('payment-ui @ feature/billing-shell') + expect(envSnapshot.branch).toBe('feature/billing-shell') + expect(envSnapshot.worktreeName).toBe('payment-ui') + expect(envSnapshot.repoRoot).toBe(resolve('.')) + expect(envSnapshot.badgeLabel).toMatch(/^PU[A-Z0-9]{2}$/) + expect(envSnapshot.dockTitle).toMatch(/^Orca Dev \[PU[A-Z0-9]{2}\]: feature\/billing-shell$/) + expect(envSnapshot.stableName).toBeNull() + expect(envSnapshot.electronExecPath).toBeNull() + + wrapper.kill('SIGINT') + }) + + it('consumes the stable-name flag before forwarding args to electron-vite', async () => { + const tempDir = mkdtempSync(join(tmpdir(), 'orca-dev-wrapper-')) + const pidFile = join(tempDir, 'grandchild.pid') + const envFile = join(tempDir, 'env.json') + const wrapperPath = resolve('config/scripts/run-electron-vite-dev.mjs') + const fakeCliPath = resolve('src/main/startup/__fixtures__/fake-electron-vite-dev-cli.mjs') + + const wrapper = spawn( + process.execPath, + [wrapperPath, '--stable-name', '--remote-debugging-port=9445'], + { + cwd: resolve('.'), + env: { + ...process.env, + ORCA_ELECTRON_VITE_CLI: fakeCliPath, + ORCA_SKIP_DEV_CLI_PREPARE: '1', + ORCA_DEV_WRAPPER_TEST_PID_FILE: pidFile, + ORCA_DEV_WRAPPER_TEST_ENV_FILE: envFile, + ORCA_DEV_BRANCH: 'feature/stable-name', + ORCA_DEV_WORKTREE_NAME: 'stable-ui' + }, + stdio: 'ignore' + } + ) + + expect(wrapper.pid).toBeTypeOf('number') + processesToCleanUp.add(wrapper.pid!) + + await waitFor(() => { + try { + return readFileSync(envFile, 'utf8').trim().length > 0 + } catch { + return false + } + }) + + const grandchildPid = Number.parseInt(readFileSync(pidFile, 'utf8').trim(), 10) + if (Number.isFinite(grandchildPid)) { + processesToCleanUp.add(grandchildPid) + } + + const envSnapshot = JSON.parse(readFileSync(envFile, 'utf8')) as { + args: string[] + stableName: string | null + electronExecPath: string | null + } + expect(envSnapshot.args).not.toContain('--stable-name') + expect(envSnapshot.args).toContain('--remote-debugging-port=9445') + expect(envSnapshot.stableName).toBe('1') + expect(envSnapshot.electronExecPath).toBeNull() + + wrapper.kill('SIGINT') + }) }) diff --git a/src/main/window/createMainWindow.ts b/src/main/window/createMainWindow.ts index 9e4f3db56b9..bfbba3b5129 100644 --- a/src/main/window/createMainWindow.ts +++ b/src/main/window/createMainWindow.ts @@ -71,6 +71,7 @@ type CreateMainWindowOptions = { /** Why: main-process startup must register IPC handlers before the renderer * begins booting, or eager renderer calls can race into missing channels. */ deferLoad?: boolean + title?: string } export function loadMainWindow(mainWindow: BrowserWindow): void { @@ -172,6 +173,7 @@ export function createMainWindow( ...(savedBounds ? { x: savedBounds.x, y: savedBounds.y } : {}), minWidth: MIN_WIDTH, minHeight: MIN_HEIGHT, + title: opts?.title ?? 'Orca', show: false, // Why: on macOS the menu lives in the system menu bar, so the in-window // menu bar is irrelevant. On Windows/Linux we auto-hide so the menu bar diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 31f32d3155d..2fff7bc5a65 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -7,6 +7,7 @@ import type { HostedReviewForBranchArgs, HostedReviewInfo } from '../shared/hosted-review' +import type { AppIdentity } from '../shared/app-identity' import type { BaseRefDefaultResult, BrowserCookieImportResult, @@ -488,6 +489,8 @@ export type OpenCodeUsageApi = { } export type AppApi = { + /** Returns the app identity currently exposed to native chrome and the titlebar. */ + getIdentity: () => Promise /** Returns a URL base for feature-wall assets. In dev this is Vite /@fs; * in packaged builds this is file:// resources. Renderer appends filenames. */ getFeatureWallAssetBaseUrl: () => Promise diff --git a/src/preload/index.ts b/src/preload/index.ts index dcba078a0de..fd866dfd21e 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -5,6 +5,7 @@ import { contextBridge, ipcRenderer, webFrame, webUtils } from 'electron' import { electronAPI } from '@electron-toolkit/preload' import { preloadE2EConfig } from './e2e-config' import { glApi } from './gitlab' +import type { AppIdentity } from '../shared/app-identity' import type { CliInstallStatus } from '../shared/cli-install-types' import type { AgentHookInstallStatus } from '../shared/agent-hook-types' import type { @@ -324,6 +325,7 @@ document.addEventListener( // Custom APIs for renderer const api = { app: { + getIdentity: (): Promise => ipcRenderer.invoke('app:getIdentity'), getFeatureWallAssetBaseUrl: (): Promise => ipcRenderer.invoke('app:getFeatureWallAssetBaseUrl'), relaunch: (): Promise => ipcRenderer.invoke('app:relaunch'), diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index f7352272bc9..2fd028b4f83 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -89,6 +89,7 @@ import { import type { VirtualizedScrollAnchor } from './hooks/useVirtualizedScrollAnchor' import type { RemoteWorkspacePatchResult } from '../../shared/remote-workspace-types' import type { OnboardingState } from '../../shared/types' +import type { AppIdentity } from '../../shared/app-identity' const isMac = navigator.userAgent.includes('Mac') const isWindows = !isMac && navigator.userAgent.includes('Windows') @@ -350,10 +351,27 @@ function App(): React.JSX.Element { const [collapsedSidebarHeaderWidth, setCollapsedSidebarHeaderWidth] = useState(0) const [mountedLazyModalIds, setMountedLazyModalIds] = useState(() => new Set()) const [onboarding, setOnboarding] = useState(null) + const [appIdentity, setAppIdentity] = useState(null) // Subscribe to IPC push events useIpcEvents() useAutomationDispatchEvents() + + useEffect(() => { + let disposed = false + void window.api.app + .getIdentity() + .then((identity) => { + if (!disposed) { + document.title = identity.name + setAppIdentity(identity) + } + }) + .catch(console.error) + return () => { + disposed = true + } + }, []) // Why: retention must run at App level so the inline per-card agents list // always sees retained entries. If retention ran inside the sidebar-card // subtree, "done" agents would vanish any time the user collapsed a card's @@ -1103,6 +1121,17 @@ function App(): React.JSX.Element { }) }, [activeModal]) + const appIdentityTitle = + appIdentity?.isDev === true + ? [ + appIdentity.name, + appIdentity.devBranch ? `Branch: ${appIdentity.devBranch}` : null, + appIdentity.devRepoRoot ? `Path: ${appIdentity.devRepoRoot}` : null + ] + .filter(Boolean) + .join(' | ') + : undefined + // Why: extracted so both the full-width titlebar (settings/landing) and // the sidebar-width left header (workspace view) can share the same // controls without duplicating the agent badge popover. @@ -1147,8 +1176,18 @@ function App(): React.JSX.Element { {settings?.showTitlebarAppName !== false && ( -
- Orca +
+ Orca + {appIdentity?.isDev && appIdentity.devLabel ? ( + {appIdentity.devLabel} + ) : null} + {appIdentity?.isDev && appIdentity.dockBadgeLabel ? ( + {appIdentity.dockBadgeLabel} + ) : null}
diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index ea6c9d0ca4d..5819aca9fb5 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -553,7 +553,9 @@ -webkit-app-region: no-drag; display: inline-flex; align-items: center; + gap: 4px; height: 24px; + max-width: min(280px, 32vw); margin-left: 2px; margin-right: 4px; padding: 0 6px; @@ -563,9 +565,42 @@ font-weight: 600; line-height: 24px; white-space: nowrap; + overflow: hidden; cursor: pointer; } +.titlebar-app-name-main { + flex: 0 0 auto; +} + +.titlebar-dev-label { + min-width: 0; + overflow: hidden; + color: var(--foreground); + font-family: var(--font-mono); + font-weight: 500; + text-overflow: ellipsis; +} + +.titlebar-dev-label::before { + content: 'Dev:'; + margin-right: 4px; + color: var(--muted-foreground); +} + +.titlebar-dev-badge { + flex: 0 0 auto; + height: 16px; + padding: 0 4px; + border: 1px solid var(--border); + border-radius: 4px; + color: var(--muted-foreground); + font-family: var(--font-mono); + font-size: 10px; + font-weight: 600; + line-height: 14px; +} + .titlebar-icon-button { -webkit-app-region: no-drag; background: none; diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 2e9e1654d00..7f09455ea68 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -61,6 +61,16 @@ export function installWebPreloadApi(): void { function createWebPreloadApi(): Partial { return { app: { + getIdentity: () => + Promise.resolve({ + name: 'Orca', + isDev: false, + devLabel: null, + devBranch: null, + devWorktreeName: null, + devRepoRoot: null, + dockBadgeLabel: null + }), getFeatureWallAssetBaseUrl: () => Promise.resolve('/'), relaunch: () => Promise.resolve(window.location.reload()), getKeyboardInputSourceId: () => Promise.resolve(null), @@ -816,9 +826,7 @@ function createCliApi(): NonNullable['cli']> { } function createAgentHooksApi(): NonNullable['agentHooks']> { - const status = ( - agent: 'claude' | 'codex' | 'gemini' | 'cursor' | 'droid' | 'grok' | 'hermes' - ) => + const status = (agent: 'claude' | 'codex' | 'gemini' | 'cursor' | 'droid' | 'grok' | 'hermes') => Promise.resolve({ agent, state: 'not_installed', diff --git a/src/shared/app-identity.ts b/src/shared/app-identity.ts new file mode 100644 index 00000000000..9a57c484508 --- /dev/null +++ b/src/shared/app-identity.ts @@ -0,0 +1,9 @@ +export type AppIdentity = { + name: string + isDev: boolean + devLabel: string | null + devBranch: string | null + devWorktreeName: string | null + devRepoRoot: string | null + dockBadgeLabel: string | null +}