Merge origin/main into brennanb2025/oom-hidden-pane-parking

Resolves the github-links.ts conflict with #7371: the parsing-core pieces
(GitHubIssueOrPRLink type, parseGitHubIssueOrPRLink signature) land in
src/shared/github-links.ts where this branch moved the core, and the
renderer-side pieces (HTTP_URL_PREFIX_RE, GitHubLinkQuery.directLink,
normalizeGitHubLinkQuery) stay in the lib shim.

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
brennanb2025
2026-07-04 16:25:12 -07:00
co-authored by Orca
45 changed files with 1528 additions and 207 deletions
@@ -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": "/"
},
+1 -1
View File
@@ -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",
+46 -1
View File
@@ -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 = <name>` 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<string, string | string[]>): Promise<string[]> {
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<GhosttyImportPreview> {
const configPaths = await findGhosttyConfigPaths()
if (configPaths.length === 0) {
@@ -93,7 +132,13 @@ export async function previewGhosttyImport(store: Store): Promise<GhosttyImportP
mergeParsedConfig(parsed, parseGhosttyConfig(content))
}
const { diff: rawDiff, unsupportedKeys } = mapGhosttyToOrca(parsed, platform() === 'darwin')
const themeUnsupportedKeys = await applyThemeReference(parsed)
const { diff: rawDiff, unsupportedKeys: mappedUnsupportedKeys } = mapGhosttyToOrca(
parsed,
platform() === 'darwin'
)
const unsupportedKeys = [...mappedUnsupportedKeys, ...themeUnsupportedKeys]
const currentSettings = store.getSettings()
const actualDiff: Partial<typeof rawDiff> = {}
+104
View File
@@ -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', () => {
+22 -16
View File
@@ -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<number, keyof TerminalColorOverrides> = {
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
+30
View File
@@ -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
}
+171
View File
@@ -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<string, unknown> = {}): 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)'])
})
})
+171
View File
@@ -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)
})
})
+120
View File
@@ -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<string, string | string[]>
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<ThemeReadResult> {
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<ThemeColors | null> {
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
}
+8 -11
View File
@@ -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'
}
})
@@ -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', () => {
@@ -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(
<T,>(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(<PetOverlay />)
})
return { container, root }
}
function installLocalStorage(): void {
const values = new Map<string, string>()
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('到 {')
})
})
+9 -14
View File
@@ -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<CustomPet['sprite']>
@@ -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 (
<>
<style>
{translate(
'auto.components.pet.PetOverlay.4712d196c6',
'@keyframes pet-{{value0}} { from { background-position: {{value1}}px {{value2}}px; } to { background-position: {{value3}}px {{value4}}px; } }',
{ value0: animKeyframesId, value1: startX, value2: startY, value3: endX, value4: startY }
)}
</style>
<style>{keyframesCss}</style>
<div
style={{
width: renderedW,
@@ -285,6 +281,10 @@ function defaultPosition(size: number = SIZE): Position {
)
}
// Why: the bob float is runtime CSS, not user-visible copy; keep CSS keywords
// out of i18n so translated locales cannot invalidate the keyframes.
const PET_BOB_KEYFRAMES_CSS =
'@keyframes pet-bob { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-4px); } }'
export function PetOverlay(): React.JSX.Element {
const documentVisible = useDocumentVisible()
const reducedMotion = usePrefersReducedMotion()
@@ -417,12 +417,7 @@ export function PetOverlay(): React.JSX.Element {
minHeight: 24
}}
>
<style>
{translate(
'auto.components.pet.PetOverlay.de932b0e8f',
'@keyframes pet-bob { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-4px); } }'
)}
</style>
<style>{PET_BOB_KEYFRAMES_CSS}</style>
{sprite ? (
<SpriteFrame
url={url}
@@ -1609,13 +1609,6 @@ function PRCommentActionBadge({
</span>
)
}
if (actionState === 'open') {
return (
<span className={presentation.statusBadgeOpen}>
{translate('auto.components.right.sidebar.checks.panel.content.7c1f0a2b11', 'Open')}
</span>
)
}
if (actionState === 'resolved') {
return (
<span className={presentation.statusBadgeResolved}>
@@ -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:
@@ -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')
})
@@ -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<CodexRateLimitAccountsState>({
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 }
)}
</p>
</div>
@@ -693,7 +698,7 @@ export function AccountsPane({
{translate(
'auto.components.settings.AccountsPane.e05d0ff737',
'Use your current {{value0}} Claude login.',
{ value0: accountRuntime.label }
{ value0: accountRuntimeSentenceLabel }
)}
</span>
</div>
@@ -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 }
)}
</div>
) : (
@@ -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 }
)}
</span>
</div>
@@ -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 }
)}
</p>
</div>
@@ -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 }
)}
</span>
</div>
@@ -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 }
)}
</div>
) : (
@@ -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 }
)}
</p>
</div>
@@ -174,8 +174,11 @@ export function BrowserProfileRow({
browser.profiles.length > 1 ? (
<DropdownMenuSub key={browser.family}>
<DropdownMenuSubTrigger>
{translate('auto.components.settings.BrowserProfileRow.7df818977e', 'From')}
{browser.label}
{translate(
'auto.components.settings.BrowserProfileRow.c5a273a809',
'From {{value0}}',
{ value0: browser.label }
)}
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent>
@@ -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 }
)}
</DropdownMenuItem>
)
)}
@@ -116,9 +116,9 @@ export function BrowserUseCookieImportStep({
<p className="text-[11px] text-muted-foreground">
{translate(
'auto.components.settings.BrowserUsePane.112f70adc4',
'Last imported from'
'Last imported from {{value0}}',
{ value0: sourceLabel }
)}
{sourceLabel}
</p>
) : null}
{onConfigureMoreBrowsers ? (
@@ -163,8 +163,11 @@ export function BrowserUseCookieImportStep({
browser.profiles.length > 1 ? (
<DropdownMenuSub key={browser.family}>
<DropdownMenuSubTrigger>
{translate('auto.components.settings.BrowserUsePane.e44c5d681e', 'From')}
{browser.label}
{translate(
'auto.components.settings.BrowserUsePane.5301857d88',
'From {{value0}}',
{ value0: browser.label }
)}
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent>
@@ -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 }
)}
</DropdownMenuItem>
)
)}
@@ -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()
@@ -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 <browser>" 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<unknown>((node, part) => {
if (typeof node === 'object' && node !== null) {
return (node as Record<string, unknown>)[part]
}
return undefined
}, en)
}
describe('import-cookies "From <browser>" 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')
})
})
@@ -4,6 +4,7 @@ export const SETTING_LABELS: Partial<Record<keyof GlobalSettings, string>> = {
terminalFontSize: 'Font Size',
terminalFontFamily: 'Font Family',
terminalFontWeight: 'Font Weight',
terminalLineHeight: 'Line Height',
terminalScrollSensitivity: 'Normal Scroll Speed',
terminalFastScrollSensitivity: 'Fast Scroll Speed',
terminalTuiScrollSensitivity: 'TUI Scroll Speed',
@@ -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])
@@ -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
})
})
})
@@ -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<WorktreeMeta> {
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
@@ -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', () => {
+123 -39
View File
@@ -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<string[]>(
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<LinkedWorkItemSummary | null>(
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<string>(() => {
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<number | null>(() => {
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<string>(
getInitialAutoManagedWorkspaceName({
draftName: persistDraft ? newWorkspaceDraft?.name : null,
draftLinkedWorkItem: persistDraft ? newWorkspaceDraft?.linkedWorkItem : null,
draftLinkedWorkItem: persistDraft ? draftLinkedWorkItemSeed : null,
initialName,
initialLinkedWorkItem
initialLinkedWorkItem: initialLinkedWorkItemSeed
})
)
const nameRef = useRef<string>(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<PendingSmartGitHubSubmitResolution> => {
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
+5 -7
View File
@@ -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": {
+5 -7
View File
@@ -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": {
+5 -7
View File
@@ -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": {
+5 -7
View File
@@ -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": {
+5 -7
View File
@@ -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": {
+30 -1
View File
@@ -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'
}
})
})
+7 -2
View File
@@ -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
}
}
@@ -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',
@@ -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
)
@@ -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,
@@ -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 }
}
@@ -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')
+21 -10
View File
@@ -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,
@@ -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
})
})
})
+9 -6
View File
@@ -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<GitHubWorkItem, 'number' | 'title' | 'type' | 'url'>
): 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
}
}
+7 -5
View File
@@ -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
+123
View File
@@ -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
}
}
+11
View File
@@ -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