From c8381f3ea780d9f0da9aa58d88d515577ca3e399 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 22 Jul 2026 17:29:14 -0700 Subject: [PATCH 01/23] Preserve Codex [tui] settings across managed CODEX_HOME remirrors (#9475) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(codex): promote [tui] settings so they survive the managed-home remirror Codex TUI preferences (/statusline, theme, terminal title) are written into the [tui] table of the managed runtime config.toml, but the write-back promotion allowlist only covered four top-level scalars — so the next mirror pass rewrote the runtime config from ~/.codex and silently discarded them. Extend promotion to the [tui] keys the Codex TUI persists (status_line, status_line_use_colors, terminal_title, theme), keyed as structured tui.* paths so the same three-way merge (runtime vs baseline vs ~/.codex) applies: in-Codex changes promote into ~/.codex before the mirror, and outside edits to ~/.codex still win over stale runtime values. The byte-preserving upsert moves to codex-config-settings-upsert.ts (max-lines) and learns [tui] placement: replace an existing bare or dotted key in place, insert into the first [tui] body, insert dotted beside existing dotted tui.* keys, or create one [tui] table at EOF — never defining tui twice, including when the system config holds an inline tui = {...} table. * Add codex-config-settings-upsert to the CLI tsconfig file list * fix(codex): keep tui upserts out of array tables * fix(codex): handle quoted tui config paths during promotion * fix(codex): harden tui promotion writes --- config/tsconfig.cli.json | 2 + .../codex/codex-config-settings-upsert.ts | 322 +++++++++++++++ .../codex/config-settings-promotion.test.ts | 384 +++++++++++++++++- src/main/codex/config-settings-promotion.ts | 176 +++++--- src/main/codex/config-toml-key-path.ts | 67 +++ 5 files changed, 882 insertions(+), 69 deletions(-) create mode 100644 src/main/codex/codex-config-settings-upsert.ts create mode 100644 src/main/codex/config-toml-key-path.ts diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json index 0a79fb4d665..f8978764ccd 100644 --- a/config/tsconfig.cli.json +++ b/config/tsconfig.cli.json @@ -22,6 +22,7 @@ "../src/main/codex/codex-app-server-session.ts", "../src/main/codex/codex-config-mirror.ts", "../src/main/codex/codex-config-path-reference-rewrite.ts", + "../src/main/codex/codex-config-settings-upsert.ts", "../src/main/codex/codex-home-paths.ts", "../src/main/codex/codex-hook-identity.ts", "../src/main/codex/codex-hook-trust-grant.ts", @@ -35,6 +36,7 @@ "../src/main/codex/codex-user-hook-trust-rebase.ts", "../src/main/codex/codex-wsl-hook-install-plan.ts", "../src/main/codex/config-settings-promotion.ts", + "../src/main/codex/config-toml-key-path.ts", "../src/main/codex/config-toml-line-scan.ts", "../src/main/codex/config-toml-trust.ts", "../src/main/codex/hook-service.ts", diff --git a/src/main/codex/codex-config-settings-upsert.ts b/src/main/codex/codex-config-settings-upsert.ts new file mode 100644 index 00000000000..963d46d239a --- /dev/null +++ b/src/main/codex/codex-config-settings-upsert.ts @@ -0,0 +1,322 @@ +import { + createTomlLineScanState, + getTomlTableHeader, + isTomlStructuralLine, + updateTomlLineScanState +} from './config-toml-line-scan' +import { parseTomlKeyPath, parseTomlTableHeaderPath } from './config-toml-key-path' + +const TUI_STRUCTURED_PREFIX = 'tui.' + +// Why: promoted [tui] settings are keyed by structured path (tui.) so their +// baseline/update entries can never collide with a top-level key of the same name. +export function tuiStructuredKey(key: string): string { + return `${TUI_STRUCTURED_PREFIX}${key}` +} + +export function isTuiStructuredKey(structuredKey: string): boolean { + return structuredKey.startsWith(TUI_STRUCTURED_PREFIX) +} + +export function tuiKeyFromStructuredKey(structuredKey: string): string { + return structuredKey.slice(TUI_STRUCTURED_PREFIX.length) +} + +// Why: promoted updates arrive keyed by structured path; the preamble and [tui] +// regions are disjoint, so a mixed batch (e.g. /model + a status-line change) +// composes in one rewrite — top-level keys land in the preamble, tui. +// entries wherever the [tui] placement rule puts them. +export function upsertPromotedSettingsInContent( + content: string, + updates: Map +): string { + const topLevelUpdates = new Map() + const tuiUpdates = new Map() + for (const [key, raw] of updates) { + if (isTuiStructuredKey(key)) { + tuiUpdates.set(tuiKeyFromStructuredKey(key), raw) + } else { + topLevelUpdates.set(key, raw) + } + } + let result = content + if (topLevelUpdates.size > 0) { + result = upsertTopLevelSettingsInContent(result, topLevelUpdates) + } + if (tuiUpdates.size > 0) { + result = upsertTuiSettingsInContent(result, tuiUpdates) + } + return result +} + +export function upsertTopLevelSettingsInContent( + content: string, + updates: Map +): string { + const lines = content.split('\n') + let state = createTomlLineScanState() + let preambleEnd = lines.length + const keyLineIndexes = new Map() + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index] ?? '' + if (isTomlStructuralLine(state)) { + if (getTomlTableHeader(line)) { + preambleEnd = index + break + } + const parsed = parseTomlKeyPath(line) + const key = parsed?.segments.length === 1 ? parsed.segments[0] : null + if (parsed && line[parsed.end] === '=' && key && updates.has(key)) { + keyLineIndexes.set(key, index) + } + } + state = updateTomlLineScanState(state, line) + } + + // Why: CRLF configs keep a trailing \r after the split; new lines must use + // the file's existing endings or a Windows-owned config becomes mixed-EOL. + const usesCrlf = content.includes('\r\n') + const insertions: string[] = [] + for (const [key, raw] of updates) { + const existingIndex = keyLineIndexes.get(key) + const rendered = `${key} = ${raw}` + if (existingIndex !== undefined) { + lines[existingIndex] = lines[existingIndex]?.endsWith('\r') ? `${rendered}\r` : rendered + } else { + insertions.push(usesCrlf ? `${rendered}\r` : rendered) + } + } + if (insertions.length > 0) { + let insertAt = preambleEnd + while (insertAt > 0 && (lines[insertAt - 1] ?? '').trim() === '') { + insertAt -= 1 + } + if (insertAt === preambleEnd && preambleEnd < lines.length) { + insertions.push(usesCrlf ? '\r' : '') + } + lines.splice(insertAt, 0, ...insertions) + } + return joinPreservingTrailingNewline(lines, usesCrlf) +} + +type TuiPlacementScan = { + bareKeyIndexes: Map + dottedKeyIndexes: Map + hasBareTuiTable: boolean + hasDottedTuiKey: boolean + blocksNewTuiTable: boolean + blockedAbsentKeys: Set + bareBodyInsertIndex: number + lastDottedTuiIndex: number +} + +/** + * Upserts promoted `[tui]` keys (keyed by bare name) into the system config, + * placing each per the design's total placement rule: replace an existing key + * in place keeping its form; else insert bare into the first `[tui]` body; else + * dotted in the preamble beside existing dotted `tui.*` keys; else create one + * `[tui]` table at EOF for every key that reaches that branch. Rendering follows + * the destination — bare inside a table, dotted in the preamble — so no `tui` + * table is ever defined twice. + */ +export function upsertTuiSettingsInContent(content: string, updates: Map): string { + const lines = content.split('\n') + const scan = scanTuiPlacement(lines, updates) + const usesCrlf = content.includes('\r\n') + const bareBodyInserts: string[] = [] + const dottedPreambleInserts: string[] = [] + const newTableKeys: string[] = [] + + for (const [key, raw] of updates) { + const dottedIndex = scan.dottedKeyIndexes.get(key) + if (dottedIndex !== undefined) { + lines[dottedIndex] = withTrailingCr(lines[dottedIndex]!, `${tuiStructuredKey(key)} = ${raw}`) + continue + } + const bareIndex = scan.bareKeyIndexes.get(key) + if (bareIndex !== undefined) { + lines[bareIndex] = withTrailingCr(lines[bareIndex]!, `${key} = ${raw}`) + continue + } + // Why: adding a scalar beside an existing tui. descendant would turn valid TOML invalid. + if (scan.blockedAbsentKeys.has(key)) { + continue + } + if (scan.hasBareTuiTable) { + bareBodyInserts.push(`${key} = ${raw}`) + } else if (scan.hasDottedTuiKey) { + dottedPreambleInserts.push(`${tuiStructuredKey(key)} = ${raw}`) + } else if (!scan.blocksNewTuiTable) { + // Why: inline/array tui definitions block this branch because adding a + // plain [tui] beside either would make the config invalid. + newTableKeys.push(`${key} = ${raw}`) + } + } + + // Why: the config shape routes every absent key to the same branch, so at most + // one insert group is non-empty; still apply EOF→body→preamble so a splice + // never shifts a lower index a later splice depends on. + if (newTableKeys.length > 0) { + appendNewTuiTable(lines, newTableKeys, usesCrlf) + } + if (bareBodyInserts.length > 0) { + lines.splice( + scan.bareBodyInsertIndex, + 0, + ...bareBodyInserts.map((line) => withCrLine(line, usesCrlf)) + ) + } + if (dottedPreambleInserts.length > 0) { + lines.splice( + scan.lastDottedTuiIndex + 1, + 0, + ...dottedPreambleInserts.map((line) => withCrLine(line, usesCrlf)) + ) + } + return joinPreservingTrailingNewline(lines, usesCrlf) +} + +function scanTuiPlacement(lines: string[], updates: Map): TuiPlacementScan { + let state = createTomlLineScanState() + let inPreamble = true + let tuiTableSeen = false + let tuiBodyActive = false + let tuiBodyHeaderIndex = -1 + let tuiBodyEndIndex = -1 + let hasDottedTuiKey = false + let blocksNewTuiTable = false + let lastDottedTuiIndex = -1 + const bareKeyIndexes = new Map() + const dottedKeyIndexes = new Map() + const blockedAbsentKeys = new Set() + + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index] ?? '' + if (isTomlStructuralLine(state)) { + const header = getTomlTableHeader(line) + if (header) { + if (tuiBodyActive) { + tuiBodyEndIndex = index + tuiBodyActive = false + } + const table = parseTomlTableHeaderPath(header) + if ( + table && + !table.isArray && + table.segments.length === 1 && + table.segments[0] === 'tui' && + !tuiTableSeen + ) { + tuiTableSeen = true + tuiBodyActive = true + tuiBodyHeaderIndex = index + } + // Why: a root [[tui]] is already an array, so appending [tui] would + // redefine it and make an otherwise valid config unparseable. + if (table?.isArray && table.segments.length === 1 && table.segments[0] === 'tui') { + blocksNewTuiTable = true + } + const descendantKey = + table?.segments[0] === 'tui' && table.segments.length > 1 ? table.segments[1] : null + if (descendantKey && updates.has(descendantKey)) { + blockedAbsentKeys.add(descendantKey) + } + inPreamble = false + state = updateTomlLineScanState(state, line) + continue + } + if (inPreamble) { + // Why: any dotted `tui.*` key (allowlisted or not) already defines the + // implicit tui table, so a new `[tui]` table at EOF would duplicate it. + const parsed = parseTomlKeyPath(line) + const isAssignment = parsed && line[parsed.end] === '=' + if (isAssignment && parsed.segments[0] === 'tui' && parsed.segments.length > 1) { + hasDottedTuiKey = true + lastDottedTuiIndex = index + const promotedKey = parsed.segments.length === 2 ? parsed.segments[1] : null + if (promotedKey && updates.has(promotedKey)) { + dottedKeyIndexes.set(promotedKey, index) + } + const descendantKey = parsed.segments.length > 2 ? parsed.segments[1] : null + if (descendantKey && updates.has(descendantKey)) { + blockedAbsentKeys.add(descendantKey) + } + } else if (isAssignment && parsed.segments.length === 1 && parsed.segments[0] === 'tui') { + blocksNewTuiTable = true + } + } else if (tuiBodyActive) { + const parsed = parseTomlKeyPath(line) + const key = parsed?.segments.length === 1 ? parsed.segments[0] : null + if (parsed && line[parsed.end] === '=' && key && updates.has(key)) { + bareKeyIndexes.set(key, index) + } + const descendantKey = parsed && parsed.segments.length > 1 ? parsed.segments[0] : null + if (descendantKey && updates.has(descendantKey)) { + blockedAbsentKeys.add(descendantKey) + } + } + } + state = updateTomlLineScanState(state, line) + } + if (tuiBodyActive) { + tuiBodyEndIndex = lines.length + } + + return { + bareKeyIndexes, + dottedKeyIndexes, + hasBareTuiTable: tuiTableSeen, + hasDottedTuiKey, + blocksNewTuiTable, + blockedAbsentKeys, + bareBodyInsertIndex: computeBareBodyInsertIndex(lines, tuiBodyHeaderIndex, tuiBodyEndIndex), + lastDottedTuiIndex + } +} + +// Why: TOML forbids adding bare keys to `[tui]` after a `[tui.*]` subtable opens, +// so absent keys land at the body's end — before trailing blanks and before the +// next header — which is the only valid spot. +function computeBareBodyInsertIndex( + lines: string[], + headerIndex: number, + endIndex: number +): number { + if (headerIndex === -1) { + return -1 + } + let insertAt = endIndex + while (insertAt > headerIndex + 1 && (lines[insertAt - 1] ?? '').trim() === '') { + insertAt -= 1 + } + return insertAt +} + +function appendNewTuiTable(lines: string[], keyRenders: string[], usesCrlf: boolean): void { + let appendAt = lines.length + while (appendAt > 0 && (lines[appendAt - 1] ?? '').trim() === '') { + appendAt -= 1 + } + // Why: separate the new table from prior content with a blank line, unless the + // file was empty/blank, where a leading blank would be spurious. + const block = appendAt > 0 ? ['', '[tui]', ...keyRenders] : ['[tui]', ...keyRenders] + lines.splice(appendAt, 0, ...block.map((line) => withCrLine(line, usesCrlf))) +} + +function withTrailingCr(originalLine: string, rendered: string): string { + return originalLine.endsWith('\r') ? `${rendered}\r` : rendered +} + +function withCrLine(rendered: string, usesCrlf: boolean): string { + return usesCrlf ? `${rendered}\r` : rendered +} + +// Why: a missing trailing newline is restored in the file's own EOL so a +// preamble-only or table-appended rewrite matches the source's newline behavior. +function joinPreservingTrailingNewline(lines: string[], usesCrlf: boolean): string { + const result = lines.join('\n') + if (result.endsWith('\n') || result.length === 0) { + return result + } + return result.endsWith('\r') ? `${result}\n` : `${result}${usesCrlf ? '\r\n' : '\n'}` +} diff --git a/src/main/codex/config-settings-promotion.test.ts b/src/main/codex/config-settings-promotion.test.ts index f6ceabc362f..1f372fa7c84 100644 --- a/src/main/codex/config-settings-promotion.test.ts +++ b/src/main/codex/config-settings-promotion.test.ts @@ -19,7 +19,7 @@ import type * as CodexFsUtils from '../codex-accounts/fs-utils' const { homedirMock, promotionTestState } = vi.hoisted(() => ({ homedirMock: vi.fn<() => string>(), - promotionTestState: { failAtomicWrite: false } + promotionTestState: { failAtomicWrite: false, atomicWritePaths: [] as string[] } })) vi.mock('node:os', async (importOriginal) => { @@ -35,6 +35,7 @@ vi.mock('../codex-accounts/fs-utils', async (importOriginal) => { return { ...actual, writeFileAtomically: (...args: Parameters) => { + promotionTestState.atomicWritePaths.push(args[0]) if (promotionTestState.failAtomicWrite) { throw new Error('injected atomic write failure') } @@ -44,7 +45,15 @@ vi.mock('../codex-accounts/fs-utils', async (importOriginal) => { }) import { syncSystemConfigIntoManagedCodexHome } from './codex-config-mirror' -import { upsertTopLevelSettingsInContent } from './config-settings-promotion' +import { + upsertPromotedSettingsInContent, + upsertTopLevelSettingsInContent +} from './codex-config-settings-upsert' + +// The exact [tui] block codex 0.144.6 writes via config/batchWrite (all four +// promoted keys single-line, theme a string). +const CODEX_TUI_BLOCK = + '[tui]\nstatus_line = ["model-with-reasoning", "task-progress"]\nstatus_line_use_colors = true\nterminal_title = ["model"]\ntheme = "dark-photon"\n' let tmpHome: string let userDataDir: string @@ -57,6 +66,7 @@ beforeEach(() => { process.env.ORCA_USER_DATA_PATH = userDataDir homedirMock.mockReturnValue(tmpHome) promotionTestState.failAtomicWrite = false + promotionTestState.atomicWritePaths.length = 0 // Why: promotion writes into homedir()/.codex — if the mock ever fails to // intercept, these tests would rewrite the developer's real Codex config. if (homedir() !== tmpHome) { @@ -117,6 +127,13 @@ function simulateCodexSettingWrite(key: string, rawValue: string): void { writeFileSync(runtimeConfigPath(), next, 'utf-8') } +// Codex reads then rewrites the whole runtime config; simulate that by writing +// a known runtime config directly (its EOL is normalized by the mirror anyway). +function setRuntimeConfig(content: string): void { + mkdirSync(runtimeHomeDir(), { recursive: true }) + writeFileSync(runtimeConfigPath(), content, 'utf-8') +} + function simulateCodexSettingRemoval(key: string): void { const existing = readFileSync(runtimeConfigPath(), 'utf-8') const linePattern = new RegExp(`^${key}[ \\t]*=.*\\n?`, 'm') @@ -402,6 +419,363 @@ describe('codex settings write-back promotion', () => { }) }) +describe('codex [tui] settings write-back promotion', () => { + it('promotes a runtime [tui] block (codex 0.144.6 shape) into ~/.codex and survives the remirror', () => { + writeSystemConfig('model = "gpt-5"\n') + syncSystemConfigIntoManagedCodexHome() + + // The user customizes the status line/theme inside Orca-launched Codex. + writeFileSync(runtimeConfigPath(), `${readRuntimeConfig()}\n${CODEX_TUI_BLOCK}`, 'utf-8') + syncSystemConfigIntoManagedCodexHome() + + expect(readSystemConfig()).toBe(`model = "gpt-5"\n\n${CODEX_TUI_BLOCK}`) + const runtime = readRuntimeConfig() + expect(runtime).toContain('status_line = ["model-with-reasoning", "task-progress"]') + expect(runtime).toContain('status_line_use_colors = true') + expect(runtime).toContain('terminal_title = ["model"]') + expect(runtime).toContain('theme = "dark-photon"') + + const settledSystem = readSystemConfig() + const settledRuntime = readRuntimeConfig() + syncSystemConfigIntoManagedCodexHome() + expect(readSystemConfig()).toBe(settledSystem) + expect(readRuntimeConfig()).toBe(settledRuntime) + }) + + it('replaces a promoted key in an existing [tui] table, leaving non-promoted neighbors untouched', () => { + writeSystemConfig('model = "gpt-5"\n\n[tui]\nanimations = true\ntheme = "dark"\n') + syncSystemConfigIntoManagedCodexHome() + + setRuntimeConfig('model = "gpt-5"\n\n[tui]\nanimations = true\ntheme = "light"\n') + syncSystemConfigIntoManagedCodexHome() + + expect(readSystemConfig()).toBe( + 'model = "gpt-5"\n\n[tui]\nanimations = true\ntheme = "light"\n' + ) + }) + + it('promotes a changed status_line array value', () => { + writeSystemConfig('model = "gpt-5"\n\n[tui]\nstatus_line = ["model"]\n') + syncSystemConfigIntoManagedCodexHome() + + setRuntimeConfig( + 'model = "gpt-5"\n\n[tui]\nstatus_line = ["model-with-reasoning", "task-progress"]\n' + ) + syncSystemConfigIntoManagedCodexHome() + + expect(readSystemConfig()).toBe( + 'model = "gpt-5"\n\n[tui]\nstatus_line = ["model-with-reasoning", "task-progress"]\n' + ) + }) + + it('promotes a model change and a status-line change in one pass into their regions', () => { + writeSystemConfig('model = "gpt-5"\n\n[tui]\ntheme = "dark-photon"\n') + syncSystemConfigIntoManagedCodexHome() + + setRuntimeConfig('model = "o4"\n\n[tui]\ntheme = "dark-photon"\nstatus_line = ["model"]\n') + syncSystemConfigIntoManagedCodexHome() + + expect(readSystemConfig()).toBe( + 'model = "o4"\n\n[tui]\ntheme = "dark-photon"\nstatus_line = ["model"]\n' + ) + }) + + it('detects and replaces a dotted-form system tui key without creating a [tui] table', () => { + writeSystemConfig('model = "gpt-5"\ntui.theme = "dark"\n') + syncSystemConfigIntoManagedCodexHome() + + // toml_edit preserves the dotted form when codex rewrites the value. + setRuntimeConfig('model = "gpt-5"\ntui.theme = "light"\n') + syncSystemConfigIntoManagedCodexHome() + + expect(readSystemConfig()).toBe('model = "gpt-5"\ntui.theme = "light"\n') + expect(readSystemConfig()).not.toContain('[tui]') + }) + + it('promotes through a quoted tui table without creating a duplicate table', () => { + writeSystemConfig('model = "gpt-5"\n\n["tui"]\ntheme = "dark"\n') + syncSystemConfigIntoManagedCodexHome() + + setRuntimeConfig('model = "gpt-5"\n\n["tui"]\ntheme = "light"\n') + syncSystemConfigIntoManagedCodexHome() + + expect(readSystemConfig()).toBe('model = "gpt-5"\n\n["tui"]\ntheme = "light"\n') + expect(readSystemConfig()).not.toContain('\n[tui]\n') + }) + + it('inserts a second dotted tui key beside an existing dotted-only tui config', () => { + writeSystemConfig('model = "gpt-5"\ntui.theme = "dark"\n') + syncSystemConfigIntoManagedCodexHome() + + setRuntimeConfig('model = "gpt-5"\ntui.theme = "dark"\ntui.status_line = ["model"]\n') + syncSystemConfigIntoManagedCodexHome() + + expect(readSystemConfig()).toBe( + 'model = "gpt-5"\ntui.theme = "dark"\ntui.status_line = ["model"]\n' + ) + expect(readSystemConfig()).not.toContain('[tui]') + }) + + it('inserts dotted beside a non-promoted dotted tui key instead of creating a [tui] table', () => { + // Why: any dotted tui.* key already defines the implicit tui table, so a + // fresh [tui] table at EOF would be a duplicate-definition parse error. + writeSystemConfig('model = "gpt-5"\ntui.pet = "cat"\n') + syncSystemConfigIntoManagedCodexHome() + + setRuntimeConfig('model = "gpt-5"\ntui.pet = "cat"\ntui.theme = "dark-photon"\n') + syncSystemConfigIntoManagedCodexHome() + + expect(readSystemConfig()).toBe('model = "gpt-5"\ntui.pet = "cat"\ntui.theme = "dark-photon"\n') + expect(readSystemConfig()).not.toContain('[tui]') + }) + + it('creates a [tui] table at EOF when the only tui presence is a subtable', () => { + writeSystemConfig('model = "gpt-5"\n\n[tui.notifications]\nenabled = true\n') + syncSystemConfigIntoManagedCodexHome() + + setRuntimeConfig( + 'model = "gpt-5"\n\n[tui.notifications]\nenabled = true\n\n[tui]\nstatus_line = ["model"]\n' + ) + syncSystemConfigIntoManagedCodexHome() + + expect(readSystemConfig()).toBe( + 'model = "gpt-5"\n\n[tui.notifications]\nenabled = true\n\n[tui]\nstatus_line = ["model"]\n' + ) + }) + + it('creates exactly one [tui] table for two keys promoted in one pass', () => { + writeSystemConfig('model = "gpt-5"\n') + syncSystemConfigIntoManagedCodexHome() + + setRuntimeConfig('model = "gpt-5"\n\n[tui]\ntheme = "dark-photon"\nstatus_line = ["model"]\n') + syncSystemConfigIntoManagedCodexHome() + + const system = readSystemConfig() + expect(system.match(/^\[tui\]$/gm)?.length).toBe(1) + expect(system).toBe( + 'model = "gpt-5"\n\n[tui]\nstatus_line = ["model"]\ntheme = "dark-photon"\n' + ) + }) + + it('lets an outside ~/.codex [tui] edit win over a conflicting in-Codex tui change', () => { + writeSystemConfig('model = "gpt-5"\n\n[tui]\ntheme = "dark"\n') + syncSystemConfigIntoManagedCodexHome() + + setRuntimeConfig('model = "gpt-5"\n\n[tui]\ntheme = "in-codex"\n') + writeSystemConfig('model = "gpt-5"\n\n[tui]\ntheme = "outside-edit"\n') + syncSystemConfigIntoManagedCodexHome() + + expect(readSystemConfig()).toBe('model = "gpt-5"\n\n[tui]\ntheme = "outside-edit"\n') + expect(readRuntimeConfig()).toContain('theme = "outside-edit"') + }) + + it('does not promote a [tui] key deletion', () => { + writeSystemConfig('model = "gpt-5"\n\n[tui]\ntheme = "dark"\n') + syncSystemConfigIntoManagedCodexHome() + + setRuntimeConfig('model = "gpt-5"\n\n[tui]\n') + syncSystemConfigIntoManagedCodexHome() + + expect(readSystemConfig()).toContain('theme = "dark"') + }) + + it('inserts a promoted key into a CRLF system [tui] table preserving CRLF', () => { + writeSystemConfig('model = "gpt-5"\r\n\r\n[tui]\r\ntheme = "dark"\r\n') + syncSystemConfigIntoManagedCodexHome() + + setRuntimeConfig('model = "gpt-5"\n\n[tui]\ntheme = "dark"\nstatus_line = ["model"]\n') + syncSystemConfigIntoManagedCodexHome() + + const system = readSystemConfig() + expect(system).toContain('status_line = ["model"]\r\n') + expect(system).toBe( + 'model = "gpt-5"\r\n\r\n[tui]\r\ntheme = "dark"\r\nstatus_line = ["model"]\r\n' + ) + }) + + it('never appends a [tui] table when the system config defines tui inline', () => { + writeSystemConfig('model = "gpt-5"\n') + syncSystemConfigIntoManagedCodexHome() + + // In-Codex tui change racing an outside edit that adds an inline tui table: + // appending [tui] would make the system config unparseable, so the change + // is dropped instead. + setRuntimeConfig('model = "gpt-5"\n\n[tui]\ntheme = "dark-photon"\n') + writeSystemConfig('model = "gpt-5"\ntui = { animations = false }\n') + promotionTestState.atomicWritePaths.length = 0 + syncSystemConfigIntoManagedCodexHome() + + expect(readSystemConfig()).toBe('model = "gpt-5"\ntui = { animations = false }\n') + expect(promotionTestState.atomicWritePaths).not.toContain(systemConfigPath()) + }) + + it('ignores an allowlisted key nested under a [tui.*] subtable', () => { + writeSystemConfig('model = "gpt-5"\n\n[tui.notifications]\ntheme = "should-not-promote"\n') + syncSystemConfigIntoManagedCodexHome() + + setRuntimeConfig('model = "gpt-5"\n\n[tui.notifications]\ntheme = "changed-in-subtable"\n') + syncSystemConfigIntoManagedCodexHome() + + expect(readSystemConfig()).toBe( + 'model = "gpt-5"\n\n[tui.notifications]\ntheme = "should-not-promote"\n' + ) + }) +}) + +describe('upsertPromotedSettingsInContent', () => { + it('replaces a bare key in place inside the [tui] table', () => { + expect( + upsertPromotedSettingsInContent( + '[tui]\ntheme = "dark"\n', + new Map([['tui.theme', '"light"']]) + ) + ).toBe('[tui]\ntheme = "light"\n') + }) + + it('inserts a bare key at the end of the [tui] body, before a subtable', () => { + expect( + upsertPromotedSettingsInContent( + '[tui]\ntheme = "dark"\n\n[tui.notifications]\nenabled = true\n', + new Map([['tui.status_line', '["model"]']]) + ) + ).toBe( + '[tui]\ntheme = "dark"\nstatus_line = ["model"]\n\n[tui.notifications]\nenabled = true\n' + ) + }) + + it('replaces a dotted preamble tui key in place, keeping the dotted form', () => { + expect( + upsertPromotedSettingsInContent('tui.theme = "dark"\n', new Map([['tui.theme', '"light"']])) + ).toBe('tui.theme = "light"\n') + }) + + it('inserts a dotted tui key beside an existing dotted tui key', () => { + expect( + upsertPromotedSettingsInContent( + 'tui.theme = "dark"\n\n[features]\nx = 1\n', + new Map([['tui.status_line', '["model"]']]) + ) + ).toBe('tui.theme = "dark"\ntui.status_line = ["model"]\n\n[features]\nx = 1\n') + }) + + it('creates a [tui] table from empty content', () => { + expect(upsertPromotedSettingsInContent('', new Map([['tui.theme', '"dark"']]))).toBe( + '[tui]\ntheme = "dark"\n' + ) + }) + + it('drops an absent key instead of appending [tui] beside an inline tui table', () => { + expect( + upsertPromotedSettingsInContent( + 'tui = { animations = false }\n', + new Map([['tui.theme', '"dark"']]) + ) + ).toBe('tui = { animations = false }\n') + }) + + it('drops an absent key beside a quoted inline tui table', () => { + expect( + upsertPromotedSettingsInContent( + '"tui" = { animations = false }\n', + new Map([['tui.theme', '"dark"']]) + ) + ).toBe('"tui" = { animations = false }\n') + }) + + it('inserts beside a quoted dotted tui key instead of appending a table', () => { + expect( + upsertPromotedSettingsInContent('"tui" . "pet" = "cat"\n', new Map([['tui.theme', '"dark"']])) + ).toBe('"tui" . "pet" = "cat"\ntui.theme = "dark"\n') + }) + + it('creates a [tui] super-table at EOF after a [tui.*] subtable', () => { + expect( + upsertPromotedSettingsInContent( + '[tui.notifications]\nenabled = true\n', + new Map([['tui.theme', '"dark"']]) + ) + ).toBe('[tui.notifications]\nenabled = true\n\n[tui]\ntheme = "dark"\n') + }) + + it('drops an absent scalar that would redefine an existing tui key table', () => { + expect( + upsertPromotedSettingsInContent( + '[tui."theme"]\nvariant = "dark"\n', + new Map([['tui.theme', '"light"']]) + ) + ).toBe('[tui."theme"]\nvariant = "dark"\n') + }) + + it('drops an absent scalar that would redefine a dotted tui key table', () => { + expect( + upsertPromotedSettingsInContent( + 'tui.theme.variant = "dark"\n', + new Map([['tui.theme', '"light"']]) + ) + ).toBe('tui.theme.variant = "dark"\n') + }) + + it('does not mistake a dotted tui key inside an array table for a root key', () => { + expect( + upsertPromotedSettingsInContent( + '[[profiles]]\ntui.theme = "profile-theme"\n', + new Map([['tui.theme', '"root-theme"']]) + ) + ).toBe('[[profiles]]\ntui.theme = "profile-theme"\n\n[tui]\ntheme = "root-theme"\n') + }) + + it('does not append a table beside a root tui array-of-tables', () => { + expect( + upsertPromotedSettingsInContent( + '[[tui]]\ntheme = "array-theme"\n', + new Map([['tui.theme', '"root-theme"']]) + ) + ).toBe('[[tui]]\ntheme = "array-theme"\n') + }) + + it('does not append a table beside a quoted root tui array-of-tables', () => { + expect( + upsertPromotedSettingsInContent( + '[["tui"]]\ntheme = "array-theme"\n', + new Map([['tui.theme', '"root-theme"']]) + ) + ).toBe('[["tui"]]\ntheme = "array-theme"\n') + }) + + it('creates one [tui] table for multiple keys reaching the new-table branch', () => { + expect( + upsertPromotedSettingsInContent( + '', + new Map([ + ['tui.status_line', '["model"]'], + ['tui.theme', '"dark"'] + ]) + ) + ).toBe('[tui]\nstatus_line = ["model"]\ntheme = "dark"\n') + }) + + it('routes a mixed top-level + tui batch to its two regions in one rewrite', () => { + expect( + upsertPromotedSettingsInContent( + 'model = "gpt-5"\n\n[tui]\ntheme = "dark"\n', + new Map([ + ['model', '"o4"'], + ['tui.theme', '"light"'] + ]) + ) + ).toBe('model = "o4"\n\n[tui]\ntheme = "light"\n') + }) + + it('inserts into a CRLF [tui] table with CRLF endings', () => { + expect( + upsertPromotedSettingsInContent( + '[tui]\r\ntheme = "dark"\r\n', + new Map([['tui.status_line', '["model"]']]) + ) + ).toBe('[tui]\r\ntheme = "dark"\r\nstatus_line = ["model"]\r\n') + }) +}) + describe('upsertTopLevelSettingsInContent', () => { it('writes into empty content', () => { expect(upsertTopLevelSettingsInContent('', new Map([['model', '"x"']]))).toBe('model = "x"\n') @@ -428,6 +802,12 @@ describe('upsertTopLevelSettingsInContent', () => { ).toBe('# keep\nmodel = "new"\n\n[t]\nk = 1\n') }) + it('replaces a quoted top-level key instead of adding its bare equivalent', () => { + expect( + upsertTopLevelSettingsInContent('"model" = "old"\n', new Map([['model', '"new"']])) + ).toBe('model = "new"\n') + }) + it('inserts with CRLF endings into CRLF content', () => { expect( upsertTopLevelSettingsInContent('[features]\r\nhooks = true\r\n', new Map([['model', '"x"']])) diff --git a/src/main/codex/config-settings-promotion.ts b/src/main/codex/config-settings-promotion.ts index a7a1add6857..8a194fef99e 100644 --- a/src/main/codex/config-settings-promotion.ts +++ b/src/main/codex/config-settings-promotion.ts @@ -18,6 +18,8 @@ import { isTomlStructuralLine, updateTomlLineScanState } from './config-toml-line-scan' +import { parseTomlKeyPath, parseTomlTableHeaderPath } from './config-toml-key-path' +import { tuiStructuredKey, upsertPromotedSettingsInContent } from './codex-config-settings-upsert' // Why: the mirror reverts in-Codex config changes each launch; promotion salvages them by diffing the last baseline. @@ -29,6 +31,45 @@ export const PROMOTED_CODEX_SETTING_KEYS = [ 'sandbox_mode' ] as const +// Why: the [tui] keys the Codex TUI's user-facing pickers persist (status line, +// terminal title, theme). Like the top-level list, every key here gets written +// into the user's real ~/.codex/config.toml on promotion — grow it deliberately. +export const PROMOTED_CODEX_TUI_SETTING_KEYS = [ + 'status_line', + 'status_line_use_colors', + 'terminal_title', + 'theme' +] as const + +// Why: promotion diffs and upserts operate on structured keys — top-level keys +// keep their bare name, [tui] keys are namespaced tui. so their baseline +// entries cannot collide with a top-level key of the same name. +const PROMOTED_STRUCTURED_KEYS: readonly string[] = [ + ...PROMOTED_CODEX_SETTING_KEYS, + ...PROMOTED_CODEX_TUI_SETTING_KEYS.map(tuiStructuredKey) +] + +function isPromotedTuiKey(key: string): boolean { + return (PROMOTED_CODEX_TUI_SETTING_KEYS as readonly string[]).includes(key) +} + +// Returns the structured tui key a scanned line's key represents, or null. In +// the preamble it recognizes the dotted `tui.` form a user may hand-author; +// inside the first `[tui]` table body it recognizes the bare `` form Codex +// writes. Both map to the same structured key so either config shape promotes. +function matchTuiStructuredKey( + keyPath: string[], + inPreamble: boolean, + tuiBodyActive: boolean +): string | null { + if (inPreamble) { + const tuiKey = keyPath.length === 2 && keyPath[0] === 'tui' ? keyPath[1] : null + return tuiKey && isPromotedTuiKey(tuiKey) ? tuiStructuredKey(tuiKey) : null + } + const tuiKey = keyPath.length === 1 ? keyPath[0] : null + return tuiBodyActive && tuiKey && isPromotedTuiKey(tuiKey) ? tuiStructuredKey(tuiKey) : null +} + type TopLevelSettingValue = { raw: string // Why: a multiline string/array value can't be replaced line-by-line, so it's excluded from promotion. @@ -70,24 +111,67 @@ function readSettingsBaseline(runtimeHomePath: string): Map | nu } } -// Why: only top-level preamble keys are scanned; rewriting nested [profiles.*] tables isn't worth the risk here. -function readTopLevelSettingValues(configPath: string): Map { +function matchPromotedStructuredKey( + line: string, + inPreamble: boolean, + tuiBodyActive: boolean +): { structuredKey: string; raw: string } | null { + const parsed = parseTomlKeyPath(line) + if (!parsed || line[parsed.end] !== '=') { + return null + } + const raw = line.slice(parsed.end + 1).trim() + const topLevelKey = parsed.segments.length === 1 ? parsed.segments[0] : null + if ( + inPreamble && + topLevelKey && + (PROMOTED_CODEX_SETTING_KEYS as readonly string[]).includes(topLevelKey) + ) { + return { structuredKey: topLevelKey, raw } + } + const tuiKey = matchTuiStructuredKey(parsed.segments, inPreamble, tuiBodyActive) + return tuiKey ? { structuredKey: tuiKey, raw } : null +} + +// Why: top-level preamble scalars keep the historical behavior; [tui] keys are +// collected from the first bare [tui] table body or the dotted preamble form, +// keyed by structured path. Any table header (including [tui.*] subtables) ends +// the [tui] body, and [profiles.*]/other tables are still ignored. +function readPromotedSettingValues(configPath: string): Map { const result = new Map() if (!existsSync(configPath)) { return result } const lines = readFileSync(configPath, 'utf-8').split('\n') let state = createTomlLineScanState() + let inPreamble = true + let tuiTableSeen = false + let tuiBodyActive = false for (const line of lines) { if (isTomlStructuralLine(state)) { - if (getTomlTableHeader(line)) { - break + const header = getTomlTableHeader(line) + if (header) { + const table = parseTomlTableHeaderPath(header) + tuiBodyActive = + table !== null && + !table.isArray && + table.segments.length === 1 && + table.segments[0] === 'tui' && + !tuiTableSeen + if (tuiBodyActive) { + tuiTableSeen = true + } + inPreamble = false + state = updateTomlLineScanState(state, line) + continue } - const match = /^[ \t]*([A-Za-z0-9_-]+)[ \t]*=[ \t]*(.*?)[ \t\r]*$/.exec(line) - const key = match?.[1] - if (key && (PROMOTED_CODEX_SETTING_KEYS as readonly string[]).includes(key)) { + const matched = matchPromotedStructuredKey(line, inPreamble, tuiBodyActive) + if (matched) { const nextState = updateTomlLineScanState(state, line) - result.set(key, { raw: match?.[2] ?? '', multiline: !isTomlStructuralLine(nextState) }) + result.set(matched.structuredKey, { + raw: matched.raw, + multiline: !isTomlStructuralLine(nextState) + }) state = nextState continue } @@ -109,7 +193,7 @@ export function snapshotCodexRuntimeSettingsBaseline( const runtimeTomlPath = join(runtimeHomePath, 'config.toml') // Why: record an empty baseline even for a missing runtime config, so Codex's first write still diffs and promotes. const settings: Record = {} - for (const [key, value] of readTopLevelSettingValues(runtimeTomlPath)) { + for (const [key, value] of readPromotedSettingValues(runtimeTomlPath)) { if (!value.multiline) { settings[key] = value.raw } @@ -173,10 +257,9 @@ function promoteCodexRuntimeSettingsToSystemUnsafe(homes: CodexSettingsPromotion if (!baseline) { return } - const runtimeValues = readTopLevelSettingValues(runtimeTomlPath) - const systemValues = readTopLevelSettingValues(systemTomlPath) - const updates = new Map() - for (const key of PROMOTED_CODEX_SETTING_KEYS) { + const runtimeValues = readPromotedSettingValues(runtimeTomlPath) + const changedRuntimeValues = new Map() + for (const key of PROMOTED_STRUCTURED_KEYS) { const runtime = runtimeValues.get(key) if (!runtime || runtime.multiline) { continue @@ -185,6 +268,14 @@ function promoteCodexRuntimeSettingsToSystemUnsafe(homes: CodexSettingsPromotion // Orca mirrored this value and nothing touched it since — not a change. continue } + changedRuntimeValues.set(key, runtime.raw) + } + if (changedRuntimeValues.size === 0) { + return + } + const systemValues = readPromotedSettingValues(systemTomlPath) + const updates = new Map() + for (const [key, runtimeRaw] of changedRuntimeValues) { const system = systemValues.get(key) if (system?.multiline) { continue @@ -193,7 +284,7 @@ function promoteCodexRuntimeSettingsToSystemUnsafe(homes: CodexSettingsPromotion if (system?.raw !== baseline.get(key)) { continue } - updates.set(key, runtime.raw) + updates.set(key, runtimeRaw) } if (updates.size === 0) { return @@ -205,7 +296,10 @@ function promoteCodexRuntimeSettingsToSystemUnsafe(homes: CodexSettingsPromotion mkdirSync(dirname(writeTarget.path), { recursive: true, mode: 0o700 }) const targetExists = existsSync(writeTarget.path) const systemContent = targetExists ? readFileSync(writeTarget.path, 'utf-8') : '' - const nextContent = upsertTopLevelSettingsInContent(systemContent, updates) + const nextContent = upsertPromotedSettingsInContent(systemContent, updates) + if (nextContent === systemContent) { + return + } if (targetExists && parseWslUncPath(writeTarget.path)) { // Why: \\wsl$ 9P symlink metadata is unreliable; write through the existing file to preserve the WSL-side inode. writeFileSync(writeTarget.path, nextContent, 'utf-8') @@ -252,55 +346,3 @@ function resolveDanglingSymlinkTarget(linkPath: string): string { // Why: replacing any link in a cycle would destroy dotfile-manager state; abort instead. throw new Error(`Codex config symlink cycle at ${linkPath}`) } - -export function upsertTopLevelSettingsInContent( - content: string, - updates: Map -): string { - const lines = content.split('\n') - let state = createTomlLineScanState() - let preambleEnd = lines.length - const keyLineIndexes = new Map() - for (let index = 0; index < lines.length; index += 1) { - const line = lines[index] ?? '' - if (isTomlStructuralLine(state)) { - if (getTomlTableHeader(line)) { - preambleEnd = index - break - } - const match = /^[ \t]*([A-Za-z0-9_-]+)[ \t]*=/.exec(line) - if (match?.[1] && updates.has(match[1])) { - keyLineIndexes.set(match[1], index) - } - } - state = updateTomlLineScanState(state, line) - } - - // Why: match the file's existing EOL (CRLF split leaves a trailing \r) so a Windows config doesn't go mixed-EOL. - const usesCrlf = content.includes('\r\n') - const insertions: string[] = [] - for (const [key, raw] of updates) { - const existingIndex = keyLineIndexes.get(key) - const rendered = `${key} = ${raw}` - if (existingIndex !== undefined) { - lines[existingIndex] = lines[existingIndex]?.endsWith('\r') ? `${rendered}\r` : rendered - } else { - insertions.push(usesCrlf ? `${rendered}\r` : rendered) - } - } - if (insertions.length > 0) { - let insertAt = preambleEnd - while (insertAt > 0 && (lines[insertAt - 1] ?? '').trim() === '') { - insertAt -= 1 - } - if (insertAt === preambleEnd && preambleEnd < lines.length) { - insertions.push(usesCrlf ? '\r' : '') - } - lines.splice(insertAt, 0, ...insertions) - } - const result = lines.join('\n') - if (result.endsWith('\n') || result.length === 0) { - return result - } - return result.endsWith('\r') ? `${result}\n` : `${result}${usesCrlf ? '\r\n' : '\n'}` -} diff --git a/src/main/codex/config-toml-key-path.ts b/src/main/codex/config-toml-key-path.ts new file mode 100644 index 00000000000..25eaa4c98c6 --- /dev/null +++ b/src/main/codex/config-toml-key-path.ts @@ -0,0 +1,67 @@ +import { parseTomlSingleLineStringValue } from './config-toml-line-scan' + +export type ParsedTomlKeyPath = { + segments: string[] + end: number +} + +export type ParsedTomlTableHeaderPath = ParsedTomlKeyPath & { + isArray: boolean +} + +export function parseTomlTableHeaderPath(header: string): ParsedTomlTableHeaderPath | null { + const trimmed = header.trim() + let source: string + let isArray: boolean + if (trimmed.startsWith('[[')) { + if (!trimmed.endsWith(']]')) { + return null + } + source = trimmed.slice(2, -2) + isArray = true + } else { + if (!trimmed.startsWith('[') || !trimmed.endsWith(']') || trimmed.endsWith(']]')) { + return null + } + source = trimmed.slice(1, -1) + isArray = false + } + const parsed = parseTomlKeyPath(source) + if (!parsed || parsed.end !== source.length) { + return null + } + return { ...parsed, isArray } +} + +export function parseTomlKeyPath(source: string, offset = 0): ParsedTomlKeyPath | null { + const segments: string[] = [] + let index = skipTomlKeyWhitespace(source, offset) + while (index < source.length) { + const quoted = parseTomlSingleLineStringValue(source, index) + if (quoted) { + segments.push(quoted.value) + index = quoted.end + } else { + const bare = /^[A-Za-z0-9_-]+/.exec(source.slice(index)) + if (!bare) { + return null + } + segments.push(bare[0]) + index += bare[0].length + } + index = skipTomlKeyWhitespace(source, index) + if (source[index] !== '.') { + return { segments, end: index } + } + index = skipTomlKeyWhitespace(source, index + 1) + } + return null +} + +function skipTomlKeyWhitespace(source: string, offset: number): number { + let index = offset + while (source[index] === ' ' || source[index] === '\t') { + index += 1 + } + return index +} From bc301de7be8c3246e8e65507cf2e889d7e9fc0ce Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 22 Jul 2026 17:43:14 -0700 Subject: [PATCH 02/23] fix(worktree-palette): guard undefined review title in matcher (crash c5d87873) (#10024) Co-authored-by: Orca --- .../lib/worktree-palette-review-match.test.ts | 102 ++++++++++++++++++ .../src/lib/worktree-palette-review-match.ts | 9 +- 2 files changed, 108 insertions(+), 3 deletions(-) create mode 100644 src/renderer/src/lib/worktree-palette-review-match.test.ts diff --git a/src/renderer/src/lib/worktree-palette-review-match.test.ts b/src/renderer/src/lib/worktree-palette-review-match.test.ts new file mode 100644 index 00000000000..c72710d2931 --- /dev/null +++ b/src/renderer/src/lib/worktree-palette-review-match.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from 'vitest' +import { matchWorktreePaletteReview } from './worktree-palette-review-match' +import { searchWorktrees } from './worktree-palette-search' +import type { Repo, Worktree } from '../../../shared/types' +import type { HostedReviewInfo } from '../../../shared/hosted-review' + +// Regression tests for the production crash (report c5d87873, macOS, Orca 1.4.147): +// TypeError: Cannot read properties of undefined (reading 'toLowerCase') +// at matchWorktreePaletteReview -> searchWorktrees -> WorktreeJumpPalette useMemo +// A rehydrated review/PR cache entry can carry an undefined `title` even though the +// type declares it non-optional, so the Cmd+J worktree palette crashed on any text query. + +type MatcherReview = Parameters[0] + +function makeWorktree(overrides: Partial = {}): Worktree { + return { + id: 'wt-1', + repoId: 'repo-1', + path: '/tmp/wt-1', + head: 'abc123', + branch: 'refs/heads/feature/worktree-jump', + isBare: false, + isMainWorktree: false, + displayName: 'Jump Palette', + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0, + ...overrides + } +} + +const repoMap = new Map([ + [ + 'repo-1', + { + id: 'repo-1', + path: '/repo/orca', + displayName: 'stablyai/orca', + badgeColor: '#22c55e', + addedAt: 0 + } + ] +]) + +describe('matchWorktreePaletteReview title null-safety', () => { + it('returns null instead of throwing when a cached review has no title', () => { + const review = { number: 42, provider: 'github' } as unknown as MatcherReview + expect(() => matchWorktreePaletteReview(review, 'feature', 'feature')).not.toThrow() + expect(matchWorktreePaletteReview(review, 'feature', 'feature')).toBeNull() + }) + + it('still matches on the title text when a title is present', () => { + const review = { + number: 42, + title: 'Fix the thing', + provider: 'github' + } as unknown as MatcherReview + const match = matchWorktreePaletteReview(review, 'thing', 'thing') + expect(match).not.toBeNull() + expect(match?.text).toBe('Fix the thing') + expect(match?.matchRange).toEqual({ start: 8, end: 13 }) + }) + + it('still matches on the PR number even when the title is missing', () => { + const review = { number: 42, provider: 'github' } as unknown as MatcherReview + const match = matchWorktreePaletteReview(review, '42', '42') + expect(match?.text).toBe('PR #42') + }) +}) + +describe('searchWorktrees with a titleless cached review (Cmd+J palette crash path)', () => { + it('does not throw when the checks-review cache entry has no title', () => { + const worktree = makeWorktree() + const titlelessReview = { + number: 7, + provider: 'github', + state: 'open', + url: 'https://example.test/pr/7' + } as unknown as HostedReviewInfo + const checksReviewByWorktree = new Map([ + [worktree, titlelessReview] + ]) + + expect(() => + searchWorktrees( + [worktree], + 'nonmatchingtext', + repoMap, + null, + null, + undefined, + checksReviewByWorktree + ) + ).not.toThrow() + }) +}) diff --git a/src/renderer/src/lib/worktree-palette-review-match.ts b/src/renderer/src/lib/worktree-palette-review-match.ts index 9945722a366..726ec1a64d3 100644 --- a/src/renderer/src/lib/worktree-palette-review-match.ts +++ b/src/renderer/src/lib/worktree-palette-review-match.ts @@ -1,6 +1,7 @@ import type { HostedReviewInfo } from '../../../shared/hosted-review' -type SearchableReview = Pick +// title is intentionally optional: rehydrated review/PR caches can hold entries without one. +type SearchableReview = Pick & { title?: string } type WorktreePaletteReviewMatch = { labelKind: 'pr' | 'mr' text: string @@ -35,13 +36,15 @@ export function matchWorktreePaletteReview( } } - const titleIndex = review.title.toLowerCase().indexOf(query) + // Null-safe: a cached review may have no title, so fall back to '' (query is non-empty, so it won't match). + const title = review.title ?? '' + const titleIndex = title.toLowerCase().indexOf(query) if (titleIndex === -1) { return null } return { labelKind: isMergeRequest ? 'mr' : 'pr', - text: review.title, + text: title, matchRange: { start: titleIndex, end: titleIndex + query.length } } } From 6a8e992c326188d8164493a81fdd3b029dcb293b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 23 Jul 2026 00:55:31 +0000 Subject: [PATCH 03/23] Update README downloads badge --- docs/assets/readme-downloads.svg | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/assets/readme-downloads.svg b/docs/assets/readme-downloads.svg index 7cc5ece98ff..50ca9cde0e2 100644 --- a/docs/assets/readme-downloads.svg +++ b/docs/assets/readme-downloads.svg @@ -1,5 +1,5 @@ - - downloads: 7.5m + + downloads: 7.6m @@ -15,7 +15,7 @@ downloads downloads - 7.5m - 7.5m + 7.6m + 7.6m From 72a0a4dbb83ba52fb0d0a8b845981496f1bf3f6b Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Wed, 22 Jul 2026 17:57:32 -0700 Subject: [PATCH 04/23] Revert "Show current branch in Source Control header (#9787)" (#10032) This reverts commit 56a31a5af0ab3d5b4585f6060fa9d601a7bd00c2. --- .../src/components/DetachedHeadBadge.tsx | 8 +- ...Control.hosted-review-header-link.test.tsx | 157 ++++++++++++++++++ .../right-sidebar/SourceControl.tsx | 39 ++++- .../hosted-review-header-chrome.tsx | 74 +++++++++ ...rce-control-create-pr-intent-state.test.ts | 43 +++++ .../source-control-create-pr-intent-state.ts | 11 ++ .../source-control-header-toolbar.test.tsx | 90 ---------- .../source-control-header-toolbar.tsx | 121 +++++++++----- src/renderer/src/i18n/locales/en.json | 3 +- src/renderer/src/i18n/locales/es.json | 3 +- src/renderer/src/i18n/locales/ja.json | 3 +- src/renderer/src/i18n/locales/ko.json | 3 +- src/renderer/src/i18n/locales/zh.json | 3 +- 13 files changed, 413 insertions(+), 145 deletions(-) create mode 100644 src/renderer/src/components/right-sidebar/SourceControl.hosted-review-header-link.test.tsx create mode 100644 src/renderer/src/components/right-sidebar/hosted-review-header-chrome.tsx create mode 100644 src/renderer/src/components/right-sidebar/source-control-create-pr-intent-state.test.ts delete mode 100644 src/renderer/src/components/right-sidebar/source-control-header-toolbar.test.tsx diff --git a/src/renderer/src/components/DetachedHeadBadge.tsx b/src/renderer/src/components/DetachedHeadBadge.tsx index bc03ce2ab52..58a12e6395e 100644 --- a/src/renderer/src/components/DetachedHeadBadge.tsx +++ b/src/renderer/src/components/DetachedHeadBadge.tsx @@ -12,15 +12,13 @@ type DetachedHeadBadgeProps = { label?: 'sidebar' | 'source-control' side?: React.ComponentProps['side'] className?: string - tabIndex?: number } export function DetachedHeadBadge({ display, label = 'source-control', side = 'right', - className, - tabIndex + className }: DetachedHeadBadgeProps): React.JSX.Element { const visibleLabel = label === 'sidebar' ? display.sidebarLabel : display.sourceControlLabel @@ -29,8 +27,6 @@ export function DetachedHeadBadge({ - {visibleLabel} + {visibleLabel} diff --git a/src/renderer/src/components/right-sidebar/SourceControl.hosted-review-header-link.test.tsx b/src/renderer/src/components/right-sidebar/SourceControl.hosted-review-header-link.test.tsx new file mode 100644 index 00000000000..ee90c607ada --- /dev/null +++ b/src/renderer/src/components/right-sidebar/SourceControl.hosted-review-header-link.test.tsx @@ -0,0 +1,157 @@ +import React from 'react' +import { renderToStaticMarkup } from 'react-dom/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { HostedReviewInfo } from '../../../../shared/hosted-review' +import { HostedReviewHeaderLink } from './hosted-review-header-chrome' + +const { openHttpLinkMock } = vi.hoisted(() => ({ openHttpLinkMock: vi.fn() })) + +vi.mock('@/lib/http-link-routing', () => ({ + openHttpLink: openHttpLinkMock, + registerHttpLinkStoreAccessor: vi.fn() +})) + +function makeReview(overrides: Partial = {}): HostedReviewInfo { + return { + provider: 'github', + number: 2192, + title: 'Open PR in checks', + state: 'open', + url: 'https://github.com/stablyai/orca/pull/2192', + status: 'pending', + updatedAt: '2026-05-17T00:00:00Z', + mergeable: 'UNKNOWN', + ...overrides + } +} + +type MinimalClickEvent = Pick< + React.MouseEvent, + 'nativeEvent' | 'stopPropagation' +> +type ClickModifiers = Partial> + +function clickEvent(modifiers: ClickModifiers = {}): MinimalClickEvent { + return { + nativeEvent: { + metaKey: modifiers.metaKey ?? false, + ctrlKey: modifiers.ctrlKey ?? false, + shiftKey: modifiers.shiftKey ?? false + } as MouseEvent, + stopPropagation: vi.fn() + } +} + +beforeEach(() => { + openHttpLinkMock.mockReset() +}) + +describe('HostedReviewHeaderLink', () => { + it('opens GitHub PRs in the Checks tab instead of rendering an external link', () => { + const onOpenHostedReviewInChecks = vi.fn() + const element = HostedReviewHeaderLink({ + review: makeReview(), + onOpenHostedReviewInChecks + }) + const markup = renderToStaticMarkup(element) + + expect(markup).toContain(' void)(event) + expect(event.stopPropagation).toHaveBeenCalledTimes(1) + expect(onOpenHostedReviewInChecks).toHaveBeenCalledTimes(1) + expect(openHttpLinkMock).not.toHaveBeenCalled() + }) + + it.each<[string, ClickModifiers]>([ + ['Cmd-click', { metaKey: true }], + ['Ctrl-click', { ctrlKey: true }], + ['Shift+Cmd-click', { metaKey: true, shiftKey: true }], + ['Shift+Ctrl-click', { ctrlKey: true, shiftKey: true }] + ])('opens GitHub PRs in the Checks tab on %s', (_label, modifiers) => { + const onOpenHostedReviewInChecks = vi.fn() + const element = HostedReviewHeaderLink({ + review: makeReview(), + onOpenHostedReviewInChecks + }) + + const event = clickEvent(modifiers) + ;(element.props.onClick as (event: MinimalClickEvent) => void)(event) + + expect(event.stopPropagation).toHaveBeenCalledTimes(1) + expect(onOpenHostedReviewInChecks).toHaveBeenCalledTimes(1) + expect(openHttpLinkMock).not.toHaveBeenCalled() + }) + + it('opens GitLab MRs in the Checks tab instead of rendering an external link', () => { + const onOpenHostedReviewInChecks = vi.fn() + const element = HostedReviewHeaderLink({ + review: makeReview({ + provider: 'gitlab', + number: 31, + url: 'https://gitlab.com/acme/widgets/-/merge_requests/31' + }), + onOpenHostedReviewInChecks + }) + const markup = renderToStaticMarkup(element) + + expect(markup).toContain(' void)(event) + expect(event.stopPropagation).toHaveBeenCalledTimes(1) + expect(onOpenHostedReviewInChecks).toHaveBeenCalledTimes(1) + expect(openHttpLinkMock).not.toHaveBeenCalled() + }) + + it.each<[string, ClickModifiers]>([ + ['Cmd-click', { metaKey: true }], + ['Ctrl-click', { ctrlKey: true }], + ['Shift+Cmd-click', { metaKey: true, shiftKey: true }], + ['Shift+Ctrl-click', { ctrlKey: true, shiftKey: true }] + ])('opens GitLab MRs in the Checks tab on %s', (_label, modifiers) => { + const onOpenHostedReviewInChecks = vi.fn() + const element = HostedReviewHeaderLink({ + review: makeReview({ + provider: 'gitlab', + number: 31, + url: 'https://gitlab.com/acme/widgets/-/merge_requests/31' + }), + onOpenHostedReviewInChecks + }) + + const event = clickEvent(modifiers) + ;(element.props.onClick as (event: MinimalClickEvent) => void)(event) + + expect(event.stopPropagation).toHaveBeenCalledTimes(1) + expect(onOpenHostedReviewInChecks).toHaveBeenCalledTimes(1) + expect(openHttpLinkMock).not.toHaveBeenCalled() + }) + + it('keeps other provider reviews as external hosted-review links', () => { + const markup = renderToStaticMarkup( + + ) + + expect(markup).toContain(' activeWorktreeId @@ -2802,6 +2806,11 @@ function SourceControlInner(): React.JSX.Element { ] ) + const openHostedReviewInChecks = useCallback(() => { + setRightSidebarOpen(true) + setRightSidebarTab('checks') + }, [setRightSidebarOpen, setRightSidebarTab]) + const handleBranchChangedByPullRequestGeneration = useCallback(async (): Promise => { // Why: AI PR detail generation may rebase before summarizing, so refresh status if HEAD moved before the user submits the draft. await refreshActiveGitStatusAfterMutation() @@ -4230,6 +4239,10 @@ function SourceControlInner(): React.JSX.Element { (!createPrHeaderAction.disabled || isCreatingPr || prGenerating) ? createPrHeaderAction : null + const visibleCreatePrHeaderAction = resolveVisibleCreatePrHeaderAction({ + createPrHeaderAction + }) + const dropdownItems: DropdownEntry[] = useMemo( () => resolveDropdownItems({ @@ -4697,6 +4710,19 @@ function SourceControlInner(): React.JSX.Element { runCreatePrIntent ]) + const handleCreatePrHeaderClick = useCallback((): void => { + if (!createPrHeaderAction || createPrHeaderAction.disabled) { + return + } + if (createPrHeaderAction.kind === 'create_pr') { + void handleCreatePullRequest() + return + } + if (createPrHeaderAction.kind === 'create_pr_intent') { + void runCreatePrIntent() + } + }, [createPrHeaderAction, handleCreatePullRequest, runCreatePrIntent]) + const branchCompareInFlightRef = useRef(false) const branchCompareRerunRef = useRef(false) const branchCompareRunPromiseRef = useRef | null>(null) @@ -5426,11 +5452,16 @@ function SourceControlInner(): React.JSX.Element { <>
+ {detachedHeadDisplay && ( +
+ +
+ )} + {/* Why: hidden when count is 0 — notes are created from the diff view, so an empty Notes shelf here is pure chrome. */} {activeWorktreeId && worktreePath && diffCommentCount > 0 && (
diff --git a/src/renderer/src/components/right-sidebar/hosted-review-header-chrome.tsx b/src/renderer/src/components/right-sidebar/hosted-review-header-chrome.tsx new file mode 100644 index 00000000000..66733779c77 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/hosted-review-header-chrome.tsx @@ -0,0 +1,74 @@ +import React from 'react' +import { GitMerge } from 'lucide-react' +import type { HostedReviewInfo } from '../../../../shared/hosted-review' +import { cn } from '@/lib/utils' +import { PullRequestIcon } from './checks-panel-content' + +function hostedReviewStateClass(review: HostedReviewInfo): string { + if (review.state === 'merged') { + return 'text-purple-500/80' + } + if (review.state === 'open') { + return 'text-emerald-500/80' + } + if (review.state === 'closed') { + return 'text-muted-foreground/60' + } + return 'text-muted-foreground/50' +} + +export function HostedReviewIcon({ + review, + className +}: { + review: HostedReviewInfo + className?: string +}): React.JSX.Element { + const Icon = review.provider === 'gitlab' ? GitMerge : PullRequestIcon + return +} + +function hostedReviewLabel(review: HostedReviewInfo): string { + return `${review.provider === 'gitlab' ? 'MR' : 'PR'} #${review.number}` +} + +export function HostedReviewHeaderLink({ + review, + onOpenHostedReviewInChecks +}: { + review: HostedReviewInfo + onOpenHostedReviewInChecks: () => void +}): React.JSX.Element { + const label = hostedReviewLabel(review) + const className = + 'shrink-0 border-0 bg-transparent p-0 text-left font-medium leading-none text-foreground underline decoration-border underline-offset-2 opacity-80 hover:text-foreground hover:decoration-foreground' + + if (review.provider === 'github' || review.provider === 'gitlab') { + return ( + + ) + } + + return ( + e.stopPropagation()} + > + {label} + + ) +} diff --git a/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-state.test.ts b/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-state.test.ts new file mode 100644 index 00000000000..b7214084d24 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-state.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest' +import { resolveVisibleCreatePrHeaderAction } from './source-control-create-pr-intent-state' +import type { PrimaryAction } from './source-control-primary-action-types' + +const disabledCreatePrAction: PrimaryAction = { + kind: 'create_pr', + label: 'Create PR', + title: 'Publish commits before creating a pull request.', + disabled: true +} + +const enabledCreatePrAction: PrimaryAction = { + kind: 'create_pr', + label: 'Create PR', + title: 'Create a pull request for this branch', + disabled: false +} + +describe('resolveVisibleCreatePrHeaderAction', () => { + it('returns null when no header action is available', () => { + expect( + resolveVisibleCreatePrHeaderAction({ + createPrHeaderAction: null + }) + ).toBeNull() + }) + + it('keeps a disabled Create PR header visible as a stable toolbar anchor', () => { + expect( + resolveVisibleCreatePrHeaderAction({ + createPrHeaderAction: disabledCreatePrAction + }) + ).toEqual(disabledCreatePrAction) + }) + + it('keeps an enabled Create PR header visible even when the body composer is open', () => { + expect( + resolveVisibleCreatePrHeaderAction({ + createPrHeaderAction: enabledCreatePrAction + }) + ).toEqual(enabledCreatePrAction) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-state.ts b/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-state.ts index 82752fdac66..b40976642e1 100644 --- a/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-state.ts +++ b/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-state.ts @@ -1,3 +1,4 @@ +import type { PrimaryAction } from './source-control-primary-action-types' import { resolveCreateReviewIntentEligibility, type CreateReviewIntentEligibility, @@ -10,3 +11,13 @@ export type CreatePrIntentKind = CreateReviewIntentKind export type CreatePrIntentEligibility = CreateReviewIntentEligibility export const resolveCreatePrIntentEligibility = resolveCreateReviewIntentEligibility + +export function resolveVisibleCreatePrHeaderAction({ + createPrHeaderAction +}: { + createPrHeaderAction: PrimaryAction | null +}): PrimaryAction | null { + // Why: keep a stable header anchor; disable Create PR when the branch is not + // ready instead of hiding it and shifting the toolbar layout. + return createPrHeaderAction +} diff --git a/src/renderer/src/components/right-sidebar/source-control-header-toolbar.test.tsx b/src/renderer/src/components/right-sidebar/source-control-header-toolbar.test.tsx deleted file mode 100644 index 77ca1a6ac52..00000000000 --- a/src/renderer/src/components/right-sidebar/source-control-header-toolbar.test.tsx +++ /dev/null @@ -1,90 +0,0 @@ -import { renderToStaticMarkup } from 'react-dom/server' -import type { ReactNode } from 'react' -import { describe, expect, it, vi } from 'vitest' -import type { WorktreeGitIdentityDisplay } from '@/lib/worktree-git-identity-display' -import { SourceControlHeaderToolbar } from './source-control-header-toolbar' - -vi.mock('@/components/ui/tooltip', () => ({ - Tooltip: ({ children }: { children: ReactNode }) => <>{children}, - TooltipContent: ({ children }: { children: ReactNode }) => <>{children}, - TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children} -})) - -vi.mock('./source-control-header-overflow-menu', () => ({ - SourceControlHeaderOverflowMenu: () => -})) - -vi.mock('./source-control-branch-context-row', () => ({ - shouldShowSourceControlBranchContextRow: () => false, - SourceControlBranchContextRow: () => null -})) - -function renderToolbar( - gitIdentityDisplay: WorktreeGitIdentityDisplay | null = { - kind: 'branch', - branchName: 'brennanb2025/source-control-branch-name' - } -): string { - return renderToStaticMarkup( - - ) -} - -describe('SourceControlHeaderToolbar', () => { - it('renders the truncating branch identity before source control actions', () => { - const markup = renderToolbar() - const branchIndex = markup.indexOf('brennanb2025/source-control-branch-name') - const filterIndex = markup.indexOf('data-testid="source-control-filter-toggle"') - - expect(branchIndex).toBeGreaterThan(-1) - expect(filterIndex).toBeGreaterThan(branchIndex) - expect(markup).toContain('aria-label="Current branch: brennanb2025/source-control-branch-name"') - expect(markup).toContain('tabindex="0"') - expect(markup).toContain('min-w-0 truncate') - expect(markup).not.toContain('Create PR') - }) - - it('renders detached HEAD in the same identity slot', () => { - const markup = renderToolbar({ - kind: 'detached', - shortHead: '8cec248', - sidebarLabel: 'Detached HEAD @ 8cec248', - sourceControlLabel: 'Detached HEAD · 8cec248', - tooltip: 'Detached HEAD at 8cec248. You are viewing a commit, not a branch.' - }) - const identityIndex = markup.indexOf('Detached HEAD · 8cec248') - const filterIndex = markup.indexOf('data-testid="source-control-filter-toggle"') - - expect(markup).not.toContain('aria-label="Current branch:') - expect(identityIndex).toBeGreaterThan(-1) - expect(filterIndex).toBeGreaterThan(identityIndex) - expect(markup).toContain( - 'aria-label="Detached HEAD at 8cec248. You are viewing a commit, not a branch."' - ) - expect(markup).toContain('tabindex="0"') - expect(markup).toContain('lucide-git-commit-horizontal') - }) - - it('keeps the identity slot empty until git identity is known', () => { - const markup = renderToolbar(null) - - expect(markup).not.toContain('aria-label="Current branch:') - expect(markup).not.toContain('Detached HEAD') - }) -}) diff --git a/src/renderer/src/components/right-sidebar/source-control-header-toolbar.tsx b/src/renderer/src/components/right-sidebar/source-control-header-toolbar.tsx index 9c8a32397fe..3cac51e1e9f 100644 --- a/src/renderer/src/components/right-sidebar/source-control-header-toolbar.tsx +++ b/src/renderer/src/components/right-sidebar/source-control-header-toolbar.tsx @@ -1,16 +1,17 @@ import React, { useCallback, useEffect, useRef } from 'react' -import { GitBranch, Search, X } from 'lucide-react' +import { GitPullRequestArrow, Loader2, Search, X } from 'lucide-react' import type { GitBranchCompareSummary, GitUpstreamStatus, SourceControlViewMode } from '../../../../shared/types' +import type { HostedReviewInfo } from '../../../../shared/hosted-review' +import type { PrimaryAction } from './source-control-primary-action' import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { cn } from '@/lib/utils' import { translate } from '@/i18n/i18n' -import { DetachedHeadBadge } from '@/components/DetachedHeadBadge' -import type { WorktreeGitIdentityDisplay } from '@/lib/worktree-git-identity-display' +import { HostedReviewHeaderLink, HostedReviewIcon } from './hosted-review-header-chrome' import { shouldShowSourceControlBranchContextRow, SourceControlBranchContextRow @@ -18,11 +19,16 @@ import { import { SourceControlHeaderOverflowMenu } from './source-control-header-overflow-menu' type SourceControlHeaderToolbarProps = { - gitIdentityDisplay: WorktreeGitIdentityDisplay | null filterQuery: string filterExpanded: boolean onFilterQueryChange: (value: string) => void onFilterExpandedChange: (expanded: boolean) => void + visibleCreatePrHeaderAction: PrimaryAction | null + hostedReview: HostedReviewInfo | null + isCreatePrIntentInFlight: boolean + isCreatingPr: boolean + onCreatePrHeaderClick: () => void + onOpenHostedReviewInChecks: () => void sourceControlViewMode: SourceControlViewMode viewModeToggleDisabled: boolean onToggleViewMode: () => void @@ -37,45 +43,65 @@ type SourceControlHeaderToolbarProps = { manualReviewUrl?: string | null } -function SourceControlGitIdentityLabel({ - display +function HostedReviewToolbarLink({ + review, + onOpenHostedReviewInChecks, + compact }: { - display: WorktreeGitIdentityDisplay + review: HostedReviewInfo + onOpenHostedReviewInChecks: () => void + compact?: boolean }): React.JSX.Element { - if (display.kind === 'detached') { - return ( - - - - ) - } - - const branchName = display.branchName - const label = translate( - 'auto.components.right.sidebar.SourceControl.a4e93c21d7', - 'Current branch: {{value0}}', - { value0: branchName } + return ( +
+ + +
) +} +function CreatePrHeaderButton({ + action, + isCreatePrIntentInFlight, + isCreatingPr, + onClick +}: { + action: PrimaryAction + isCreatePrIntentInFlight: boolean + isCreatingPr: boolean + onClick: () => void +}): React.JSX.Element { return ( - - - - {branchName} + + {action.title} ) @@ -98,11 +124,16 @@ function renderOverflowMenu( } export function SourceControlHeaderToolbar({ - gitIdentityDisplay, filterQuery, filterExpanded, onFilterQueryChange, onFilterExpandedChange, + visibleCreatePrHeaderAction, + hostedReview, + isCreatePrIntentInFlight, + isCreatingPr, + onCreatePrHeaderClick, + onOpenHostedReviewInChecks, sourceControlViewMode, viewModeToggleDisabled, onToggleViewMode, @@ -165,11 +196,25 @@ export function SourceControlHeaderToolbar({ > {showCollapsedToolbar ? ( <> - {gitIdentityDisplay ? ( - + {hostedReview ? ( + + ) : visibleCreatePrHeaderAction ? ( + ) : (