parent
05bf2bdd8e
commit
1a22e2d05c
|
|
@ -150,7 +150,7 @@ describe('StarNagService', () => {
|
|||
emitAgentStarted(46)
|
||||
|
||||
expect(window.webContents.send).toHaveBeenCalledTimes(1)
|
||||
expect(window.webContents.send).toHaveBeenCalledWith('star-nag:show')
|
||||
expect(window.webContents.send).toHaveBeenCalledWith('star-nag:show', { mode: 'gh' })
|
||||
expect(consoleInfoMock).toHaveBeenCalledTimes(1)
|
||||
expect(consoleInfoMock).toHaveBeenCalledWith({
|
||||
event: 'star_nag_shown',
|
||||
|
|
@ -161,22 +161,39 @@ describe('StarNagService', () => {
|
|||
})
|
||||
})
|
||||
|
||||
it.each([null, true])(
|
||||
'does not log a threshold exposure when checkOrcaStarred returns %s',
|
||||
async (result) => {
|
||||
const window = createWindow()
|
||||
browserWindowMock.getAllWindows.mockReturnValue([window])
|
||||
checkOrcaStarredMock.mockResolvedValue(result)
|
||||
const { service, emitAgentStarted } = createHarness()
|
||||
it('shows the browser fallback when checkOrcaStarred cannot determine star state', async () => {
|
||||
const window = createWindow()
|
||||
browserWindowMock.getAllWindows.mockReturnValue([window])
|
||||
checkOrcaStarredMock.mockResolvedValue(null)
|
||||
const { service, emitAgentStarted } = createHarness()
|
||||
|
||||
service.start()
|
||||
emitAgentStarted(45)
|
||||
await flushAsyncWork()
|
||||
service.start()
|
||||
emitAgentStarted(45)
|
||||
await flushAsyncWork()
|
||||
|
||||
expect(window.webContents.send).not.toHaveBeenCalled()
|
||||
expect(consoleInfoMock).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
expect(window.webContents.send).toHaveBeenCalledWith('star-nag:show', { mode: 'web' })
|
||||
expect(consoleInfoMock).toHaveBeenCalledWith({
|
||||
event: 'star_nag_shown',
|
||||
app_version: '1.2.3',
|
||||
threshold: STAR_NAG_INITIAL_THRESHOLD,
|
||||
agents_since_baseline: 35,
|
||||
source: 'threshold'
|
||||
})
|
||||
})
|
||||
|
||||
it('does not log a threshold exposure when checkOrcaStarred returns true', async () => {
|
||||
const window = createWindow()
|
||||
browserWindowMock.getAllWindows.mockReturnValue([window])
|
||||
checkOrcaStarredMock.mockResolvedValue(true)
|
||||
const { service, emitAgentStarted } = createHarness()
|
||||
|
||||
service.start()
|
||||
emitAgentStarted(45)
|
||||
await flushAsyncWork()
|
||||
|
||||
expect(window.webContents.send).not.toHaveBeenCalled()
|
||||
expect(consoleInfoMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not block a later real prompt after crossing the threshold with no window', async () => {
|
||||
const { service, emitAgentStarted } = createHarness()
|
||||
|
|
@ -192,7 +209,7 @@ describe('StarNagService', () => {
|
|||
emitAgentStarted(46)
|
||||
await flushAsyncWork()
|
||||
|
||||
expect(window.webContents.send).toHaveBeenCalledWith('star-nag:show')
|
||||
expect(window.webContents.send).toHaveBeenCalledWith('star-nag:show', { mode: 'gh' })
|
||||
expect(consoleInfoMock).toHaveBeenCalledWith({
|
||||
event: 'star_nag_shown',
|
||||
app_version: '1.2.3',
|
||||
|
|
@ -265,7 +282,7 @@ describe('StarNagService', () => {
|
|||
browserWindowMock.getAllWindows.mockReturnValue([window])
|
||||
forceShow()
|
||||
|
||||
expect(window.webContents.send).toHaveBeenCalledWith('star-nag:show')
|
||||
expect(window.webContents.send).toHaveBeenCalledWith('star-nag:show', { mode: 'gh' })
|
||||
expect(consoleInfoMock).toHaveBeenCalledWith({
|
||||
event: 'star_nag_shown',
|
||||
app_version: '1.2.3',
|
||||
|
|
@ -320,7 +337,7 @@ describe('StarNagService', () => {
|
|||
await flushAsyncWork()
|
||||
getIpcHandler('star-nag:dismiss')()
|
||||
|
||||
emitAgentStarted(115)
|
||||
emitAgentStarted(114)
|
||||
await flushAsyncWork()
|
||||
|
||||
expect(window.webContents.send).toHaveBeenCalledTimes(1)
|
||||
|
|
@ -368,7 +385,7 @@ describe('StarNagService', () => {
|
|||
expect(consoleInfoMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('replays force_show after an in-flight threshold evaluation exits without showing', async () => {
|
||||
it('keeps threshold source when an in-flight star check falls back to the browser', async () => {
|
||||
const window = createWindow()
|
||||
browserWindowMock.getAllWindows.mockReturnValue([window])
|
||||
const deferredStarCheck = createDeferred<boolean | null>()
|
||||
|
|
@ -388,12 +405,13 @@ describe('StarNagService', () => {
|
|||
|
||||
expect(window.webContents.send).toHaveBeenCalledTimes(1)
|
||||
expect(consoleInfoMock).toHaveBeenCalledTimes(1)
|
||||
expect(window.webContents.send).toHaveBeenCalledWith('star-nag:show', { mode: 'web' })
|
||||
expect(consoleInfoMock).toHaveBeenCalledWith({
|
||||
event: 'star_nag_shown',
|
||||
app_version: '1.2.3',
|
||||
threshold: STAR_NAG_INITIAL_THRESHOLD,
|
||||
agents_since_baseline: 35,
|
||||
source: 'force_show'
|
||||
source: 'threshold'
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import type { Store } from '../persistence'
|
|||
import type { StatsCollector } from '../stats/collector'
|
||||
|
||||
type StarNagPromptSource = 'threshold' | 'force_show'
|
||||
type StarNagPromptMode = 'gh' | 'web'
|
||||
|
||||
type StarNagPromptSession = {
|
||||
source: StarNagPromptSource
|
||||
|
|
@ -13,7 +14,7 @@ type StarNagPromptSession = {
|
|||
/**
|
||||
* 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
|
||||
* seen; crosses a doubling threshold (default 35 → 70 → 140 …) to fire the
|
||||
* renderer notification via 'star-nag:show'.
|
||||
*
|
||||
* State lives in PersistedUIState so it survives restarts alongside the rest
|
||||
|
|
@ -27,7 +28,7 @@ export class StarNagService {
|
|||
// 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
|
||||
// at 40 agents with threshold 35 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
|
||||
|
|
@ -64,6 +65,7 @@ export class StarNagService {
|
|||
registerIpcHandlers(): void {
|
||||
ipcMain.handle('star-nag:dismiss', () => this.dismiss())
|
||||
ipcMain.handle('star-nag:complete', () => this.markCompleted())
|
||||
ipcMain.handle('star-nag:disable', () => this.markCompleted())
|
||||
ipcMain.handle('star-nag:forceShow', () => this.forceShow())
|
||||
}
|
||||
|
||||
|
|
@ -118,14 +120,17 @@ export class StarNagService {
|
|||
}
|
||||
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.
|
||||
// Why: checkOrcaStarred lets us skip users who already starred outside
|
||||
// the app. When gh cannot tell us, keep the prompt available but route
|
||||
// the renderer to the browser fallback instead of a dead direct-star
|
||||
// button.
|
||||
const starred = await checkOrcaStarred()
|
||||
if (this.store.getUI().starNagCompleted) {
|
||||
this.pendingForceShow = false
|
||||
return
|
||||
}
|
||||
if (starred === null) {
|
||||
this.broadcastShow(source, 'web')
|
||||
return
|
||||
}
|
||||
if (starred) {
|
||||
|
|
@ -134,14 +139,10 @@ export class StarNagService {
|
|||
this.markCompleted()
|
||||
return
|
||||
}
|
||||
if (this.store.getUI().starNagCompleted) {
|
||||
this.pendingForceShow = false
|
||||
return
|
||||
}
|
||||
if (this.promptVisible) {
|
||||
return
|
||||
}
|
||||
this.broadcastShow(source)
|
||||
this.broadcastShow(source, 'gh')
|
||||
} finally {
|
||||
this.evaluating = false
|
||||
this.flushPendingForceShow()
|
||||
|
|
@ -156,17 +157,17 @@ export class StarNagService {
|
|||
if (this.promptVisible) {
|
||||
return
|
||||
}
|
||||
this.broadcastShow('force_show')
|
||||
this.broadcastShow('force_show', 'gh')
|
||||
}
|
||||
|
||||
private broadcastShow(source: StarNagPromptSource): boolean {
|
||||
private broadcastShow(source: StarNagPromptSource, mode: StarNagPromptMode): boolean {
|
||||
const win = BrowserWindow.getAllWindows().find((w) => !w.isDestroyed())
|
||||
if (!win) {
|
||||
this.promptVisible = false
|
||||
this.promptSession = null
|
||||
return false
|
||||
}
|
||||
win.webContents.send('star-nag:show')
|
||||
win.webContents.send('star-nag:show', { mode })
|
||||
this.promptVisible = true
|
||||
this.promptSession = { source }
|
||||
this.logConsoleEvent('star_nag_shown', source)
|
||||
|
|
@ -198,7 +199,7 @@ export class StarNagService {
|
|||
* 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
|
||||
* product intent of exponential back-off: 35 more, then 70 more, then 140
|
||||
* more, etc.
|
||||
*/
|
||||
private dismiss(): void {
|
||||
|
|
@ -219,7 +220,7 @@ export class StarNagService {
|
|||
this.promptSession = null
|
||||
}
|
||||
|
||||
/** User successfully starred → never nag again. */
|
||||
/** User successfully starred or opted out → never nag again. */
|
||||
private markCompleted(): void {
|
||||
this.store.updateUI({ starNagCompleted: true })
|
||||
this.promptVisible = false
|
||||
|
|
@ -236,6 +237,6 @@ export class StarNagService {
|
|||
this.pendingForceShow = true
|
||||
return
|
||||
}
|
||||
this.broadcastShow('force_show')
|
||||
this.broadcastShow('force_show', 'gh')
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -198,12 +198,7 @@ describe('mergePathSegments', () => {
|
|||
})
|
||||
|
||||
it('moves user-local shell paths ahead of packaged Homebrew fallbacks', () => {
|
||||
process.env.PATH = joinPath(
|
||||
'/opt/homebrew/bin',
|
||||
'/Users/tester/.local/bin',
|
||||
'/usr/bin',
|
||||
'/bin'
|
||||
)
|
||||
process.env.PATH = joinPath('/opt/homebrew/bin', '/Users/tester/.local/bin', '/usr/bin', '/bin')
|
||||
|
||||
const added = mergePathSegments(['/Users/tester/.local/bin', '/opt/homebrew/bin'])
|
||||
|
||||
|
|
|
|||
|
|
@ -1576,9 +1576,10 @@ export type PreloadApi = {
|
|||
listTransitions: (args: { key: string; siteId?: string }) => Promise<JiraTransition[]>
|
||||
}
|
||||
starNag: {
|
||||
onShow: (callback: () => void) => () => void
|
||||
onShow: (callback: (payload?: { mode?: 'gh' | 'web' }) => void) => () => void
|
||||
dismiss: () => Promise<void>
|
||||
complete: () => Promise<void>
|
||||
disable: () => Promise<void>
|
||||
forceShow: () => Promise<void>
|
||||
}
|
||||
/** Fire-and-forget track. Loose typing at the IPC boundary on purpose —
|
||||
|
|
|
|||
|
|
@ -1455,13 +1455,17 @@ const api = {
|
|||
},
|
||||
|
||||
starNag: {
|
||||
onShow: (callback: () => void): (() => void) => {
|
||||
const listener = (_event: Electron.IpcRendererEvent): void => callback()
|
||||
onShow: (callback: (payload?: { mode?: 'gh' | 'web' }) => void): (() => void) => {
|
||||
const listener = (
|
||||
_event: Electron.IpcRendererEvent,
|
||||
payload?: { mode?: 'gh' | 'web' }
|
||||
): void => callback(payload)
|
||||
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'),
|
||||
disable: (): Promise<void> => ipcRenderer.invoke('star-nag:disable'),
|
||||
forceShow: (): Promise<void> => ipcRenderer.invoke('star-nag:forceShow')
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -69,7 +69,9 @@ function getPreflightIssues(status: {
|
|||
return issues
|
||||
}
|
||||
|
||||
type StarState = 'loading' | 'starred' | 'not-starred' | 'hidden'
|
||||
const ORCA_STARGAZERS_URL = 'https://github.com/stablyai/orca/stargazers'
|
||||
|
||||
type StarState = 'loading' | 'starred' | 'not-starred' | 'web-fallback' | 'hidden'
|
||||
|
||||
function GitHubStarButton({ hasRepos }: { hasRepos: boolean }): React.JSX.Element | null {
|
||||
const [state, setState] = useState<StarState>('loading')
|
||||
|
|
@ -84,7 +86,7 @@ function GitHubStarButton({ hasRepos }: { hasRepos: boolean }): React.JSX.Elemen
|
|||
return
|
||||
}
|
||||
if (result === null) {
|
||||
setState('hidden')
|
||||
setState('web-fallback')
|
||||
} else {
|
||||
setState(result ? 'starred' : 'not-starred')
|
||||
}
|
||||
|
|
@ -112,6 +114,11 @@ function GitHubStarButton({ hasRepos }: { hasRepos: boolean }): React.JSX.Elemen
|
|||
setMenuOpen((v) => !v)
|
||||
return
|
||||
}
|
||||
if (state === 'web-fallback') {
|
||||
await window.api.shell.openUrl(ORCA_STARGAZERS_URL)
|
||||
await window.api.starNag.complete()
|
||||
return
|
||||
}
|
||||
if (state !== 'not-starred') {
|
||||
return
|
||||
}
|
||||
|
|
@ -119,7 +126,7 @@ function GitHubStarButton({ hasRepos }: { hasRepos: boolean }): React.JSX.Elemen
|
|||
const ok = await window.api.gh.starOrca('landing')
|
||||
if (!ok) {
|
||||
if (mountedRef.current) {
|
||||
setState('not-starred')
|
||||
setState('web-fallback')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
|
@ -129,7 +136,7 @@ function GitHubStarButton({ hasRepos }: { hasRepos: boolean }): React.JSX.Elemen
|
|||
await window.api.starNag.complete()
|
||||
}
|
||||
|
||||
// Hide if gh CLI is unavailable, or if the user has already starred and added a repo
|
||||
// Hide once the user has already starred and added a repo.
|
||||
if (state === 'hidden' || (state === 'starred' && hasRepos)) {
|
||||
return null
|
||||
}
|
||||
|
|
@ -140,7 +147,7 @@ function GitHubStarButton({ hasRepos }: { hasRepos: boolean }): React.JSX.Elemen
|
|||
className={cn(
|
||||
'inline-flex items-center gap-2 rounded-full border px-4 py-1.5 text-[13px] font-medium transition-all duration-300',
|
||||
state === 'loading' && 'pointer-events-none opacity-0',
|
||||
state === 'not-starred' &&
|
||||
state !== 'starred' &&
|
||||
'cursor-pointer border-amber-500/60 text-amber-700 hover:border-amber-500/80 hover:bg-amber-400/10 dark:border-amber-400/30 dark:text-amber-300/90 dark:hover:border-amber-400/50 dark:hover:bg-amber-400/[0.08]',
|
||||
state === 'starred' &&
|
||||
'cursor-pointer border-amber-500/50 bg-amber-400/10 text-amber-700 dark:border-amber-400/25 dark:bg-amber-400/[0.06] dark:text-amber-400/60'
|
||||
|
|
@ -148,17 +155,23 @@ function GitHubStarButton({ hasRepos }: { hasRepos: boolean }): React.JSX.Elemen
|
|||
onClick={handleClick}
|
||||
disabled={state === 'loading'}
|
||||
>
|
||||
<Star
|
||||
className={cn(
|
||||
'size-3.5 transition-all duration-300',
|
||||
state === 'starred'
|
||||
? 'fill-amber-500/70 text-amber-500/70 dark:fill-amber-400/60 dark:text-amber-400/60'
|
||||
: 'text-amber-600 dark:text-amber-400/80'
|
||||
)}
|
||||
/>
|
||||
{state === 'web-fallback' ? (
|
||||
<ExternalLink className="size-3.5 text-amber-600 transition-all duration-300 dark:text-amber-400/80" />
|
||||
) : (
|
||||
<Star
|
||||
className={cn(
|
||||
'size-3.5 transition-all duration-300',
|
||||
state === 'starred'
|
||||
? 'fill-amber-500/70 text-amber-500/70 dark:fill-amber-400/60 dark:text-amber-400/60'
|
||||
: 'text-amber-600 dark:text-amber-400/80'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{state === 'starred'
|
||||
? translate('auto.components.Landing.ec43b38ba7', 'Starred on GitHub')
|
||||
: translate('auto.components.Landing.0d0ace8861', 'Star on GitHub')}
|
||||
: state === 'web-fallback'
|
||||
? translate('auto.components.Landing.157bb5ecbb', 'Open GitHub')
|
||||
: translate('auto.components.Landing.0d0ace8861', 'Star on GitHub')}
|
||||
</button>
|
||||
{state === 'starred' && menuOpen && (
|
||||
<div className="absolute right-0 top-[calc(100%+4px)] z-10 min-w-[100px] rounded-md border border-border bg-popover py-1 shadow-md">
|
||||
|
|
|
|||
|
|
@ -1,11 +1,14 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { Star, X } from 'lucide-react'
|
||||
import { ExternalLink, Star, X } from 'lucide-react'
|
||||
import { Card } from './ui/card'
|
||||
import { Button } from './ui/button'
|
||||
import { useAppStore } from '../store'
|
||||
import { useMountedRef } from '@/hooks/useMountedRef'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
const ORCA_STARGAZERS_URL = 'https://github.com/stablyai/orca/stargazers'
|
||||
type StarNagMode = 'gh' | 'web'
|
||||
|
||||
/**
|
||||
* Persistent "star Orca on GitHub" notification card.
|
||||
*
|
||||
|
|
@ -20,7 +23,7 @@ import { translate } from '@/i18n/i18n'
|
|||
export function StarNagCard(): React.JSX.Element | null {
|
||||
const [visible, setVisible] = useState(false)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState(false)
|
||||
const [mode, setMode] = useState<StarNagMode>('gh')
|
||||
const mountedRef = useMountedRef()
|
||||
// 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
|
||||
|
|
@ -30,8 +33,8 @@ export function StarNagCard(): React.JSX.Element | null {
|
|||
const updateCardVisible = updateStatus.state !== 'idle' && updateStatus.state !== 'not-available'
|
||||
|
||||
useEffect(() => {
|
||||
return window.api.starNag.onShow(() => {
|
||||
setError(false)
|
||||
return window.api.starNag.onShow((payload) => {
|
||||
setMode(payload?.mode === 'web' ? 'web' : 'gh')
|
||||
setVisible(true)
|
||||
})
|
||||
}, [])
|
||||
|
|
@ -44,6 +47,11 @@ export function StarNagCard(): React.JSX.Element | null {
|
|||
void window.api.starNag.dismiss()
|
||||
}
|
||||
|
||||
const handleDisable = (): void => {
|
||||
setVisible(false)
|
||||
void window.api.starNag.disable()
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) {
|
||||
return
|
||||
|
|
@ -67,15 +75,24 @@ export function StarNagCard(): React.JSX.Element | null {
|
|||
if (busy) {
|
||||
return
|
||||
}
|
||||
if (mode === 'web') {
|
||||
setBusy(true)
|
||||
await window.api.shell.openUrl(ORCA_STARGAZERS_URL)
|
||||
await window.api.starNag.disable()
|
||||
if (mountedRef.current) {
|
||||
setBusy(false)
|
||||
setVisible(false)
|
||||
}
|
||||
return
|
||||
}
|
||||
setBusy(true)
|
||||
setError(false)
|
||||
const ok = await window.api.gh.starOrca('star_nag')
|
||||
if (mountedRef.current) {
|
||||
setBusy(false)
|
||||
}
|
||||
if (!ok) {
|
||||
if (mountedRef.current) {
|
||||
setError(true)
|
||||
setMode('web')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
|
@ -122,20 +139,6 @@ export function StarNagCard(): React.JSX.Element | null {
|
|||
)}
|
||||
</p>
|
||||
|
||||
{error ? (
|
||||
<p className="text-xs text-destructive">
|
||||
{translate(
|
||||
'auto.components.StarNagCard.cf82170065',
|
||||
'Could not star the repo. Make sure'
|
||||
)}
|
||||
<code>{translate('auto.components.StarNagCard.cd8c34aac1', 'gh')}</code>{' '}
|
||||
{translate(
|
||||
'auto.components.StarNagCard.92b0f9d921',
|
||||
'is authenticated and try again.'
|
||||
)}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
|
|
@ -143,11 +146,23 @@ export function StarNagCard(): React.JSX.Element | null {
|
|||
disabled={busy}
|
||||
className="mt-0.5 w-full gap-1.5"
|
||||
>
|
||||
<Star className="size-3.5" />
|
||||
{mode === 'web' ? <ExternalLink className="size-3.5" /> : <Star className="size-3.5" />}
|
||||
{busy
|
||||
? translate('auto.components.StarNagCard.af3c9bbb37', 'Starring…')
|
||||
: translate('auto.components.StarNagCard.2d67b6c849', 'Star on GitHub')}
|
||||
? mode === 'web'
|
||||
? translate('auto.components.StarNagCard.d32015fec7', 'Opening...')
|
||||
: translate('auto.components.StarNagCard.af3c9bbb37', 'Starring…')
|
||||
: mode === 'web'
|
||||
? translate('auto.components.StarNagCard.157bb5ecbb', 'Open GitHub')
|
||||
: translate('auto.components.StarNagCard.2d67b6c849', 'Star on GitHub')}
|
||||
</Button>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Button variant="ghost" size="sm" className="h-7 px-2" onClick={handleClose}>
|
||||
{translate('auto.components.StarNagCard.8c967b4d15', 'Not now')}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-7 px-2" onClick={handleDisable}>
|
||||
{translate('auto.components.StarNagCard.73dfd4eb8d', "Don't ask again")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -28,8 +28,14 @@ function TaskSourceNameList(props: { names: readonly string[] }): React.JSX.Elem
|
|||
{index > 0
|
||||
? index === props.names.length - 1
|
||||
? props.names.length > 2
|
||||
? translate('auto.components.feature.wall.ConnectIntegrationsList.list_end', ', and ')
|
||||
: translate('auto.components.feature.wall.ConnectIntegrationsList.list_pair', ' and ')
|
||||
? translate(
|
||||
'auto.components.feature.wall.ConnectIntegrationsList.list_end',
|
||||
', and '
|
||||
)
|
||||
: translate(
|
||||
'auto.components.feature.wall.ConnectIntegrationsList.list_pair',
|
||||
' and '
|
||||
)
|
||||
: translate('auto.components.feature.wall.ConnectIntegrationsList.list_mid', ', ')
|
||||
: null}
|
||||
<span className="font-semibold text-foreground">{name}</span>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import type React from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Loader2, Star } from 'lucide-react'
|
||||
import { ExternalLink, Loader2, Star } from 'lucide-react'
|
||||
import { useMountedRef } from '@/hooks/useMountedRef'
|
||||
import { Button } from '../ui/button'
|
||||
import { Label } from '../ui/label'
|
||||
|
|
@ -9,7 +9,16 @@ import { SearchableSetting } from './SearchableSetting'
|
|||
import { SettingsSubsectionHeader } from './SettingsFormControls'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
type SupportState = 'loading' | 'not-starred' | 'starring' | 'starred' | 'hidden' | 'error'
|
||||
const ORCA_STARGAZERS_URL = 'https://github.com/stablyai/orca/stargazers'
|
||||
|
||||
type SupportState =
|
||||
| 'loading'
|
||||
| 'not-starred'
|
||||
| 'web-fallback'
|
||||
| 'opening-github'
|
||||
| 'starring'
|
||||
| 'starred'
|
||||
| 'hidden'
|
||||
|
||||
type GeneralSupportSectionProps = {
|
||||
hasPrecedingSections: boolean
|
||||
|
|
@ -20,9 +29,8 @@ export function GeneralSupportSection({
|
|||
}: GeneralSupportSectionProps): React.JSX.Element {
|
||||
const mountedRef = useMountedRef()
|
||||
// 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.
|
||||
// live in the global settings store. 'hidden' covers already-starred users
|
||||
// so the section drops out for people who 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
|
||||
|
|
@ -37,7 +45,7 @@ export function GeneralSupportSection({
|
|||
return
|
||||
}
|
||||
if (result === null) {
|
||||
setStarState('hidden')
|
||||
setStarState('web-fallback')
|
||||
} else {
|
||||
setStarState(result ? 'starred' : 'not-starred')
|
||||
}
|
||||
|
|
@ -48,14 +56,23 @@ export function GeneralSupportSection({
|
|||
}, [])
|
||||
|
||||
const handleStarClick = async (): Promise<void> => {
|
||||
if (starState !== 'not-starred' && starState !== 'error') {
|
||||
if (starState === 'web-fallback') {
|
||||
setStarState('opening-github')
|
||||
await window.api.shell.openUrl(ORCA_STARGAZERS_URL)
|
||||
await window.api.starNag.complete()
|
||||
if (mountedRef.current) {
|
||||
setStarState('web-fallback')
|
||||
}
|
||||
return
|
||||
}
|
||||
if (starState !== 'not-starred') {
|
||||
return
|
||||
}
|
||||
setStarState('starring')
|
||||
const ok = await window.api.gh.starOrca('settings')
|
||||
if (!ok) {
|
||||
if (mountedRef.current) {
|
||||
setStarState('error')
|
||||
setStarState('web-fallback')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
|
@ -133,7 +150,7 @@ function SupportRow({
|
|||
state,
|
||||
onStarClick
|
||||
}: {
|
||||
state: 'not-starred' | 'starring' | 'starred' | 'error'
|
||||
state: 'not-starred' | 'web-fallback' | 'opening-github' | 'starring' | 'starred'
|
||||
onStarClick: () => void | Promise<void>
|
||||
}): React.JSX.Element {
|
||||
// Why: the left-hand label is the setting's identity and must not change
|
||||
|
|
@ -147,7 +164,7 @@ function SupportRow({
|
|||
)}
|
||||
description={translate(
|
||||
'auto.components.settings.GeneralSupportSection.511782265b',
|
||||
'Support the project with a GitHub star via the gh CLI.'
|
||||
'Support the project with a GitHub star.'
|
||||
)}
|
||||
keywords={['star', 'github', 'support', 'feedback', 'like']}
|
||||
className="flex items-center justify-between gap-4 py-2"
|
||||
|
|
@ -165,19 +182,26 @@ function SupportRow({
|
|||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => void onStarClick()}
|
||||
disabled={state === 'starring'}
|
||||
disabled={state === 'starring' || state === 'opening-github'}
|
||||
className="shrink-0 gap-1.5"
|
||||
>
|
||||
{state === 'starring' ? (
|
||||
{state === 'starring' || state === 'opening-github' ? (
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
) : state === 'web-fallback' ? (
|
||||
<ExternalLink className="size-3.5" />
|
||||
) : (
|
||||
<Star className="size-3.5" />
|
||||
<Star className="size-3.5 fill-amber-400 text-amber-400" />
|
||||
)}
|
||||
{state === 'starring'
|
||||
? translate('auto.components.settings.GeneralSupportSection.397719bee5', 'Starring...')
|
||||
: state === 'error'
|
||||
? translate('auto.components.settings.GeneralSupportSection.73b327e793', 'Try Again')
|
||||
: translate('auto.components.settings.GeneralSupportSection.964acc6bb4', 'Star')}
|
||||
: state === 'opening-github'
|
||||
? translate('auto.components.settings.GeneralSupportSection.cb65c75b11', 'Opening...')
|
||||
: state === 'web-fallback'
|
||||
? translate(
|
||||
'auto.components.settings.GeneralSupportSection.f2d4f877b2',
|
||||
'Open GitHub'
|
||||
)
|
||||
: translate('auto.components.settings.GeneralSupportSection.964acc6bb4', 'Star')}
|
||||
</Button>
|
||||
)}
|
||||
</SearchableSetting>
|
||||
|
|
|
|||
|
|
@ -1,21 +1,10 @@
|
|||
import React from 'react'
|
||||
import { Bell, CalendarClock, EyeOff, Github, Gitlab, List, Search, Smartphone } from 'lucide-react'
|
||||
import { Bell, CalendarClock, EyeOff, Search, Smartphone } from 'lucide-react'
|
||||
import { useAppStore } from '@/store'
|
||||
import { useRepoMap } from '@/store/selectors'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { isGitRepoKind } from '../../../../shared/repo-kind'
|
||||
import type { GlobalSettings } from '../../../../shared/types'
|
||||
import { getTaskPresetQuery, PER_REPO_FETCH_LIMIT } from '@/lib/new-workspace'
|
||||
import { LinearIcon } from '@/components/icons/LinearIcon'
|
||||
import { JiraIcon } from '@/components/icons/JiraIcon'
|
||||
import {
|
||||
normalizeVisibleTaskProviders,
|
||||
restoreAvailableDefaultTaskProvider,
|
||||
resolveVisibleTaskProvider
|
||||
} from '../../../../shared/task-providers'
|
||||
import { useActivityUnreadCount } from '@/components/activity/useActivityUnreadCount'
|
||||
import { useShortcutLabel } from '@/hooks/useShortcutLabel'
|
||||
import { getLocalPreflightContext, localPreflightContextKey } from '@/lib/local-preflight-context'
|
||||
import { useMobileSidebarOnboardingBadge } from './mobile-sidebar-onboarding-badge'
|
||||
import {
|
||||
ContextMenu,
|
||||
|
|
@ -24,6 +13,7 @@ import {
|
|||
ContextMenuTrigger
|
||||
} from '@/components/ui/context-menu'
|
||||
import { SetupGuideSidebarEntry } from './SetupGuideSidebarEntry'
|
||||
import { SidebarTaskNavButton } from './SidebarTaskNavButton'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
export { getSetupGuideSidebarEntryReady, shouldShowSetupGuideEntry } from './SetupGuideSidebarEntry'
|
||||
|
|
@ -57,157 +47,23 @@ function HideSidebarMenu({ onHide }: { onHide: () => void }): React.JSX.Element
|
|||
)
|
||||
}
|
||||
|
||||
function TaskProviderShortcut({
|
||||
canBrowseTasks,
|
||||
label,
|
||||
onOpen,
|
||||
children
|
||||
}: {
|
||||
canBrowseTasks: boolean
|
||||
label: string
|
||||
onOpen: () => void
|
||||
children: React.ReactNode
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<span
|
||||
role={canBrowseTasks ? 'button' : undefined}
|
||||
tabIndex={-1}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (!canBrowseTasks) {
|
||||
return
|
||||
}
|
||||
onOpen()
|
||||
}}
|
||||
className={cn(
|
||||
'rounded p-0.5 text-muted-foreground/70',
|
||||
canBrowseTasks ? 'transition-colors hover:text-foreground' : 'cursor-default'
|
||||
)}
|
||||
aria-label={canBrowseTasks ? label : undefined}
|
||||
aria-hidden={canBrowseTasks ? undefined : true}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const SidebarNav = React.memo(function SidebarNav() {
|
||||
const worktreePaletteShortcut = useShortcutLabel('worktree.palette')
|
||||
const openTaskPage = useAppStore((s) => s.openTaskPage)
|
||||
const openAutomationsPage = useAppStore((s) => s.openAutomationsPage)
|
||||
const openActivityPage = useAppStore((s) => s.openActivityPage)
|
||||
const openMobilePage = useAppStore((s) => s.openMobilePage)
|
||||
const openModal = useAppStore((s) => s.openModal)
|
||||
const updateSettings = useAppStore((s) => s.updateSettings)
|
||||
const activeView = useAppStore((s) => s.activeView)
|
||||
const repos = useAppStore((s) => s.repos)
|
||||
const repoMap = useRepoMap()
|
||||
const canBrowseTasks = repos.some((repo) => isGitRepoKind(repo))
|
||||
// Why: the setting is opt-out (default true). `!== false` keeps the button
|
||||
// visible for users whose persisted settings predate this field.
|
||||
const showTasksButton = useAppStore((s) => s.settings?.showTasksButton !== false)
|
||||
const rawVisibleTaskProviders = useAppStore((s) => s.settings?.visibleTaskProviders)
|
||||
const defaultTaskSource = useAppStore((s) => s.settings?.defaultTaskSource ?? 'github')
|
||||
const preflightStatus = useAppStore((s) => s.preflightStatus)
|
||||
const preflightStatusChecked = useAppStore((s) => s.preflightStatusChecked)
|
||||
const preflightStatusContextKey = useAppStore((s) => s.preflightStatusContextKey)
|
||||
const refreshPreflightStatus = useAppStore((s) => s.refreshPreflightStatus)
|
||||
const expectedPreflightContextKey = useAppStore((s) =>
|
||||
localPreflightContextKey(getLocalPreflightContext(s))
|
||||
)
|
||||
const linearStatus = useAppStore((s) => s.linearStatus)
|
||||
const linearStatusChecked = useAppStore((s) => s.linearStatusChecked)
|
||||
const checkLinearConnection = useAppStore((s) => s.checkLinearConnection)
|
||||
const showAgentsButton = useAppStore((s) => shouldShowAgentsButton(s.settings))
|
||||
const showAutomationsButton = useAppStore((s) => shouldShowAutomationsButton(s.settings))
|
||||
const showMobileButton = useAppStore((s) => shouldShowMobileButton(s.settings))
|
||||
const preferredVisibleTaskProviders = React.useMemo(
|
||||
() => normalizeVisibleTaskProviders(rawVisibleTaskProviders),
|
||||
[rawVisibleTaskProviders]
|
||||
)
|
||||
const preflightStatusCurrent = preflightStatusContextKey === expectedPreflightContextKey
|
||||
const visibleTaskProviders = React.useMemo(
|
||||
() =>
|
||||
restoreAvailableDefaultTaskProvider(
|
||||
preferredVisibleTaskProviders,
|
||||
{
|
||||
gitlabInstalled: preflightStatusCurrent && preflightStatus?.glab?.installed === true,
|
||||
linearConnected: linearStatus.connected === true
|
||||
},
|
||||
defaultTaskSource
|
||||
),
|
||||
[
|
||||
defaultTaskSource,
|
||||
linearStatus.connected,
|
||||
preferredVisibleTaskProviders,
|
||||
preflightStatusCurrent,
|
||||
preflightStatus?.glab?.installed
|
||||
]
|
||||
)
|
||||
const resolvedDefaultTaskSource = React.useMemo(
|
||||
() => resolveVisibleTaskProvider(defaultTaskSource, visibleTaskProviders),
|
||||
[defaultTaskSource, visibleTaskProviders]
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!preflightStatusChecked || !preflightStatusCurrent) {
|
||||
void refreshPreflightStatus()
|
||||
}
|
||||
if (!linearStatusChecked) {
|
||||
void checkLinearConnection()
|
||||
}
|
||||
}, [
|
||||
checkLinearConnection,
|
||||
linearStatusChecked,
|
||||
preflightStatusChecked,
|
||||
preflightStatusCurrent,
|
||||
refreshPreflightStatus
|
||||
])
|
||||
|
||||
// Why: warm the GitHub work-item cache on hover/focus so by the time the
|
||||
// user's click finishes the round-trip has either completed or is already
|
||||
// in-flight. Shaves ~200–600ms off perceived page-load latency.
|
||||
const prefetchWorkItems = useAppStore((s) => s.prefetchWorkItems)
|
||||
const activeRepoId = useAppStore((s) => s.activeRepoId)
|
||||
const defaultTaskViewPreset = useAppStore((s) => s.settings?.defaultTaskViewPreset ?? 'all')
|
||||
const handlePrefetch = React.useCallback(() => {
|
||||
if (!canBrowseTasks || resolvedDefaultTaskSource !== 'github') {
|
||||
return
|
||||
}
|
||||
const activeRepo = activeRepoId ? (repoMap.get(activeRepoId) ?? null) : null
|
||||
const activeGitRepo = activeRepo && isGitRepoKind(activeRepo) ? activeRepo : null
|
||||
const firstGitRepo = activeGitRepo ?? repos.find((r) => isGitRepoKind(r))
|
||||
if (firstGitRepo?.path) {
|
||||
// Why: warm the exact cache key the page will read on mount — must
|
||||
// match TaskPage's `initialTaskQuery` derived from the same default
|
||||
// preset, otherwise the prefetch lands in a key the page never reads
|
||||
// and we pay the full round-trip after click.
|
||||
prefetchWorkItems(
|
||||
firstGitRepo.id,
|
||||
firstGitRepo.path,
|
||||
PER_REPO_FETCH_LIMIT,
|
||||
getTaskPresetQuery(defaultTaskViewPreset)
|
||||
)
|
||||
}
|
||||
}, [
|
||||
activeRepoId,
|
||||
canBrowseTasks,
|
||||
defaultTaskViewPreset,
|
||||
prefetchWorkItems,
|
||||
repoMap,
|
||||
repos,
|
||||
resolvedDefaultTaskSource
|
||||
])
|
||||
|
||||
const tasksActive = activeView === 'tasks'
|
||||
const automationsActive = activeView === 'automations'
|
||||
const activityActive = activeView === 'activity'
|
||||
const mobileActive = activeView === 'mobile'
|
||||
const activityUnreadCount = useActivityUnreadCount(showAgentsButton, 'sidebar-badge')
|
||||
const mobileOnboardingBadge = useMobileSidebarOnboardingBadge(showMobileButton)
|
||||
const hideTasksButton = React.useCallback(() => {
|
||||
void updateSettings({ showTasksButton: false })
|
||||
}, [updateSettings])
|
||||
const hideAutomationsButton = React.useCallback(() => {
|
||||
void updateSettings({ showAutomationsButton: false })
|
||||
}, [updateSettings])
|
||||
|
|
@ -221,103 +77,7 @@ const SidebarNav = React.memo(function SidebarNav() {
|
|||
data-contextual-tour-target="sidebar-navigation"
|
||||
>
|
||||
<SetupGuideSidebarEntry />
|
||||
{showTasksButton ? (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (!canBrowseTasks) {
|
||||
return
|
||||
}
|
||||
openTaskPage()
|
||||
}}
|
||||
onPointerEnter={handlePrefetch}
|
||||
onFocus={handlePrefetch}
|
||||
aria-disabled={!canBrowseTasks}
|
||||
aria-current={tasksActive ? 'page' : undefined}
|
||||
data-contextual-tour-target="sidebar-tasks"
|
||||
className={cn(
|
||||
'group flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-[13px] font-medium tracking-tight transition-colors',
|
||||
tasksActive
|
||||
? 'bg-worktree-sidebar-accent text-worktree-sidebar-accent-foreground'
|
||||
: 'text-worktree-sidebar-foreground/60 hover:bg-worktree-sidebar-foreground/8',
|
||||
!canBrowseTasks && 'cursor-not-allowed opacity-50 hover:bg-transparent'
|
||||
)}
|
||||
>
|
||||
<List
|
||||
className={cn(
|
||||
'size-4 shrink-0',
|
||||
!tasksActive && 'text-worktree-sidebar-foreground/30'
|
||||
)}
|
||||
strokeWidth={tasksActive ? 2.25 : 1.75}
|
||||
/>
|
||||
<span className="flex-1">
|
||||
{translate('auto.components.sidebar.SidebarNav.fee535205b', 'Tasks')}
|
||||
</span>
|
||||
<span className="hidden items-center gap-1 group-hover:flex group-focus-within:flex">
|
||||
{visibleTaskProviders.includes('github') ? (
|
||||
<TaskProviderShortcut
|
||||
canBrowseTasks={canBrowseTasks}
|
||||
label={translate(
|
||||
'auto.components.sidebar.SidebarNav.0ccba862b8',
|
||||
'Open GitHub tasks'
|
||||
)}
|
||||
onOpen={() => {
|
||||
openTaskPage({ taskSource: 'github' })
|
||||
}}
|
||||
>
|
||||
<Github className="size-3.5" aria-hidden />
|
||||
</TaskProviderShortcut>
|
||||
) : null}
|
||||
{visibleTaskProviders.includes('gitlab') ? (
|
||||
<TaskProviderShortcut
|
||||
canBrowseTasks={canBrowseTasks}
|
||||
label={translate(
|
||||
'auto.components.sidebar.SidebarNav.196c1b5362',
|
||||
'Open GitLab tasks'
|
||||
)}
|
||||
onOpen={() => {
|
||||
openTaskPage({ taskSource: 'gitlab' })
|
||||
}}
|
||||
>
|
||||
<Gitlab className="size-3.5" aria-hidden />
|
||||
</TaskProviderShortcut>
|
||||
) : null}
|
||||
{visibleTaskProviders.includes('linear') ? (
|
||||
<TaskProviderShortcut
|
||||
canBrowseTasks={canBrowseTasks}
|
||||
label={translate(
|
||||
'auto.components.sidebar.SidebarNav.c39ab10000',
|
||||
'Open Linear tasks'
|
||||
)}
|
||||
onOpen={() => {
|
||||
openTaskPage({ taskSource: 'linear' })
|
||||
}}
|
||||
>
|
||||
<LinearIcon className="size-3.5" />
|
||||
</TaskProviderShortcut>
|
||||
) : null}
|
||||
{visibleTaskProviders.includes('jira') ? (
|
||||
<TaskProviderShortcut
|
||||
canBrowseTasks={canBrowseTasks}
|
||||
label={translate(
|
||||
'auto.components.sidebar.SidebarNav.e7ad3c540d',
|
||||
'Open Jira tasks'
|
||||
)}
|
||||
onOpen={() => {
|
||||
openTaskPage({ taskSource: 'jira' })
|
||||
}}
|
||||
>
|
||||
<JiraIcon className="size-3.5" />
|
||||
</TaskProviderShortcut>
|
||||
) : null}
|
||||
</span>
|
||||
</button>
|
||||
</ContextMenuTrigger>
|
||||
<HideSidebarMenu onHide={hideTasksButton} />
|
||||
</ContextMenu>
|
||||
) : null}
|
||||
<SidebarTaskNavButton />
|
||||
{showAutomationsButton ? (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,256 @@
|
|||
import React from 'react'
|
||||
import { EyeOff, Github, Gitlab, List } from 'lucide-react'
|
||||
import { JiraIcon } from '@/components/icons/JiraIcon'
|
||||
import { LinearIcon } from '@/components/icons/LinearIcon'
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuTrigger
|
||||
} from '@/components/ui/context-menu'
|
||||
import { getTaskPresetQuery, PER_REPO_FETCH_LIMIT } from '@/lib/new-workspace'
|
||||
import { getLocalPreflightContext, localPreflightContextKey } from '@/lib/local-preflight-context'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useAppStore } from '@/store'
|
||||
import { useRepoMap } from '@/store/selectors'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { isGitRepoKind } from '../../../../shared/repo-kind'
|
||||
import {
|
||||
normalizeVisibleTaskProviders,
|
||||
restoreAvailableDefaultTaskProvider,
|
||||
resolveVisibleTaskProvider
|
||||
} from '../../../../shared/task-providers'
|
||||
|
||||
function HideTaskSidebarMenu({ onHide }: { onHide: () => void }): React.JSX.Element {
|
||||
return (
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem onSelect={onHide}>
|
||||
<EyeOff className="size-3.5" />
|
||||
{translate('auto.components.sidebar.SidebarNav.d599269755', 'Hide from sidebar')}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
)
|
||||
}
|
||||
|
||||
function TaskProviderShortcut({
|
||||
canBrowseTasks,
|
||||
label,
|
||||
onOpen,
|
||||
children
|
||||
}: {
|
||||
canBrowseTasks: boolean
|
||||
label: string
|
||||
onOpen: () => void
|
||||
children: React.ReactNode
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<span
|
||||
role={canBrowseTasks ? 'button' : undefined}
|
||||
tabIndex={-1}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (!canBrowseTasks) {
|
||||
return
|
||||
}
|
||||
onOpen()
|
||||
}}
|
||||
className={cn(
|
||||
'rounded p-0.5 text-muted-foreground/70',
|
||||
canBrowseTasks ? 'transition-colors hover:text-foreground' : 'cursor-default'
|
||||
)}
|
||||
aria-label={canBrowseTasks ? label : undefined}
|
||||
aria-hidden={canBrowseTasks ? undefined : true}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function SidebarTaskNavButton(): React.JSX.Element | null {
|
||||
const openTaskPage = useAppStore((s) => s.openTaskPage)
|
||||
const updateSettings = useAppStore((s) => s.updateSettings)
|
||||
const activeView = useAppStore((s) => s.activeView)
|
||||
const repos = useAppStore((s) => s.repos)
|
||||
const repoMap = useRepoMap()
|
||||
const canBrowseTasks = repos.some((repo) => isGitRepoKind(repo))
|
||||
const showTasksButton = useAppStore((s) => s.settings?.showTasksButton !== false)
|
||||
const rawVisibleTaskProviders = useAppStore((s) => s.settings?.visibleTaskProviders)
|
||||
const defaultTaskSource = useAppStore((s) => s.settings?.defaultTaskSource ?? 'github')
|
||||
const preflightStatus = useAppStore((s) => s.preflightStatus)
|
||||
const preflightStatusChecked = useAppStore((s) => s.preflightStatusChecked)
|
||||
const preflightStatusContextKey = useAppStore((s) => s.preflightStatusContextKey)
|
||||
const refreshPreflightStatus = useAppStore((s) => s.refreshPreflightStatus)
|
||||
const expectedPreflightContextKey = useAppStore((s) =>
|
||||
localPreflightContextKey(getLocalPreflightContext(s))
|
||||
)
|
||||
const linearStatus = useAppStore((s) => s.linearStatus)
|
||||
const linearStatusChecked = useAppStore((s) => s.linearStatusChecked)
|
||||
const checkLinearConnection = useAppStore((s) => s.checkLinearConnection)
|
||||
const prefetchWorkItems = useAppStore((s) => s.prefetchWorkItems)
|
||||
const activeRepoId = useAppStore((s) => s.activeRepoId)
|
||||
const defaultTaskViewPreset = useAppStore((s) => s.settings?.defaultTaskViewPreset ?? 'all')
|
||||
const preferredVisibleTaskProviders = React.useMemo(
|
||||
() => normalizeVisibleTaskProviders(rawVisibleTaskProviders),
|
||||
[rawVisibleTaskProviders]
|
||||
)
|
||||
const preflightStatusCurrent = preflightStatusContextKey === expectedPreflightContextKey
|
||||
const visibleTaskProviders = React.useMemo(
|
||||
() =>
|
||||
restoreAvailableDefaultTaskProvider(
|
||||
preferredVisibleTaskProviders,
|
||||
{
|
||||
gitlabInstalled: preflightStatusCurrent && preflightStatus?.glab?.installed === true,
|
||||
linearConnected: linearStatus.connected === true
|
||||
},
|
||||
defaultTaskSource
|
||||
),
|
||||
[
|
||||
defaultTaskSource,
|
||||
linearStatus.connected,
|
||||
preferredVisibleTaskProviders,
|
||||
preflightStatusCurrent,
|
||||
preflightStatus?.glab?.installed
|
||||
]
|
||||
)
|
||||
const resolvedDefaultTaskSource = React.useMemo(
|
||||
() => resolveVisibleTaskProvider(defaultTaskSource, visibleTaskProviders),
|
||||
[defaultTaskSource, visibleTaskProviders]
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!preflightStatusChecked || !preflightStatusCurrent) {
|
||||
void refreshPreflightStatus()
|
||||
}
|
||||
if (!linearStatusChecked) {
|
||||
void checkLinearConnection()
|
||||
}
|
||||
}, [
|
||||
checkLinearConnection,
|
||||
linearStatusChecked,
|
||||
preflightStatusChecked,
|
||||
preflightStatusCurrent,
|
||||
refreshPreflightStatus
|
||||
])
|
||||
|
||||
const handlePrefetch = React.useCallback(() => {
|
||||
if (!canBrowseTasks || resolvedDefaultTaskSource !== 'github') {
|
||||
return
|
||||
}
|
||||
const activeRepo = activeRepoId ? (repoMap.get(activeRepoId) ?? null) : null
|
||||
const activeGitRepo = activeRepo && isGitRepoKind(activeRepo) ? activeRepo : null
|
||||
const firstGitRepo = activeGitRepo ?? repos.find((r) => isGitRepoKind(r))
|
||||
if (firstGitRepo?.path) {
|
||||
prefetchWorkItems(
|
||||
firstGitRepo.id,
|
||||
firstGitRepo.path,
|
||||
PER_REPO_FETCH_LIMIT,
|
||||
getTaskPresetQuery(defaultTaskViewPreset)
|
||||
)
|
||||
}
|
||||
}, [
|
||||
activeRepoId,
|
||||
canBrowseTasks,
|
||||
defaultTaskViewPreset,
|
||||
prefetchWorkItems,
|
||||
repoMap,
|
||||
repos,
|
||||
resolvedDefaultTaskSource
|
||||
])
|
||||
|
||||
const hideTasksButton = React.useCallback(() => {
|
||||
void updateSettings({ showTasksButton: false })
|
||||
}, [updateSettings])
|
||||
|
||||
if (!showTasksButton) {
|
||||
return null
|
||||
}
|
||||
|
||||
const tasksActive = activeView === 'tasks'
|
||||
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (!canBrowseTasks) {
|
||||
return
|
||||
}
|
||||
openTaskPage()
|
||||
}}
|
||||
onPointerEnter={handlePrefetch}
|
||||
onFocus={handlePrefetch}
|
||||
aria-disabled={!canBrowseTasks}
|
||||
aria-current={tasksActive ? 'page' : undefined}
|
||||
data-contextual-tour-target="sidebar-tasks"
|
||||
className={cn(
|
||||
'group flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-[13px] font-medium tracking-tight transition-colors',
|
||||
tasksActive
|
||||
? 'bg-worktree-sidebar-accent text-worktree-sidebar-accent-foreground'
|
||||
: 'text-worktree-sidebar-foreground/60 hover:bg-worktree-sidebar-foreground/8',
|
||||
!canBrowseTasks && 'cursor-not-allowed opacity-50 hover:bg-transparent'
|
||||
)}
|
||||
>
|
||||
<List
|
||||
className={cn('size-4 shrink-0', !tasksActive && 'text-worktree-sidebar-foreground/30')}
|
||||
strokeWidth={tasksActive ? 2.25 : 1.75}
|
||||
/>
|
||||
<span className="flex-1">
|
||||
{translate('auto.components.sidebar.SidebarNav.fee535205b', 'Tasks')}
|
||||
</span>
|
||||
<span className="hidden items-center gap-1 group-hover:flex group-focus-within:flex">
|
||||
{visibleTaskProviders.includes('github') ? (
|
||||
<TaskProviderShortcut
|
||||
canBrowseTasks={canBrowseTasks}
|
||||
label={translate(
|
||||
'auto.components.sidebar.SidebarNav.0ccba862b8',
|
||||
'Open GitHub tasks'
|
||||
)}
|
||||
onOpen={() => openTaskPage({ taskSource: 'github' })}
|
||||
>
|
||||
<Github className="size-3.5" aria-hidden />
|
||||
</TaskProviderShortcut>
|
||||
) : null}
|
||||
{visibleTaskProviders.includes('gitlab') ? (
|
||||
<TaskProviderShortcut
|
||||
canBrowseTasks={canBrowseTasks}
|
||||
label={translate(
|
||||
'auto.components.sidebar.SidebarNav.196c1b5362',
|
||||
'Open GitLab tasks'
|
||||
)}
|
||||
onOpen={() => openTaskPage({ taskSource: 'gitlab' })}
|
||||
>
|
||||
<Gitlab className="size-3.5" aria-hidden />
|
||||
</TaskProviderShortcut>
|
||||
) : null}
|
||||
{visibleTaskProviders.includes('linear') ? (
|
||||
<TaskProviderShortcut
|
||||
canBrowseTasks={canBrowseTasks}
|
||||
label={translate(
|
||||
'auto.components.sidebar.SidebarNav.c39ab10000',
|
||||
'Open Linear tasks'
|
||||
)}
|
||||
onOpen={() => openTaskPage({ taskSource: 'linear' })}
|
||||
>
|
||||
<LinearIcon className="size-3.5" />
|
||||
</TaskProviderShortcut>
|
||||
) : null}
|
||||
{visibleTaskProviders.includes('jira') ? (
|
||||
<TaskProviderShortcut
|
||||
canBrowseTasks={canBrowseTasks}
|
||||
label={translate(
|
||||
'auto.components.sidebar.SidebarNav.e7ad3c540d',
|
||||
'Open Jira tasks'
|
||||
)}
|
||||
onOpen={() => openTaskPage({ taskSource: 'jira' })}
|
||||
>
|
||||
<JiraIcon className="size-3.5" />
|
||||
</TaskProviderShortcut>
|
||||
) : null}
|
||||
</span>
|
||||
</button>
|
||||
</ContextMenuTrigger>
|
||||
<HideTaskSidebarMenu onHide={hideTasksButton} />
|
||||
</ContextMenu>
|
||||
)
|
||||
}
|
||||
|
|
@ -869,7 +869,8 @@
|
|||
"9c00bd4adf": "Select a workspace from the sidebar to begin.",
|
||||
"16e9e3df89": "starred",
|
||||
"0d0ace8861": "Star on GitHub",
|
||||
"ec43b38ba7": "Starred on GitHub"
|
||||
"ec43b38ba7": "Starred on GitHub",
|
||||
"157bb5ecbb": "Open GitHub"
|
||||
},
|
||||
"LinearIssueMarkdownDescriptionEditor": {
|
||||
"d9c47069ef": "Markdown",
|
||||
|
|
@ -1201,7 +1202,13 @@
|
|||
"b5e685e4d9": "Dismiss",
|
||||
"5f6df21046": "Enjoying Orca?",
|
||||
"2d67b6c849": "Star on GitHub",
|
||||
"af3c9bbb37": "Starring…"
|
||||
"af3c9bbb37": "Starring…",
|
||||
"68a41bc3aa": "Could not star with",
|
||||
"996bf76e46": "Open GitHub to finish in your browser.",
|
||||
"d32015fec7": "Opening...",
|
||||
"157bb5ecbb": "Open GitHub",
|
||||
"8c967b4d15": "Not now",
|
||||
"73dfd4eb8d": "Don't ask again"
|
||||
},
|
||||
"TaskPage": {
|
||||
"513cddfa7a": "Verifying…",
|
||||
|
|
@ -3344,7 +3351,10 @@
|
|||
"2991a0106c": "Help",
|
||||
"4e8f5710d3": "Couldn't restart Orca.",
|
||||
"5161eef55d": "Restarting Orca…",
|
||||
"d396773ef0": "checking"
|
||||
"d396773ef0": "checking",
|
||||
"f8a2c91d4e": "Milestones",
|
||||
"b7e4d2a19c": "Onboarding",
|
||||
"c4f8e1b72a": "X"
|
||||
},
|
||||
"SidebarToolbar": {
|
||||
"19e32d0e5f": "Open folder picker to add a project",
|
||||
|
|
@ -4369,7 +4379,9 @@
|
|||
"1e29570462": "starring",
|
||||
"9d181300e3": "starred",
|
||||
"5c49f02662": "hidden",
|
||||
"b3f0584f5d": "loading"
|
||||
"b3f0584f5d": "loading",
|
||||
"cb65c75b11": "Opening...",
|
||||
"f2d4f877b2": "Open GitHub"
|
||||
},
|
||||
"GeneralUpdateSettingsSection": {
|
||||
"8a52ca1d02": "Release notes",
|
||||
|
|
|
|||
|
|
@ -869,7 +869,8 @@
|
|||
"9c00bd4adf": "Seleccione un espacio de trabajo de la barra lateral para comenzar.",
|
||||
"16e9e3df89": "sembrado de estrellas",
|
||||
"0d0ace8861": "Estrella en GitHub",
|
||||
"ec43b38ba7": "Destacado en GitHub"
|
||||
"ec43b38ba7": "Destacado en GitHub",
|
||||
"157bb5ecbb": "Open GitHub"
|
||||
},
|
||||
"LinearIssueMarkdownDescriptionEditor": {
|
||||
"d9c47069ef": "Markdown",
|
||||
|
|
@ -1201,7 +1202,13 @@
|
|||
"b5e685e4d9": "Despedir",
|
||||
"5f6df21046": "¿Disfrutando de Orca?",
|
||||
"2d67b6c849": "Estrella en GitHub",
|
||||
"af3c9bbb37": "Protagonizada…"
|
||||
"af3c9bbb37": "Protagonizada…",
|
||||
"68a41bc3aa": "Could not star with",
|
||||
"996bf76e46": "Open GitHub to finish in your browser.",
|
||||
"d32015fec7": "Opening...",
|
||||
"157bb5ecbb": "Open GitHub",
|
||||
"8c967b4d15": "Not now",
|
||||
"73dfd4eb8d": "Don't ask again"
|
||||
},
|
||||
"TaskPage": {
|
||||
"513cddfa7a": "Verificando…",
|
||||
|
|
@ -3344,7 +3351,10 @@
|
|||
"2991a0106c": "Ayuda",
|
||||
"4e8f5710d3": "No se pudo reiniciar Orca.",
|
||||
"5161eef55d": "Reiniciando Orca...",
|
||||
"d396773ef0": "de cheques"
|
||||
"d396773ef0": "de cheques",
|
||||
"f8a2c91d4e": "Milestones",
|
||||
"b7e4d2a19c": "Onboarding",
|
||||
"c4f8e1b72a": "X"
|
||||
},
|
||||
"SidebarToolbar": {
|
||||
"19e32d0e5f": "Abra el selector de carpetas para agregar un proyecto",
|
||||
|
|
@ -4369,7 +4379,9 @@
|
|||
"1e29570462": "protagonizada",
|
||||
"9d181300e3": "sembrado de estrellas",
|
||||
"5c49f02662": "oculto",
|
||||
"b3f0584f5d": "cargando"
|
||||
"b3f0584f5d": "cargando",
|
||||
"cb65c75b11": "Opening...",
|
||||
"f2d4f877b2": "Open GitHub"
|
||||
},
|
||||
"GeneralUpdateSettingsSection": {
|
||||
"8a52ca1d02": "Notas de la versión",
|
||||
|
|
|
|||
|
|
@ -869,7 +869,8 @@
|
|||
"9c00bd4adf": "開始するには、サイドバーからワークスペースを選択します。",
|
||||
"16e9e3df89": "星付き",
|
||||
"0d0ace8861": "GitHub でスターを付ける",
|
||||
"ec43b38ba7": "GitHub でスターを獲得"
|
||||
"ec43b38ba7": "GitHub でスターを獲得",
|
||||
"157bb5ecbb": "Open GitHub"
|
||||
},
|
||||
"LinearIssueMarkdownDescriptionEditor": {
|
||||
"d9c47069ef": "Markdown",
|
||||
|
|
@ -1201,7 +1202,13 @@
|
|||
"b5e685e4d9": "閉じる",
|
||||
"5f6df21046": "Orcaを楽しんでいますか?",
|
||||
"2d67b6c849": "GitHub でスターを付ける",
|
||||
"af3c9bbb37": "主演…"
|
||||
"af3c9bbb37": "主演…",
|
||||
"68a41bc3aa": "Could not star with",
|
||||
"996bf76e46": "Open GitHub to finish in your browser.",
|
||||
"d32015fec7": "Opening...",
|
||||
"157bb5ecbb": "Open GitHub",
|
||||
"8c967b4d15": "Not now",
|
||||
"73dfd4eb8d": "Don't ask again"
|
||||
},
|
||||
"TaskPage": {
|
||||
"513cddfa7a": "確認中…",
|
||||
|
|
@ -3325,7 +3332,10 @@
|
|||
"2991a0106c": "ヘルプ",
|
||||
"4e8f5710d3": "Orca を再起動できませんでした。",
|
||||
"5161eef55d": "Orca を再起動しています…",
|
||||
"d396773ef0": "チェック中"
|
||||
"d396773ef0": "チェック中",
|
||||
"f8a2c91d4e": "Milestones",
|
||||
"b7e4d2a19c": "Onboarding",
|
||||
"c4f8e1b72a": "X"
|
||||
},
|
||||
"SidebarToolbar": {
|
||||
"19e32d0e5f": "フォルダーピッカーを開いてプロジェクトを追加します",
|
||||
|
|
@ -4354,7 +4364,9 @@
|
|||
"1e29570462": "主演",
|
||||
"9d181300e3": "星付き",
|
||||
"5c49f02662": "隠れた",
|
||||
"b3f0584f5d": "読み込み中"
|
||||
"b3f0584f5d": "読み込み中",
|
||||
"cb65c75b11": "Opening...",
|
||||
"f2d4f877b2": "Open GitHub"
|
||||
},
|
||||
"GeneralUpdateSettingsSection": {
|
||||
"8a52ca1d02": "リリースノート",
|
||||
|
|
|
|||
|
|
@ -869,7 +869,8 @@
|
|||
"9c00bd4adf": "시작하려면 사이드바에서 워크스페이스를 선택하세요.",
|
||||
"16e9e3df89": "별표가 붙은",
|
||||
"0d0ace8861": "GitHub의 스타",
|
||||
"ec43b38ba7": "GitHub에 별표 표시됨"
|
||||
"ec43b38ba7": "GitHub에 별표 표시됨",
|
||||
"157bb5ecbb": "Open GitHub"
|
||||
},
|
||||
"LinearIssueMarkdownDescriptionEditor": {
|
||||
"d9c47069ef": "Markdown",
|
||||
|
|
@ -1201,7 +1202,13 @@
|
|||
"b5e685e4d9": "닫기",
|
||||
"5f6df21046": "Orca를 즐기고 있나요?",
|
||||
"2d67b6c849": "GitHub의 스타",
|
||||
"af3c9bbb37": "스타 표시 중…"
|
||||
"af3c9bbb37": "스타 표시 중…",
|
||||
"68a41bc3aa": "Could not star with",
|
||||
"996bf76e46": "Open GitHub to finish in your browser.",
|
||||
"d32015fec7": "Opening...",
|
||||
"157bb5ecbb": "Open GitHub",
|
||||
"8c967b4d15": "Not now",
|
||||
"73dfd4eb8d": "Don't ask again"
|
||||
},
|
||||
"TaskPage": {
|
||||
"513cddfa7a": "확인 중…",
|
||||
|
|
@ -3325,7 +3332,10 @@
|
|||
"2991a0106c": "돕다",
|
||||
"4e8f5710d3": "Orca를 다시 시작할 수 없습니다.",
|
||||
"5161eef55d": "Orca를 다시 시작하는 중…",
|
||||
"d396773ef0": "확인 중"
|
||||
"d396773ef0": "확인 중",
|
||||
"f8a2c91d4e": "Milestones",
|
||||
"b7e4d2a19c": "Onboarding",
|
||||
"c4f8e1b72a": "X"
|
||||
},
|
||||
"SidebarToolbar": {
|
||||
"19e32d0e5f": "폴더 선택기를 열어 프로젝트를 추가하세요.",
|
||||
|
|
@ -4354,7 +4364,9 @@
|
|||
"1e29570462": "스타 표시",
|
||||
"9d181300e3": "별표가 붙은",
|
||||
"5c49f02662": "숨겨진",
|
||||
"b3f0584f5d": "로드 중"
|
||||
"b3f0584f5d": "로드 중",
|
||||
"cb65c75b11": "Opening...",
|
||||
"f2d4f877b2": "Open GitHub"
|
||||
},
|
||||
"GeneralUpdateSettingsSection": {
|
||||
"8a52ca1d02": "릴리스 노트",
|
||||
|
|
|
|||
|
|
@ -869,7 +869,8 @@
|
|||
"9c00bd4adf": "从侧边栏中选择一个工作区开始。",
|
||||
"16e9e3df89": "已加星标",
|
||||
"0d0ace8861": "在 GitHub 上加星标",
|
||||
"ec43b38ba7": "在 GitHub 上加星标"
|
||||
"ec43b38ba7": "在 GitHub 上加星标",
|
||||
"157bb5ecbb": "Open GitHub"
|
||||
},
|
||||
"LinearIssueMarkdownDescriptionEditor": {
|
||||
"d9c47069ef": "Markdown",
|
||||
|
|
@ -1201,7 +1202,13 @@
|
|||
"b5e685e4d9": "关闭",
|
||||
"5f6df21046": "喜欢 Orca 吗?",
|
||||
"2d67b6c849": "在 GitHub 上加星标",
|
||||
"af3c9bbb37": "主演…"
|
||||
"af3c9bbb37": "主演…",
|
||||
"68a41bc3aa": "Could not star with",
|
||||
"996bf76e46": "Open GitHub to finish in your browser.",
|
||||
"d32015fec7": "Opening...",
|
||||
"157bb5ecbb": "Open GitHub",
|
||||
"8c967b4d15": "Not now",
|
||||
"73dfd4eb8d": "Don't ask again"
|
||||
},
|
||||
"TaskPage": {
|
||||
"513cddfa7a": "正在验证...",
|
||||
|
|
@ -3325,7 +3332,10 @@
|
|||
"2991a0106c": "帮助",
|
||||
"4e8f5710d3": "无法重新启动 Orca。",
|
||||
"5161eef55d": "正在重启 Orca…",
|
||||
"d396773ef0": "检查中"
|
||||
"d396773ef0": "检查中",
|
||||
"f8a2c91d4e": "Milestones",
|
||||
"b7e4d2a19c": "Onboarding",
|
||||
"c4f8e1b72a": "X"
|
||||
},
|
||||
"SidebarToolbar": {
|
||||
"19e32d0e5f": "打开文件夹选择器以添加项目",
|
||||
|
|
@ -4354,7 +4364,9 @@
|
|||
"1e29570462": "主演",
|
||||
"9d181300e3": "已加星标",
|
||||
"5c49f02662": "隐",
|
||||
"b3f0584f5d": "加载中"
|
||||
"b3f0584f5d": "加载中",
|
||||
"cb65c75b11": "Opening...",
|
||||
"f2d4f877b2": "Open GitHub"
|
||||
},
|
||||
"GeneralUpdateSettingsSection": {
|
||||
"8a52ca1d02": "发行说明",
|
||||
|
|
|
|||
|
|
@ -2737,7 +2737,7 @@ export type PersistedUIState = {
|
|||
* 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
|
||||
* notification should fire. Starts at 35 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
|
||||
|
|
|
|||
Loading…
Reference in New Issue