feat(settings): Cmd+Shift affordances for RC channel and hidden experimental (#887)

Cmd+Shift-click "Check for Updates" (menu or Settings > General) opts
into the RC release channel by switching the feed to the github provider
with allowPrerelease=true for the rest of the process.

Cmd+Shift-click the Experimental sidebar entry reveals a "Hidden
experimental" group with an orange-tinted header and a disabled
placeholder toggle — the slot for future unfinished/staff-only options.

Also reword the Experimental description to something less alarmist:
"New features that are still taking shape. Give them a try."
This commit is contained in:
Neil 2026-04-21 00:07:02 -07:00 committed by GitHub
parent 53f911ddc3
commit d66d86645d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 233 additions and 20 deletions

View file

@ -163,7 +163,7 @@ app.whenReady().then(async () => {
runtime.setAgentBrowserBridge(new AgentBrowserBridge(browserManager))
nativeTheme.themeSource = store.getSettings().theme ?? 'system'
registerAppMenu({
onCheckForUpdates: () => checkForUpdatesFromMenu(),
onCheckForUpdates: (options) => checkForUpdatesFromMenu(options),
onOpenSettings: () => {
mainWindow?.webContents.send('ui:openSettings')
},

View file

@ -110,6 +110,36 @@ describe('registerAppMenu', () => {
expect(reloadMock).not.toHaveBeenCalled()
})
it('includes prereleases when Check for Updates is clicked with cmd+shift', () => {
const options = buildMenuOptions()
registerAppMenu(options)
const template = buildFromTemplateMock.mock.calls[0][0] as Electron.MenuItemConstructorOptions[]
const appMenu = template.find((item) => item.label === 'Orca')
const submenu = appMenu?.submenu as Electron.MenuItemConstructorOptions[]
const item = submenu.find((entry) => entry.label === 'Check for Updates...')
item?.click?.(
{} as never,
undefined as never,
{ metaKey: true, shiftKey: true } as Electron.KeyboardEvent
)
item?.click?.(
{} as never,
undefined as never,
{ ctrlKey: true, shiftKey: true } as Electron.KeyboardEvent
)
item?.click?.({} as never, undefined as never, {} as Electron.KeyboardEvent)
item?.click?.({} as never, undefined as never, { metaKey: true } as Electron.KeyboardEvent)
expect(options.onCheckForUpdates.mock.calls).toEqual([
[{ includePrerelease: true }],
[{ includePrerelease: true }],
[{ includePrerelease: false }],
[{ includePrerelease: false }]
])
})
it('shows the worktree palette shortcut as a display-only menu hint', () => {
registerAppMenu(buildMenuOptions())

View file

@ -2,7 +2,7 @@ import { BrowserWindow, Menu, app } from 'electron'
type RegisterAppMenuOptions = {
onOpenSettings: () => void
onCheckForUpdates: () => void
onCheckForUpdates: (options: { includePrerelease: boolean }) => void
onZoomIn: () => void
onZoomOut: () => void
onZoomReset: () => void
@ -38,7 +38,19 @@ export function registerAppMenu({
{ role: 'about' },
{
label: 'Check for Updates...',
click: () => onCheckForUpdates()
// Why: holding Cmd+Shift (or Ctrl+Shift on win/linux) while clicking
// opts this check into the release-candidate channel. The event
// carries the modifier keys down from the native menu — we only act
// on the mouse chord, not accelerator-triggered invocations (there
// is no accelerator on this item, so triggeredByAccelerator should
// always be false here, but guarding makes the intent explicit).
click: (_menuItem, _window, event) => {
const includePrerelease =
!event.triggeredByAccelerator &&
(event.metaKey === true || event.ctrlKey === true) &&
event.shiftKey === true
onCheckForUpdates({ includePrerelease })
}
},
{
label: 'Settings',

View file

@ -48,12 +48,14 @@ const {
autoUpdaterMock.downloadUpdate.mockReset()
autoUpdaterMock.quitAndInstall.mockReset()
autoUpdaterMock.setFeedURL.mockClear()
autoUpdaterMock.allowPrerelease = false
delete (autoUpdaterMock as Record<string, unknown>).verifyUpdateCodeSignature
}
const autoUpdaterMock = {
autoDownload: false,
autoInstallOnAppQuit: false,
allowPrerelease: false,
on,
checkForUpdates: vi.fn(),
downloadUpdate: vi.fn(),
@ -203,6 +205,47 @@ describe('updater', () => {
)
})
it('opts into the RC channel when checkForUpdatesFromMenu is called with includePrerelease', async () => {
autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined)
const mainWindow = { webContents: { send: vi.fn() } }
const { setupAutoUpdater, checkForUpdatesFromMenu } = await import('./updater')
// Why: pass a recent timestamp so the startup background check is
// deferred. We want to observe the state of the updater *before* any
// RC-mode call, not race with the startup check.
setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => Date.now() })
const setupFeedUrlCalls = autoUpdaterMock.setFeedURL.mock.calls.length
expect(autoUpdaterMock.allowPrerelease).not.toBe(true)
checkForUpdatesFromMenu({ includePrerelease: true })
expect(autoUpdaterMock.allowPrerelease).toBe(true)
const newCalls = autoUpdaterMock.setFeedURL.mock.calls.slice(setupFeedUrlCalls)
expect(newCalls).toEqual([[{ provider: 'github', owner: 'stablyai', repo: 'orca' }]])
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1)
// Second RC-mode invocation should not re-set the feed URL.
checkForUpdatesFromMenu({ includePrerelease: true })
expect(autoUpdaterMock.setFeedURL.mock.calls.length).toBe(setupFeedUrlCalls + 1)
})
it('leaves the feed URL alone for a normal user-initiated check', async () => {
autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined)
const mainWindow = { webContents: { send: vi.fn() } }
const { setupAutoUpdater, checkForUpdatesFromMenu } = await import('./updater')
setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => Date.now() })
const initialFeedUrlCalls = autoUpdaterMock.setFeedURL.mock.calls.length
checkForUpdatesFromMenu()
checkForUpdatesFromMenu({ includePrerelease: false })
expect(autoUpdaterMock.setFeedURL.mock.calls.length).toBe(initialFeedUrlCalls)
expect(autoUpdaterMock.allowPrerelease).not.toBe(true)
})
it('defers quitAndInstall through the shared main-process entrypoint', async () => {
vi.useFakeTimers()

View file

@ -25,6 +25,14 @@ let currentStatus: UpdateStatus = { state: 'idle' }
let userInitiatedCheck = false
let onBeforeQuitCleanup: (() => void) | null = null
let autoUpdaterInitialized = false
// Why: Cmd+Shift-clicking "Check for Updates" opts the user into the RC
// release channel for the rest of this process. We switch to the GitHub
// provider with allowPrerelease=true so both the check AND any follow-up
// download resolve against the same (possibly prerelease) release manifest.
// Resetting only after the check would leave a downloaded RC pointing at a
// feed URL that no longer advertises it. See design comment in
// enableIncludePrerelease.
let includePrereleaseActive = false
let availableVersion: string | null = null
let availableReleaseUrl: string | null = null
let pendingCheckFailureKey: string | null = null
@ -267,13 +275,36 @@ export function checkForUpdates(): void {
runBackgroundUpdateCheck()
}
function enableIncludePrerelease(): void {
if (includePrereleaseActive) {
return
}
// Why: the default feed points at GitHub's /releases/latest/download/
// manifest, which is scoped to the most recent non-prerelease release.
// Switch to the native github provider with allowPrerelease so latest.yml
// is sourced from the newest release on the repo regardless of the
// prerelease flag. Staying on this feed for the rest of the process
// keeps the download manifest consistent with the check result.
autoUpdater.allowPrerelease = true
autoUpdater.setFeedURL({
provider: 'github',
owner: 'stablyai',
repo: 'orca'
})
includePrereleaseActive = true
}
/** Menu-triggered check — delegates feedback to renderer toasts via userInitiated flag */
export function checkForUpdatesFromMenu(): void {
export function checkForUpdatesFromMenu(options?: { includePrerelease?: boolean }): void {
if (!app.isPackaged || is.dev) {
sendStatus({ state: 'not-available', userInitiated: true })
return
}
if (options?.includePrerelease) {
enableIncludePrerelease()
}
userInitiatedCheck = true
// Why: a manual check is independent of any active nudge campaign. Reset the
// nudge marker so the resulting status is not decorated with activeNudgeId,

View file

@ -231,7 +231,9 @@ export function registerUpdaterHandlers(_store: Store): void {
ipcMain.handle('updater:getStatus', () => getUpdateStatus())
ipcMain.handle('updater:getVersion', () => app.getVersion())
ipcMain.handle('updater:check', () => checkForUpdatesFromMenu())
ipcMain.handle('updater:check', (_event, options?: { includePrerelease?: boolean }) =>
checkForUpdatesFromMenu(options)
)
ipcMain.handle('updater:download', () => downloadUpdate())
ipcMain.handle('updater:quitAndInstall', () => quitAndInstall())
ipcMain.handle('updater:dismissNudge', () => dismissNudge())

View file

@ -484,7 +484,7 @@ export type PreloadApi = {
updater: {
getVersion: () => Promise<string>
getStatus: () => Promise<UpdateStatus>
check: () => Promise<void>
check: (options?: { includePrerelease?: boolean }) => Promise<void>
download: () => Promise<void>
quitAndInstall: () => Promise<void>
dismissNudge: () => Promise<void>

View file

@ -826,7 +826,8 @@ const api = {
updater: {
getStatus: (): Promise<unknown> => ipcRenderer.invoke('updater:getStatus'),
getVersion: (): Promise<string> => ipcRenderer.invoke('updater:getVersion'),
check: (): Promise<void> => ipcRenderer.invoke('updater:check'),
check: (options?: { includePrerelease?: boolean }): Promise<void> =>
ipcRenderer.invoke('updater:check', options),
download: (): Promise<void> => ipcRenderer.invoke('updater:download'),
dismissNudge: (): Promise<void> => ipcRenderer.invoke('updater:dismissNudge'),
quitAndInstall: async (): Promise<void> => {

View file

@ -13,11 +13,15 @@ export { EXPERIMENTAL_PANE_SEARCH_ENTRIES }
type ExperimentalPaneProps = {
settings: GlobalSettings
updateSettings: (updates: Partial<GlobalSettings>) => void
/** Hidden-experimental group is only rendered once the user has unlocked
* it via Cmd+Shift-clicking the Experimental sidebar entry. */
hiddenExperimentalUnlocked?: boolean
}
export function ExperimentalPane({
settings,
updateSettings
updateSettings,
hiddenExperimentalUnlocked = false
}: ExperimentalPaneProps): React.JSX.Element {
const searchQuery = useAppStore((s) => s.settingsSearchQuery)
// Why: "daemon enabled at startup" is the effective runtime state, read
@ -134,6 +138,44 @@ export function ExperimentalPane({
) : null}
</SearchableSetting>
) : null}
{hiddenExperimentalUnlocked ? <HiddenExperimentalGroup /> : null}
</div>
)
}
// Why: anything in this group is deliberately unfinished or staff-only. The
// orange treatment (header tint, label colors) is the shared visual signal
// for hidden-experimental items so future entries inherit the same
// affordance without another round of styling decisions.
function HiddenExperimentalGroup(): React.JSX.Element {
return (
<section className="space-y-3 rounded-lg border border-orange-500/40 bg-orange-500/5 p-3">
<div className="space-y-0.5">
<h4 className="text-sm font-semibold text-orange-500 dark:text-orange-300">
Hidden experimental
</h4>
<p className="text-xs text-orange-500/80 dark:text-orange-300/80">
Unlisted toggles for internal testing. Nothing here is supported.
</p>
</div>
<div className="flex items-start justify-between gap-4 rounded-md border border-orange-500/30 bg-orange-500/10 px-3 py-2.5">
<div className="min-w-0 shrink space-y-0.5">
<Label className="text-orange-600 dark:text-orange-300">Placeholder toggle</Label>
<p className="text-xs text-orange-600/80 dark:text-orange-300/80">
Does nothing today. Reserved as the first slot for hidden experimental options.
</p>
</div>
<button
type="button"
aria-label="Placeholder toggle"
className="relative inline-flex h-5 w-9 shrink-0 cursor-not-allowed items-center rounded-full border border-orange-500/40 bg-orange-500/20 opacity-70"
disabled
>
<span className="inline-block h-3.5 w-3.5 translate-x-0.5 transform rounded-full bg-orange-200 shadow-sm dark:bg-orange-100" />
</button>
</div>
</section>
)
}

View file

@ -768,7 +768,14 @@ export function GeneralPane({ settings, updateSettings }: GeneralPaneProps): Rea
<Button
variant="outline"
size="sm"
onClick={() => window.api.updater.check()}
// Why: Cmd+Shift-click (Ctrl+Shift on win/linux) opts this check
// into the release-candidate channel. Keep the affordance hidden
// — it's a power-user shortcut, not a discoverable toggle.
onClick={(event) =>
window.api.updater.check({
includePrerelease: (event.metaKey || event.ctrlKey) && event.shiftKey
})
}
disabled={updateStatus.state === 'checking' || updateStatus.state === 'downloading'}
className="gap-2"
>

View file

@ -137,6 +137,11 @@ function Settings(): React.JSX.Element {
getFallbackTerminalFonts()
)
const [activeSectionId, setActiveSectionId] = useState('general')
// Why: the hidden-experimental group is an unlock — Cmd+Shift-clicking the
// Experimental sidebar entry reveals it for the remainder of the session.
// Not persisted on purpose: it's a power-user affordance we don't want to
// leak through into a normal reopen of Settings.
const [hiddenExperimentalUnlocked, setHiddenExperimentalUnlocked] = useState(false)
const contentScrollRef = useRef<HTMLDivElement | null>(null)
const terminalFontsLoadedRef = useRef(false)
const pendingNavSectionRef = useRef<string | null>(null)
@ -343,7 +348,7 @@ function Settings(): React.JSX.Element {
{
id: 'experimental',
title: 'Experimental',
description: 'Features that are still being stabilized. Enable at your own risk.',
description: 'New features that are still taking shape. Give them a try.',
icon: FlaskConical,
searchEntries: EXPERIMENTAL_PANE_SEARCH_ENTRIES
},
@ -464,11 +469,30 @@ function Settings(): React.JSX.Element {
}
}, [visibleNavSections])
const scrollToSection = useCallback((sectionId: string) => {
scrollSectionIntoView(sectionId, contentScrollRef.current)
flashSectionHighlight(sectionId)
setActiveSectionId(sectionId)
}, [])
const scrollToSection = useCallback(
(
sectionId: string,
modifiers?: { metaKey: boolean; ctrlKey: boolean; shiftKey: boolean; altKey: boolean }
) => {
// Why: Cmd+Shift-clicking (Ctrl+Shift on win/linux) the Experimental
// sidebar entry unlocks a hidden power-user group. Keep this scoped to
// the Experimental row so normal shortcut combos on other rows don't
// accidentally flip state. The unlock persists for the life of the
// Settings view (resets when Settings is reopened).
if (
sectionId === 'experimental' &&
modifiers &&
(modifiers.metaKey || modifiers.ctrlKey) &&
modifiers.shiftKey
) {
setHiddenExperimentalUnlocked((previous) => !previous)
}
scrollSectionIntoView(sectionId, contentScrollRef.current)
flashSectionHighlight(sectionId)
setActiveSectionId(sectionId)
},
[]
)
if (!settings) {
return (
@ -617,10 +641,14 @@ function Settings(): React.JSX.Element {
<SettingsSection
id="experimental"
title="Experimental"
description="Features that are still being stabilized. Enable at your own risk."
description="New features that are still taking shape. Give them a try."
searchEntries={EXPERIMENTAL_PANE_SEARCH_ENTRIES}
>
<ExperimentalPane settings={settings} updateSettings={updateSettings} />
<ExperimentalPane
settings={settings}
updateSettings={updateSettings}
hiddenExperimentalUnlocked={hiddenExperimentalUnlocked}
/>
</SettingsSection>
{repos.map((repo) => {

View file

@ -22,7 +22,10 @@ type SettingsSidebarProps = {
searchQuery: string
onBack: () => void
onSearchChange: (query: string) => void
onSelectSection: (sectionId: string) => void
onSelectSection: (
sectionId: string,
modifiers: { metaKey: boolean; ctrlKey: boolean; shiftKey: boolean; altKey: boolean }
) => void
}
export function SettingsSidebar({
@ -71,7 +74,14 @@ export function SettingsSidebar({
return (
<button
key={section.id}
onClick={() => onSelectSection(section.id)}
onClick={(event) =>
onSelectSection(section.id, {
metaKey: event.metaKey,
ctrlKey: event.ctrlKey,
shiftKey: event.shiftKey,
altKey: event.altKey
})
}
className={`flex w-full items-center rounded-lg px-3 py-2 text-left text-sm transition-colors ${
isActive
? 'bg-accent font-medium text-accent-foreground'
@ -103,7 +113,14 @@ export function SettingsSidebar({
return (
<button
key={section.id}
onClick={() => onSelectSection(section.id)}
onClick={(event) =>
onSelectSection(section.id, {
metaKey: event.metaKey,
ctrlKey: event.ctrlKey,
shiftKey: event.shiftKey,
altKey: event.altKey
})
}
className={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm transition-colors ${
isActive
? 'bg-accent font-medium text-accent-foreground'