Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
123 changes: 123 additions & 0 deletions apps/desktop/src/app/updates-overlay.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import type { ReactNode } from 'react'
import { afterEach, describe, expect, it, vi } from 'vitest'

import { Dialog, DialogContent } from '@/components/ui/dialog'
import type { DesktopUpdateStatus } from '@/global'

import { ManagedSourceUpdateView } from './updates-overlay'

afterEach(() => cleanup())

function managedStatus(overrides: Partial<NonNullable<DesktopUpdateStatus['managedSource']>> = {}): DesktopUpdateStatus {
return {
supported: true,
behind: 4,
updateAvailable: true,
targetSha: 'b'.repeat(40),
managedSource: {
availability: 'ready',
stale: false,
statusError: null,
runningRelease: 'ava-converge-p1-f22a217b8dab',
runningUpstreamBase: 'a'.repeat(40),
trackedUpstream: 'NousResearch/main',
upstreamHead: 'b'.repeat(40),
commitsBehind: 4,
localPatchCount: 2,
lastFetchedAt: '2026-07-27T18:00:00+00:00',
generatedAt: '2026-07-27T18:00:00+00:00',
candidateStatus: 'not_built',
blockers: [],
nextAction: 'Build an immutable candidate.',
sourceWorktreeClean: true,
sourceRefsRemotelyReachable: true,
canBuildCandidate: true,
candidateRequestAvailable: true,
refreshRequestAvailable: true,
refreshRequest: null,
...overrides
}
}
}

function renderManaged(ui: ReactNode) {
return render(
<Dialog open>
<DialogContent>{ui}</DialogContent>
</Dialog>
)
}

describe('ManagedSourceUpdateView', () => {
it('renders immutable source state and labels both request-only actions honestly', () => {
const onCheckNow = vi.fn()
const onBuildCandidate = vi.fn()

renderManaged(
<ManagedSourceUpdateView
building={false}
checking={false}
onBuildCandidate={onBuildCandidate}
onCheckNow={onCheckNow}
status={managedStatus()}
/>
)

expect(screen.getByText('Immutable update train')).toBeTruthy()
expect(screen.getByText(/4 upstream commits/)).toBeTruthy()
expect(screen.getByText('Candidate')).toBeTruthy()
expect(screen.getByText('Not built')).toBeTruthy()
expect(screen.getByText(/Production is not changed or restarted/)).toBeTruthy()

fireEvent.click(screen.getByRole('button', { name: 'Check now' }))
fireEvent.click(screen.getByRole('button', { name: 'Build candidate' }))
expect(onCheckNow).toHaveBeenCalledOnce()
expect(onBuildCandidate).toHaveBeenCalledOnce()
})

it('shows stale and blocker states and disables unsafe candidate requests', () => {
renderManaged(
<ManagedSourceUpdateView
building={false}
checking={false}
onBuildCandidate={vi.fn()}
onCheckNow={vi.fn()}
status={managedStatus({
availability: 'stale',
stale: true,
canBuildCandidate: false,
blockers: ['Source refs are not remotely reachable.'],
nextAction: 'Publish the source refs, then check again.'
})}
/>
)

expect(screen.getByText(/stale/i)).toBeTruthy()
expect(screen.getByText('Source refs are not remotely reachable.')).toBeTruthy()
expect((screen.getByRole('button', { name: 'Build candidate' }) as HTMLButtonElement).disabled).toBe(true)
expect((screen.getByRole('button', { name: 'Check now' }) as HTMLButtonElement).disabled).toBe(false)
})

it('keeps missing or invalid monitor state visible instead of calling it unsupported', () => {
renderManaged(
<ManagedSourceUpdateView
building={false}
checking={false}
onBuildCandidate={vi.fn()}
onCheckNow={vi.fn()}
status={managedStatus({
availability: 'invalid',
statusError: 'status_schema_invalid',
canBuildCandidate: false,
candidateRequestAvailable: false,
refreshRequestAvailable: true
})}
/>
)

expect(screen.getByText(/status is invalid/i)).toBeTruthy()
expect((screen.getByRole('button', { name: 'Check now' }) as HTMLButtonElement).disabled).toBe(false)
expect((screen.getByRole('button', { name: 'Build candidate' }) as HTMLButtonElement).disabled).toBe(true)
})
})
163 changes: 162 additions & 1 deletion apps/desktop/src/app/updates-overlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,10 @@ export function UpdatesOverlay() {
void install()
}

