mirror of
https://github.com/stablyai/orca
synced 2026-04-21 14:17:16 +00:00
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:
parent
53f911ddc3
commit
d66d86645d
12 changed files with 233 additions and 20 deletions
|
|
@ -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')
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
2
src/preload/api-types.d.ts
vendored
2
src/preload/api-types.d.ts
vendored
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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> => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
Loading…
Reference in a new issue