Files
orca/tests/e2e/claude-code-theme-sync.spec.ts
T
Brennan Benson a37ff2fa62 Show matching settings as a results list in Settings search (#40852)
* Show matching settings as a results list in the Settings sidebar

Typing in the Settings search now replaces the nav with a ranked list of
individual settings (pane title on the second line, matched text marked).
The first result is selected and previewed: its pane opens unfiltered,
scrolled to the setting with the target highlight. Arrow keys move and
preview, Enter or a click picks and keeps the query, Esc or the clear
button returns to the nav while leaving the shown pane open.

The sidebar query no longer filters page content. The Back to app row is
now 48px so its border lines up with the Settings header.

* Highlight settings search targets with a full-bleed bar and find unanchored rows

Matches in the results list are marked with semibold text (the jump palette's
HighlightedText) instead of a grey box. The landed-on row gets a tint from card
edge to card edge with a left bar that fades after ~3s, replacing the inset
grey block that sat off-center in the card padding. Integration provider rows
get anchors, and rows not wrapped in SearchableSetting are found by their
visible label.

* Fit the search highlight to each row's enclosing card

Settings cards pad differently (groups, plain sections, shadcn cards, the
Appearance accordion), so the highlight now measures the gap from the row to
its nearest card's inner edges instead of assuming one gutter. A group title
match highlights that group's card rather than the heading text.

* Give the settings target highlight a single owner

Components applied the highlight through SettingsTargetHighlightContext while the
page effect applied it to whatever element the jump scrolled to; when those
differed (Jira's provider row vs. its card shell) two highlights showed. The
effect now owns it alone, and the context, its provider and the macOS
Permissions prop are removed. Jira and Linear reuse their existing anchor ids.

* Use a plain fading tint for the settings target highlight

Drops the left bar; the tint still spans the enclosing card and fades after ~3s.

* Mark settings result titles with the ranking fold

The title highlight lowercased by hand and skipped titles whose length changed,
so İstanbul matching "stan" (or a query of İ) ranked but was never marked. Fold
per code point with the ranking's own fold and map offsets back to the title.

* Make settings search list-only until a pick and reveal collapsed targets

Typing now only changes the result rows: nothing is selected and the page does
not move until Enter or a click (arrows move the selection only). This removes
previews that discarded the Open in draft, re-ran repo hook checks, mounted a
pane per keystroke and drifted after deep links.

- Rank once on a deferred query, cap the list at 50 with a 'keep typing' hint,
  and skip rendering the normal nav while searching.
- A settings jump dispatches a reveal event from its target; Appearance sections,
  Advanced disclosures and the network/mobile collapsibles (now kept mounted)
  open themselves instead of reading the search query.
- Nested scrollers (Shortcuts) scroll the target into view; General sub-page
  results open their page; mirror recovery deep-links to the mirror field; a
  target that never mounts drops its pending jump; group-title matches use
  data-settings-group.

* Fix follow-ups from the settings search repair review

- Two more e2e specs pick a result with ArrowDown + Enter instead of relying on
  typing to navigate.
- After a jump reveals a growing section, re-scroll once if the row is still cut off.
- Hide 'No settings match' while the deferred ranking catches up.
- The reveal hook is a ref callback so disclosures that mount later still open.
- The Advanced disclosure fixture shows the real forced-open case (unsaved edits).

* Give every settings search result a reachable row

* Type the General sub-page anchors as a list instead of casting map entries

* Keep the reveal callback ref out of render

* Match the e2e shortcut labels to main's sentence-case names

* Highlight the result's card, not its section title or a bare label
2026-11-12 19:39:39 -08:00

316 lines
12 KiB
TypeScript

import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import type { ElectronApplication, Page } from '@playwright/test'
import { expect, test } from './helpers/orca-app'
import { ensureTerminalVisible, waitForSessionReady } from './helpers/store'
import {
discoverActivePtyId,
execInTerminal,
sendToTerminal,
waitForActiveTerminalManager
} from './helpers/terminal'
// Search lists results; the page moves on an explicit pick.
async function openSettingsSearchResult(page: Page, query: string): Promise<void> {
const searchInput = page.getByPlaceholder('Search settings')
await searchInput.fill(query)
await searchInput.press('ArrowDown')
await searchInput.press('Enter')
}
const enabled = process.env.ORCA_REAL_CLAUDE_THEME_TEST === '1'
// Claude Code's own light theme paints its prompt border this gray.
const CLAUDE_LIGHT_BORDER = 0x8a8a8a
const TOKYO_NIGHT_ACCENT = 0x7aa2f7
const GRUVBOX_DARK_ACCENT = 0x458588
const TOKYO_NIGHT_LIGHT_ACCENT = 0x34548a
// Covers Orca's first five reload attempts (to 120 s) when macOS drops file events.
const LIVE_SWITCH_TIMEOUT_MS = 130_000
test.use({
launchEnv: { ORCA_BACKGROUND_LAUNCH: '1' },
orcaAppExtraEnv: { DISABLE_AUTOUPDATER: '1' }
})
test.skip(!enabled, 'Set ORCA_REAL_CLAUDE_THEME_TEST=1 to run the installed Claude Code')
// The journey types POSIX shell commands.
test.skip(process.platform === 'win32', 'Uses a POSIX shell command line')
/** Claude's prompt border: the first cell of the first full-width rule it draws. */
function promptBorderColor(page: Page): Promise<number | null> {
return page.evaluate(() => {
const state = window.__store!.getState()
const pane = window.__paneManagers?.get(state.activeTabId ?? '')?.getActivePane()
const buffer = pane?.terminal.buffer.active
if (!buffer) {
return null
}
for (let y = buffer.length - 1; y >= 0; y -= 1) {
const line = buffer.getLine(y)
if (line?.translateToString(true).startsWith('────────')) {
const cell = line.getCell(0)
return cell?.isFgRGB() ? cell.getFgColor() : null
}
}
return null
})
}
/** Samples the border until it shows `target`; every earlier sample must still show `previous`
* (a redraw can briefly hide the border, so samples without one are skipped). */
async function expectLiveSwitch(page: Page, previous: number, target: number): Promise<number> {
const started = Date.now()
const frames: number[] = []
await expect
.poll(
async () => {
const color = await promptBorderColor(page)
if (color !== null) {
frames.push(color)
}
return color
},
{ timeout: LIVE_SWITCH_TIMEOUT_MS, intervals: [100] }
)
.toBe(target)
expect(frames.slice(0, -1).every((color) => color === previous)).toBe(true)
return Date.now() - started
}
/** Plain text of the active pane; Claude styles words separately, so serialized output splits them. */
function terminalText(page: Page): Promise<string> {
return page.evaluate(() => {
const state = window.__store!.getState()
const buffer = window.__paneManagers?.get(state.activeTabId ?? '')?.getActivePane()?.terminal
.buffer.active
const lines: string[] = []
for (let y = 0; buffer && y < buffer.length; y += 1) {
lines.push(buffer.getLine(y)?.translateToString(true) ?? '')
}
return lines.join('\n')
})
}
async function startClaude(page: Page, ptyId: string, workDir: string): Promise<void> {
await execInTerminal(page, ptyId, `cd '${workDir}' && clear && claude`)
await expect.poll(() => terminalText(page), { timeout: 30_000 }).toContain('Claude Code v')
await expect.poll(() => promptBorderColor(page), { timeout: 15_000 }).not.toBeNull()
}
async function exitClaude(page: Page, ptyId: string): Promise<void> {
await sendToTerminal(page, ptyId, '/exit')
// Enter must land after the command menu picked /exit, or Claude submits a prompt.
await expect.poll(() => terminalText(page), { timeout: 10_000 }).toContain('Exit the CLI')
await sendToTerminal(page, ptyId, '\r')
// Back at the shell prompt.
await expect
.poll(async () => (await terminalText(page)).trimEnd().split('\n').at(-1)?.trimEnd(), {
timeout: 15_000
})
.toMatch(/[$%#]$/)
}
type Journey = {
home: string
workDir: string
ptyId: string
readTheme: () => unknown
readThemeFile: () => string
updateSettings: (updates: Record<string, unknown>) => Promise<unknown>
shot: (name: string) => Promise<unknown>
windowShot: (name: string) => Promise<unknown>
}
/** An existing profile (the e2e fixture seeds one) whose Claude Code uses its own light theme. */
async function startJourney(
electronApp: ElectronApplication,
orcaPage: Page,
testInfo: { outputPath: (name: string) => string },
appTheme: 'dark' | 'light'
): Promise<Journey> {
await waitForSessionReady(orcaPage)
await ensureTerminalVisible(orcaPage)
await waitForActiveTerminalManager(orcaPage)
const updateSettings = (updates: Record<string, unknown>) =>
orcaPage.evaluate((next) => window.__store!.getState().updateSettings(next), updates)
const settings = () =>
orcaPage.evaluate(() => {
const { claudeCodeThemeSync, claudeCodeThemeSyncOffer } = window.__store!.getState().settings!
return { claudeCodeThemeSync, claudeCodeThemeSyncOffer }
})
// Existing profiles start with the sync off and an offer owed.
expect(await settings()).toEqual({
claudeCodeThemeSync: false,
claudeCodeThemeSyncOffer: 'pending'
})
await updateSettings({
theme: appTheme,
terminalThemeDark: 'Tokyo Night',
terminalThemeLight: 'Tokyo Night Light',
terminalUseSeparateLightTheme: true
})
const home = await electronApp.evaluate(({ app }) => app.getPath('home'))
const settingsPath = join(home, '.claude', 'settings.json')
const workDir = join(home, 'claude-theme-work')
mkdirSync(workDir, { recursive: true })
mkdirSync(dirname(settingsPath), { recursive: true })
writeFileSync(
join(home, '.claude.json'),
JSON.stringify({
hasCompletedOnboarding: true,
projects: { [workDir]: { hasTrustDialogAccepted: true } }
})
)
const readSettings = (): Record<string, unknown> => {
try {
return JSON.parse(readFileSync(settingsPath, 'utf-8'))
} catch {
return {}
}
}
writeFileSync(settingsPath, `${JSON.stringify({ ...readSettings(), theme: 'light' }, null, 2)}\n`)
const readThemeFile = () => {
try {
return readFileSync(join(home, '.claude', 'themes', 'orca.json'), 'utf-8')
} catch {
return ''
}
}
return {
home,
workDir,
ptyId: await discoverActivePtyId(orcaPage),
readTheme: () => readSettings().theme,
readThemeFile,
updateSettings,
shot: (name) =>
orcaPage
.locator('.pane:visible .xterm-screen')
.first()
.screenshot({ path: testInfo.outputPath(`${name}.png`) }),
windowShot: (name) => orcaPage.screenshot({ path: testInfo.outputPath(`${name}.png`) })
}
}
const offerTitle = 'Match Claude Code to your Orca theme?'
test('an existing user accepts the offer, then Claude Code follows the Orca theme live and restores', async ({
electronApp,
orcaPage
}, testInfo) => {
// Real Claude Code launches, three live switches and a restore, each allowed Orca's reload retries.
test.setTimeout(6 * 60_000)
const journey = await startJourney(electronApp, orcaPage, testInfo, 'dark')
const { ptyId, workDir, readTheme, readThemeFile, updateSettings, shot, windowShot } = journey
// Off matches main: Claude keeps its own light theme on the dark terminal, and Orca offers the sync once.
await startClaude(orcaPage, ptyId, workDir)
await expect
.poll(() => promptBorderColor(orcaPage), { timeout: 15_000 })
.toBe(CLAUDE_LIGHT_BORDER)
await shot('before-dark')
const offer = orcaPage.locator('[data-sonner-toast]').filter({ hasText: offerTitle })
// Agent detection for a typed `claude` can lag its first frame.
await expect(offer).toBeVisible({ timeout: 30_000 })
// Shoot once the slide-in settled, so the proof shows the toast as users see it.
await expect(offer).toHaveAttribute('data-mounted', 'true')
await expect(offer).toBeInViewport({ ratio: 1 })
await expect(offer).toHaveCSS('opacity', '1')
await windowShot('offer-toast-dark')
await offer.screenshot({ path: testInfo.outputPath('offer-toast-dark-closeup.png') })
await offer.getByRole('button', { name: 'Match Theme' }).click()
await expect.poll(readTheme, { timeout: 30_000 }).toBe('custom:orca')
await expect.poll(readThemeFile, { timeout: 30_000 }).toContain('"promptBorder": "#7aa2f7"')
await exitClaude(orcaPage, ptyId)
// The previous session's frames stay in scrollback; wait for the new session's border.
await startClaude(orcaPage, ptyId, workDir)
await expect.poll(() => promptBorderColor(orcaPage), { timeout: 15_000 }).toBe(TOKYO_NIGHT_ACCENT)
await shot('after-dark')
await expect(orcaPage.locator('[data-sonner-toast]').filter({ hasText: offerTitle })).toHaveCount(
0
)
// The running session recolors without a restart.
await updateSettings({ terminalThemeDark: 'Gruvbox Dark' })
const darkSwitchMs = await expectLiveSwitch(orcaPage, TOKYO_NIGHT_ACCENT, GRUVBOX_DARK_ACCENT)
await shot('after-dark-live-gruvbox')
await updateSettings({ theme: 'light' })
const lightSwitchMs = await expectLiveSwitch(
orcaPage,
GRUVBOX_DARK_ACCENT,
TOKYO_NIGHT_LIGHT_ACCENT
)
await shot('after-light-live')
// Turning it off through the real switch restores the user's theme, live too.
await orcaPage.evaluate(() => window.__store!.getState().openSettingsPage())
await openSettingsSearchResult(orcaPage, 'Match Claude Code')
const toggle = orcaPage.getByRole('switch', { name: 'Match Claude Code to Terminal Theme' })
await expect(toggle).toBeChecked()
await windowShot('settings-on-light')
await toggle.click()
await expect(toggle).not.toBeChecked()
await windowShot('settings-off-light')
await expect.poll(readTheme, { timeout: 15_000 }).toBe('light')
await orcaPage.evaluate(() => window.__store!.getState().closeSettingsPage())
const restoreMs = await expectLiveSwitch(orcaPage, TOKYO_NIGHT_LIGHT_ACCENT, CLAUDE_LIGHT_BORDER)
await shot('restored-light')
// The same switch in dark mode, off then on.
await updateSettings({ theme: 'dark' })
await orcaPage.evaluate(() => window.__store!.getState().openSettingsPage())
await openSettingsSearchResult(orcaPage, 'Match Claude Code')
await expect(toggle).not.toBeChecked()
await windowShot('settings-off-dark')
await toggle.click()
await expect(toggle).toBeChecked()
await expect.poll(readTheme, { timeout: 15_000 }).toBe('custom:orca')
await windowShot('settings-on-dark')
await orcaPage.evaluate(() => window.__store!.getState().closeSettingsPage())
testInfo.annotations.push({
type: 'live-switch-ms',
description: JSON.stringify({ darkSwitchMs, lightSwitchMs, restoreMs })
})
await exitClaude(orcaPage, ptyId)
})
test('an existing user who declines the offer keeps Claude Code as it was and is not asked again', async ({
electronApp,
orcaPage
}, testInfo) => {
// Two real Claude Code launches.
test.setTimeout(3 * 60_000)
const { ptyId, workDir, readTheme, shot, windowShot } = await startJourney(
electronApp,
orcaPage,
testInfo,
'light'
)
await startClaude(orcaPage, ptyId, workDir)
const offer = orcaPage.locator('[data-sonner-toast]').filter({ hasText: offerTitle })
// Agent detection for a typed `claude` can lag its first frame.
await expect(offer).toBeVisible({ timeout: 30_000 })
// Shoot once the slide-in settled, so the proof shows the toast as users see it.
await expect(offer).toHaveAttribute('data-mounted', 'true')
await expect(offer).toBeInViewport({ ratio: 1 })
await expect(offer).toHaveCSS('opacity', '1')
await windowShot('offer-toast-light')
await offer.screenshot({ path: testInfo.outputPath('offer-toast-light-closeup.png') })
await offer.getByRole('button', { name: 'No Thanks' }).click()
await expect(offer).toHaveCount(0)
await expect
.poll(() =>
orcaPage.evaluate(() => window.__store!.getState().settings!.claudeCodeThemeSyncOffer)
)
.toBe('settled')
await exitClaude(orcaPage, ptyId)
await startClaude(orcaPage, ptyId, workDir)
await expect
.poll(() => promptBorderColor(orcaPage), { timeout: 15_000 })
.toBe(CLAUDE_LIGHT_BORDER)
await shot('declined-light')
expect(readTheme()).toBe('light')
await expect(offer).toHaveCount(0)
await exitClaude(orcaPage, ptyId)
})