diff --git a/config/scripts/locale-ko-key-overrides.json b/config/scripts/locale-ko-key-overrides.json
index 071a249e01c..953eb8a21fc 100644
--- a/config/scripts/locale-ko-key-overrides.json
+++ b/config/scripts/locale-ko-key-overrides.json
@@ -1559,12 +1559,6 @@
"auto.components.onboarding.ThemeStep.94b9dc561d": {
"ko": "설정 -> Terminal"
},
- "auto.components.pet.PetOverlay.4712d196c6": {
- "ko": "@keyframes pet-{{value0}} { from { background-position: {{value1}}px {{value2}}px; } to { background-position: {{value3}}px {{value4}}px; } }"
- },
- "auto.components.pet.PetOverlay.de932b0e8f": {
- "ko": "@keyframes pet-bob { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-4px); } }"
- },
"auto.components.repo.NestedRepoChecklist.ea54c7bf8f": {
"ko": "/"
},
diff --git a/package.json b/package.json
index 52cae65e873..8c0ff488d53 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "orca",
- "version": "1.4.122-rc.3",
+ "version": "1.4.122-rc.4",
"description": "Next-gen IDE for parallel agentic development",
"homepage": "https://github.com/stablyai/orca",
"author": "stablyai",
diff --git a/src/main/ghostty/index.ts b/src/main/ghostty/index.ts
index ac8917154a1..8557193fe9f 100644
--- a/src/main/ghostty/index.ts
+++ b/src/main/ghostty/index.ts
@@ -5,6 +5,7 @@ import type { Store } from '../persistence'
import { findGhosttyConfigPaths } from './discovery'
import { parseGhosttyConfig } from './parser'
import { mapGhosttyToOrca } from './mapper'
+import { resolveGhosttyThemeColors } from './theme-resolution'
// Why: defensive upper bound on the Ghostty config size we're willing to read
// into memory on the main process. Real configs are a few KB; anything past
@@ -61,6 +62,44 @@ function mergeParsedConfig(
}
}
+function usesConditionalThemePair(themeName: string): boolean {
+ return themeName
+ .split(',')
+ .map((part) => part.trim())
+ .some((part) => part.startsWith('light:') || part.startsWith('dark:'))
+}
+
+// Why: `theme = ` is how most Ghostty configs pick their colors, so
+// dropping it silently loses the entire palette. Resolve the referenced theme
+// file and merge its colors as defaults: explicit config keys win, and config
+// palette entries are appended after the theme's so per-index overrides apply.
+async function applyThemeReference(parsed: Record): Promise {
+ const rawTheme = parsed['theme']
+ if (rawTheme === undefined) {
+ return []
+ }
+ delete parsed['theme']
+
+ const themeName = (Array.isArray(rawTheme) ? (rawTheme.at(-1) ?? '') : rawTheme).trim()
+ if (usesConditionalThemePair(themeName)) {
+ return ['theme (light:/dark: pairs not supported)']
+ }
+
+ const themeColors = await resolveGhosttyThemeColors(themeName)
+ if (themeColors === null) {
+ return ['theme (theme file not found)']
+ }
+
+ for (const [key, value] of Object.entries(themeColors)) {
+ if (key === 'palette') {
+ parsed[key] = [...asArray(value), ...asArray(parsed[key])]
+ } else if (parsed[key] === undefined) {
+ parsed[key] = value
+ }
+ }
+ return []
+}
+
export async function previewGhosttyImport(store: Store): Promise {
const configPaths = await findGhosttyConfigPaths()
if (configPaths.length === 0) {
@@ -93,7 +132,13 @@ export async function previewGhosttyImport(store: Store): Promise = {}
diff --git a/src/main/ghostty/mapper-extended.test.ts b/src/main/ghostty/mapper-extended.test.ts
index 5c551139120..777110630c1 100644
--- a/src/main/ghostty/mapper-extended.test.ts
+++ b/src/main/ghostty/mapper-extended.test.ts
@@ -77,6 +77,110 @@ describe('mapGhosttyToOrca — window-padding', () => {
expect(result.diff).toEqual({})
expect(result.unsupportedKeys).toEqual(['window-padding-x'])
})
+
+ it('averages a dual-value window-padding-y', () => {
+ const result = mapGhosttyToOrca({ 'window-padding-y': '10,8' })
+ expect(result.diff).toEqual({ terminalPaddingY: 9 })
+ expect(result.unsupportedKeys).toEqual([])
+ })
+
+ it('averages a dual-value window-padding-x with surrounding whitespace', () => {
+ const result = mapGhosttyToOrca({ 'window-padding-x': '16, 12' })
+ expect(result.diff).toEqual({ terminalPaddingX: 14 })
+ expect(result.unsupportedKeys).toEqual([])
+ })
+
+ it('preserves odd-sum dual-value padding as a fractional average', () => {
+ const result = mapGhosttyToOrca({ 'window-padding-x': '1,2' })
+ expect(result.diff).toEqual({ terminalPaddingX: 1.5 })
+ expect(result.unsupportedKeys).toEqual([])
+ })
+
+ it('rejects a dual-value padding with an invalid half', () => {
+ const result = mapGhosttyToOrca({ 'window-padding-y': '10,wide' })
+ expect(result.diff).toEqual({})
+ expect(result.unsupportedKeys).toEqual(['window-padding-y'])
+ })
+
+ it('rejects a dual-value padding with an out-of-range half', () => {
+ const result = mapGhosttyToOrca({ 'window-padding-y': '10,600' })
+ expect(result.diff).toEqual({})
+ expect(result.unsupportedKeys).toEqual(['window-padding-y'])
+ })
+
+ it('rejects paddings with more than two values', () => {
+ const result = mapGhosttyToOrca({ 'window-padding-y': '10,8,6' })
+ expect(result.diff).toEqual({})
+ expect(result.unsupportedKeys).toEqual(['window-padding-y'])
+ })
+})
+
+describe('mapGhosttyToOrca — adjust-cell-height', () => {
+ it('maps a percentage to terminalLineHeight', () => {
+ const result = mapGhosttyToOrca({ 'adjust-cell-height': '35%' })
+ expect(result.diff).toEqual({ terminalLineHeight: 1.35 })
+ expect(result.unsupportedKeys).toEqual([])
+ })
+
+ it('rounds the mapped line height to two decimals', () => {
+ const result = mapGhosttyToOrca({ 'adjust-cell-height': '33%' })
+ expect(result.diff).toEqual({ terminalLineHeight: 1.33 })
+ expect(result.unsupportedKeys).toEqual([])
+ })
+
+ it('rounds half-boundary decimal percentages to the nearest hundredth', () => {
+ const result = mapGhosttyToOrca({ 'adjust-cell-height': '0.5%' })
+ expect(result.diff).toEqual({ terminalLineHeight: 1.01 })
+ expect(result.unsupportedKeys).toEqual([])
+ })
+
+ it('rounds higher half-boundary decimal percentages to the nearest hundredth', () => {
+ const result = mapGhosttyToOrca({ 'adjust-cell-height': '1.5%' })
+ expect(result.diff).toEqual({ terminalLineHeight: 1.02 })
+ expect(result.unsupportedKeys).toEqual([])
+ })
+
+ it('rounds high half-boundary decimal percentages without floating-point drift', () => {
+ const result = mapGhosttyToOrca({ 'adjust-cell-height': '101.5%' })
+ expect(result.diff).toEqual({ terminalLineHeight: 2.02 })
+ expect(result.unsupportedKeys).toEqual([])
+ })
+
+ it('accepts the inclusive 1x line-height floor', () => {
+ const result = mapGhosttyToOrca({ 'adjust-cell-height': '0%' })
+ expect(result.diff).toEqual({ terminalLineHeight: 1 })
+ expect(result.unsupportedKeys).toEqual([])
+ })
+
+ it('accepts the inclusive 3x line-height ceiling', () => {
+ const result = mapGhosttyToOrca({ 'adjust-cell-height': '200%' })
+ expect(result.diff).toEqual({ terminalLineHeight: 3 })
+ expect(result.unsupportedKeys).toEqual([])
+ })
+
+ it('rejects a pixel value (not convertible to a line-height ratio)', () => {
+ const result = mapGhosttyToOrca({ 'adjust-cell-height': '2' })
+ expect(result.diff).toEqual({})
+ expect(result.unsupportedKeys).toEqual(['adjust-cell-height'])
+ })
+
+ it('rejects a negative percentage (below the 1x line-height floor)', () => {
+ const result = mapGhosttyToOrca({ 'adjust-cell-height': '-10%' })
+ expect(result.diff).toEqual({})
+ expect(result.unsupportedKeys).toEqual(['adjust-cell-height'])
+ })
+
+ it('rejects a percentage above the 3x line-height ceiling', () => {
+ const result = mapGhosttyToOrca({ 'adjust-cell-height': '250%' })
+ expect(result.diff).toEqual({})
+ expect(result.unsupportedKeys).toEqual(['adjust-cell-height'])
+ })
+
+ it('rejects values that only fall inside the ceiling after rounding', () => {
+ const result = mapGhosttyToOrca({ 'adjust-cell-height': '200.4%' })
+ expect(result.diff).toEqual({})
+ expect(result.unsupportedKeys).toEqual(['adjust-cell-height'])
+ })
})
describe('mapGhosttyToOrca — cursor-text', () => {
diff --git a/src/main/ghostty/mapper.ts b/src/main/ghostty/mapper.ts
index 69abcbff4d7..7c0588f4c2b 100644
--- a/src/main/ghostty/mapper.ts
+++ b/src/main/ghostty/mapper.ts
@@ -4,6 +4,7 @@ import type {
GhosttyImportPreview
} from '../../shared/types'
import { HEX_COLOR_RE } from '../../shared/color-validation'
+import { parsePaddingValue, parseStrictInt } from './numeric-config-values'
const PALETTE_INDEX_MAP: Record = {
0: 'black',
@@ -34,18 +35,6 @@ type FieldResult =
const normalizeHex = (v: string): string => (v.startsWith('#') ? v : `#${v}`)
-// Why: `Number("1e10")` succeeds and passes `Number.isInteger`, so a Ghostty
-// config with `window-padding-x = 1e9` would sail through the mapper and land
-// an absurd value in the store. Restrict to plain decimal integers.
-const STRICT_INT_RE = /^-?\d+$/
-const parseStrictInt = (v: string): number | null => {
- if (!STRICT_INT_RE.test(v)) {
- return null
- }
- const num = Number(v)
- return Number.isFinite(num) ? num : null
-}
-
type FieldParser = (value: string, rawValue: string | string[]) => FieldResult
export function mapGhosttyToOrca(
@@ -171,21 +160,38 @@ export function mapGhosttyToOrca(
},
'window-padding-x': (v) => {
- const num = parseStrictInt(v)
- if (num === null || num < 0 || num > 512) {
+ const num = parsePaddingValue(v)
+ if (num === null) {
return null
}
return { key: 'terminalPaddingX', value: num }
},
'window-padding-y': (v) => {
- const num = parseStrictInt(v)
- if (num === null || num < 0 || num > 512) {
+ const num = parsePaddingValue(v)
+ if (num === null) {
return null
}
return { key: 'terminalPaddingY', value: num }
},
+ // Why: only percentages translate to xterm's line-height ratio; Ghostty's
+ // pixel form depends on the rendered cell height, which we can't know here.
+ // The settings UI clamps terminalLineHeight to [1, 3], so reject outside it.
+ 'adjust-cell-height': (v) => {
+ const match = /^\+?(\d+(?:\.\d+)?)%$/.exec(v.trim())
+ if (!match) {
+ return null
+ }
+ const percent = Number(match[1])
+ const rawLineHeight = 1 + percent / 100
+ if (rawLineHeight > 3) {
+ return null
+ }
+ const lineHeight = Math.round(100 + percent) / 100
+ return { key: 'terminalLineHeight', value: lineHeight }
+ },
+
'cursor-text': (v) => {
if (!HEX_COLOR_RE.test(v)) {
return null
diff --git a/src/main/ghostty/numeric-config-values.ts b/src/main/ghostty/numeric-config-values.ts
new file mode 100644
index 00000000000..f8a7ce7b468
--- /dev/null
+++ b/src/main/ghostty/numeric-config-values.ts
@@ -0,0 +1,30 @@
+// Why: `Number("1e10")` succeeds and passes `Number.isInteger`, so a Ghostty
+// config with `window-padding-x = 1e9` would sail through the mapper and land
+// an absurd value in the store. Restrict to plain decimal integers.
+const STRICT_INT_RE = /^-?\d+$/
+export const parseStrictInt = (v: string): number | null => {
+ if (!STRICT_INT_RE.test(v)) {
+ return null
+ }
+ const num = Number(v)
+ return Number.isFinite(num) ? num : null
+}
+
+// Why: Ghostty accepts "top,bottom" / "left,right" pairs for window paddings,
+// but Orca stores a single value per axis — average the pair so the total
+// padding along the axis stays the same.
+export const parsePaddingValue = (v: string): number | null => {
+ const parts = v.split(',')
+ if (parts.length > 2) {
+ return null
+ }
+ const nums: number[] = []
+ for (const part of parts) {
+ const num = parseStrictInt(part.trim())
+ if (num === null || num < 0 || num > 512) {
+ return null
+ }
+ nums.push(num)
+ }
+ return nums.reduce((sum, num) => sum + num, 0) / nums.length
+}
diff --git a/src/main/ghostty/theme-import.test.ts b/src/main/ghostty/theme-import.test.ts
new file mode 100644
index 00000000000..c2d2ec61ab9
--- /dev/null
+++ b/src/main/ghostty/theme-import.test.ts
@@ -0,0 +1,171 @@
+import type { Store } from '../persistence'
+import type { GlobalSettings } from '../../shared/types'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+const { statMock, readFileMock } = vi.hoisted(() => ({
+ statMock: vi.fn(),
+ readFileMock: vi.fn()
+}))
+
+vi.mock('fs/promises', () => ({
+ stat: statMock,
+ readFile: readFileMock
+}))
+
+vi.mock('os', () => ({
+ platform: vi.fn(() => 'darwin'),
+ homedir: vi.fn(() => '/Users/alice')
+}))
+
+import { previewGhosttyImport } from './index'
+
+const originalXdgConfigHome = process.env.XDG_CONFIG_HOME
+const originalGhosttyResourcesDir = process.env.GHOSTTY_RESOURCES_DIR
+
+beforeEach(() => {
+ delete process.env.XDG_CONFIG_HOME
+ delete process.env.GHOSTTY_RESOURCES_DIR
+})
+
+afterEach(() => {
+ vi.clearAllMocks()
+ if (originalXdgConfigHome !== undefined) {
+ process.env.XDG_CONFIG_HOME = originalXdgConfigHome
+ } else {
+ delete process.env.XDG_CONFIG_HOME
+ }
+ if (originalGhosttyResourcesDir !== undefined) {
+ process.env.GHOSTTY_RESOURCES_DIR = originalGhosttyResourcesDir
+ } else {
+ delete process.env.GHOSTTY_RESOURCES_DIR
+ }
+})
+
+function createStore(settings: Record = {}): Store {
+ return {
+ getSettings: () => settings as GlobalSettings
+ } as Store
+}
+
+describe('previewGhosttyImport theme references', () => {
+ it('resolves a theme reference into color overrides', async () => {
+ const configPath = '/Users/alice/Library/Application Support/com.mitchellh.ghostty/config'
+ const themePath = '/Users/alice/.config/ghostty/themes/Tomorrow Night Bright'
+ statMock.mockImplementation(async (p: string) => {
+ if (p === configPath || p === themePath) {
+ return { isFile: () => true, size: 128 }
+ }
+ throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
+ })
+ readFileMock.mockImplementation(async (p: string) => {
+ if (p === themePath) {
+ return 'palette = 1=#d54e53\nbackground = #000000\nforeground = #eaeaea\n'
+ }
+ return 'theme = Tomorrow Night Bright\nfont-size = 14\n'
+ })
+
+ const result = await previewGhosttyImport(createStore())
+
+ expect(result.diff).toEqual({
+ terminalFontSize: 14,
+ terminalColorOverrides: {
+ red: '#d54e53',
+ background: '#000000',
+ foreground: '#eaeaea'
+ }
+ })
+ expect(result.unsupportedKeys).toEqual([])
+ })
+
+ it('lets explicit config colors override theme colors', async () => {
+ const configPath = '/Users/alice/Library/Application Support/com.mitchellh.ghostty/config'
+ const themePath = '/Users/alice/.config/ghostty/themes/night'
+ statMock.mockImplementation(async (p: string) => {
+ if (p === configPath || p === themePath) {
+ return { isFile: () => true, size: 128 }
+ }
+ throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
+ })
+ readFileMock.mockImplementation(async (p: string) => {
+ if (p === themePath) {
+ return 'palette = 1=#d54e53\npalette = 2=#b9ca4a\nbackground = #000000\n'
+ }
+ return 'theme = night\nbackground = #101010\npalette = 1=#ff0000\n'
+ })
+
+ const result = await previewGhosttyImport(createStore())
+
+ expect(result.diff).toEqual({
+ terminalColorOverrides: {
+ // Why: config's palette index 1 and background win; theme keeps index 2.
+ red: '#ff0000',
+ green: '#b9ca4a',
+ background: '#101010'
+ }
+ })
+ expect(result.unsupportedKeys).toEqual([])
+ })
+
+ it('resolves an absolute theme path without probing theme search dirs', async () => {
+ const configPath = '/Users/alice/Library/Application Support/com.mitchellh.ghostty/config'
+ const themePath = '/Users/alice/themes/work'
+ statMock.mockImplementation(async (p: string) => {
+ if (p === configPath || p === themePath) {
+ return { isFile: () => true, size: 128 }
+ }
+ throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
+ })
+ readFileMock.mockImplementation(async (p: string) => {
+ if (p === themePath) {
+ return 'background = #202020\nforeground = #f0f0f0\n'
+ }
+ return `theme = ${themePath}\n`
+ })
+
+ const result = await previewGhosttyImport(createStore())
+
+ expect(result.diff).toEqual({
+ terminalColorOverrides: {
+ background: '#202020',
+ foreground: '#f0f0f0'
+ }
+ })
+ expect(statMock).toHaveBeenCalledWith(themePath)
+ expect(statMock).not.toHaveBeenCalledWith(
+ '/Users/alice/.config/ghostty/themes/Users/alice/themes/work'
+ )
+ expect(result.unsupportedKeys).toEqual([])
+ })
+
+ it('marks an unresolvable theme as unsupported', async () => {
+ const configPath = '/Users/alice/Library/Application Support/com.mitchellh.ghostty/config'
+ statMock.mockImplementation(async (p: string) => {
+ if (p === configPath) {
+ return { isFile: () => true, size: 128 }
+ }
+ throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
+ })
+ readFileMock.mockResolvedValue('theme = Missing Theme\n')
+
+ const result = await previewGhosttyImport(createStore())
+
+ expect(result.diff).toEqual({})
+ expect(result.unsupportedKeys).toEqual(['theme (theme file not found)'])
+ })
+
+ it('marks light:/dark: theme pairs as unsupported', async () => {
+ const configPath = '/Users/alice/Library/Application Support/com.mitchellh.ghostty/config'
+ statMock.mockImplementation(async (p: string) => {
+ if (p === configPath) {
+ return { isFile: () => true, size: 128 }
+ }
+ throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
+ })
+ readFileMock.mockResolvedValue('theme = light:Tomorrow,dark:Tomorrow Night\n')
+
+ const result = await previewGhosttyImport(createStore())
+
+ expect(result.diff).toEqual({})
+ expect(result.unsupportedKeys).toEqual(['theme (light:/dark: pairs not supported)'])
+ })
+})
diff --git a/src/main/ghostty/theme-resolution.test.ts b/src/main/ghostty/theme-resolution.test.ts
new file mode 100644
index 00000000000..607e69a39e8
--- /dev/null
+++ b/src/main/ghostty/theme-resolution.test.ts
@@ -0,0 +1,171 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+const { statMock, readFileMock } = vi.hoisted(() => ({
+ statMock: vi.fn(),
+ readFileMock: vi.fn()
+}))
+
+vi.mock('fs/promises', () => ({
+ stat: statMock,
+ readFile: readFileMock
+}))
+
+vi.mock('os', () => ({
+ platform: vi.fn(() => 'darwin'),
+ homedir: vi.fn(() => '/Users/alice')
+}))
+
+import { getGhosttyThemeSearchDirs, resolveGhosttyThemeColors } from './theme-resolution'
+
+const originalXdgConfigHome = process.env.XDG_CONFIG_HOME
+const originalGhosttyResourcesDir = process.env.GHOSTTY_RESOURCES_DIR
+
+beforeEach(() => {
+ delete process.env.XDG_CONFIG_HOME
+ delete process.env.GHOSTTY_RESOURCES_DIR
+})
+
+afterEach(() => {
+ vi.clearAllMocks()
+ if (originalXdgConfigHome !== undefined) {
+ process.env.XDG_CONFIG_HOME = originalXdgConfigHome
+ } else {
+ delete process.env.XDG_CONFIG_HOME
+ }
+ if (originalGhosttyResourcesDir !== undefined) {
+ process.env.GHOSTTY_RESOURCES_DIR = originalGhosttyResourcesDir
+ } else {
+ delete process.env.GHOSTTY_RESOURCES_DIR
+ }
+})
+
+const THEME_FILE = `
+palette = 0=#000000
+palette = 1=#d54e53
+background = #000000
+foreground = #eaeaea
+cursor-color = #eaeaea
+selection-background = #424242
+selection-foreground = #eaeaea
+`
+
+describe('getGhosttyThemeSearchDirs', () => {
+ it('probes XDG, then the Ghostty.app resources dir', () => {
+ delete process.env.XDG_CONFIG_HOME
+ expect(getGhosttyThemeSearchDirs()).toEqual([
+ '/Users/alice/.config/ghostty/themes',
+ '/Applications/Ghostty.app/Contents/Resources/ghostty/themes'
+ ])
+ })
+
+ it('honors XDG_CONFIG_HOME for the custom themes dir', () => {
+ process.env.XDG_CONFIG_HOME = '/xdg'
+ expect(getGhosttyThemeSearchDirs()[0]).toBe('/xdg/ghostty/themes')
+ })
+
+ it('honors GHOSTTY_RESOURCES_DIR for bundled themes', () => {
+ process.env.GHOSTTY_RESOURCES_DIR = '/opt/ghostty'
+ expect(getGhosttyThemeSearchDirs()).toEqual([
+ '/Users/alice/.config/ghostty/themes',
+ '/opt/ghostty/themes'
+ ])
+ })
+})
+
+describe('resolveGhosttyThemeColors', () => {
+ it('reads the first matching theme file and returns only color keys', async () => {
+ statMock.mockImplementation(async (p: string) => {
+ if (p === '/Users/alice/.config/ghostty/themes/Tomorrow Night Bright') {
+ return { isFile: () => true, size: THEME_FILE.length }
+ }
+ throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
+ })
+ readFileMock.mockResolvedValue(THEME_FILE)
+
+ const result = await resolveGhosttyThemeColors('Tomorrow Night Bright')
+ expect(result).toEqual({
+ palette: ['0=#000000', '1=#d54e53'],
+ background: '#000000',
+ foreground: '#eaeaea',
+ 'cursor-color': '#eaeaea',
+ 'selection-background': '#424242',
+ 'selection-foreground': '#eaeaea'
+ })
+ })
+
+ it('falls through to later dirs when earlier ones miss', async () => {
+ statMock.mockImplementation(async (p: string) => {
+ if (p === '/Applications/Ghostty.app/Contents/Resources/ghostty/themes/Tomorrow') {
+ return { isFile: () => true, size: THEME_FILE.length }
+ }
+ throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
+ })
+ readFileMock.mockResolvedValue('background = #1d1f21')
+
+ const result = await resolveGhosttyThemeColors('Tomorrow')
+ expect(result).toEqual({ background: '#1d1f21' })
+ })
+
+ it('resolves absolute theme paths without searching named theme dirs', async () => {
+ statMock.mockImplementation(async (p: string) => {
+ if (p === '/Users/alice/themes/work') {
+ return { isFile: () => true, size: 64 }
+ }
+ throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
+ })
+ readFileMock.mockResolvedValue('background = #1d1f21')
+
+ const result = await resolveGhosttyThemeColors('/Users/alice/themes/work')
+ expect(result).toEqual({ background: '#1d1f21' })
+ expect(statMock).toHaveBeenCalledTimes(1)
+ })
+
+ it('drops non-color keys a theme file might carry', async () => {
+ statMock.mockImplementation(async (p: string) => {
+ if (p === '/Users/alice/.config/ghostty/themes/custom') {
+ return { isFile: () => true, size: 64 }
+ }
+ throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
+ })
+ readFileMock.mockResolvedValue('background = #101010\nfont-size = 20')
+
+ const result = await resolveGhosttyThemeColors('custom')
+ expect(result).toEqual({ background: '#101010' })
+ })
+
+ it('returns null when the theme is not found anywhere', async () => {
+ statMock.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }))
+ expect(await resolveGhosttyThemeColors('Missing Theme')).toBeNull()
+ })
+
+ it('rejects names containing path separators or traversal', async () => {
+ expect(await resolveGhosttyThemeColors('../etc/passwd')).toBeNull()
+ expect(await resolveGhosttyThemeColors('a/b')).toBeNull()
+ expect(await resolveGhosttyThemeColors('a\\b')).toBeNull()
+ expect(await resolveGhosttyThemeColors('..')).toBeNull()
+ expect(statMock).not.toHaveBeenCalled()
+ })
+
+ it('rejects oversized theme files', async () => {
+ statMock.mockImplementation(async () => ({ isFile: () => true, size: 10_000_000 }))
+ expect(await resolveGhosttyThemeColors('huge')).toBeNull()
+ expect(readFileMock).not.toHaveBeenCalled()
+ expect(statMock).toHaveBeenCalledTimes(1)
+ })
+
+ it('does not fall through when a shadowing theme path is invalid', async () => {
+ statMock.mockImplementation(async (p: string) => {
+ if (p === '/Users/alice/.config/ghostty/themes/night') {
+ return { isFile: () => false, size: 128 }
+ }
+ if (p === '/Applications/Ghostty.app/Contents/Resources/ghostty/themes/night') {
+ return { isFile: () => true, size: 128 }
+ }
+ throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
+ })
+
+ expect(await resolveGhosttyThemeColors('night')).toBeNull()
+ expect(readFileMock).not.toHaveBeenCalled()
+ expect(statMock).toHaveBeenCalledTimes(1)
+ })
+})
diff --git a/src/main/ghostty/theme-resolution.ts b/src/main/ghostty/theme-resolution.ts
new file mode 100644
index 00000000000..eedb2c1592d
--- /dev/null
+++ b/src/main/ghostty/theme-resolution.ts
@@ -0,0 +1,120 @@
+import { homedir, platform } from 'node:os'
+import path from 'node:path'
+import { readFile, stat } from 'node:fs/promises'
+import { parseGhosttyConfig } from './parser'
+
+// Why: theme files ship a few dozen short lines; anything larger is not a
+// Ghostty theme and should not be read into the main process.
+const MAX_THEME_BYTES = 262_144
+type ThemeColors = Record
+type ThemeReadResult =
+ | { status: 'found'; colors: ThemeColors }
+ | { status: 'missing' }
+ | { status: 'invalid' }
+
+// Why: only color-bearing keys may flow from a theme into the import — a theme
+// file must not be able to smuggle font/window settings past the user's config.
+const THEME_COLOR_KEYS = new Set([
+ 'palette',
+ 'background',
+ 'foreground',
+ 'cursor-color',
+ 'cursor-text',
+ 'selection-background',
+ 'selection-foreground',
+ 'bold-color',
+ 'split-divider-color'
+])
+
+function xdgThemeDirs(home: string): string[] {
+ if (process.env.XDG_CONFIG_HOME) {
+ return [path.posix.join(process.env.XDG_CONFIG_HOME, 'ghostty', 'themes')]
+ }
+ return [path.posix.join(home, '.config', 'ghostty', 'themes')]
+}
+
+function resourceThemeDirs(plat: 'darwin' | 'linux'): string[] {
+ if (process.env.GHOSTTY_RESOURCES_DIR) {
+ return [path.posix.join(process.env.GHOSTTY_RESOURCES_DIR, 'themes')]
+ }
+
+ if (plat === 'darwin') {
+ return ['/Applications/Ghostty.app/Contents/Resources/ghostty/themes']
+ }
+ return ['/usr/share/ghostty/themes', '/usr/local/share/ghostty/themes']
+}
+
+// Why: mirrors Ghostty's lookup — user themes shadow the bundled themes shipped
+// inside the Ghostty resources directory.
+export function getGhosttyThemeSearchDirs(): string[] {
+ const home = homedir()
+ const plat = platform()
+ if (plat !== 'darwin' && plat !== 'linux') {
+ // Why: Ghostty has no Windows build, so there are no named theme dirs to probe.
+ return []
+ }
+ return [...xdgThemeDirs(home), ...resourceThemeDirs(plat)]
+}
+
+function isMissingThemeError(err: unknown): boolean {
+ if (typeof err !== 'object' || err === null || !('code' in err)) {
+ return false
+ }
+ const code = (err as { code?: unknown }).code
+ return code === 'ENOENT' || code === 'ENOTDIR'
+}
+
+function isRelativeThemeName(name: string): boolean {
+ return !name.includes('/') && !name.includes('\\') && name !== '..' && name !== '.'
+}
+
+function themeColorsFromContent(content: string): ThemeColors {
+ const parsed = parseGhosttyConfig(content)
+ const colors: ThemeColors = {}
+ for (const [key, value] of Object.entries(parsed)) {
+ if (THEME_COLOR_KEYS.has(key)) {
+ colors[key] = value
+ }
+ }
+ return colors
+}
+
+async function readThemeColors(themePath: string): Promise {
+ let content: string
+ try {
+ const info = await stat(themePath)
+ if (!info.isFile() || info.size > MAX_THEME_BYTES) {
+ return { status: 'invalid' }
+ }
+ content = await readFile(themePath, 'utf-8')
+ } catch (err) {
+ return { status: isMissingThemeError(err) ? 'missing' : 'invalid' }
+ }
+
+ return { status: 'found', colors: themeColorsFromContent(content) }
+}
+
+export async function resolveGhosttyThemeColors(name: string): Promise {
+ if (path.isAbsolute(name)) {
+ const result = await readThemeColors(name)
+ return result.status === 'found' ? result.colors : null
+ }
+
+ // Why: relative theme names become filenames below; only absolute paths may
+ // contain separators, matching Ghostty's traversal guard.
+ if (!isRelativeThemeName(name)) {
+ return null
+ }
+
+ for (const dir of getGhosttyThemeSearchDirs()) {
+ const themePath = path.posix.join(dir, name)
+ const result = await readThemeColors(themePath)
+ if (result.status === 'found') {
+ return result.colors
+ }
+ if (result.status === 'invalid') {
+ return null
+ }
+ }
+ return null
+}
diff --git a/src/main/github/pr-start-point.test.ts b/src/main/github/pr-start-point.test.ts
index 91d8e5ad07e..8783b2a4af4 100644
--- a/src/main/github/pr-start-point.test.ts
+++ b/src/main/github/pr-start-point.test.ts
@@ -22,12 +22,12 @@ describe('resolveGitHubPrStartPoint', () => {
getPullRequestPushTargetMock.mockResolvedValue({
pushTarget: {
remoteName: 'pr-contributor-orca',
- branchName: 'feat/onboarding-model-choice-782',
+ branchName: 'fix-issue-6933',
remoteUrl: 'git@github.com:contributor/orca.git'
}
})
const fetchRemoteTrackingRef = vi.fn(async (_remote: string, branch: string) => {
- if (branch === 'feat/onboarding-model-choice-782') {
+ if (branch === 'fix-issue-6933') {
throw new Error('fatal: could not find remote ref')
}
})
@@ -40,28 +40,25 @@ describe('resolveGitHubPrStartPoint', () => {
const result = await resolveGitHubPrStartPoint({
repoPath: '/repo-root',
- prNumber: 1849,
- headRefName: 'feat/onboarding-model-choice-782',
+ prNumber: 6934,
+ headRefName: 'fix-issue-6933',
baseRefName: 'main',
gitExec,
fetchRemoteTrackingRef,
resolveRemote: async () => 'origin'
})
- expect(fetchRemoteTrackingRef).toHaveBeenCalledWith(
- 'origin',
- 'feat/onboarding-model-choice-782'
- )
+ expect(fetchRemoteTrackingRef).toHaveBeenCalledWith('origin', 'fix-issue-6933')
expect(fetchRemoteTrackingRef).toHaveBeenCalledWith('origin', 'main')
- expect(gitExec).toHaveBeenCalledWith(['fetch', 'origin', 'refs/pull/1849/head'])
+ expect(gitExec).toHaveBeenCalledWith(['fetch', 'origin', 'refs/pull/6934/head'])
expect(result).toEqual({
baseBranch: 'def456',
compareBaseRef: 'refs/remotes/origin/main',
headSha: 'def456',
- branchNameOverride: 'feat/onboarding-model-choice-782',
+ branchNameOverride: 'fix-issue-6933',
pushTarget: {
remoteName: 'pr-contributor-orca',
- branchName: 'feat/onboarding-model-choice-782',
+ branchName: 'fix-issue-6933',
remoteUrl: 'git@github.com:contributor/orca.git'
}
})
diff --git a/src/main/providers/local-pty-provider.test.ts b/src/main/providers/local-pty-provider.test.ts
index bc236e9f9bd..d37faa0ac57 100644
--- a/src/main/providers/local-pty-provider.test.ts
+++ b/src/main/providers/local-pty-provider.test.ts
@@ -98,6 +98,7 @@ describe('LocalPtyProvider', () => {
let exitCb: ((info: { exitCode: number }) => void) | undefined
let origShell: string | undefined
let origPowerlevelWizardDisable: string | undefined
+ let origHistFile: string | undefined
let origPlatform: PropertyDescriptor | undefined
beforeEach(() => {
@@ -105,8 +106,11 @@ describe('LocalPtyProvider', () => {
Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' })
origShell = process.env.SHELL
origPowerlevelWizardDisable = process.env.POWERLEVEL9K_DISABLE_CONFIGURATION_WIZARD
+ origHistFile = process.env.HISTFILE
process.env.SHELL = '/bin/zsh'
delete process.env.POWERLEVEL9K_DISABLE_CONFIGURATION_WIZARD
+ // injectHistoryEnv preserves an inherited HISTFILE, so clear it for hermetic history assertions.
+ delete process.env.HISTFILE
existsSyncMock.mockReturnValue(true)
statSyncMock.mockReturnValue({ isDirectory: () => true, mode: 0o755 })
@@ -158,6 +162,11 @@ describe('LocalPtyProvider', () => {
} else {
process.env.POWERLEVEL9K_DISABLE_CONFIGURATION_WIZARD = origPowerlevelWizardDisable
}
+ if (origHistFile === undefined) {
+ delete process.env.HISTFILE
+ } else {
+ process.env.HISTFILE = origHistFile
+ }
})
describe('spawn', () => {
diff --git a/src/renderer/src/components/pet/PetOverlay.keyframes.test.tsx b/src/renderer/src/components/pet/PetOverlay.keyframes.test.tsx
new file mode 100644
index 00000000000..5e264ff06b6
--- /dev/null
+++ b/src/renderer/src/components/pet/PetOverlay.keyframes.test.tsx
@@ -0,0 +1,107 @@
+// @vitest-environment happy-dom
+
+import { act } from 'react'
+import { createRoot, type Root } from 'react-dom/client'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+import { i18n } from '../../i18n/i18n'
+
+const storeState = vi.hoisted(() => ({
+ agentStatusByPaneKey: {},
+ agentStatusEpoch: 0,
+ retainedAgentsByPaneKey: {},
+ petSize: 180
+}))
+
+vi.mock('../../store', () => ({
+ useAppStore: Object.assign(
+ (selector: (state: typeof storeState) => T): T => {
+ return selector(storeState)
+ },
+ {
+ getState: () => storeState
+ }
+ )
+}))
+
+vi.mock('./usePetUrl', () => ({
+ usePetUrl: () => ({
+ url: 'blob:custom-pet',
+ ready: true,
+ sprite: {
+ frameWidth: 32,
+ frameHeight: 24,
+ columns: 6,
+ rows: 1,
+ sheetWidth: 192,
+ sheetHeight: 24,
+ fps: 6,
+ defaultAnimation: 'idle',
+ animations: {
+ idle: { row: 0, frames: 6 }
+ }
+ },
+ detected: null
+ })
+}))
+
+import { PetOverlay } from './PetOverlay'
+
+function renderPetOverlay(): { container: HTMLDivElement; root: Root } {
+ const container = document.createElement('div')
+ document.body.appendChild(container)
+ const root = createRoot(container)
+ act(() => {
+ root.render()
+ })
+ return { container, root }
+}
+
+function installLocalStorage(): void {
+ const values = new Map()
+ Object.defineProperty(window, 'localStorage', {
+ configurable: true,
+ value: {
+ clear: () => values.clear(),
+ getItem: (key: string) => values.get(key) ?? null,
+ removeItem: (key: string) => values.delete(key),
+ setItem: (key: string, value: string) => values.set(key, value)
+ }
+ })
+}
+
+describe('PetOverlay sprite keyframes', () => {
+ let root: Root | null = null
+ let container: HTMLDivElement | null = null
+
+ beforeEach(async () => {
+ await i18n.changeLanguage('zh')
+ installLocalStorage()
+ })
+
+ afterEach(async () => {
+ if (root) {
+ act(() => root?.unmount())
+ }
+ container?.remove()
+ root = null
+ container = null
+ await i18n.changeLanguage('en')
+ })
+
+ it('keeps custom spritesheet keyframes valid in translated locales', () => {
+ ;({ container, root } = renderPetOverlay())
+
+ const css = Array.from(container.querySelectorAll('style'))
+ .map((style) => style.textContent ?? '')
+ .join('\n')
+
+ expect(css).toContain('@keyframes pet-bob')
+ expect(css).toContain('transform: translateY(0)')
+ expect(css).toContain('background-position:')
+ expect(css).toContain('to { background-position:')
+ expect(css).not.toContain('变换')
+ expect(css).not.toContain('背景位置')
+ expect(css).not.toContain('到 {')
+ })
+})
diff --git a/src/renderer/src/components/pet/PetOverlay.tsx b/src/renderer/src/components/pet/PetOverlay.tsx
index e55004a4788..89fc81dc6da 100644
--- a/src/renderer/src/components/pet/PetOverlay.tsx
+++ b/src/renderer/src/components/pet/PetOverlay.tsx
@@ -6,7 +6,6 @@ import type { CustomPet } from '../../../../shared/types'
import { useAppStore } from '../../store'
import { AGENT_STATUS_STALE_AFTER_MS } from '../../../../shared/agent-status-types'
import { selectPetAnimationName, type PetAnimationName } from './pet-agent-state'
-import { translate } from '@/i18n/i18n'
type Sprite = NonNullable
@@ -66,15 +65,12 @@ function SpriteFrame({
const startY = -(row * sprite.frameHeight * scale)
const endX = -(frames * sprite.frameWidth * scale)
const duration = Math.max(0.1, frames / Math.max(0.1, sprite.fps))
+ // Why: sprite keyframes are runtime CSS, not user-visible copy; translated
+ // CSS keywords make the browser discard the animation.
+ const keyframesCss = `@keyframes pet-${animKeyframesId} { from { background-position: ${startX}px ${startY}px; } to { background-position: ${endX}px ${startY}px; } }`
return (
<>
-
+
-
+
{sprite ? (
)
}
- if (actionState === 'open') {
- return (
-
- {translate('auto.components.right.sidebar.checks.panel.content.7c1f0a2b11', 'Open')}
-
- )
- }
if (actionState === 'resolved') {
return (
diff --git a/src/renderer/src/components/right-sidebar/pr-comment-presentation.ts b/src/renderer/src/components/right-sidebar/pr-comment-presentation.ts
index dcd72e755a7..6ce3560f6a1 100644
--- a/src/renderer/src/components/right-sidebar/pr-comment-presentation.ts
+++ b/src/renderer/src/components/right-sidebar/pr-comment-presentation.ts
@@ -41,7 +41,6 @@ export type PRCommentPresentationClasses = {
audienceTab: string
audienceTabActive: string
sectionTriageLabel: string
- statusBadgeOpen: string
statusBadgeResolved: string
statusBadgeQueued: string
commentHeaderPrimary: string
@@ -160,8 +159,6 @@ export function getPRCommentPresentationClasses(
'flex h-7 items-center justify-center gap-1 rounded-md px-1.5 text-[11px] font-medium text-muted-foreground transition-colors',
audienceTabActive: 'bg-muted text-foreground',
sectionTriageLabel: cn('px-3 pt-2', RESOLVED_SECTION_LABEL),
- statusBadgeOpen:
- 'shrink-0 rounded border border-status-success-border bg-status-success-background px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-status-success',
statusBadgeResolved:
'shrink-0 rounded border border-border bg-muted px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground',
statusBadgeQueued:
@@ -211,8 +208,6 @@ export function getPRCommentPresentationClasses(
'flex h-7 items-center justify-center gap-1 rounded-md px-1.5 text-[11px] font-medium text-muted-foreground transition-colors',
audienceTabActive: 'bg-muted text-foreground shadow-xs',
sectionTriageLabel: cn('px-3 pt-1', RESOLVED_SECTION_LABEL),
- statusBadgeOpen:
- 'shrink-0 rounded border border-status-success-border bg-status-success-background px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-status-success',
statusBadgeResolved:
'shrink-0 rounded border border-border bg-muted px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground',
statusBadgeQueued:
diff --git a/src/renderer/src/components/settings/AccountsPane.test.tsx b/src/renderer/src/components/settings/AccountsPane.test.tsx
index 0bf97d581ea..3e247f88db5 100644
--- a/src/renderer/src/components/settings/AccountsPane.test.tsx
+++ b/src/renderer/src/components/settings/AccountsPane.test.tsx
@@ -54,8 +54,8 @@ describe('AccountsPane', () => {
it('keeps the runtime label inside the localized account copy', () => {
const markup = renderPane(getDefaultSettings('/tmp'))
- expect(markup).toContain('Showing accounts for This device. New accounts are added there.')
- expect(markup).toContain('authenticate with Google for This device. This uses credentials')
+ expect(markup).toContain('Showing accounts for this device. New accounts are added there.')
+ expect(markup).toContain('authenticate with Google for this device. This uses credentials')
expect(markup).not.toContain('ShowingThis device')
expect(markup).not.toContain('forThis device')
})
@@ -65,8 +65,8 @@ describe('AccountsPane', () => {
const markup = renderPane(getDefaultSettings('/tmp'))
- expect(markup).toContain(
- 'Mostrando cuentas para este dispositivo. Las nuevas cuentas se agregan allí.'
+ expect(markup).toMatch(
+ /Mostrando cuentas para [Ee]ste dispositivo\. Las nuevas cuentas se agregan allí\./
)
expect(markup).not.toContain('This device')
})
diff --git a/src/renderer/src/components/settings/AccountsPane.tsx b/src/renderer/src/components/settings/AccountsPane.tsx
index 947ec64c228..d50003b5122 100644
--- a/src/renderer/src/components/settings/AccountsPane.tsx
+++ b/src/renderer/src/components/settings/AccountsPane.tsx
@@ -264,6 +264,11 @@ export function AccountsPane({
wslDistros,
wslCapabilitiesLoading
)
+ // Why: host runtime labels are standalone UI labels; interpolated prose needs sentence casing.
+ const accountRuntimeSentenceLabel =
+ accountRuntime.runtime === 'host' && !navigator.userAgent.includes('Windows')
+ ? `${accountRuntime.label.charAt(0).toLocaleLowerCase()}${accountRuntime.label.slice(1)}`
+ : accountRuntime.label
const [codexAccounts, setCodexAccounts] = useState({
accounts: [],
@@ -611,7 +616,7 @@ export function AccountsPane({
{translate(
'auto.components.settings.AccountsPane.c0a52abfc5',
'Showing accounts for {{value0}}. New accounts are added there.',
- { value0: accountRuntime.label }
+ { value0: accountRuntimeSentenceLabel }
)}
@@ -693,7 +698,7 @@ export function AccountsPane({
{translate(
'auto.components.settings.AccountsPane.e05d0ff737',
'Use your current {{value0}} Claude login.',
- { value0: accountRuntime.label }
+ { value0: accountRuntimeSentenceLabel }
)}
@@ -703,7 +708,7 @@ export function AccountsPane({
{translate(
'auto.components.settings.AccountsPane.3fe7862418',
"No managed Claude accounts for {{value0}}. Orca will use that environment's system default Claude login until you add one here.",
- { value0: accountRuntime.label }
+ { value0: accountRuntimeSentenceLabel }
)}
) : (
@@ -863,7 +868,7 @@ export function AccountsPane({
: translate(
'auto.components.settings.AccountsPane.e4a28e8894',
'Codex reported that the {{value0}} login needs a fresh sign-in. Sign in again before starting new Codex sessions.',
- { value0: accountRuntime.label }
+ { value0: accountRuntimeSentenceLabel }
)}
@@ -877,7 +882,7 @@ export function AccountsPane({
{translate(
'auto.components.settings.AccountsPane.c0a52abfc5',
'Showing accounts for {{value0}}. New accounts are added there.',
- { value0: accountRuntime.label }
+ { value0: accountRuntimeSentenceLabel }
)}
@@ -964,12 +969,12 @@ export function AccountsPane({
? translate(
'auto.components.settings.AccountsPane.fd62f37c24',
'Codex reported this {{value0}} login is out of date.',
- { value0: accountRuntime.label }
+ { value0: accountRuntimeSentenceLabel }
)
: translate(
'auto.components.settings.AccountsPane.fcc4093fc1',
'Use your current {{value0}} Codex login.',
- { value0: accountRuntime.label }
+ { value0: accountRuntimeSentenceLabel }
)}
@@ -979,7 +984,7 @@ export function AccountsPane({
{translate(
'auto.components.settings.AccountsPane.b4c9450319',
"No managed Codex accounts for {{value0}}. Orca will use that environment's system default Codex login until you add one here.",
- { value0: accountRuntime.label }
+ { value0: accountRuntimeSentenceLabel }
)}
) : (
@@ -1177,7 +1182,7 @@ export function AccountsPane({
{translate(
'auto.components.settings.AccountsPane.c2aee76420',
'Extracts OAuth credentials from your local Gemini CLI installation to authenticate with Google for {{value0}}. This uses credentials issued to the Gemini CLI app, not Orca. May break if Google updates the CLI. Use at your own risk.',
- { value0: accountRuntime.label }
+ { value0: accountRuntimeSentenceLabel }
)}
diff --git a/src/renderer/src/components/settings/BrowserProfileRow.tsx b/src/renderer/src/components/settings/BrowserProfileRow.tsx
index 6c3c06dd3df..0d1c377c45f 100644
--- a/src/renderer/src/components/settings/BrowserProfileRow.tsx
+++ b/src/renderer/src/components/settings/BrowserProfileRow.tsx
@@ -174,8 +174,11 @@ export function BrowserProfileRow({
browser.profiles.length > 1 ? (
- {translate('auto.components.settings.BrowserProfileRow.7df818977e', 'From')}
- {browser.label}
+ {translate(
+ 'auto.components.settings.BrowserProfileRow.c5a273a809',
+ 'From {{value0}}',
+ { value0: browser.label }
+ )}
@@ -197,8 +200,11 @@ export function BrowserProfileRow({
key={browser.family}
onSelect={() => void handleImportFromBrowser(browser.family)}
>
- {translate('auto.components.settings.BrowserProfileRow.7df818977e', 'From')}
- {browser.label}
+ {translate(
+ 'auto.components.settings.BrowserProfileRow.c5a273a809',
+ 'From {{value0}}',
+ { value0: browser.label }
+ )}
)
)}
diff --git a/src/renderer/src/components/settings/BrowserUseCookieImportStep.tsx b/src/renderer/src/components/settings/BrowserUseCookieImportStep.tsx
index 5104ace581f..f73668fa908 100644
--- a/src/renderer/src/components/settings/BrowserUseCookieImportStep.tsx
+++ b/src/renderer/src/components/settings/BrowserUseCookieImportStep.tsx
@@ -116,9 +116,9 @@ export function BrowserUseCookieImportStep({
{translate(
'auto.components.settings.BrowserUsePane.112f70adc4',
- 'Last imported from'
+ 'Last imported from {{value0}}',
+ { value0: sourceLabel }
)}
- {sourceLabel}
) : null}
{onConfigureMoreBrowsers ? (
@@ -163,8 +163,11 @@ export function BrowserUseCookieImportStep({
browser.profiles.length > 1 ? (
- {translate('auto.components.settings.BrowserUsePane.e44c5d681e', 'From')}
- {browser.label}
+ {translate(
+ 'auto.components.settings.BrowserUsePane.5301857d88',
+ 'From {{value0}}',
+ { value0: browser.label }
+ )}
@@ -186,8 +189,11 @@ export function BrowserUseCookieImportStep({
key={browser.family}
onSelect={() => void handleImportFromBrowser(browser.family)}
>
- {translate('auto.components.settings.BrowserUsePane.e44c5d681e', 'From')}
- {browser.label}
+ {translate(
+ 'auto.components.settings.BrowserUsePane.5301857d88',
+ 'From {{value0}}',
+ { value0: browser.label }
+ )}
)
)}
diff --git a/src/renderer/src/components/settings/GhosttyImportModal.test.ts b/src/renderer/src/components/settings/GhosttyImportModal.test.ts
index 6806f9a2151..67590e710f2 100644
--- a/src/renderer/src/components/settings/GhosttyImportModal.test.ts
+++ b/src/renderer/src/components/settings/GhosttyImportModal.test.ts
@@ -99,6 +99,25 @@ describe('GhosttyImportModal', () => {
expect(onOpenChange).toHaveBeenCalledWith(false)
})
+ it('labels Ghostty line-height imports with the settings display name', () => {
+ const element = GhosttyImportModal({
+ open: true,
+ onOpenChange: () => {},
+ preview: {
+ found: true,
+ configPath: '/Users/alice/.config/ghostty/config',
+ diff: { terminalLineHeight: 1.35 },
+ unsupportedKeys: []
+ },
+ loading: false,
+ onApply: () => {},
+ applied: false
+ })
+
+ expect(containsText(element, 'Line Height')).toBe(true)
+ expect(containsText(element, 'terminalLineHeight')).toBe(false)
+ })
+
it('renders success summary with done button when applied', () => {
const onOpenChange = vi.fn()
const onApply = vi.fn()
diff --git a/src/renderer/src/components/settings/browser-cookie-import-label.test.ts b/src/renderer/src/components/settings/browser-cookie-import-label.test.ts
new file mode 100644
index 00000000000..108a2e4c8d2
--- /dev/null
+++ b/src/renderer/src/components/settings/browser-cookie-import-label.test.ts
@@ -0,0 +1,53 @@
+import { beforeEach, describe, expect, it } from 'vitest'
+
+import en from '@/i18n/locales/en.json'
+import { i18n, translate } from '@/i18n/i18n'
+
+// Keys for the "From " import-cookies menu entries. The label is built
+// by interpolating the browser name into a single localized string; a regression
+// where the name was concatenated directly onto "From" dropped the space and
+// rendered e.g. "FromGoogle Chrome".
+const IMPORT_FROM_KEYS = [
+ 'auto.components.settings.BrowserProfileRow.c5a273a809',
+ 'auto.components.settings.BrowserUsePane.5301857d88'
+]
+const LAST_IMPORTED_FROM_KEY = 'auto.components.settings.BrowserUsePane.112f70adc4'
+
+function catalogEntry(key: string): unknown {
+ return key.split('.').reduce((node, part) => {
+ if (typeof node === 'object' && node !== null) {
+ return (node as Record)[part]
+ }
+ return undefined
+ }, en)
+}
+
+describe('import-cookies "From " label', () => {
+ beforeEach(async () => {
+ await i18n.changeLanguage('en')
+ })
+
+ it('keeps a space between "From" and the browser name', () => {
+ for (const key of IMPORT_FROM_KEYS) {
+ expect(translate(key, 'From {{value0}}', { value0: 'Google Chrome' })).toBe(
+ 'From Google Chrome'
+ )
+ expect(translate(key, 'From {{value0}}', { value0: 'Safari' })).toBe('From Safari')
+ }
+ })
+
+ it('stores the catalog entry with an interpolation placeholder', () => {
+ for (const key of IMPORT_FROM_KEYS) {
+ expect(catalogEntry(key)).toBe('From {{value0}}')
+ }
+ expect(catalogEntry(LAST_IMPORTED_FROM_KEY)).toBe('Last imported from {{value0}}')
+ })
+
+ it('keeps a space in the last-imported source label', () => {
+ expect(
+ translate(LAST_IMPORTED_FROM_KEY, 'Last imported from {{value0}}', {
+ value0: 'Google Chrome'
+ })
+ ).toBe('Last imported from Google Chrome')
+ })
+})
diff --git a/src/renderer/src/components/settings/setting-labels.ts b/src/renderer/src/components/settings/setting-labels.ts
index cdc459d9790..0f108fadeae 100644
--- a/src/renderer/src/components/settings/setting-labels.ts
+++ b/src/renderer/src/components/settings/setting-labels.ts
@@ -4,6 +4,7 @@ export const SETTING_LABELS: Partial> = {
terminalFontSize: 'Font Size',
terminalFontFamily: 'Font Family',
terminalFontWeight: 'Font Weight',
+ terminalLineHeight: 'Line Height',
terminalScrollSensitivity: 'Normal Scroll Speed',
terminalFastScrollSensitivity: 'Fast Scroll Speed',
terminalTuiScrollSensitivity: 'TUI Scroll Speed',
diff --git a/src/renderer/src/components/sidebar/WorktreeMetaDialog.tsx b/src/renderer/src/components/sidebar/WorktreeMetaDialog.tsx
index 217d2fd6735..5163a839ebf 100644
--- a/src/renderer/src/components/sidebar/WorktreeMetaDialog.tsx
+++ b/src/renderer/src/components/sidebar/WorktreeMetaDialog.tsx
@@ -11,8 +11,11 @@ import {
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
-import { parseGitHubIssueOrPRNumber } from '@/lib/github-links'
-import { buildWorktreeMetaUpdates, type WorktreeMetaSavedPayload } from './worktree-meta-updates'
+import {
+ buildWorktreeMetaUpdates,
+ parseGitHubWorkItemNumberForMetaField,
+ type WorktreeMetaSavedPayload
+} from './worktree-meta-updates'
import { useWorktreeIssueLink } from './use-worktree-issue-link'
import { getScreenSubmitShortcutLabel, isScreenSubmitShortcut } from '@/lib/screen-submit-shortcut'
import { ExternalLink, LoaderCircle } from 'lucide-react'
@@ -95,8 +98,10 @@ const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() {
}
const trimmedIssue = issueInput.trim()
const trimmedPR = prInput.trim()
- const issueValid = trimmedIssue === '' || parseGitHubIssueOrPRNumber(trimmedIssue) !== null
- const prValid = trimmedPR === '' || parseGitHubIssueOrPRNumber(trimmedPR) !== null
+ const issueValid =
+ trimmedIssue === '' || parseGitHubWorkItemNumberForMetaField(trimmedIssue, 'issue') !== null
+ const prValid =
+ trimmedPR === '' || parseGitHubWorkItemNumberForMetaField(trimmedPR, 'pr') !== null
return issueValid && prValid
}, [worktreeId, issueInput, prInput])
diff --git a/src/renderer/src/components/sidebar/worktree-meta-updates.test.ts b/src/renderer/src/components/sidebar/worktree-meta-updates.test.ts
new file mode 100644
index 00000000000..a53eeae4163
--- /dev/null
+++ b/src/renderer/src/components/sidebar/worktree-meta-updates.test.ts
@@ -0,0 +1,66 @@
+import { describe, expect, it } from 'vitest'
+import { buildWorktreeMetaUpdates } from './worktree-meta-updates'
+
+describe('buildWorktreeMetaUpdates', () => {
+ it('rejects issue URLs in the PR input', () => {
+ expect(
+ buildWorktreeMetaUpdates({
+ displayNameInput: 'Workspace',
+ currentDisplayName: 'Workspace',
+ issueInput: '',
+ prInput: 'https://github.com/stablyai/orca/issues/6933',
+ commentInput: ''
+ })
+ ).toEqual({
+ comment: '',
+ linkedIssue: null
+ })
+ })
+
+ it('accepts PR URLs in the PR input', () => {
+ expect(
+ buildWorktreeMetaUpdates({
+ displayNameInput: 'Workspace',
+ currentDisplayName: 'Workspace',
+ issueInput: '',
+ prInput: 'https://github.com/stablyai/orca/pull/6934',
+ commentInput: ''
+ })
+ ).toEqual({
+ comment: '',
+ linkedIssue: null,
+ linkedPR: 6934
+ })
+ })
+
+ it('accepts issue URLs in the issue input', () => {
+ expect(
+ buildWorktreeMetaUpdates({
+ displayNameInput: 'Workspace',
+ currentDisplayName: 'Workspace',
+ issueInput: 'https://github.com/stablyai/orca/issues/6933',
+ prInput: '',
+ commentInput: ''
+ })
+ ).toEqual({
+ comment: '',
+ linkedIssue: 6933,
+ linkedPR: null
+ })
+ })
+
+ it('rejects PR URLs in the issue input', () => {
+ expect(
+ buildWorktreeMetaUpdates({
+ displayNameInput: 'Workspace',
+ currentDisplayName: 'Workspace',
+ issueInput: 'https://github.com/stablyai/orca/pull/6934',
+ prInput: '',
+ commentInput: ''
+ })
+ ).toEqual({
+ comment: '',
+ linkedPR: null
+ })
+ })
+})
diff --git a/src/renderer/src/components/sidebar/worktree-meta-updates.ts b/src/renderer/src/components/sidebar/worktree-meta-updates.ts
index 7f6f5d70935..26769c65061 100644
--- a/src/renderer/src/components/sidebar/worktree-meta-updates.ts
+++ b/src/renderer/src/components/sidebar/worktree-meta-updates.ts
@@ -16,6 +16,20 @@ export function parseExplicitGitHubIssueUrl(input: string): string | null {
return trimmed
}
+export function parseGitHubWorkItemNumberForMetaField(
+ input: string,
+ expectedType: 'issue' | 'pr'
+): number | null {
+ const link = parseGitHubIssueOrPRLink(input)
+ if (link) {
+ // Why: issue and PR numbers live in separate GitHub namespaces for refs;
+ // a URL path mismatch must not silently link the other field.
+ return link.type === expectedType ? link.number : null
+ }
+
+ return parseGitHubIssueOrPRNumber(input)
+}
+
/** Pure save-payload builder for the worktree meta dialog: empty inputs clear
* the link (null), unparseable inputs leave it untouched (omitted). */
export function buildWorktreeMetaUpdates(args: {
@@ -26,11 +40,11 @@ export function buildWorktreeMetaUpdates(args: {
commentInput: string
}): Partial {
const trimmedIssue = args.issueInput.trim()
- const linkedIssueNumber = parseGitHubIssueOrPRNumber(trimmedIssue)
+ const linkedIssueNumber = parseGitHubWorkItemNumberForMetaField(trimmedIssue, 'issue')
const finalLinkedIssue =
trimmedIssue === '' ? null : linkedIssueNumber !== null ? linkedIssueNumber : undefined
const trimmedPR = args.prInput.trim()
- const linkedPRNumber = parseGitHubIssueOrPRNumber(trimmedPR)
+ const linkedPRNumber = parseGitHubWorkItemNumberForMetaField(trimmedPR, 'pr')
const finalLinkedPR =
trimmedPR === '' ? null : linkedPRNumber !== null ? linkedPRNumber : undefined
diff --git a/src/renderer/src/hooks/useComposerState-host-context-boundaries.test.ts b/src/renderer/src/hooks/useComposerState-host-context-boundaries.test.ts
index f45f7ee3c17..c43a051c8ab 100644
--- a/src/renderer/src/hooks/useComposerState-host-context-boundaries.test.ts
+++ b/src/renderer/src/hooks/useComposerState-host-context-boundaries.test.ts
@@ -209,6 +209,8 @@ describe('useComposerState host-context boundaries', () => {
'const applyLinkedWorkItem = useCallback'
)
expect(directLookup).toContain('sourceContext: selectedRepoGitHubSourceContext')
+ expect(directLookup).toContain('lookupGitHubWorkItemByOwnerRepoForSource')
+ expect(directLookup).toContain('type: normalizedLinkQuery.directLink.type')
const submitLookup = sourceBetween(
HOOK_SOURCE,
@@ -236,7 +238,11 @@ describe('useComposerState host-context boundaries', () => {
'const intent = getSmartGitHubSubmitIntent(name)'
)
expect(selectedPrSubmitLookup).toContain('smartGitHubPrStartPointSelectionRef.current')
- expect(selectedPrSubmitLookup).toContain("linkedWorkItem.type === 'pr'")
+ expect(selectedPrSubmitLookup).toContain("linkedWorkItemIdentity?.type === 'pr'")
+ expect(selectedPrSubmitLookup).toContain("startPointIdentity?.type === 'pr'")
+ expect(selectedPrSubmitLookup).toContain(
+ 'startPointIdentity.number === linkedWorkItemIdentity.number'
+ )
expect(selectedPrSubmitLookup).toContain('resolveGitHubPrStartPointForRepo')
expect(selectedPrSubmitLookup.indexOf('resolveGitHubPrStartPointForRepo')).toBeLessThan(
selectedPrSubmitLookup.indexOf("return { kind: 'none' }")
@@ -421,8 +427,12 @@ describe('useComposerState host-context boundaries', () => {
)
expect(projectGroupSmartHandlers).toContain('setLinkedGitLabIssue(null)')
expect(projectGroupSmartHandlers).toContain('setLinkedGitLabMR(null)')
- expect(projectGroupSmartHandlers).toContain("setLinkedIssue('')")
- expect(projectGroupSmartHandlers).toContain('setLinkedPR(null)')
+ expect(projectGroupSmartHandlers).toContain(
+ "setLinkedIssue(identity.type === 'issue' ? String(identity.number) : '')"
+ )
+ expect(projectGroupSmartHandlers).toContain(
+ "setLinkedPR(identity.type === 'pr' ? identity.number : null)"
+ )
})
it('disables repo-backed folder smart lookup when a folder target has no source repos', () => {
diff --git a/src/renderer/src/hooks/useComposerState.ts b/src/renderer/src/hooks/useComposerState.ts
index 202563742eb..dc6de85382b 100644
--- a/src/renderer/src/hooks/useComposerState.ts
+++ b/src/renderer/src/hooks/useComposerState.ts
@@ -95,6 +95,10 @@ import {
lookupGitHubWorkItemByOwnerRepoForSource,
lookupGitHubWorkItemForSource
} from '@/lib/github-work-item-source-lookup'
+import {
+ resolveGitHubWorkItemIdentity,
+ type GitHubWorkItemIdentity
+} from '@/lib/github-work-item-identity'
import { resolveGitHubPrStartPointForRepo } from '@/lib/github-pr-start-point'
import { isWorkItemLookupText } from '@/lib/work-item-lookup-text'
import {
@@ -475,6 +479,38 @@ function getLinkedWorkItemSeedName(item: LinkedWorkItemSummary | null | undefine
return getLinkedWorkItemWorkspaceName(item)?.seedName ?? getLinkedWorkItemSuggestedName(item)
}
+function getGitHubLinkedWorkItemIdentity(
+ item: LinkedWorkItemSummary | null | undefined
+): GitHubWorkItemIdentity | null {
+ if (
+ !item ||
+ getLinkedWorkItemProvider(item) !== 'github' ||
+ (item.type !== 'issue' && item.type !== 'pr')
+ ) {
+ return null
+ }
+
+ return resolveGitHubWorkItemIdentity({
+ type: item.type,
+ number: item.number,
+ url: item.url
+ })
+}
+
+function normalizeGitHubLinkedWorkItem(
+ item: LinkedWorkItemSummary | null | undefined
+): LinkedWorkItemSummary | null {
+ if (!item) {
+ return null
+ }
+ const identity = getGitHubLinkedWorkItemIdentity(item)
+ if (!identity || (identity.type === item.type && identity.number === item.number)) {
+ return item
+ }
+
+ return { ...item, type: identity.type, number: identity.number }
+}
+
export function getInitialAutoManagedWorkspaceName({
draftName,
draftLinkedWorkItem,
@@ -905,10 +941,16 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
const [attachmentPaths, setAttachmentPaths] = useState(
persistDraft ? (newWorkspaceDraft?.attachments ?? []) : []
)
+ const initialLinkedWorkItemSeed = normalizeGitHubLinkedWorkItem(initialLinkedWorkItem)
+ const draftLinkedWorkItemSeed = persistDraft
+ ? normalizeGitHubLinkedWorkItem(newWorkspaceDraft?.linkedWorkItem)
+ : null
+ const linkedWorkItemSeed = persistDraft
+ ? (draftLinkedWorkItemSeed ?? initialLinkedWorkItemSeed)
+ : initialLinkedWorkItemSeed
+ const linkedWorkItemSeedIdentity = getGitHubLinkedWorkItemIdentity(linkedWorkItemSeed)
const [linkedWorkItem, setLinkedWorkItem] = useState(
- persistDraft
- ? (newWorkspaceDraft?.linkedWorkItem ?? initialLinkedWorkItem)
- : initialLinkedWorkItem
+ () => linkedWorkItemSeed
)
const taskSourceContext = useMemo(() => {
if (
@@ -982,6 +1024,9 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
})
}, [projects, selectedRepo, selectedRepoIsGit, selectedWorkspaceTarget, taskSourceContext])
const [linkedIssue, setLinkedIssue] = useState(() => {
+ if (linkedWorkItemSeedIdentity?.type === 'issue') {
+ return String(linkedWorkItemSeedIdentity.number)
+ }
if (persistDraft && newWorkspaceDraft?.linkedIssue) {
return newWorkspaceDraft.linkedIssue
}
@@ -994,6 +1039,12 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
return ''
})
const [linkedPR, setLinkedPR] = useState(() => {
+ if (linkedWorkItemSeedIdentity?.type === 'pr') {
+ return linkedWorkItemSeedIdentity.number
+ }
+ if (linkedWorkItemSeedIdentity?.type === 'issue') {
+ return null
+ }
if (persistDraft && newWorkspaceDraft?.linkedPR !== undefined) {
return newWorkspaceDraft.linkedPR
}
@@ -1134,9 +1185,9 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
const lastAutoNameRef = useRef(
getInitialAutoManagedWorkspaceName({
draftName: persistDraft ? newWorkspaceDraft?.name : null,
- draftLinkedWorkItem: persistDraft ? newWorkspaceDraft?.linkedWorkItem : null,
+ draftLinkedWorkItem: persistDraft ? draftLinkedWorkItemSeed : null,
initialName,
- initialLinkedWorkItem
+ initialLinkedWorkItem: initialLinkedWorkItemSeed
})
)
const nameRef = useRef(name)
@@ -1922,16 +1973,28 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
let cancelled = false
setLinkDirectLoading(true)
// Why: Superset lets users paste a full GitHub URL or type a raw issue/PR
- // number and still get a concrete selectable result. Orca mirrors that by
- // resolving direct lookups against the selected repo instead of requiring a
- // text match in the recent-items list.
+ // number and still get a concrete selectable result. Full URLs carry
+ // issue-vs-PR intent, so preserve the URL route instead of probing by
+ // number only.
const lookupRepoId = selectedRepo.id
- void lookupGitHubWorkItemForSource({
- repoPath: selectedRepo.path,
- repoId: selectedRepo.id,
- sourceContext: selectedRepoGitHubSourceContext,
- number: normalizedLinkQuery.directNumber
- })
+ const lookup =
+ normalizedLinkQuery.directLink !== undefined
+ ? lookupGitHubWorkItemByOwnerRepoForSource({
+ repoPath: selectedRepo.path,
+ repoId: selectedRepo.id,
+ sourceContext: selectedRepoGitHubSourceContext,
+ owner: normalizedLinkQuery.directLink.slug.owner,
+ repo: normalizedLinkQuery.directLink.slug.repo,
+ number: normalizedLinkQuery.directLink.number,
+ type: normalizedLinkQuery.directLink.type
+ })
+ : lookupGitHubWorkItemForSource({
+ repoPath: selectedRepo.path,
+ repoId: selectedRepo.id,
+ sourceContext: selectedRepoGitHubSourceContext,
+ number: normalizedLinkQuery.directNumber
+ })
+ void lookup
.then((item) => {
if (!cancelled) {
setLinkDirectItem(
@@ -1954,6 +2017,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
cancelled = true
}
}, [
+ normalizedLinkQuery.directLink,
linkPopoverOpen,
normalizedLinkQuery.directNumber,
selectedRepo,
@@ -1963,24 +2027,31 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
const applyLinkedWorkItem = useCallback(
(item: GitHubWorkItem, options: { preserveBranchNameOverride?: boolean } = {}): void => {
- if (item.type === 'issue') {
- setLinkedIssue(String(item.number))
+ const identity = resolveGitHubWorkItemIdentity(item)
+ const normalizedItem: GitHubWorkItem = {
+ ...item,
+ type: identity.type,
+ number: identity.number
+ }
+ if (identity.type === 'issue') {
+ setLinkedIssue(String(identity.number))
setLinkedPR(null)
} else {
setLinkedIssue('')
- setLinkedPR(item.number)
+ setLinkedPR(identity.number)
}
setLinkedGitLabIssue(null)
setLinkedGitLabMR(null)
setLinkedWorkItem({
- type: item.type,
+ type: identity.type,
provider: 'github',
- number: item.number,
+ number: identity.number,
title: item.title,
url: item.url
})
const suggestedName =
- getLinkedWorkItemWorkspaceName(item)?.seedName ?? getLinkedWorkItemSuggestedName(item)
+ getLinkedWorkItemWorkspaceName(normalizedItem)?.seedName ??
+ getLinkedWorkItemSuggestedName(normalizedItem)
// Why: a pasted URL/#123 in the field is the lookup query that found
// this item, not a deliberate name — replace it with the title-derived
// name or it silently becomes a slugified-URL workspace name.
@@ -2002,20 +2073,25 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
useCallback(async (): Promise => {
if (linkedWorkItem) {
const startPointSelection = smartGitHubPrStartPointSelectionRef.current
+ const linkedWorkItemIdentity = getGitHubLinkedWorkItemIdentity(linkedWorkItem)
+ const startPointIdentity = startPointSelection
+ ? resolveGitHubWorkItemIdentity(startPointSelection.item)
+ : null
if (
!isProjectGroupTarget &&
- linkedWorkItem.type === 'pr' &&
+ linkedWorkItemIdentity?.type === 'pr' &&
+ startPointIdentity?.type === 'pr' &&
getLinkedWorkItemProvider(linkedWorkItem) === 'github' &&
selectedRepo &&
selectedRepoIsGit &&
startPointSelection?.repoId === selectedRepo.id &&
- startPointSelection.item.number === linkedWorkItem.number
+ startPointIdentity.number === linkedWorkItemIdentity.number
) {
const selectedPrStartPoint =
startPointSelection.resolved ??
(await resolveGitHubPrStartPointForRepo({
repoId: selectedRepo.id,
- prNumber: startPointSelection.item.number,
+ prNumber: startPointIdentity.number,
settings: getSettingsForRepoRuntimeOwner(
{ repos: [selectedRepo], settings },
selectedRepo.id
@@ -2102,11 +2178,12 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
throw new Error('Could not resolve the GitHub item before creating the workspace.')
}
+ const itemIdentity = resolveGitHubWorkItemIdentity(item)
const prStartPoint =
- !isProjectGroupTarget && item.type === 'pr' && selectedRepo && selectedRepoIsGit
+ !isProjectGroupTarget && itemIdentity.type === 'pr' && selectedRepo && selectedRepoIsGit
? await resolveGitHubPrStartPointForRepo({
repoId: selectedRepo.id,
- prNumber: item.number,
+ prNumber: itemIdentity.number,
settings: getSettingsForRepoRuntimeOwner(
{ repos: [selectedRepo], settings },
selectedRepo.id
@@ -2757,8 +2834,9 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
// worktree's comment should surface (`orca worktree current`, sidebar).
// Prefill the note if it's empty or still equal to a prior auto-fill, so
// we don't overwrite anything the user has typed.
- if (item.type === 'pr') {
- const suggestedNote = `PR #${item.number} — ${item.title}`
+ const identity = resolveGitHubWorkItemIdentity(item)
+ if (identity.type === 'pr') {
+ const suggestedNote = `PR #${identity.number} — ${item.title}`
const currentNote = noteRef.current
if (!currentNote.trim() || currentNote === lastAutoNoteRef.current) {
setNote(suggestedNote)
@@ -2800,10 +2878,16 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
const handleSmartGitHubItemSelect = useCallback(
(item: GitHubWorkItem): void => {
+ const identity = resolveGitHubWorkItemIdentity(item)
+ const normalizedItem: GitHubWorkItem = {
+ ...item,
+ type: identity.type,
+ number: identity.number
+ }
if (isProjectGroupTarget) {
- const linkedItem = toGitHubLinkedWorkItem(item)
- setLinkedIssue(String(item.number))
- setLinkedPR(item.type === 'pr' ? item.number : null)
+ const linkedItem = toGitHubLinkedWorkItem(normalizedItem)
+ setLinkedIssue(identity.type === 'issue' ? String(identity.number) : '')
+ setLinkedPR(identity.type === 'pr' ? identity.number : null)
setLinkedGitLabIssue(null)
setLinkedGitLabMR(null)
setLinkedWorkItem(linkedItem)
@@ -2826,8 +2910,8 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
// selected run host. Resolve git refs against the run repo; keep item
// metadata/source context separate for provider identity.
const runRepo = selectedRepo ?? eligibleRepos.find((repo) => repo.id === item.repoId)
- applyLinkedWorkItem(item)
- if (item.type !== 'pr' || !runRepo) {
+ applyLinkedWorkItem(normalizedItem)
+ if (identity.type !== 'pr' || !runRepo) {
setBaseBranch(undefined)
setCompareBaseRef(undefined)
setPushTarget(undefined)
@@ -2838,7 +2922,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
setPushTarget(undefined)
const startPointSelection: SmartGitHubPrStartPointSelection = {
repoId: runRepo.id,
- item
+ item: normalizedItem
}
smartGitHubPrStartPointSelectionRef.current = startPointSelection
const itemRepoSettings = getSettingsForRepoRuntimeOwner(
@@ -2847,12 +2931,12 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
)
const resolvePrBase = resolveGitHubPrStartPointForRepo({
repoId: runRepo.id,
- prNumber: item.number,
+ prNumber: identity.number,
settings: itemRepoSettings,
- ...(item.branchName ? { headRefName: item.branchName } : {}),
- ...(item.baseRefName ? { baseRefName: item.baseRefName } : {}),
- ...(item.isCrossRepository !== undefined
- ? { isCrossRepository: item.isCrossRepository }
+ ...(normalizedItem.branchName ? { headRefName: normalizedItem.branchName } : {}),
+ ...(normalizedItem.baseRefName ? { baseRefName: normalizedItem.baseRefName } : {}),
+ ...(normalizedItem.isCrossRepository !== undefined
+ ? { isCrossRepository: normalizedItem.isCrossRepository }
: {})
})
void resolvePrBase
@@ -2863,7 +2947,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
startPointSelection.resolved = result
handleBaseBranchPrSelect(
result.baseBranch,
- item,
+ normalizedItem,
result.pushTarget,
result.branchNameOverride,
result.compareBaseRef
diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json
index 20f44425297..ca2bbd195a2 100644
--- a/src/renderer/src/i18n/locales/en.json
+++ b/src/renderer/src/i18n/locales/en.json
@@ -4882,7 +4882,8 @@
"d420c43729": "Imported {{value0}} cookies from {{value1}}{{value2}} into {{value3}}.",
"b4c167764d": "Imported {{value0}} cookies from file into {{value1}}.",
"a3f8c2d1e0b4": "Imported {{value0}} cookies from {{value1}} ({{value2}}) into {{value3}}.",
- "b4e9d3f2a1c5": "Imported {{value0}} cookies from {{value1}} into {{value2}}."
+ "b4e9d3f2a1c5": "Imported {{value0}} cookies from {{value1}} into {{value2}}.",
+ "c5a273a809": "From {{value0}}"
},
"BrowserUseComputerUseNotice": {
"15b5e680ba": "Open Computer Use",
@@ -4906,7 +4907,7 @@
"be6df68384": "From File…",
"e44c5d681e": "From",
"67d9a53f47": "Manage profiles for separate logins",
- "112f70adc4": "Last imported from",
+ "112f70adc4": "Last imported from {{value0}}",
"72d4815523": "Bring your existing logins into Orca so agents can reach authenticated pages. Imports into the default profile.",
"2eb906706c": "Import Browser Cookies",
"af8c83ed61": "Import cookies from Chrome, Edge, or other browsers so agents can reuse your logins.",
@@ -4928,7 +4929,8 @@
"ad8cb0ee22": "Fix PATH",
"0289434ed6": "Enabled",
"8b3054dac7": "Registering...",
- "8f2675c2f3": "Imported {{value0}} cookies from file."
+ "8f2675c2f3": "Imported {{value0}} cookies from file.",
+ "5301857d88": "From {{value0}}"
},
"BrowserUseSkillStep": {
"0871b6998d": "Enables agents to navigate and verify pages in Orca's browser.",
@@ -9793,10 +9795,6 @@
"a84d5677ff": "OpenCode",
"2528586aa7": "Claudino"
}
- },
- "PetOverlay": {
- "de932b0e8f": "@keyframes pet-bob { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-4px); } }",
- "4712d196c6": "@keyframes pet-{{value0}} { from { background-position: {{value1}}px {{value2}}px; } to { background-position: {{value3}}px {{value4}}px; } }"
}
},
"onboarding": {
diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json
index 2dc3780a125..9d4cdf90b27 100644
--- a/src/renderer/src/i18n/locales/es.json
+++ b/src/renderer/src/i18n/locales/es.json
@@ -4882,7 +4882,8 @@
"d420c43729": "Se importaron {{value0}} cookies de {{value1}}{{value2}} a {{value3}}.",
"b4c167764d": "Se importaron {{value0}} cookies desde un archivo a {{value1}}.",
"a3f8c2d1e0b4": "Se importaron {{value0}} cookies de {{value1}} ({{value2}}) a {{value3}}.",
- "b4e9d3f2a1c5": "Se importaron {{value0}} cookies de {{value1}} a {{value2}}."
+ "b4e9d3f2a1c5": "Se importaron {{value0}} cookies de {{value1}} a {{value2}}.",
+ "c5a273a809": "Desde {{value0}}"
},
"BrowserUseComputerUseNotice": {
"15b5e680ba": "Abrir Computer Use",
@@ -4906,7 +4907,7 @@
"be6df68384": "Desde archivo…",
"e44c5d681e": "Desde ",
"67d9a53f47": "Gestionar perfiles para logins separados",
- "112f70adc4": "Última importación desde ",
+ "112f70adc4": "Última importación desde {{value0}}",
"72d4815523": "Trae tus logins existentes a Orca para que los agents puedan acceder a páginas autenticadas. Importa al perfil predeterminado.",
"2eb906706c": "Importar cookies del navegador",
"af8c83ed61": "Importa cookies de Chrome, Edge u otros navegadores para que los agents puedan reutilizar tus logins.",
@@ -4928,7 +4929,8 @@
"ad8cb0ee22": "Corregir PATH",
"0289434ed6": "Activado",
"8b3054dac7": "Registrando...",
- "8f2675c2f3": "Se importaron {{value0}} cookies desde un archivo."
+ "8f2675c2f3": "Se importaron {{value0}} cookies desde un archivo.",
+ "5301857d88": "Desde {{value0}}"
},
"BrowserUseSkillStep": {
"0871b6998d": "Permite a los agents navegar y verificar páginas en el navegador de Orca.",
@@ -9793,10 +9795,6 @@
"a84d5677ff": "OpenCode",
"2528586aa7": "Claudino"
}
- },
- "PetOverlay": {
- "de932b0e8f": "@keyframes pet-bob { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-4px); } }",
- "4712d196c6": "@keyframes pet-{{value0}} { from { background-position: {{value1}}px {{value2}}px; } to { background-position: {{value3}}px {{value4}}px; } }"
}
},
"onboarding": {
diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json
index 28a1044c682..775f4b584b3 100644
--- a/src/renderer/src/i18n/locales/ja.json
+++ b/src/renderer/src/i18n/locales/ja.json
@@ -4867,7 +4867,8 @@
"d420c43729": "{{value0}} Cookie を {{value1}}{{value2}} から {{value3}} にインポートしました。",
"b4c167764d": "ファイルから {{value0}} 個の Cookie を {{value1}} にインポートしました。",
"a3f8c2d1e0b4": "{{value1}} ({{value2}}) から {{value0}} 件の Cookie を {{value3}} にインポートしました。",
- "b4e9d3f2a1c5": "{{value1}} から {{value0}} 件の Cookie を {{value2}} にインポートしました。"
+ "b4e9d3f2a1c5": "{{value1}} から {{value0}} 件の Cookie を {{value2}} にインポートしました。",
+ "c5a273a809": "{{value0}} から"
},
"BrowserUseComputerUseNotice": {
"15b5e680ba": "Open Computer Use",
@@ -4891,7 +4892,7 @@
"be6df68384": "ファイルから…",
"e44c5d681e": "から",
"67d9a53f47": "個別のログインのプロファイルを管理する",
- "112f70adc4": "最後にインポートされたのは",
+ "112f70adc4": "{{value0}} から最後にインポート",
"72d4815523": "既存のログインを Orca に取り込み、agents が認証されたページにアクセスできるようにします。デフォルトのプロファイルにインポートします。",
"2eb906706c": "ブラウザの Cookie をインポートする",
"af8c83ed61": "Chrome、Edge、またはその他のブラウザから Cookie をインポートすると、agents がログインを再利用できるようになります。",
@@ -4913,7 +4914,8 @@
"ad8cb0ee22": "パスを修正する",
"0289434ed6": "有効",
"8b3054dac7": "登録中...",
- "8f2675c2f3": "ファイルから {{value0}} 個の Cookie をインポートしました。"
+ "8f2675c2f3": "ファイルから {{value0}} 個の Cookie をインポートしました。",
+ "5301857d88": "{{value0}} から"
},
"BrowserUseSkillStep": {
"0871b6998d": "agents が Orca のブラウザでページに移動して確認できるようにします。",
@@ -9793,10 +9795,6 @@
"a84d5677ff": "OpenCode",
"2528586aa7": "クラウディーノ"
}
- },
- "PetOverlay": {
- "de932b0e8f": "@keyframes ペットボブ { 0%,100% { 変換:translateY(0); } 50% { 変換:translateY(-4px); } }",
- "4712d196c6": "@keyframes pet-{{value0}} { from { 背景位置: {{value1}}px {{value2}}px; } から { 背景位置: {{value3}}px {{value4}}px; } }"
}
},
"onboarding": {
diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json
index 88c4baada28..62132106ac7 100644
--- a/src/renderer/src/i18n/locales/ko.json
+++ b/src/renderer/src/i18n/locales/ko.json
@@ -4867,7 +4867,8 @@
"d420c43729": "{{value1}}{{value2}}에서 {{value0}}개의 쿠키를 {{value3}}(으)로 가져왔습니다.",
"b4c167764d": "파일에서 {{value0}}개의 쿠키를 {{value1}}에 가져왔습니다.",
"a3f8c2d1e0b4": "{{value1}} ({{value2}})에서 {{value0}}개의 쿠키를 {{value3}}(으)로 가져왔습니다.",
- "b4e9d3f2a1c5": "{{value1}}에서 {{value0}}개의 쿠키를 {{value2}}(으)로 가져왔습니다."
+ "b4e9d3f2a1c5": "{{value1}}에서 {{value0}}개의 쿠키를 {{value2}}(으)로 가져왔습니다.",
+ "c5a273a809": "{{value0}}에서"
},
"BrowserUseComputerUseNotice": {
"15b5e680ba": "컴퓨터 사용 열기",
@@ -4891,7 +4892,7 @@
"be6df68384": "파일에서…",
"e44c5d681e": "에서",
"67d9a53f47": "별도의 로그인을 위한 프로필 관리",
- "112f70adc4": "마지막으로 가져온 위치",
+ "112f70adc4": "{{value0}}에서 마지막으로 가져옴",
"72d4815523": "agents가 인증된 페이지에 접근할 수 있도록 기존 로그인을 Orca로 가져오세요. 기본 프로필로 가져옵니다.",
"2eb906706c": "브라우저 쿠키 가져오기",
"af8c83ed61": "agents가 로그인을 재사용할 수 있도록 Chrome, Edge 또는 기타 브라우저에서 쿠키를 가져옵니다.",
@@ -4913,7 +4914,8 @@
"ad8cb0ee22": "경로 수정",
"0289434ed6": "활성화됨",
"8b3054dac7": "등록 중...",
- "8f2675c2f3": "파일에서 {{value0}}개의 쿠키를 가져왔습니다."
+ "8f2675c2f3": "파일에서 {{value0}}개의 쿠키를 가져왔습니다.",
+ "5301857d88": "{{value0}}에서"
},
"BrowserUseSkillStep": {
"0871b6998d": "agents가 Orca 브라우저에서 페이지를 탐색하고 확인할 수 있습니다.",
@@ -9793,10 +9795,6 @@
"a84d5677ff": "OpenCode",
"2528586aa7": "클라우디노"
}
- },
- "PetOverlay": {
- "de932b0e8f": "@keyframes pet-bob { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-4px); } }",
- "4712d196c6": "@keyframes pet-{{value0}} { from { background-position: {{value1}}px {{value2}}px; } to { background-position: {{value3}}px {{value4}}px; } }"
}
},
"onboarding": {
diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json
index 18fd3bae5fc..41d3bbeb76c 100644
--- a/src/renderer/src/i18n/locales/zh.json
+++ b/src/renderer/src/i18n/locales/zh.json
@@ -4867,7 +4867,8 @@
"d420c43729": "将 {{value0}} cookie 从 {{value1}}{{value2}} 导入到 {{value3}} 中。",
"b4c167764d": "已从文件将 {{value0}} 个 Cookie 导入到 {{value1}}。",
"a3f8c2d1e0b4": "已从 {{value1}} ({{value2}}) 导入 {{value0}} 个 Cookie 到 {{value3}}。",
- "b4e9d3f2a1c5": "已从 {{value1}} 导入 {{value0}} 个 Cookie 到 {{value2}}。"
+ "b4e9d3f2a1c5": "已从 {{value1}} 导入 {{value0}} 个 Cookie 到 {{value2}}。",
+ "c5a273a809": "从 {{value0}}"
},
"BrowserUseComputerUseNotice": {
"15b5e680ba": "开放计算机使用",
@@ -4891,7 +4892,7 @@
"be6df68384": "从文件...",
"e44c5d681e": "从",
"67d9a53f47": "管理单独登录的配置文件",
- "112f70adc4": "上次导入自",
+ "112f70adc4": "上次导入自 {{value0}}",
"72d4815523": "将您现有的登录信息带入 Orca,以便智能体可以访问经过身份验证的页面。导入到默认配置文件中。",
"2eb906706c": "导入浏览器 Cookie",
"af8c83ed61": "从 Chrome、Edge 或其他浏览器导入 cookie,以便智能体可以重复使用您的登录信息。",
@@ -4913,7 +4914,8 @@
"ad8cb0ee22": "修复路径",
"0289434ed6": "启用",
"8b3054dac7": "注册...",
- "8f2675c2f3": "已从文件导入 {{value0}} 个 Cookie。"
+ "8f2675c2f3": "已从文件导入 {{value0}} 个 Cookie。",
+ "5301857d88": "从 {{value0}}"
},
"BrowserUseSkillStep": {
"0871b6998d": "使智能体能够在 Orca 浏览器中导航和验证页面。",
@@ -9793,10 +9795,6 @@
"a84d5677ff": "OpenCode",
"2528586aa7": "克劳迪诺"
}
- },
- "PetOverlay": {
- "de932b0e8f": "@keyframes pet-bob { 0%,100% { 变换:translateY(0); } 50% { 变换:translateY(-4px); } }",
- "4712d196c6": "@keyframes pet-{{value0}} { from { 背景位置:{{value1}}px {{value2}}px; } 到 { 背景位置:{{value3}}px {{value4}}px; } }"
}
},
"onboarding": {
diff --git a/src/renderer/src/lib/github-links.test.ts b/src/renderer/src/lib/github-links.test.ts
index 31c7f72aadd..64a9bd59df3 100644
--- a/src/renderer/src/lib/github-links.test.ts
+++ b/src/renderer/src/lib/github-links.test.ts
@@ -133,7 +133,36 @@ describe('normalizeGitHubLinkQuery', () => {
it('accepts full GitHub URLs whose slug differs from the selected repo slug', () => {
expect(normalizeGitHubLinkQuery('https://github.com/stablyai/orca/issues/923')).toEqual({
query: 'https://github.com/stablyai/orca/issues/923',
- directNumber: 923
+ directNumber: 923,
+ directLink: {
+ slug: { owner: 'stablyai', repo: 'orca' },
+ number: 923,
+ type: 'issue'
+ }
+ })
+ })
+
+ it('preserves PR route intent for full GitHub URLs', () => {
+ expect(normalizeGitHubLinkQuery('https://github.com/stablyai/orca/pull/6934')).toEqual({
+ query: 'https://github.com/stablyai/orca/pull/6934',
+ directNumber: 6934,
+ directLink: {
+ slug: { owner: 'stablyai', repo: 'orca' },
+ number: 6934,
+ type: 'pr'
+ }
+ })
+ })
+
+ it('preserves route intent for URLs with uppercase schemes', () => {
+ expect(normalizeGitHubLinkQuery('HTTPS://github.com/stablyai/orca/pull/6934')).toEqual({
+ query: 'HTTPS://github.com/stablyai/orca/pull/6934',
+ directNumber: 6934,
+ directLink: {
+ slug: { owner: 'stablyai', repo: 'orca' },
+ number: 6934,
+ type: 'pr'
+ }
})
})
diff --git a/src/renderer/src/lib/github-links.ts b/src/renderer/src/lib/github-links.ts
index 3ef7119d32f..57e1988c33e 100644
--- a/src/renderer/src/lib/github-links.ts
+++ b/src/renderer/src/lib/github-links.ts
@@ -3,15 +3,19 @@
// Re-exported here so renderer consumers keep their '@/lib' import path.
// normalizeGitHubLinkQuery stays renderer-side: its too-large guard is link-
// picker input policy, not parsing.
+import type { GitHubIssueOrPRLink } from '../../../shared/github-links'
import { parseGitHubIssueOrPRLink, parseGitHubIssueOrPRNumber } from '../../../shared/github-links'
import { isWorkItemLinkQueryTooLarge } from './work-item-link-query-bounds'
export * from '../../../shared/github-links'
+const HTTP_URL_PREFIX_RE = /^https?:\/\//i
+
export type GitHubLinkQuery = {
query: string
directNumber: number | null
+ directLink?: GitHubIssueOrPRLink
tooLarge?: boolean
}
@@ -29,7 +33,7 @@ export function normalizeGitHubLinkQuery(raw: string): GitHubLinkQuery {
}
const direct = parseGitHubIssueOrPRNumber(trimmed)
- if (direct !== null && !trimmed.startsWith('http')) {
+ if (direct !== null && !HTTP_URL_PREFIX_RE.test(trimmed)) {
return { query: trimmed, directNumber: direct }
}
@@ -43,6 +47,7 @@ export function normalizeGitHubLinkQuery(raw: string): GitHubLinkQuery {
// slug differs from the origin remote.
return {
query: trimmed,
- directNumber: link.number
+ directNumber: link.number,
+ directLink: link
}
}
diff --git a/src/renderer/src/lib/github-work-item-background-create.test.ts b/src/renderer/src/lib/github-work-item-background-create.test.ts
index a4e6d8f0adf..ef7adfdb2b9 100644
--- a/src/renderer/src/lib/github-work-item-background-create.test.ts
+++ b/src/renderer/src/lib/github-work-item-background-create.test.ts
@@ -163,6 +163,46 @@ describe('createGitHubWorkItemWorkspaceInBackground', () => {
)
})
+ it('does not resolve a PR start point for a stale PR-typed issue URL', async () => {
+ const deps = makeDeps()
+
+ await createGitHubWorkItemWorkspaceInBackground(
+ {
+ item: makeIssue({
+ type: 'pr',
+ number: 6933,
+ title: 'The board columns are displayed backwards',
+ url: 'https://github.com/stablyai/orca/issues/6933',
+ branchName: 'fix-issue-6933',
+ baseRefName: 'main',
+ isCrossRepository: true
+ }),
+ repoId: 'repo-1',
+ telemetrySource: 'sidebar',
+ openModalFallback: vi.fn()
+ },
+ deps
+ )
+
+ expect(deps.resolvePrStartPoint).not.toHaveBeenCalled()
+ const beginCalls = deps.beginBackgroundCreate.mock.calls as unknown as [
+ WorktreeCreationRequest
+ ][]
+ expect(beginCalls[0]?.[0].linkedIssue).toBe(6933)
+ expect(beginCalls[0]?.[0].linkedPR).toBeUndefined()
+ const continueCalls = deps.continueBackgroundCreate.mock.calls as unknown as [
+ string,
+ WorktreeCreationRequest
+ ][]
+ const request = continueCalls[0]?.[1]
+ expect(request?.linkedIssue).toBe(6933)
+ expect(request?.linkedPR).toBeUndefined()
+ expect(request?.baseBranch).toBeUndefined()
+ expect(request?.pushTarget).toBeUndefined()
+ expect(request?.branchNameOverride).toBeUndefined()
+ expect(request?.compareBaseRef).toBeUndefined()
+ })
+
it('shows the pending workspace before async preflight resolves', async () => {
let resolveSetupDecision: ((value: { kind: 'decided'; decision: 'inherit' }) => void) | null =
null
@@ -566,17 +606,36 @@ describe('createGitHubWorkItemWorkspaceInBackground', () => {
await createGitHubWorkItemWorkspaceInBackground(
{
- item: makeIssue({ type: 'pr', number: 7, url: 'https://github.com/stablyai/orca/pull/7' }),
+ item: makeIssue({
+ type: 'pr',
+ number: 6934,
+ url: 'https://github.com/stablyai/orca/pull/6934',
+ branchName: 'fix-issue-6933',
+ baseRefName: 'main',
+ isCrossRepository: true
+ }),
repoId: 'repo-1',
openModalFallback: vi.fn()
},
deps
)
+ expect(deps.resolvePrStartPoint).toHaveBeenCalledWith(
+ 'repo-1',
+ 6934,
+ expect.anything(),
+ expect.objectContaining({
+ type: 'pr',
+ number: 6934,
+ branchName: 'fix-issue-6933',
+ baseRefName: 'main',
+ isCrossRepository: true
+ })
+ )
expect(deps.continueBackgroundCreate).toHaveBeenCalledWith(
'creation-1',
expect.objectContaining({
- linkedPR: 7,
+ linkedPR: 6934,
baseBranch: 'feature/from-pr',
pushTarget: { remote: 'origin', branch: 'feature/from-pr' },
branchNameOverride: 'feature/from-pr',
diff --git a/src/renderer/src/lib/github-work-item-background-create.ts b/src/renderer/src/lib/github-work-item-background-create.ts
index 7fa94fd9d2b..260cd2ad657 100644
--- a/src/renderer/src/lib/github-work-item-background-create.ts
+++ b/src/renderer/src/lib/github-work-item-background-create.ts
@@ -32,6 +32,7 @@ import {
import type { GitHubWorkItem, SetupDecision } from '../../../shared/types'
import type { Repo } from '../../../shared/types'
import type { TaskSourceContext, WorkspaceRunContext } from '../../../shared/task-source-context'
+import { resolveGitHubWorkItemIdentity } from '@/lib/github-work-item-identity'
export type BackgroundGitHubWorkItemCreateResult =
| { kind: 'background-started' }
@@ -182,6 +183,7 @@ export async function createGitHubWorkItemWorkspaceInBackground(
const restoreView = deps.getActiveView()
const creationId = deps.beginBackgroundCreate(initialRequest)
+ const itemIdentity = resolveGitHubWorkItemIdentity(args.item)
try {
const repoOwnerSettings = getSettingsForRepoRuntimeOwner(store, args.repoId)
@@ -201,11 +203,11 @@ export async function createGitHubWorkItemWorkspaceInBackground(
let pushTarget: WorktreeCreationRequest['pushTarget']
let branchNameOverride: string | undefined
let compareBaseRef: string | undefined
- if (args.item.type === 'pr' && args.item.number) {
+ if (itemIdentity.type === 'pr' && itemIdentity.number) {
try {
const result = await deps.resolvePrStartPoint(
args.repoId,
- args.item.number,
+ itemIdentity.number,
repoOwnerSettings,
args.item
)
diff --git a/src/renderer/src/lib/github-work-item-background-request.ts b/src/renderer/src/lib/github-work-item-background-request.ts
index f37c71ded0a..5b157525053 100644
--- a/src/renderer/src/lib/github-work-item-background-request.ts
+++ b/src/renderer/src/lib/github-work-item-background-request.ts
@@ -14,6 +14,7 @@ import { CLIENT_PLATFORM, getWorkspaceIntentName, getWorkspaceSeedName } from '@
import { getLocalRepoProjectExecutionRuntimeContext } from '@/lib/local-preflight-context'
import { resolveSourceControlLaunchPlatform } from '@/lib/source-control-launch-platform'
import { repoIsRemote } from '../../../shared/agent-launch-remote'
+import { resolveGitHubWorkItemIdentity } from '@/lib/github-work-item-identity'
import {
resolveTuiAgentLaunchArgs,
resolveTuiAgentLaunchEnv
@@ -232,19 +233,20 @@ export function buildGitHubWorkItemStartupPlan(args: {
}
function getGitHubWorkItemName(item: GitHubWorkItem): { seedName: string; displayName?: string } {
+ const identity = resolveGitHubWorkItemIdentity(item)
const intent =
- item.number !== null
+ identity.number !== null
? getWorkspaceIntentName({
sourceText: item.title,
- workItem: { type: item.type, number: item.number, title: item.title }
+ workItem: { type: identity.type, number: identity.number, title: item.title }
})
: null
return {
seedName: getWorkspaceSeedName({
explicitName: intent?.seedName ?? '',
prompt: '',
- linkedIssueNumber: item.type === 'issue' ? item.number : null,
- linkedPR: item.type === 'pr' ? item.number : null
+ linkedIssueNumber: identity.type === 'issue' ? identity.number : null,
+ linkedPR: identity.type === 'pr' ? identity.number : null
}),
...(intent?.displayName ? { displayName: intent.displayName } : {})
}
@@ -257,6 +259,7 @@ export function buildInitialGitHubWorkItemRequest(
const { seedName, displayName } = getGitHubWorkItemName(args.item)
const workspaceRunContext = getWorkspaceRunContextForRepo(repo, args.workspaceRunContext)
const ownerHost = parseExecutionHostId(getRepoExecutionHostId(repo))
+ const identity = resolveGitHubWorkItemIdentity(args.item)
return {
repoId: args.repoId,
worktreeCreateProgressMode: ownerHost?.kind === 'local' ? 'stepped' : 'indeterminate',
@@ -264,8 +267,8 @@ export function buildInitialGitHubWorkItemRequest(
...(workspaceRunContext ? { workspaceRunContext } : {}),
name: seedName,
...(displayName ? { displayName } : {}),
- ...(args.item.type === 'issue' && args.item.number ? { linkedIssue: args.item.number } : {}),
- ...(args.item.type === 'pr' && args.item.number ? { linkedPR: args.item.number } : {}),
+ ...(identity.type === 'issue' && identity.number ? { linkedIssue: identity.number } : {}),
+ ...(identity.type === 'pr' && identity.number ? { linkedPR: identity.number } : {}),
...(args.telemetrySource ? { telemetrySource: args.telemetrySource } : {}),
setupDecision: 'inherit',
agent: null,
diff --git a/src/renderer/src/lib/github-work-item-identity.ts b/src/renderer/src/lib/github-work-item-identity.ts
new file mode 100644
index 00000000000..c9b24e316ff
--- /dev/null
+++ b/src/renderer/src/lib/github-work-item-identity.ts
@@ -0,0 +1,20 @@
+import { parseGitHubIssueOrPRLink } from '@/lib/github-links'
+
+export type GitHubWorkItemIdentity = {
+ type: 'issue' | 'pr'
+ number: number
+}
+
+export function resolveGitHubWorkItemIdentity(item: {
+ type: 'issue' | 'pr'
+ number: number
+ url?: string | null
+}): GitHubWorkItemIdentity {
+ const link = item.url ? parseGitHubIssueOrPRLink(item.url) : null
+ if (link) {
+ // Why: stale cached work-item payloads can disagree with a pasted URL. The
+ // URL path is the user-visible intent, so it decides issue-vs-PR launches.
+ return { type: link.type, number: link.number }
+ }
+ return { type: item.type, number: item.number }
+}
diff --git a/src/renderer/src/lib/launch-work-item-direct.test.ts b/src/renderer/src/lib/launch-work-item-direct.test.ts
index 98913111af8..33ae88fde57 100644
--- a/src/renderer/src/lib/launch-work-item-direct.test.ts
+++ b/src/renderer/src/lib/launch-work-item-direct.test.ts
@@ -229,9 +229,9 @@ describe('launchWorkItemDirect', () => {
openModalFallback: vi.fn(),
item: {
type: 'pr',
- number: 42,
+ number: 6934,
title: 'Fix the bug',
- url: 'https://github.com/acme/repo/pull/42',
+ url: 'https://github.com/stablyai/orca/pull/6934',
branchName: 'feature/fix',
baseRefName: 'main',
isCrossRepository: true
@@ -240,21 +240,21 @@ describe('launchWorkItemDirect', () => {
expect(mocks.resolvePrBase).toHaveBeenCalledWith({
repoId: 'repo-1',
- prNumber: 42,
+ prNumber: 6934,
headRefName: 'feature/fix',
baseRefName: 'main',
isCrossRepository: true
})
expect(mocks.createWorktree).toHaveBeenCalledWith(
'repo-1',
- 'review-pr-42',
+ 'review-pr-6934',
'abc123',
'inherit',
undefined,
'sidebar',
- 'Review PR 42',
+ 'Review PR 6934',
undefined,
- 42,
+ 6934,
{ remoteName: 'origin', branchName: 'feature/fix' },
undefined,
undefined,
@@ -274,6 +274,41 @@ describe('launchWorkItemDirect', () => {
)
})
+ it('treats a PR-typed GitHub issue URL as an issue without resolving a PR head', async () => {
+ const { launchWorkItemDirect } = await import('./launch-work-item-direct')
+ const openModalFallback = vi.fn()
+
+ await expect(
+ launchWorkItemDirect({
+ repoId: 'repo-1',
+ launchSource: 'task_page',
+ telemetrySource: 'sidebar',
+ openModalFallback,
+ item: {
+ type: 'pr',
+ number: 6933,
+ title: 'The board columns are displayed backwards',
+ url: 'https://github.com/stablyai/orca/issues/6933',
+ branchName: 'fix-issue-6933',
+ baseRefName: 'main',
+ isCrossRepository: true
+ }
+ })
+ ).resolves.toBe(true)
+
+ expect(mocks.resolvePrBase).not.toHaveBeenCalled()
+ expect(openModalFallback).not.toHaveBeenCalled()
+ const createArgs = mocks.createWorktree.mock.calls[0]
+ expect(createArgs?.[1]).toBe('issue-6933')
+ expect(createArgs?.[2]).toBeUndefined()
+ expect(createArgs?.[6]).toBe('Issue 6933')
+ expect(createArgs?.[7]).toBe(6933)
+ expect(createArgs?.[8]).toBeUndefined()
+ expect(createArgs?.[9]).toBeUndefined()
+ expect(createArgs?.[12]).toBeUndefined()
+ expect(createArgs?.[24]).toBeUndefined()
+ })
+
it('uses the Linear identifier in direct-launch workspace names', async () => {
const { launchWorkItemDirect } = await import('./launch-work-item-direct')
diff --git a/src/renderer/src/lib/launch-work-item-direct.ts b/src/renderer/src/lib/launch-work-item-direct.ts
index def1c5c6c8a..61f49274edb 100644
--- a/src/renderer/src/lib/launch-work-item-direct.ts
+++ b/src/renderer/src/lib/launch-work-item-direct.ts
@@ -16,6 +16,7 @@ import { ensureHooksConfirmed } from '@/lib/ensure-hooks-confirmed'
import { getConnectionId } from '@/lib/connection-context'
import type { GitPushTarget, SetupDecision, TuiAgent } from '../../../shared/types'
import { getLinearIssueWorkspaceName } from '../../../shared/workspace-name'
+import { resolveGitHubWorkItemIdentity } from '@/lib/github-work-item-identity'
import {
buildDirectWorkItemAgentStartupPlan,
buildDirectWorkItemStartupOpts,
@@ -70,6 +71,16 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom
const repoOwnerSettings = getSettingsForRepoRuntimeOwner(store, repoId)
const promptDelivery = args.promptDelivery ?? 'draft'
const repoConnectionId = repo.connectionId?.trim() || null
+ const githubIdentity =
+ item.number !== null && (item.type === 'issue' || item.type === 'pr')
+ ? resolveGitHubWorkItemIdentity({
+ type: item.type,
+ number: item.number,
+ url: item.url
+ })
+ : null
+ const itemType = githubIdentity?.type ?? item.type
+ const itemNumber = githubIdentity?.number ?? item.number
const repoProjectRuntime = repoConnectionId
? undefined
: getLocalRepoProjectExecutionRuntimeContext(store, repoId, CLIENT_PLATFORM)
@@ -107,10 +118,10 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom
trustDecision === 'skip' ? 'skip' : setupResolution.decision
const workspaceIntentName =
- item.number !== null
+ itemNumber !== null
? getWorkspaceIntentName({
sourceText: item.pasteContent,
- workItem: { ...item, number: item.number }
+ workItem: { ...item, type: itemType, number: itemNumber }
})
: null
const workspaceName = getWorkspaceSeedName({
@@ -118,18 +129,18 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom
? getLinearIssueWorkspaceName({ identifier: item.linearIdentifier, title: item.title })
: (workspaceIntentName?.seedName ?? ''),
prompt: '',
- linkedIssueNumber: item.type === 'issue' ? (item.number ?? null) : null,
- linkedPR: item.type === 'pr' ? (item.number ?? null) : null
+ linkedIssueNumber: itemType === 'issue' ? (itemNumber ?? null) : null,
+ linkedPR: itemType === 'pr' ? (itemNumber ?? null) : null
})
let resolvedBaseBranch = baseBranch
let resolvedPushTarget: GitPushTarget | undefined
let resolvedBranchNameOverride: string | undefined
let resolvedCompareBaseRef: string | undefined
- if (!resolvedBaseBranch && item.type === 'pr' && item.number) {
+ if (!resolvedBaseBranch && itemType === 'pr' && itemNumber) {
try {
// Why: direct "Use PR" launches bypass the Start-from picker, so they
// must still resolve the PR head before `git worktree add`.
- const result = await resolveDirectPrStartPoint(repoId, item.number, repoOwnerSettings, item)
+ const result = await resolveDirectPrStartPoint(repoId, itemNumber, repoOwnerSettings, item)
resolvedBaseBranch = result.baseBranch
resolvedPushTarget = result.pushTarget
resolvedBranchNameOverride = result.branchNameOverride
@@ -157,15 +168,15 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom
undefined,
telemetrySource,
workspaceIntentName?.displayName ?? item.title,
- item.type === 'issue' && item.number ? item.number : undefined,
- item.type === 'pr' && item.number ? item.number : undefined,
+ itemType === 'issue' && itemNumber ? itemNumber : undefined,
+ itemType === 'pr' && itemNumber ? itemNumber : undefined,
resolvedPushTarget,
undefined,
item.linearIdentifier,
resolvedBranchNameOverride,
undefined,
- item.type === 'mr' && item.number ? item.number : undefined,
- gitLabIssueNumber(item),
+ itemType === 'mr' && itemNumber ? itemNumber : undefined,
+ gitLabIssueNumber({ ...item, type: itemType, number: itemNumber }),
undefined,
undefined,
undefined,
diff --git a/src/renderer/src/lib/smart-github-submit.test.ts b/src/renderer/src/lib/smart-github-submit.test.ts
index 43ec3c36325..64e58824e0c 100644
--- a/src/renderer/src/lib/smart-github-submit.test.ts
+++ b/src/renderer/src/lib/smart-github-submit.test.ts
@@ -299,4 +299,26 @@ describe('getSmartGitHubSubmitResolution', () => {
expect(resolution.linkedIssueNumber).toBe(2050)
expect(resolution.linkedPR).toBeNull()
})
+
+ it('uses the URL path to normalize stale PR-typed issue results', () => {
+ expect(
+ getSmartGitHubSubmitResolution({
+ type: 'pr',
+ number: 6933,
+ title: 'The board columns are displayed backwards',
+ url: 'https://github.com/stablyai/orca/issues/6933'
+ })
+ ).toEqual({
+ workspaceName: 'the-board-columns-are-displayed-backwards',
+ displayName: 'The board columns are displayed backwards',
+ linkedWorkItem: {
+ type: 'issue',
+ number: 6933,
+ title: 'The board columns are displayed backwards',
+ url: 'https://github.com/stablyai/orca/issues/6933'
+ },
+ linkedIssueNumber: 6933,
+ linkedPR: null
+ })
+ })
})
diff --git a/src/renderer/src/lib/smart-github-submit.ts b/src/renderer/src/lib/smart-github-submit.ts
index c6ee5abff47..1b4e818f26a 100644
--- a/src/renderer/src/lib/smart-github-submit.ts
+++ b/src/renderer/src/lib/smart-github-submit.ts
@@ -4,6 +4,7 @@ import { getTaskSourceCacheScope } from '../../../shared/task-source-context'
import { getLinkedWorkItemWorkspaceName } from '../../../shared/workspace-name'
import type { LinkedWorkItemSummary } from './new-workspace'
import { parseGitHubIssueOrPRLink } from './github-links'
+import { resolveGitHubWorkItemIdentity } from '@/lib/github-work-item-identity'
export type SmartGitHubSubmitIntent =
| {
@@ -196,12 +197,14 @@ export function getSmartGitHubSubmitLookupCacheSizeForTests(): number {
export function getSmartGitHubSubmitResolution(
item: Pick
): SmartGitHubSubmitResolution {
- const fallbackName = `${item.type}-${item.number}`
- const titleName = getLinkedWorkItemWorkspaceName(item)
+ const identity = resolveGitHubWorkItemIdentity(item)
+ const normalizedItem = { ...item, type: identity.type, number: identity.number }
+ const fallbackName = `${identity.type}-${identity.number}`
+ const titleName = getLinkedWorkItemWorkspaceName(normalizedItem)
const workspaceName = titleName?.seedName || fallbackName
const linkedWorkItem: LinkedWorkItemSummary = {
- type: item.type,
- number: item.number,
+ type: identity.type,
+ number: identity.number,
title: item.title,
url: item.url
}
@@ -210,7 +213,7 @@ export function getSmartGitHubSubmitResolution(
workspaceName,
displayName: titleName?.displayName ?? fallbackName,
linkedWorkItem,
- linkedIssueNumber: item.type === 'issue' ? item.number : null,
- linkedPR: item.type === 'pr' ? item.number : null
+ linkedIssueNumber: identity.type === 'issue' ? identity.number : null,
+ linkedPR: identity.type === 'pr' ? identity.number : null
}
}
diff --git a/src/shared/github-links.ts b/src/shared/github-links.ts
index 0d55bbacdee..a53562f5d5d 100644
--- a/src/shared/github-links.ts
+++ b/src/shared/github-links.ts
@@ -8,6 +8,12 @@ export type RepoSlug = {
repo: string
}
+export type GitHubIssueOrPRLink = {
+ slug: RepoSlug
+ number: number
+ type: 'issue' | 'pr'
+}
+
export function buildGitHubRepoUrl(slug: RepoSlug | null | undefined): string | null {
if (!slug?.owner || !slug.repo) {
return null
@@ -62,11 +68,7 @@ export function parseGitHubIssueOrPRNumber(input: string): number | null {
* Parses an owner/repo slug plus issue/PR number from a GitHub URL. Returns
* null for anything that isn't a recognizable GitHub-shaped issue or pull URL.
*/
-export function parseGitHubIssueOrPRLink(input: string): {
- slug: RepoSlug
- number: number
- type: 'issue' | 'pr'
-} | null {
+export function parseGitHubIssueOrPRLink(input: string): GitHubIssueOrPRLink | null {
const trimmed = input.trim()
if (!trimmed) {
return null
diff --git a/src/shared/secure-file-coarse-ctime.test.ts b/src/shared/secure-file-coarse-ctime.test.ts
new file mode 100644
index 00000000000..64c33ba36f1
--- /dev/null
+++ b/src/shared/secure-file-coarse-ctime.test.ts
@@ -0,0 +1,123 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+const { chmodSyncMock, existsSyncMock, paths, statSyncMock, state } = vi.hoisted(() => ({
+ chmodSyncMock: vi.fn(),
+ existsSyncMock: vi.fn(),
+ paths: {
+ dir: '/secure',
+ file: '/secure/secret.json'
+ },
+ statSyncMock: vi.fn(),
+ state: {
+ dirMode: 0o755,
+ fileMode: 0o600
+ }
+}))
+
+vi.mock('fs', () => ({
+ chmodSync: chmodSyncMock,
+ existsSync: existsSyncMock,
+ mkdirSync: vi.fn(),
+ renameSync: vi.fn(),
+ rmSync: vi.fn(),
+ statSync: statSyncMock,
+ writeFileSync: vi.fn()
+}))
+
+vi.mock('child_process', () => ({
+ execFile: vi.fn(),
+ execFileSync: vi.fn()
+}))
+
+import {
+ __resetSecureFileHardenedPathsForTests,
+ __resetSecureFileWindowsUserSidForTests,
+ hardenExistingSecureFile
+} from './secure-file'
+
+describe('secure-file coarse-ctime mode drift', () => {
+ const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
+
+ beforeEach(() => {
+ Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' })
+ state.dirMode = 0o755
+ state.fileMode = 0o600
+ existsSyncMock.mockReturnValue(true)
+ statSyncMock.mockImplementation((targetPath: string) => {
+ const path = String(targetPath)
+ if (path === paths.dir) {
+ return fakeStats(true, state.dirMode)
+ }
+ if (path === paths.file) {
+ return fakeStats(false, state.fileMode)
+ }
+ throw new Error(`unexpected stat path ${path}`)
+ })
+ chmodSyncMock.mockImplementation((targetPath: string, mode: number) => {
+ const path = String(targetPath)
+ if (path === paths.dir) {
+ state.dirMode = mode & 0o777
+ } else if (path === paths.file) {
+ state.fileMode = mode & 0o777
+ }
+ })
+ __resetSecureFileWindowsUserSidForTests()
+ __resetSecureFileHardenedPathsForTests()
+ })
+
+ afterEach(() => {
+ chmodSyncMock.mockReset()
+ existsSyncMock.mockReset()
+ statSyncMock.mockReset()
+ __resetSecureFileWindowsUserSidForTests()
+ __resetSecureFileHardenedPathsForTests()
+ if (originalPlatform) {
+ Object.defineProperty(process, 'platform', originalPlatform)
+ }
+ })
+
+ it('re-hardens a directory when only POSIX mode changes under stable timestamps', () => {
+ hardenExistingSecureFile(paths.file)
+ expect(chmodSyncMock.mock.calls).toEqual([
+ [paths.dir, 0o700],
+ [paths.file, 0o600]
+ ])
+
+ chmodSyncMock.mockClear()
+ // Coarse-ctime filesystems can report identical timestamps after chmod; only mode changes.
+ state.dirMode = 0o755
+
+ hardenExistingSecureFile(paths.file)
+
+ expect(chmodSyncMock.mock.calls).toEqual([[paths.dir, 0o700]])
+ })
+
+ it('re-hardens a file when only POSIX mode changes under stable timestamps', () => {
+ hardenExistingSecureFile(paths.file)
+ expect(chmodSyncMock.mock.calls).toEqual([
+ [paths.dir, 0o700],
+ [paths.file, 0o600]
+ ])
+
+ chmodSyncMock.mockClear()
+ // Coarse-ctime filesystems can report identical timestamps after chmod; only mode changes.
+ state.fileMode = 0o644
+
+ hardenExistingSecureFile(paths.file)
+
+ expect(chmodSyncMock.mock.calls).toEqual([[paths.file, 0o600]])
+ })
+})
+
+function fakeStats(isDirectory: boolean, mode: number) {
+ return {
+ birthtimeMs: 10,
+ ctimeMs: 20,
+ dev: 30,
+ ino: isDirectory ? 40 : 41,
+ isDirectory: () => isDirectory,
+ mode,
+ mtimeMs: 50,
+ size: isDirectory ? 0 : 2
+ }
+}
diff --git a/src/shared/secure-file.ts b/src/shared/secure-file.ts
index 146c62b09e0..1ed451b5633 100644
--- a/src/shared/secure-file.ts
+++ b/src/shared/secure-file.ts
@@ -18,6 +18,7 @@ type HardenedPathCacheEntry = {
dev: number
ino: number
size: number
+ mode: number
ctimeMs: number
mtimeMs: number
birthtimeMs: number
@@ -130,6 +131,7 @@ export function hardenExistingSecureFile(targetPath: string): void {
}
}
+/** Applies the platform-appropriate permission restriction to a path once, bypassing the cache. */
export function hardenSecurePath(
targetPath: string,
options: {
@@ -146,6 +148,7 @@ export function hardenSecurePath(
)
}
+/** Applies hardening; async Windows calls only report that best-effort ACL work was accepted. */
function applySecurePathRestriction(
targetPath: string,
isDirectory: boolean,
@@ -170,6 +173,7 @@ function applySecurePathRestriction(
return true
}
+/** Caches the current metadata snapshot for a just-hardened path, or clears it if the path is gone. */
function rememberHardenedPath(targetPath: string, isDirectory: boolean): void {
const entry = getHardenedPathCacheEntry(targetPath, isDirectory)
if (entry) {
@@ -179,6 +183,10 @@ function rememberHardenedPath(targetPath: string, isDirectory: boolean): void {
}
}
+/**
+ * Snapshots a path's identity, mode, and timestamps so later drift is detectable.
+ * Mode is tracked directly so a chmod is caught even where coarse ctime granularity hides it.
+ */
function getHardenedPathCacheEntry(
targetPath: string,
isDirectory: boolean
@@ -193,6 +201,7 @@ function getHardenedPathCacheEntry(
dev: stats.dev,
ino: stats.ino,
size: stats.size,
+ mode: stats.mode & 0o777,
ctimeMs: stats.ctimeMs,
mtimeMs: stats.mtimeMs,
birthtimeMs: stats.birthtimeMs
@@ -202,6 +211,7 @@ function getHardenedPathCacheEntry(
}
}
+/** True when two snapshots describe the same unchanged path (identity, mode, timestamps). */
function hardenedPathCacheEntriesMatch(
a: HardenedPathCacheEntry,
b: HardenedPathCacheEntry
@@ -211,6 +221,7 @@ function hardenedPathCacheEntriesMatch(
a.dev === b.dev &&
a.ino === b.ino &&
a.size === b.size &&
+ a.mode === b.mode &&
a.ctimeMs === b.ctimeMs &&
a.mtimeMs === b.mtimeMs &&
a.birthtimeMs === b.birthtimeMs