mirror of
https://github.com/stablyai/orca
synced 2026-04-21 14:17:16 +00:00
support orca
This commit is contained in:
parent
b659fb339f
commit
3a67eb0f9c
12 changed files with 566 additions and 2 deletions
|
|
@ -33,6 +33,7 @@ import { createMainWindow } from './window/createMainWindow'
|
|||
import { CodexAccountService } from './codex-accounts/service'
|
||||
import { CodexRuntimeHomeService } from './codex-accounts/runtime-home-service'
|
||||
import { openCodeHookService } from './opencode/hook-service'
|
||||
import { StarNagService } from './star-nag/service'
|
||||
|
||||
let mainWindow: BrowserWindow | null = null
|
||||
/** Whether a manual app.quit() (Cmd+Q, etc.) is in progress. Shared with the
|
||||
|
|
@ -48,6 +49,7 @@ let codexRuntimeHome: CodexRuntimeHomeService | null = null
|
|||
let runtime: OrcaRuntimeService | null = null
|
||||
let rateLimits: RateLimitService | null = null
|
||||
let runtimeRpc: OrcaRuntimeRpcServer | null = null
|
||||
let starNag: StarNagService | null = null
|
||||
|
||||
installUncaughtPipeErrorGuard()
|
||||
patchPackagedProcessPath()
|
||||
|
|
@ -137,6 +139,9 @@ app.whenReady().then(async () => {
|
|||
codexAccounts = new CodexAccountService(store, rateLimits, codexRuntimeHome)
|
||||
rateLimits.setCodexHomePathResolver(() => codexRuntimeHome!.prepareForRateLimitFetch())
|
||||
runtime = new OrcaRuntimeService(store, stats)
|
||||
starNag = new StarNagService(store, stats)
|
||||
starNag.start()
|
||||
starNag.registerIpcHandlers()
|
||||
nativeTheme.themeSource = store.getSettings().theme ?? 'system'
|
||||
registerAppMenu({
|
||||
onCheckForUpdates: () => checkForUpdatesFromMenu(),
|
||||
|
|
@ -242,6 +247,7 @@ app.on('will-quit', () => {
|
|||
// so without this ordering, running agents would produce orphaned
|
||||
// agent_start events with no matching stops.
|
||||
openCodeHookService.stop()
|
||||
starNag?.stop()
|
||||
stats?.flush()
|
||||
killAllPty()
|
||||
// Why: in daemon mode, killAllPty is a no-op (daemon sessions survive app
|
||||
|
|
|
|||
172
src/main/star-nag/service.ts
Normal file
172
src/main/star-nag/service.ts
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
import { app, BrowserWindow, ipcMain } from 'electron'
|
||||
import { STAR_NAG_INITIAL_THRESHOLD } from '../../shared/constants'
|
||||
import { checkOrcaStarred } from '../github/client'
|
||||
import type { Store } from '../persistence'
|
||||
import type { StatsCollector } from '../stats/collector'
|
||||
|
||||
/**
|
||||
* Service that decides when to prompt the user with the "star Orca on GitHub"
|
||||
* notification. Counts agents spawned since the current app version was first
|
||||
* seen; crosses a doubling threshold (default 50 → 100 → 200 …) to fire the
|
||||
* renderer notification via 'star-nag:show'.
|
||||
*
|
||||
* State lives in PersistedUIState so it survives restarts alongside the rest
|
||||
* of the UI preferences (dismissed update versions, etc).
|
||||
*/
|
||||
export class StarNagService {
|
||||
private store: Store
|
||||
private stats: StatsCollector
|
||||
private disposeStatsListener: (() => void) | null = null
|
||||
// Why: once we broadcast the card, the renderer owns the UI until the user
|
||||
// dismisses or stars. Without this in-memory guard, every subsequent
|
||||
// agent_start past the threshold would re-enter maybeShow() and spawn a new
|
||||
// `gh api` subprocess on each spawn — cheap individually, but a power user
|
||||
// at 55 agents with threshold 50 would fork gh on every spawn until they
|
||||
// act on the card.
|
||||
private promptVisible = false
|
||||
// Why: prevent concurrent gh invocations if agents spawn rapidly during the
|
||||
// tiny window between crossing the threshold and the first gh check
|
||||
// resolving.
|
||||
private evaluating = false
|
||||
|
||||
constructor(store: Store, stats: StatsCollector) {
|
||||
this.store = store
|
||||
this.stats = stats
|
||||
}
|
||||
|
||||
start(): void {
|
||||
// Why: capture the baseline eagerly on first boot after an update so the
|
||||
// "agents since update" counter doesn't include pre-update spawns. We do
|
||||
// this here instead of waiting for the next agent_start so that a brand
|
||||
// new install with a pre-existing stats file (unusual, but possible via
|
||||
// copy of userData) starts from a sensible baseline.
|
||||
this.ensureBaseline()
|
||||
this.disposeStatsListener = this.stats.onAgentStarted((total) => {
|
||||
this.handleAgentSpawned(total)
|
||||
})
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.disposeStatsListener?.()
|
||||
this.disposeStatsListener = null
|
||||
}
|
||||
|
||||
registerIpcHandlers(): void {
|
||||
ipcMain.handle('star-nag:dismiss', () => this.dismiss())
|
||||
ipcMain.handle('star-nag:complete', () => this.markCompleted())
|
||||
ipcMain.handle('star-nag:forceShow', () => this.forceShow())
|
||||
}
|
||||
|
||||
// ── State helpers ─────────────────────────────────────────────────
|
||||
|
||||
private ensureBaseline(): void {
|
||||
const ui = this.store.getUI()
|
||||
const currentVersion = app.getVersion()
|
||||
if (ui.starNagAppVersion === currentVersion && ui.starNagBaselineAgents != null) {
|
||||
return
|
||||
}
|
||||
// Why: reset both the baseline and the threshold so the user gets a fresh
|
||||
// nag countdown after each update. Past dismissal state is intentionally
|
||||
// discarded — shipping new value is the whole reason we bother asking
|
||||
// again. `starNagCompleted` is preserved so we never re-ask someone who
|
||||
// already starred.
|
||||
this.store.updateUI({
|
||||
starNagAppVersion: currentVersion,
|
||||
starNagBaselineAgents: this.stats.getTotalAgentsSpawned(),
|
||||
starNagNextThreshold: STAR_NAG_INITIAL_THRESHOLD
|
||||
})
|
||||
}
|
||||
|
||||
private handleAgentSpawned(total: number): void {
|
||||
if (this.promptVisible || this.evaluating) {
|
||||
return
|
||||
}
|
||||
const ui = this.store.getUI()
|
||||
if (ui.starNagCompleted) {
|
||||
return
|
||||
}
|
||||
// Guard against drift: if the version changed since last boot but we
|
||||
// haven't rehydrated yet (e.g. in-process update on Linux AppImage), fix
|
||||
// the baseline before evaluating the threshold so we don't instantly fire.
|
||||
const currentVersion = app.getVersion()
|
||||
if (ui.starNagAppVersion !== currentVersion) {
|
||||
this.ensureBaseline()
|
||||
return
|
||||
}
|
||||
const baseline = ui.starNagBaselineAgents ?? total
|
||||
const threshold = ui.starNagNextThreshold ?? STAR_NAG_INITIAL_THRESHOLD
|
||||
const sinceBaseline = total - baseline
|
||||
if (sinceBaseline < threshold) {
|
||||
return
|
||||
}
|
||||
void this.maybeShow()
|
||||
}
|
||||
|
||||
private async maybeShow(): Promise<void> {
|
||||
if (this.promptVisible || this.evaluating) {
|
||||
return
|
||||
}
|
||||
this.evaluating = true
|
||||
try {
|
||||
// Why: the notification is only useful for users whose gh CLI can
|
||||
// actually perform the star. Calling checkOrcaStarred both gates on gh
|
||||
// availability and skips users who already starred outside the app.
|
||||
// Errors (network, gh missing) map to null — skip silently and leave
|
||||
// state unchanged so we retry on the next spawn without racing forward
|
||||
// to the next threshold.
|
||||
const starred = await checkOrcaStarred()
|
||||
if (starred === null) {
|
||||
return
|
||||
}
|
||||
if (starred) {
|
||||
// Already starred somewhere — lock in the permanent suppression so we
|
||||
// stop recomputing thresholds on every spawn.
|
||||
this.markCompleted()
|
||||
return
|
||||
}
|
||||
this.promptVisible = true
|
||||
this.broadcastShow()
|
||||
} finally {
|
||||
this.evaluating = false
|
||||
}
|
||||
}
|
||||
|
||||
private broadcastShow(): void {
|
||||
const win = BrowserWindow.getAllWindows().find((w) => !w.isDestroyed())
|
||||
if (!win) {
|
||||
return
|
||||
}
|
||||
win.webContents.send('star-nag:show')
|
||||
}
|
||||
|
||||
// ── Public actions (invoked from IPC) ─────────────────────────────
|
||||
|
||||
/**
|
||||
* User closed the notification without starring → double the threshold and
|
||||
* rebase the baseline so the next fire is "threshold more agents since this
|
||||
* dismissal" (not "threshold total since install"). This matches the
|
||||
* product intent of exponential back-off: 50 more, then 100 more, then 200
|
||||
* more, etc.
|
||||
*/
|
||||
private dismiss(): void {
|
||||
const ui = this.store.getUI()
|
||||
const threshold = ui.starNagNextThreshold ?? STAR_NAG_INITIAL_THRESHOLD
|
||||
this.store.updateUI({
|
||||
starNagNextThreshold: threshold * 2,
|
||||
starNagBaselineAgents: this.stats.getTotalAgentsSpawned()
|
||||
})
|
||||
this.promptVisible = false
|
||||
}
|
||||
|
||||
/** User successfully starred → never nag again. */
|
||||
private markCompleted(): void {
|
||||
this.store.updateUI({ starNagCompleted: true })
|
||||
this.promptVisible = false
|
||||
}
|
||||
|
||||
/** Dev-only entry point: skip all gating and fire the notification. */
|
||||
private forceShow(): void {
|
||||
this.promptVisible = true
|
||||
this.broadcastShow()
|
||||
}
|
||||
}
|
||||
|
|
@ -55,6 +55,10 @@ export class StatsCollector {
|
|||
private aggregates: StatsAggregates
|
||||
private liveAgents = new Map<string, number>() // ptyId → startTimestamp
|
||||
private writeTimer: ReturnType<typeof setTimeout> | null = null
|
||||
// Why: star-nag lives in its own service but needs to observe the running
|
||||
// agent-spawned counter. A lightweight listener avoids cyclic imports and
|
||||
// keeps StatsCollector unaware of how the counter is consumed.
|
||||
private agentStartListeners: ((totalAgentsSpawned: number) => void)[] = []
|
||||
|
||||
constructor() {
|
||||
const data = this.load()
|
||||
|
|
@ -62,6 +66,17 @@ export class StatsCollector {
|
|||
this.aggregates = data.aggregates
|
||||
}
|
||||
|
||||
onAgentStarted(listener: (totalAgentsSpawned: number) => void): () => void {
|
||||
this.agentStartListeners.push(listener)
|
||||
return () => {
|
||||
this.agentStartListeners = this.agentStartListeners.filter((l) => l !== listener)
|
||||
}
|
||||
}
|
||||
|
||||
getTotalAgentsSpawned(): number {
|
||||
return this.aggregates.totalAgentsSpawned
|
||||
}
|
||||
|
||||
// ── Recording ──────────────────────────────────────────────────────
|
||||
|
||||
record(event: StatsEvent): void {
|
||||
|
|
@ -173,6 +188,17 @@ export class StatsCollector {
|
|||
switch (event.type) {
|
||||
case 'agent_start':
|
||||
this.aggregates.totalAgentsSpawned++
|
||||
// Why: notify listeners synchronously AFTER increment so observers
|
||||
// see the post-increment count. Listener errors are swallowed to
|
||||
// keep stat recording robust — a buggy listener must not lose the
|
||||
// event from the on-disk log.
|
||||
for (const listener of this.agentStartListeners) {
|
||||
try {
|
||||
listener(this.aggregates.totalAgentsSpawned)
|
||||
} catch (err) {
|
||||
console.error('[stats] agent-start listener threw:', err)
|
||||
}
|
||||
}
|
||||
break
|
||||
case 'pr_created':
|
||||
this.aggregates.totalPRsCreated++
|
||||
|
|
|
|||
6
src/preload/api-types.d.ts
vendored
6
src/preload/api-types.d.ts
vendored
|
|
@ -369,6 +369,12 @@ export type PreloadApi = {
|
|||
checkOrcaStarred: () => Promise<boolean | null>
|
||||
starOrca: () => Promise<boolean>
|
||||
}
|
||||
starNag: {
|
||||
onShow: (callback: () => void) => () => void
|
||||
dismiss: () => Promise<void>
|
||||
complete: () => Promise<void>
|
||||
forceShow: () => Promise<void>
|
||||
}
|
||||
settings: {
|
||||
get: () => Promise<GlobalSettings>
|
||||
set: (args: Partial<GlobalSettings>) => Promise<GlobalSettings>
|
||||
|
|
|
|||
|
|
@ -408,6 +408,17 @@ const api = {
|
|||
starOrca: (): Promise<boolean> => ipcRenderer.invoke('gh:starOrca')
|
||||
},
|
||||
|
||||
starNag: {
|
||||
onShow: (callback: () => void): (() => void) => {
|
||||
const listener = (_event: Electron.IpcRendererEvent): void => callback()
|
||||
ipcRenderer.on('star-nag:show', listener)
|
||||
return () => ipcRenderer.removeListener('star-nag:show', listener)
|
||||
},
|
||||
dismiss: (): Promise<void> => ipcRenderer.invoke('star-nag:dismiss'),
|
||||
complete: (): Promise<void> => ipcRenderer.invoke('star-nag:complete'),
|
||||
forceShow: (): Promise<void> => ipcRenderer.invoke('star-nag:forceShow')
|
||||
},
|
||||
|
||||
settings: {
|
||||
get: (): Promise<unknown> => ipcRenderer.invoke('settings:get'),
|
||||
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import WorktreeJumpPalette from './components/WorktreeJumpPalette'
|
|||
import NewWorkspaceComposerModal from './components/NewWorkspaceComposerModal'
|
||||
import { StatusBar } from './components/status-bar/StatusBar'
|
||||
import { UpdateCard } from './components/UpdateCard'
|
||||
import { StarNagCard } from './components/StarNagCard'
|
||||
import { ZoomOverlay } from './components/ZoomOverlay'
|
||||
import { SshPassphraseDialog } from './components/settings/SshPassphraseDialog'
|
||||
import { useGitStatusPolling } from './components/right-sidebar/useGitStatusPolling'
|
||||
|
|
@ -884,6 +885,7 @@ function App(): React.JSX.Element {
|
|||
<QuickOpen />
|
||||
<WorktreeJumpPalette />
|
||||
<UpdateCard />
|
||||
<StarNagCard />
|
||||
<ZoomOverlay />
|
||||
<SshPassphraseDialog />
|
||||
<Toaster closeButton toastOptions={{ className: 'font-sans text-sm' }} />
|
||||
|
|
|
|||
|
|
@ -87,7 +87,12 @@ function GitHubStarButton({ hasRepos }: { hasRepos: boolean }): React.JSX.Elemen
|
|||
const ok = await window.api.gh.starOrca()
|
||||
if (!ok) {
|
||||
setState('not-starred')
|
||||
return
|
||||
}
|
||||
// Why: starring from any entry point mutes the threshold-based nag.
|
||||
// Without this the background notification could still fire on the next
|
||||
// threshold crossing, which would feel like a bug to the user.
|
||||
await window.api.starNag.complete()
|
||||
}
|
||||
|
||||
// Hide if gh CLI is unavailable, or if the user has already starred and added a repo
|
||||
|
|
|
|||
134
src/renderer/src/components/StarNagCard.tsx
Normal file
134
src/renderer/src/components/StarNagCard.tsx
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { Star, X } from 'lucide-react'
|
||||
import { Card } from './ui/card'
|
||||
import { Button } from './ui/button'
|
||||
import { useAppStore } from '../store'
|
||||
|
||||
/**
|
||||
* Persistent "star Orca on GitHub" notification card.
|
||||
*
|
||||
* Rendered at the bottom-right of the app (alongside UpdateCard). It is
|
||||
* intentionally non-auto-dismissing: the user must either click Star or the
|
||||
* close button. Dismissing doubles the next-trigger threshold in the main
|
||||
* process so the nag backs off exponentially.
|
||||
*
|
||||
* Visibility is driven by the main-process 'star-nag:show' IPC event — this
|
||||
* component does no threshold math or gh-CLI checks locally.
|
||||
*/
|
||||
export function StarNagCard(): React.JSX.Element | null {
|
||||
const [visible, setVisible] = useState(false)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState(false)
|
||||
// Why: UpdateCard lives at the same bottom-right slot. When it is visible
|
||||
// (any non-idle / non-not-available state), stack the star-nag card above
|
||||
// it instead of overlapping — we must not cover a pending update prompt
|
||||
// because that's a higher-priority action.
|
||||
const updateStatus = useAppStore((s) => s.updateStatus)
|
||||
const updateCardVisible = updateStatus.state !== 'idle' && updateStatus.state !== 'not-available'
|
||||
|
||||
useEffect(() => {
|
||||
return window.api.starNag.onShow(() => {
|
||||
setError(false)
|
||||
setVisible(true)
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handleClose = (): void => {
|
||||
setVisible(false)
|
||||
// Why: fire-and-forget. If persisting the dismissal fails the worst case
|
||||
// is we re-fire the same threshold on next launch — not worth blocking
|
||||
// the close animation on.
|
||||
void window.api.starNag.dismiss()
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) {
|
||||
return
|
||||
}
|
||||
const onKeyDown = (event: KeyboardEvent): void => {
|
||||
if (event.key === 'Escape') {
|
||||
handleClose()
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
return () => window.removeEventListener('keydown', onKeyDown)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- handleClose closes
|
||||
// over stable refs; re-binding on each render is unnecessary.
|
||||
}, [visible])
|
||||
|
||||
if (!visible) {
|
||||
return null
|
||||
}
|
||||
|
||||
const handleStar = async (): Promise<void> => {
|
||||
if (busy) {
|
||||
return
|
||||
}
|
||||
setBusy(true)
|
||||
setError(false)
|
||||
const ok = await window.api.gh.starOrca()
|
||||
setBusy(false)
|
||||
if (!ok) {
|
||||
setError(true)
|
||||
return
|
||||
}
|
||||
await window.api.starNag.complete()
|
||||
setVisible(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
// Why: when UpdateCard is up, it occupies bottom-10. Raise the star-nag
|
||||
// card above it so both are visible — the update action stays on top
|
||||
// visually (it's the higher-priority one) and the star-nag sits above.
|
||||
className={`fixed right-4 z-40 w-[360px] max-w-[calc(100vw-32px)]
|
||||
max-[480px]:left-4 max-[480px]:right-4 max-[480px]:w-auto ${
|
||||
updateCardVisible ? 'bottom-[220px]' : 'bottom-10'
|
||||
}`}
|
||||
>
|
||||
<Card className="py-0 gap-0" role="complementary" aria-labelledby="star-nag-heading">
|
||||
<div className="flex flex-col gap-2.5 p-3.5">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Star className="size-4 fill-amber-400/60 text-amber-400/80" />
|
||||
<h3 id="star-nag-heading" className="text-sm font-semibold">
|
||||
Enjoying Orca?
|
||||
</h3>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 shrink-0"
|
||||
onClick={handleClose}
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-muted-foreground">
|
||||
If Orca has saved you time, a GitHub star goes a long way. It helps other developers
|
||||
discover the project and keeps the team motivated to ship improvements.
|
||||
</p>
|
||||
|
||||
{error ? (
|
||||
<p className="text-xs text-destructive">
|
||||
Could not star the repo. Make sure <code>gh</code> is authenticated and try again.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => void handleStar()}
|
||||
disabled={busy}
|
||||
className="mt-0.5 w-full gap-1.5"
|
||||
>
|
||||
<Star className="size-3.5" />
|
||||
{busy ? 'Starring…' : 'Star on GitHub'}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -8,7 +8,7 @@ import { Button } from '../ui/button'
|
|||
import { Input } from '../ui/input'
|
||||
import { Label } from '../ui/label'
|
||||
import { Separator } from '../ui/separator'
|
||||
import { Download, FolderOpen, Loader2, Plus, RefreshCw, Timer, Trash2 } from 'lucide-react'
|
||||
import { Download, FolderOpen, Loader2, Plus, RefreshCw, Star, Timer, Trash2 } from 'lucide-react'
|
||||
import { useAppStore } from '../../store'
|
||||
import { CliSection } from './CliSection'
|
||||
import { toast } from 'sonner'
|
||||
|
|
@ -24,6 +24,7 @@ import {
|
|||
GENERAL_CLI_SEARCH_ENTRIES,
|
||||
GENERAL_EDITOR_SEARCH_ENTRIES,
|
||||
GENERAL_PANE_SEARCH_ENTRIES,
|
||||
GENERAL_SUPPORT_SEARCH_ENTRIES,
|
||||
GENERAL_UPDATE_SEARCH_ENTRIES,
|
||||
GENERAL_WORKSPACE_SEARCH_ENTRIES
|
||||
} from './general-search'
|
||||
|
|
@ -107,11 +108,56 @@ export function GeneralPane({ settings, updateSettings }: GeneralPaneProps): Rea
|
|||
'idle' | 'adding' | `reauth:${string}` | `remove:${string}` | `select:${string | 'system'}`
|
||||
>('idle')
|
||||
const [removeAccountId, setRemoveAccountId] = useState<string | null>(null)
|
||||
// Why: the star state is derived from gh, not from settings, so it does not
|
||||
// live in the global settings store. 'hidden' covers the gh-unavailable and
|
||||
// already-starred-on-a-previous-session cases so the section drops out for
|
||||
// users who can't or don't need to act.
|
||||
//
|
||||
// We start in 'loading' and render a placeholder at the exact same
|
||||
// dimensions as the resolved section. When gh resolves to 'hidden', the
|
||||
// placeholder collapses with a grid-rows transition so content above it
|
||||
// doesn't shift; anything below (nothing today, but future-proof) eases up.
|
||||
const [starState, setStarState] = useState<
|
||||
'loading' | 'not-starred' | 'starred' | 'starring' | 'hidden' | 'error'
|
||||
>('loading')
|
||||
|
||||
useEffect(() => {
|
||||
window.api.updater.getVersion().then(setAppVersion)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
void window.api.gh.checkOrcaStarred().then((result) => {
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
if (result === null) {
|
||||
setStarState('hidden')
|
||||
} else {
|
||||
setStarState(result ? 'starred' : 'not-starred')
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleStarClick = async (): Promise<void> => {
|
||||
if (starState !== 'not-starred' && starState !== 'error') {
|
||||
return
|
||||
}
|
||||
setStarState('starring')
|
||||
const ok = await window.api.gh.starOrca()
|
||||
if (!ok) {
|
||||
setStarState('error')
|
||||
return
|
||||
}
|
||||
setStarState('starred')
|
||||
// Why: clicking star anywhere should also permanently mute the
|
||||
// threshold-based nag so the user isn't re-prompted via the popup.
|
||||
await window.api.starNag.complete()
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setAutoSaveDelayDraft(String(settings.editorAutoSaveDelayMs))
|
||||
}, [settings.editorAutoSaveDelayMs])
|
||||
|
|
@ -802,6 +848,10 @@ export function GeneralPane({ settings, updateSettings }: GeneralPaneProps): Rea
|
|||
</SearchableSetting>
|
||||
</section>
|
||||
) : null
|
||||
// Note: the Support section is rendered outside this array so it can own
|
||||
// its own loading placeholder and its own collapsing Separator. Without
|
||||
// that separation, a dangling divider would remain above the collapsed
|
||||
// section.
|
||||
].filter(Boolean)
|
||||
|
||||
return (
|
||||
|
|
@ -846,6 +896,127 @@ export function GeneralPane({ settings, updateSettings }: GeneralPaneProps): Rea
|
|||
{section}
|
||||
</div>
|
||||
))}
|
||||
{matchesSettingsSearch(searchQuery, GENERAL_SUPPORT_SEARCH_ENTRIES) ? (
|
||||
<SupportSection
|
||||
state={starState}
|
||||
hasPrecedingSections={visibleSections.length > 0}
|
||||
onStarClick={handleStarClick}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type SupportSectionProps = {
|
||||
state: 'loading' | 'not-starred' | 'starring' | 'starred' | 'hidden' | 'error'
|
||||
hasPrecedingSections: boolean
|
||||
onStarClick: () => void | Promise<void>
|
||||
}
|
||||
|
||||
function SupportSection({
|
||||
state,
|
||||
hasPrecedingSections,
|
||||
onStarClick
|
||||
}: SupportSectionProps): React.JSX.Element {
|
||||
// Why: 'hidden' means gh is unavailable or the user had already starred on a
|
||||
// previous session — in both cases we collapse the entire section (including
|
||||
// its leading Separator) so the settings pane doesn't carry an empty strip.
|
||||
// For every other state we render the full row so the initial layout is
|
||||
// stable: the skeleton-to-live swap happens in place and a post-click
|
||||
// "Starred" confirmation does not shift anything above or below it.
|
||||
const collapsed = state === 'hidden'
|
||||
|
||||
return (
|
||||
<section
|
||||
className={`grid transition-[grid-template-rows,opacity] duration-300 ease-out ${
|
||||
collapsed ? 'grid-rows-[0fr] opacity-0' : 'grid-rows-[1fr] opacity-100'
|
||||
}`}
|
||||
aria-hidden={collapsed}
|
||||
>
|
||||
<div className="min-h-0 overflow-hidden">
|
||||
<div className="space-y-8">
|
||||
{hasPrecedingSections ? <Separator /> : null}
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-semibold">Support Orca</h3>
|
||||
</div>
|
||||
{state === 'loading' ? <SupportRowSkeleton /> : null}
|
||||
{state !== 'loading' && state !== 'hidden' ? (
|
||||
<SupportRow state={state} onStarClick={onStarClick} />
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function SupportRowSkeleton(): React.JSX.Element {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-4 px-1 py-2" aria-hidden="true">
|
||||
<div className="h-4 w-36 rounded bg-muted/50 animate-pulse" />
|
||||
<div className="h-8 w-24 rounded-md bg-muted/50 animate-pulse" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SupportRow({
|
||||
state,
|
||||
onStarClick
|
||||
}: {
|
||||
state: 'not-starred' | 'starring' | 'starred' | 'error'
|
||||
onStarClick: () => void | Promise<void>
|
||||
}): React.JSX.Element {
|
||||
// Why: the left-hand label is the setting's identity and must not change
|
||||
// when the user clicks — the row should still read "Star Orca on GitHub"
|
||||
// afterwards. The right-hand control is what changes: before starring it
|
||||
// is a button; after a successful star we swap in a small inline "Thanks"
|
||||
// confirmation so the row keeps the same shape without showing a stale,
|
||||
// disabled button.
|
||||
return (
|
||||
<SearchableSetting
|
||||
title="Star Orca on GitHub"
|
||||
description="Support the project with a GitHub star via the gh CLI."
|
||||
keywords={['star', 'github', 'support', 'feedback', 'like']}
|
||||
className="flex items-center justify-between gap-4 px-1 py-2"
|
||||
>
|
||||
<Label>Star Orca on GitHub</Label>
|
||||
{state === 'starred' ? (
|
||||
<SupportRowThanks />
|
||||
) : (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => void onStarClick()}
|
||||
disabled={state === 'starring'}
|
||||
className="shrink-0 gap-1.5"
|
||||
>
|
||||
{state === 'starring' ? (
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<Star className="size-3.5" />
|
||||
)}
|
||||
{state === 'starring' ? 'Starring…' : state === 'error' ? 'Try Again' : 'Star'}
|
||||
</Button>
|
||||
)}
|
||||
</SearchableSetting>
|
||||
)
|
||||
}
|
||||
|
||||
function SupportRowThanks(): React.JSX.Element {
|
||||
// Why: match the size="sm" button's h-8 / gap-1.5 / px-3 dimensions so the
|
||||
// row height stays identical when the button is swapped out. Without the
|
||||
// fixed height, the text baseline collapses ~6px and the entire row
|
||||
// shrinks, shifting everything below.
|
||||
return (
|
||||
<div
|
||||
className="shrink-0 inline-flex h-8 items-center gap-1.5 px-3 text-sm font-medium
|
||||
text-amber-400/90 animate-in fade-in slide-in-from-right-1 duration-300"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<Star className="size-3.5 fill-amber-400/80 text-amber-400/80" aria-hidden="true" />
|
||||
Thanks for the support!
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -86,11 +86,20 @@ export const GENERAL_AGENT_SEARCH_ENTRIES: SettingsSearchEntry[] = [
|
|||
}
|
||||
]
|
||||
|
||||
export const GENERAL_SUPPORT_SEARCH_ENTRIES: SettingsSearchEntry[] = [
|
||||
{
|
||||
title: 'Star Orca on GitHub',
|
||||
description: 'Support the project with a GitHub star via the gh CLI.',
|
||||
keywords: ['star', 'github', 'support', 'feedback', 'like']
|
||||
}
|
||||
]
|
||||
|
||||
export const GENERAL_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [
|
||||
...GENERAL_WORKSPACE_SEARCH_ENTRIES,
|
||||
...GENERAL_EDITOR_SEARCH_ENTRIES,
|
||||
...GENERAL_CLI_SEARCH_ENTRIES,
|
||||
...GENERAL_CACHE_TIMER_SEARCH_ENTRIES,
|
||||
...GENERAL_CODEX_ACCOUNTS_SEARCH_ENTRIES,
|
||||
...GENERAL_UPDATE_SEARCH_ENTRIES
|
||||
...GENERAL_UPDATE_SEARCH_ENTRIES,
|
||||
...GENERAL_SUPPORT_SEARCH_ENTRIES
|
||||
]
|
||||
|
|
|
|||
|
|
@ -43,6 +43,12 @@ export const DEFAULT_EDITOR_AUTO_SAVE_DELAY_MS = 1000
|
|||
export const MIN_EDITOR_AUTO_SAVE_DELAY_MS = 250
|
||||
export const MAX_EDITOR_AUTO_SAVE_DELAY_MS = 10_000
|
||||
|
||||
// Why: initial threshold of agents spawned (since last update) before we show
|
||||
// the star-on-GitHub notification. Doubles each time the user dismisses
|
||||
// without starring — e.g. 50 → 100 → 200 → 400. Past dismissals are encoded
|
||||
// in starNagNextThreshold, so this constant is only the first-time seed.
|
||||
export const STAR_NAG_INITIAL_THRESHOLD = 50
|
||||
|
||||
export const DEFAULT_WORKTREE_CARD_PROPERTIES: WorktreeCardProperty[] = [
|
||||
'status',
|
||||
'unread',
|
||||
|
|
|
|||
|
|
@ -722,6 +722,22 @@ export type PersistedUIState = {
|
|||
* migration never re-fires — allowing users to intentionally select the
|
||||
* new 'recent' (creation-time) sort without it being clobbered on restart. */
|
||||
_sortBySmartMigrated?: boolean
|
||||
/** Snapshot of totalAgentsSpawned captured the first time we see the current
|
||||
* app version. Why: the nag threshold counts agents spawned *since the
|
||||
* user's last update* so a fresh install or new release does not trigger
|
||||
* the notification immediately. Reset whenever starNagAppVersion changes. */
|
||||
starNagBaselineAgents?: number | null
|
||||
/** The app version that set the current baseline. When the live app version
|
||||
* differs from this value, the baseline is re-captured on next agent
|
||||
* spawn — effectively restarting the nag countdown after each update. */
|
||||
starNagAppVersion?: string | null
|
||||
/** Next threshold (agents spawned since baseline) at which the star-nag
|
||||
* notification should fire. Starts at 50 and doubles each time the user
|
||||
* dismisses the notification without starring. */
|
||||
starNagNextThreshold?: number
|
||||
/** Once the user has starred Orca (from any entry point) we permanently
|
||||
* suppress the nag — no further thresholds, no notifications. */
|
||||
starNagCompleted?: boolean
|
||||
}
|
||||
|
||||
// ─── Persistence shape ──────────────────────────────────────────────
|
||||
|
|
|
|||
Loading…
Reference in a new issue