Label macOS dev Dock instances by branch (#2068)

* fix: label macOS dev Dock instances

* fix: keep dev badge visible in Dock labels

* chore: clean up dev identity plumbing

* feat: add stable-name dev command
This commit is contained in:
Neil
2026-05-15 22:46:13 -07:00
committed by GitHub
parent 4bfd1eb157
commit 2ead67c650
17 changed files with 720 additions and 17 deletions
+203 -3
View File
@@ -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
+1
View File
@@ -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",
+48
View File
@@ -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+')
})
})
+23 -5
View File
@@ -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()
}
+8 -4
View File
@@ -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<void> | 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()
+16
View File
@@ -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())
@@ -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)
@@ -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')
)
})
})
+113
View File
@@ -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)
}
}
@@ -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')
})
})
+2
View File
@@ -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
+3
View File
@@ -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<AppIdentity>
/** 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<string>
+2
View File
@@ -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<AppIdentity> => ipcRenderer.invoke('app:getIdentity'),
getFeatureWallAssetBaseUrl: (): Promise<string> =>
ipcRenderer.invoke('app:getFeatureWallAssetBaseUrl'),
relaunch: (): Promise<void> => ipcRenderer.invoke('app:relaunch'),
+41 -2
View File
@@ -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<string>())
const [onboarding, setOnboarding] = useState<OnboardingState | null>(null)
const [appIdentity, setAppIdentity] = useState<AppIdentity | null>(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 && (
<ContextMenu>
<ContextMenuTrigger asChild>
<div className="titlebar-app-name" aria-label="Orca">
Orca
<div
className="titlebar-app-name"
aria-label={appIdentity?.name ?? 'Orca'}
title={appIdentityTitle}
>
<span className="titlebar-app-name-main">Orca</span>
{appIdentity?.isDev && appIdentity.devLabel ? (
<span className="titlebar-dev-label">{appIdentity.devLabel}</span>
) : null}
{appIdentity?.isDev && appIdentity.dockBadgeLabel ? (
<span className="titlebar-dev-badge">{appIdentity.dockBadgeLabel}</span>
) : null}
</div>
</ContextMenuTrigger>
<ContextMenuContent>
+35
View File
@@ -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;
+11 -3
View File
@@ -61,6 +61,16 @@ export function installWebPreloadApi(): void {
function createWebPreloadApi(): Partial<PreloadApi> {
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<Partial<PreloadApi>['cli']> {
}
function createAgentHooksApi(): NonNullable<Partial<PreloadApi>['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',
+9
View File
@@ -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
}