const handleCheckNow = () => {
void (isBackend ? checkBackendUpdates(true) : checkUpdates())
}

return (
<Dialog onOpenChange={handleClose} open={open}>
{/* This dialog has no inputs, so Radix's default autofocus would land on
Expand All @@ -123,11 +127,14 @@ export function UpdatesOverlay() {
{phase === 'idle' && (
<IdleView
behind={behind}
building={isBackend && backendApply.applying}
buildMessage={isBackend ? backendApply.message : undefined}
checking={checking}
commits={status?.commits ?? []}
onCheckNow={handleCheckNow}
onInstall={handleInstall}
onLater={() => handleClose(false)}
onRetryCheck={() => void check()}
onRetryCheck={handleCheckNow}
status={status}
target={target}
updateAvailable={updateAvailable}
Expand All @@ -142,6 +149,9 @@ function IdleView({
behind,
checking,
commits,
building,
buildMessage,
onCheckNow,
onInstall,
onLater,
onRetryCheck,
Expand All @@ -152,6 +162,9 @@ function IdleView({
behind: number
checking: boolean
commits: readonly DesktopUpdateCommit[]
building: boolean
buildMessage?: string
onCheckNow: () => void
onInstall: () => void
onLater: () => void
onRetryCheck: () => void
Expand Down Expand Up @@ -185,6 +198,19 @@ function IdleView({
)
}

if (target === 'backend' && status.managedSource) {
return (
<ManagedSourceUpdateView
building={building}
buildMessage={buildMessage}
checking={checking}
onBuildCandidate={onInstall}
onCheckNow={onCheckNow}
status={status}
/>
)
}

if (!status.supported) {
return (
<CenteredStatus
Expand Down Expand Up @@ -269,6 +295,141 @@ function IdleView({
)
}

export function ManagedSourceUpdateView({
building,
buildMessage,
checking,
onBuildCandidate,
onCheckNow,
status
}: {
building: boolean
buildMessage?: string
checking: boolean
onBuildCandidate: () => void
onCheckNow: () => void
status: DesktopUpdateStatus
}) {
const { t } = useI18n()
const u = t.updates
const source = status.managedSource

if (!source) {
return null
}

const availabilityCopy = {
invalid: u.managedInvalid,
missing: u.managedMissing,
ready: source.stale ? u.managedStale : u.managedReady,
stale: u.managedStale,
unreadable: u.managedUnreadable
}[source.availability]

const candidate = source.candidateStatus
? u.managedCandidateStatuses[source.candidateStatus]
: u.managedUnknown

const behind = source.commitsBehind ?? status.behind ?? 0
const localPatches = source.localPatchCount
const canCheck = source.refreshRequestAvailable && !checking
const canBuild = source.canBuildCandidate && source.candidateRequestAvailable && !building

return (
<div className="grid gap-5 px-6 pb-6 pt-7 pr-8">
<div className="flex flex-col items-center gap-3 text-center">
<BrandMark className="size-14" />
<DialogTitle className="text-center text-xl">{u.managedTitle}</DialogTitle>
<DialogDescription className="max-w-prose text-center text-sm leading-5">
{u.managedSubtitle}
</DialogDescription>
</div>

<div className="rounded-md border border-border/70 bg-muted/25 px-4 py-3">
<div className="flex items-start gap-2">
{source.stale || source.availability !== 'ready' ? (
<AlertCircle className="mt-0.5 size-4 shrink-0 text-amber-500" />
) : (
<Check className="mt-0.5 size-4 shrink-0 text-emerald-500" />
)}
<div className="min-w-0">
<p className="text-sm font-medium">{availabilityCopy}</p>
{source.runningRelease ? (
<p className="mt-1 truncate font-mono text-[11px] text-muted-foreground">
{u.managedRunningRelease}: {source.runningRelease}
</p>
) : null}
</div>
</div>
</div>

<dl className="grid grid-cols-2 gap-2 text-xs">
<div className="rounded-md border border-border/70 px-3 py-2">
<dt className="text-muted-foreground">{u.managedUpstream}</dt>
<dd className="mt-1 font-semibold">{u.managedCommitsBehind(behind)}</dd>
</div>
<div className="rounded-md border border-border/70 px-3 py-2">
<dt className="text-muted-foreground">{u.managedCandidate}</dt>
<dd className="mt-1 font-semibold">{candidate}</dd>
</div>
<div className="rounded-md border border-border/70 px-3 py-2">
<dt className="text-muted-foreground">{u.managedLocalPatches}</dt>
<dd className="mt-1 font-semibold">{localPatches ?? u.managedUnknown}</dd>
</div>
<div className="rounded-md border border-border/70 px-3 py-2">
<dt className="text-muted-foreground">{u.managedSourceRefs}</dt>
<dd className="mt-1 font-semibold">
{source.sourceRefsRemotelyReachable === true
? u.managedReachable
: source.sourceRefsRemotelyReachable === false
? u.managedNotReachable
: u.managedUnknown}
</dd>
</div>
</dl>

{source.blockers?.length ? (
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{u.managedBlockers}
</p>
<ul className="mt-2 grid gap-1.5 text-xs">
{source.blockers.map(blocker => (
<li className="flex items-start gap-2" key={blocker}>
<AlertCircle className="mt-0.5 size-3.5 shrink-0 text-amber-500" />
<span>{blocker}</span>
</li>
))}
</ul>
</div>
) : null}

{source.nextAction ? (
<p className="rounded-md bg-muted/35 px-3 py-2 text-xs leading-5">
<span className="font-semibold">{u.managedNextAction}: </span>
{source.nextAction}
</p>
) : null}

{source.refreshRequest?.requested ? (
<p className="text-center text-xs text-muted-foreground">{u.managedRefreshRequested}</p>
) : null}
{buildMessage ? <p className="text-center text-xs text-muted-foreground">{buildMessage}</p> : null}

<div className="grid grid-cols-2 gap-2">
<Button disabled={!canCheck} onClick={onCheckNow} variant="secondary">
{checking ? u.checking : u.managedCheckNow}
</Button>
<Button disabled={!canBuild} onClick={onBuildCandidate}>
{building ? u.managedRequestingCandidate : u.managedBuildCandidate}
</Button>
</div>

<p className="text-center text-[11px] leading-4 text-muted-foreground">{u.managedRequestOnlyNotice}</p>
</div>
)
}

function ManualView({ command, message, onDone }: { command: string | null; message?: string; onDone: () => void }) {
const { t } = useI18n()
const u = t.updates
Expand Down
32 changes: 32 additions & 0 deletions apps/desktop/src/global.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,37 @@ export interface DesktopUpdateCommit {
at: number
}

export type DesktopManagedSourceAvailability = 'invalid' | 'missing' | 'ready' | 'stale' | 'unreadable'

export interface DesktopManagedUpdateRefreshRequest {
requested: boolean
error: string | null
}

export interface DesktopManagedSourceUpdate {
availability: DesktopManagedSourceAvailability
stale: boolean
statusError: string | null
runningRelease?: string
runningUpstreamBase?: string
trackedUpstream?: string
upstreamHead?: string
commitsBehind?: number
localPatchCount?: number
lastFetchedAt?: string
generatedAt?: string
ageSeconds?: number
candidateStatus?: 'blocked' | 'building' | 'not_built' | 'passed' | 'ready'
blockers?: string[]
nextAction?: string
sourceWorktreeClean?: boolean
sourceRefsRemotelyReachable?: boolean
canBuildCandidate: boolean
candidateRequestAvailable: boolean
refreshRequestAvailable: boolean
refreshRequest: DesktopManagedUpdateRefreshRequest | null
}

export interface DesktopUpdateStatus {
supported: boolean
updateAvailable?: boolean
Expand All @@ -377,6 +408,7 @@ export interface DesktopUpdateStatus {
commits?: DesktopUpdateCommit[]
dirty?: boolean
fetchedAt?: number
managedSource?: DesktopManagedSourceUpdate
}

export type DesktopUpdateDirtyStrategy = 'abort' | 'stash' | 'force'
Expand Down
Loading
Loading