mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 18:37:24 +00:00
Merge pull request #109 from nwparker/cloud/core-ui-finish-2
Build sheet: W10 round-9 follow-ups
This commit is contained in:
@@ -35,6 +35,22 @@ it('opens only a github.com page for Connect GitHub', async () => {
|
||||
expect(openExternal).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('re-reads the config after 5 minutes so a new build sheet sees a fresh median', async () => {
|
||||
vi.useFakeTimers({ now: 0 })
|
||||
try {
|
||||
const config = register('https://github.com/x')
|
||||
await handlers.get(CH.getConfig)?.()
|
||||
vi.setSystemTime(4 * 60_000)
|
||||
await handlers.get(CH.getConfig)?.()
|
||||
expect(config).toHaveBeenCalledTimes(1)
|
||||
vi.setSystemTime(5 * 60_000)
|
||||
await handlers.get(CH.getConfig)?.()
|
||||
expect(config).toHaveBeenCalledTimes(2)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('reads the config once, and again after a failed read', async () => {
|
||||
let fail = true
|
||||
const config = vi.fn(async () => {
|
||||
|
||||
@@ -5,20 +5,31 @@ import type { HostedSandboxConfigResponse } from '../../shared/hosted-sandbox/ho
|
||||
import type { HostedSandboxControlPlaneClient } from './hosted-sandbox-control-plane-client'
|
||||
import { failure } from './hosted-workspace-runtime'
|
||||
|
||||
const CONFIG_TTL_MS = 5 * 60_000
|
||||
|
||||
export const HOSTED_ACCOUNT_CHANNELS = [CH.getConfig, CH.connectGitHub] as const
|
||||
|
||||
export function registerHostedAccountHandlers(
|
||||
client: Pick<HostedSandboxControlPlaneClient, 'config' | 'connectGitHub'>
|
||||
): void {
|
||||
// Timings move slowly and the Worker caches them for 5 minutes; one read per app session is plenty.
|
||||
let config: Promise<HostedSandboxConfigResponse | null> | null = null
|
||||
// The Worker re-measures every 5 minutes; match it so each new build sheet sees a fresh median.
|
||||
let config: { at: number; value: Promise<HostedSandboxConfigResponse | null> } | null = null
|
||||
ipcMain.handle(CH.getConfig, () => {
|
||||
config ??= client.config().catch(() => {
|
||||
// Offline now says nothing about later: ask again next time.
|
||||
config = null
|
||||
return null
|
||||
})
|
||||
return config
|
||||
const now = Date.now()
|
||||
if (!config || now - config.at >= CONFIG_TTL_MS) {
|
||||
const entry = {
|
||||
at: now,
|
||||
value: client.config().catch(() => {
|
||||
// Offline now says nothing about later: ask again next time.
|
||||
if (config === entry) {
|
||||
config = null
|
||||
}
|
||||
return null
|
||||
})
|
||||
}
|
||||
config = entry
|
||||
}
|
||||
return config.value
|
||||
})
|
||||
|
||||
ipcMain.handle(CH.connectGitHub, async () => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { TOGGLE_TERMINAL_PANE_EXPAND_EVENT } from '@/constants/terminal'
|
||||
import { ActivityTitlebarControls } from '../components/activity/ActivityTitlebarControls'
|
||||
import { useShortcutLabel } from '../hooks/useShortcutLabel'
|
||||
import { useAppStore } from '../store'
|
||||
import { selectVmBuildSurfaceShown } from '@/lib/workspace/creation/worktree-creation-surface'
|
||||
import { selectActiveTerminalChromeState } from './active-terminal-chrome-selector'
|
||||
import { hasCustomTitleBar } from './app-window-chrome'
|
||||
import type { AppChromeLayout } from './use-app-chrome-layout'
|
||||
@@ -13,21 +14,29 @@ export function RightSidebarToggle(): React.JSX.Element {
|
||||
const t = useTranslate()
|
||||
const toggleRightSidebar = useAppStore((s) => s.toggleRightSidebar)
|
||||
const rightSidebarShortcutLabel = useShortcutLabel('sidebar.right.toggle')
|
||||
// A build sheet keeps the sidebar closed (it has no workspace to show yet), so say why instead of doing nothing.
|
||||
const building = useAppStore(selectVmBuildSurfaceShown)
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
className="sidebar-toggle mr-2"
|
||||
onClick={toggleRightSidebar}
|
||||
className="sidebar-toggle mr-2 aria-disabled:cursor-not-allowed aria-disabled:opacity-50"
|
||||
onClick={building ? undefined : toggleRightSidebar}
|
||||
aria-disabled={building || undefined}
|
||||
aria-label={t('auto.App.9e0b441a91', 'Toggle right sidebar')}
|
||||
>
|
||||
<PanelRight size={16} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={6}>
|
||||
{t('auto.App.c184e056de', 'Toggle right sidebar ({{value0}})', {
|
||||
value0: rightSidebarShortcutLabel
|
||||
})}
|
||||
{building
|
||||
? t(
|
||||
'auto.app.shell.TitlebarMainStrip.sidebarAfterBuild',
|
||||
'Available when the workspace is ready'
|
||||
)
|
||||
: t('auto.App.c184e056de', 'Toggle right sidebar ({{value0}})', {
|
||||
value0: rightSidebarShortcutLabel
|
||||
})}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, expect, it } from 'vitest'
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
import { useAppStore } from '@/store'
|
||||
import type { PendingWorktreeCreation } from '@/lib/workspace/creation/pending-worktree-creation'
|
||||
import { RightSidebarToggle } from './TitlebarMainStrip'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
function showCloudBuild(): void {
|
||||
useAppStore.setState({
|
||||
activeView: 'terminal',
|
||||
activePendingCreationId: 'c1',
|
||||
rightSidebarOpen: false,
|
||||
pendingWorktreeCreations: {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the toggle reads only the fields set here.
|
||||
c1: {
|
||||
creationId: 'c1',
|
||||
status: 'creating',
|
||||
loaderVisible: true,
|
||||
request: {
|
||||
name: 'fix-login',
|
||||
ephemeralVmRecipe: {
|
||||
sourceRepoId: 'r',
|
||||
recipeId: 'cloud',
|
||||
projectId: 'p',
|
||||
provider: 'orca-cloud'
|
||||
}
|
||||
}
|
||||
} as PendingWorktreeCreation
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
it('explains itself instead of toggling a hidden sidebar during a build, by click or shortcut', () => {
|
||||
showCloudBuild()
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<RightSidebarToggle />
|
||||
</TooltipProvider>
|
||||
)
|
||||
const button = screen.getByRole('button', { name: 'Toggle right sidebar' })
|
||||
expect(button.getAttribute('aria-disabled')).toBe('true')
|
||||
fireEvent.click(button)
|
||||
// The shortcut path calls the store action directly.
|
||||
act(() => useAppStore.getState().toggleRightSidebar())
|
||||
expect(useAppStore.getState().rightSidebarOpen).toBe(false)
|
||||
act(() => {
|
||||
fireEvent.focus(button)
|
||||
})
|
||||
expect(document.body.textContent).toContain('Available when the workspace is ready')
|
||||
})
|
||||
@@ -299,7 +299,7 @@ describe('formatStepDuration', () => {
|
||||
it('formats compactly', () => {
|
||||
expect(formatStepDuration(null)).toBe('')
|
||||
expect(formatStepDuration(4_200)).toBe('4s')
|
||||
expect(formatStepDuration(125_000)).toBe('2m 05s')
|
||||
expect(formatStepDuration(3_725_000)).toBe('62m 05s')
|
||||
expect(formatStepDuration(125_000)).toBe('2m 5s')
|
||||
expect(formatStepDuration(3_725_000)).toBe('1h 2m 5s')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -41,7 +41,7 @@ describe('formatting', () => {
|
||||
expect(formatRunDuration(null)).toBe('—')
|
||||
expect(formatRunDuration(38_000)).toBe('38s')
|
||||
expect(formatRunDuration(252_000)).toBe('4m 12s')
|
||||
expect(formatRunDuration(1_800_000)).toBe('30m 00s')
|
||||
expect(formatRunDuration(1_800_000)).toBe('30m 0s')
|
||||
expect(formatCountdown(582_000)).toBe('9:42')
|
||||
expect(formatCountdown(-5)).toBe('0:00')
|
||||
})
|
||||
|
||||
@@ -155,7 +155,8 @@ export function VmBuildAgentSignInRowView({
|
||||
<li className="flex flex-col gap-2 py-1.5" data-vm-build-agent-sign-in={model.kind}>
|
||||
<div className="flex items-center gap-2.5">
|
||||
{icon}
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
{/* Sign-in progresses on its own (waiting, code shown, done); screen readers hear each step. */}
|
||||
<div className="flex min-w-0 flex-1 flex-col" aria-live="polite" aria-atomic="true">
|
||||
<span className="truncate">
|
||||
{t('vmBuildAgentSignIn.title', 'Agent sign-in')}
|
||||
{tag ? <span className="text-xs text-muted-foreground"> · {tag}</span> : null}
|
||||
|
||||
@@ -129,7 +129,7 @@ function failureActions(
|
||||
label: signingIn
|
||||
? t('auto.components.OrcaSignInProgress.signingIn', 'Signing in…')
|
||||
: t('auto.components.worktree.creation.VmBuildFailureCard.signIn', 'Sign in'),
|
||||
icon: signingIn ? <Loader2 className="motion-safe:animate-spin" /> : <LogIn />,
|
||||
icon: signingIn ? <Loader2 className="animate-spin" /> : <LogIn />,
|
||||
onClick: actions.onSignIn,
|
||||
pending: signingIn
|
||||
}
|
||||
@@ -216,7 +216,7 @@ function failureActions(
|
||||
'auto.components.worktree.creation.VmBuildFailureCard.connectGitHub',
|
||||
'Connect GitHub'
|
||||
),
|
||||
icon: connectingGitHub ? <Loader2 className="motion-safe:animate-spin" /> : <Github />,
|
||||
icon: connectingGitHub ? <Loader2 className="animate-spin" /> : <Github />,
|
||||
onClick: actions.onConnectGitHub,
|
||||
pending: connectingGitHub
|
||||
},
|
||||
|
||||
@@ -69,7 +69,8 @@ export function VmBuildFailureCard({
|
||||
const headingRef = React.useRef<HTMLHeadingElement>(null)
|
||||
const cardRef = React.useRef<HTMLDivElement>(null)
|
||||
// Move focus to the card when it opens, so keyboard and screen-reader users land on the way forward.
|
||||
React.useEffect(() => {
|
||||
// Layout effect: focus lands before paint, so the collapsing card never strands it on <body>.
|
||||
React.useLayoutEffect(() => {
|
||||
const card = cardRef.current
|
||||
const sheet = card?.closest('[data-vm-build-sheet]')
|
||||
const active = document.activeElement
|
||||
|
||||
@@ -173,10 +173,7 @@ export function VmBuildSheet({
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="grid size-4 shrink-0 place-items-center">
|
||||
{building ? (
|
||||
<Loader2
|
||||
aria-hidden
|
||||
className="size-4 text-muted-foreground motion-safe:animate-spin"
|
||||
/>
|
||||
<Loader2 aria-hidden className="size-4 text-muted-foreground animate-spin" />
|
||||
) : state === 'ready' ? (
|
||||
<CircleCheck aria-hidden className="size-4 text-foreground" />
|
||||
) : gated ? (
|
||||
|
||||
@@ -77,7 +77,7 @@ export function VmBuildStepRow({
|
||||
</span>
|
||||
) : null}
|
||||
{step.elapsedMs !== null && step.state !== 'pending' ? (
|
||||
<span className="w-12 text-right text-xs tabular-nums text-muted-foreground">
|
||||
<span className="min-w-12 shrink-0 whitespace-nowrap text-right text-xs tabular-nums text-muted-foreground">
|
||||
{formatElapsedTime(step.elapsedMs)}
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
@@ -9,7 +9,11 @@ import {
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { useTranslate } from '@/i18n/use-translate'
|
||||
import WorktreeCreationTabStrip from './WorktreeCreationTabStrip'
|
||||
import { formatElapsedTime, useWorktreeCreationClock } from './worktree-creation-clock'
|
||||
import {
|
||||
buildClockElapsedMs,
|
||||
formatElapsedTime,
|
||||
useWorktreeCreationClock
|
||||
} from './worktree-creation-clock'
|
||||
import { VmBuildSheet } from './VmBuildSheet'
|
||||
import { VmDeleteConfirmDialog } from './VmDeleteConfirmDialog'
|
||||
|
||||
@@ -25,12 +29,10 @@ export default function WorktreeCreationPanel({
|
||||
const entry = useAppStore((s) => s.pendingWorktreeCreations[creationId])
|
||||
const progressLabelId = React.useId()
|
||||
const [confirmingDelete, setConfirmingDelete] = React.useState(false)
|
||||
const checksFinishedAt =
|
||||
entry?.environmentCheck?.status === 'failed' ? entry.environmentCheck.finishedAt : undefined
|
||||
// Depend on primitives so stderr’s fresh entry objects cannot continuously reset the interval;
|
||||
// a build paused on failed checks waits on the user, so its clock stops.
|
||||
const now = useWorktreeCreationClock(
|
||||
entry?.status === 'creating' && entry.environmentCheck?.status !== 'failed'
|
||||
entry?.status === 'creating' && entry.waitingSince === undefined
|
||||
)
|
||||
if (!entry) {
|
||||
return null
|
||||
@@ -46,8 +48,7 @@ export default function WorktreeCreationPanel({
|
||||
// A VM build keeps its sheet through fetch and checkout, so it never drops to the one-line status.
|
||||
const isVmCreation = Boolean(entry.request.ephemeralVmRecipe) || entry.phase === 'provisioning-vm'
|
||||
const title = entry.request.displayName || entry.request.name
|
||||
const stoppedAt = checksFinishedAt ?? (isError ? entry.errorAt : undefined)
|
||||
const elapsedLabel = formatElapsedTime((stoppedAt ?? now) - entry.startedAt)
|
||||
const elapsedLabel = formatElapsedTime(buildClockElapsedMs(entry, now))
|
||||
const checkoutProgress = getVisibleCheckoutProgress(entry)
|
||||
|
||||
return (
|
||||
|
||||
@@ -32,10 +32,7 @@ export function StepIcon({ state }: { state: WorktreeSetupStep['state'] }): Reac
|
||||
switch (state) {
|
||||
case 'running':
|
||||
return (
|
||||
<Loader2
|
||||
aria-hidden
|
||||
className="size-3.5 shrink-0 text-muted-foreground motion-safe:animate-spin"
|
||||
/>
|
||||
<Loader2 aria-hidden className="size-3.5 shrink-0 text-muted-foreground animate-spin" />
|
||||
)
|
||||
case 'done':
|
||||
return <CircleCheck aria-hidden className="size-3.5 shrink-0 text-foreground" />
|
||||
@@ -85,7 +82,7 @@ export function WorktreeSetupSteps({
|
||||
<span className="text-xs tabular-nums text-muted-foreground">{step.percent}%</span>
|
||||
) : null}
|
||||
{step.elapsedMs !== null ? (
|
||||
<span className="w-12 text-right text-xs tabular-nums text-muted-foreground">
|
||||
<span className="min-w-12 shrink-0 whitespace-nowrap text-right text-xs tabular-nums text-muted-foreground">
|
||||
{formatElapsedTime(step.elapsedMs)}
|
||||
</span>
|
||||
) : null}
|
||||
@@ -102,7 +99,10 @@ export function WorktreeSetupSteps({
|
||||
</ol>
|
||||
{error !== null ? (
|
||||
<div className="mt-1 flex flex-col gap-2 border-t border-border pt-2">
|
||||
<p className="break-words text-xs text-muted-foreground">{error}</p>
|
||||
{/* The failure is the one thing that changed; announce it like the VM sheet's card does. */}
|
||||
<p role="alert" className="break-words text-xs text-foreground">
|
||||
{error}
|
||||
</p>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button type="button" variant="outline" size="xs" onClick={onRetry}>
|
||||
<RotateCcw />
|
||||
|
||||
@@ -45,8 +45,8 @@ function phaseLabel(phase: string, t: Translate): string | null {
|
||||
)
|
||||
case 'rolling-over':
|
||||
return t(
|
||||
'auto.components.worktree.creation.orcaCloudBuildLog.rollingOver',
|
||||
'Starting a fresh session…'
|
||||
'auto.components.worktree.creation.orcaCloudBuildLog.rollingOver2',
|
||||
'Restarting the sandbox…'
|
||||
)
|
||||
case 'destroying':
|
||||
return t(
|
||||
|
||||
@@ -98,8 +98,8 @@ export function vmBuildFailureCopy(
|
||||
'Your team has reached this month’s spend cap. Raise it to keep starting sandboxes. Nothing was created, and you weren’t charged.'
|
||||
)
|
||||
: t(
|
||||
'auto.components.worktree.creation.VmBuildFailureCard.spendCapReachedBodyMember',
|
||||
'Your team has reached this month’s spend cap. Ask an owner or admin to add hours. Nothing was created, and you weren’t charged.'
|
||||
'auto.components.worktree.creation.VmBuildFailureCard.spendCapReachedBodyMember2',
|
||||
'Your team has reached this month’s spend cap. Ask an owner or admin to raise the spend cap. Nothing was created, and you weren’t charged.'
|
||||
)
|
||||
}
|
||||
case 'payment':
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { cleanup, renderHook, waitFor } from '@testing-library/react'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { useVmBuildStepTimings } from './vm-build-timing'
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('asks for the measured timing on every new build sheet, never for VM recipes', async () => {
|
||||
const getConfig = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
idleSleepMinutes: 15,
|
||||
createTiming: { p50Ms: 3000, p95Ms: 9000, samples: 40 }
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
idleSleepMinutes: 15,
|
||||
createTiming: { p50Ms: 5000, p95Ms: 9000, samples: 41 }
|
||||
})
|
||||
vi.stubGlobal('window', Object.assign(window, { api: { orcaCloudWorkspace: { getConfig } } }))
|
||||
|
||||
const first = renderHook(() => useVmBuildStepTimings(true))
|
||||
await waitFor(() => expect(first.result.current.provision?.p50Ms).toBe(3000))
|
||||
first.unmount()
|
||||
|
||||
const second = renderHook(() => useVmBuildStepTimings(true))
|
||||
// The last answer shows at once, then the fresh one replaces it.
|
||||
expect(second.result.current.provision?.p50Ms).toBe(3000)
|
||||
await waitFor(() => expect(second.result.current.provision?.p50Ms).toBe(5000))
|
||||
expect(getConfig).toHaveBeenCalledTimes(2)
|
||||
|
||||
renderHook(() => useVmBuildStepTimings(false))
|
||||
expect(getConfig).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
@@ -9,8 +9,8 @@ export type VmBuildStepTimings = Partial<Record<VmBuildStepId, VmBuildStepTiming
|
||||
|
||||
const NONE: VmBuildStepTimings = {}
|
||||
|
||||
// One read per window: the control plane measures recent sandbox starts (GET /v1/config) and
|
||||
// main caches the answer, so every build sheet shows the same median.
|
||||
// Each new build sheet asks again: the control plane re-measures recent sandbox starts
|
||||
// (GET /v1/config) and main caches the answer for 5 minutes. The last answer shows meanwhile.
|
||||
let measured: VmBuildStepTimings | null = null
|
||||
let pending: Promise<VmBuildStepTimings> | null = null
|
||||
|
||||
@@ -20,15 +20,16 @@ function loadMeasuredTimings(): Promise<VmBuildStepTimings> {
|
||||
if (!config) {
|
||||
// Unreachable now says nothing about later: the next sheet asks again.
|
||||
pending = null
|
||||
return NONE
|
||||
return measured ?? NONE
|
||||
}
|
||||
// Only "Start sandbox" is measured. Without a measurement there is no hint, never a guess.
|
||||
measured = config.createTiming ? { provision: config.createTiming } : NONE
|
||||
pending = null
|
||||
return measured
|
||||
})
|
||||
.catch(() => {
|
||||
pending = null
|
||||
return NONE
|
||||
return measured ?? NONE
|
||||
})
|
||||
return pending
|
||||
}
|
||||
@@ -39,7 +40,7 @@ export function useVmBuildStepTimings(cloud: boolean): VmBuildStepTimings {
|
||||
cloud ? (measured ?? NONE) : NONE
|
||||
)
|
||||
React.useEffect(() => {
|
||||
if (!cloud || measured) {
|
||||
if (!cloud) {
|
||||
return
|
||||
}
|
||||
let live = true
|
||||
@@ -52,7 +53,7 @@ export function useVmBuildStepTimings(cloud: boolean): VmBuildStepTimings {
|
||||
live = false
|
||||
}
|
||||
}, [cloud])
|
||||
return cloud ? (measured ?? timings) : NONE
|
||||
return cloud ? timings : NONE
|
||||
}
|
||||
|
||||
/** The hint in whole seconds, never "~0s". */
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@/i18n/i18n', () => ({ getIntlLocale: () => 'en' }))
|
||||
|
||||
import { buildClockElapsedMs, formatElapsedTime } from './worktree-creation-clock'
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('formatElapsedTime', () => {
|
||||
it('formats with Intl in the given locale and keeps minutes once past an hour', () => {
|
||||
expect(formatElapsedTime(5_000, 'en')).toBe('5s')
|
||||
expect(formatElapsedTime(125_000, 'en')).toBe('2m 5s')
|
||||
expect(formatElapsedTime(3_605_000, 'en')).toBe('1h 0m 5s')
|
||||
expect(formatElapsedTime(125_000, 'de')).toBe('2 Min., 5 Sek.')
|
||||
})
|
||||
|
||||
it('falls back to the same English shape where Intl.DurationFormat is missing', () => {
|
||||
vi.stubGlobal('Intl', { ...Intl, DurationFormat: undefined })
|
||||
// A locale no earlier call cached, so the stub decides.
|
||||
expect(formatElapsedTime(5_000, 'en-x-fallback')).toBe('5s')
|
||||
expect(formatElapsedTime(125_000, 'en-x-fallback')).toBe('2m 5s')
|
||||
expect(formatElapsedTime(3_605_000, 'en-x-fallback')).toBe('1h 0m 5s')
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildClockElapsedMs', () => {
|
||||
const T0 = 1_000_000
|
||||
const base = { startedAt: T0, status: 'creating' as const }
|
||||
|
||||
it('stops while failed checks wait, resumes where it stopped, and starts over on Try again', () => {
|
||||
// Checks fail 10s in; the user waits 50s.
|
||||
const waiting = { ...base, waitingSince: T0 + 10_000 }
|
||||
expect(buildClockElapsedMs(waiting, T0 + 60_000)).toBe(10_000)
|
||||
// Retry checks at 60s: the wait is folded away.
|
||||
const resumed = { ...base, pausedMs: 50_000, resumedAt: T0 + 60_000 }
|
||||
// A stale tick from before the resume never reads below the frozen value.
|
||||
expect(buildClockElapsedMs(resumed, T0 + 30_000)).toBe(10_000)
|
||||
expect(buildClockElapsedMs(resumed, T0 + 65_000)).toBe(15_000)
|
||||
// Checks fail again 5s later; another 100s wait stays frozen at 15s.
|
||||
const waitingAgain = { ...resumed, waitingSince: T0 + 65_000 }
|
||||
expect(buildClockElapsedMs(waitingAgain, T0 + 165_000)).toBe(15_000)
|
||||
// Try again starts a new run with its own clock.
|
||||
const retried = { startedAt: T0 + 200_000, status: 'creating' as const }
|
||||
expect(buildClockElapsedMs(retried, T0 + 203_000)).toBe(3_000)
|
||||
})
|
||||
|
||||
it('stops at an error', () => {
|
||||
expect(
|
||||
buildClockElapsedMs({ ...base, status: 'error', errorAt: T0 + 7_000 }, T0 + 99_000)
|
||||
).toBe(7_000)
|
||||
})
|
||||
})
|
||||
@@ -1,3 +1,5 @@
|
||||
import { getIntlLocale } from '@/i18n/i18n'
|
||||
import type { PendingWorktreeCreation } from '@/lib/workspace/creation/pending-worktree-creation'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { installWindowVisibilityInterval } from '@/lib/window-visibility-interval'
|
||||
|
||||
@@ -8,17 +10,80 @@ export function useWorktreeCreationClock(running: boolean): number {
|
||||
if (!running) {
|
||||
return
|
||||
}
|
||||
// A resumed clock reads the time at once rather than a tick later.
|
||||
setNow(Date.now())
|
||||
return installWindowVisibilityInterval({ run: () => setNow(Date.now()), intervalMs: 1000 })
|
||||
}, [running])
|
||||
return now
|
||||
}
|
||||
|
||||
export function formatElapsedTime(elapsedMs: number): string {
|
||||
/**
|
||||
* The build's own time: stopped while failed checks wait on the user (and at an error), with the
|
||||
* waits left out, and never reading earlier than the last resume, so a stale tick cannot flicker back.
|
||||
*/
|
||||
export function buildClockElapsedMs(
|
||||
entry: Pick<
|
||||
PendingWorktreeCreation,
|
||||
'startedAt' | 'status' | 'errorAt' | 'waitingSince' | 'pausedMs' | 'resumedAt'
|
||||
>,
|
||||
now: number
|
||||
): number {
|
||||
const end =
|
||||
entry.waitingSince ??
|
||||
(entry.status === 'error' ? entry.errorAt : undefined) ??
|
||||
Math.max(now, entry.resumedAt ?? 0)
|
||||
return Math.max(0, end - entry.startedAt - (entry.pausedMs ?? 0))
|
||||
}
|
||||
|
||||
type DurationFormatter = { format: (duration: Record<string, number>) => string }
|
||||
type DurationFormatOptions = {
|
||||
style: 'narrow'
|
||||
secondsDisplay: 'always'
|
||||
minutesDisplay: 'always' | 'auto'
|
||||
}
|
||||
type DurationFormatConstructor = new (
|
||||
locale: string,
|
||||
options: DurationFormatOptions
|
||||
) => DurationFormatter
|
||||
|
||||
const durationFormatters = new Map<string, DurationFormatter | null>()
|
||||
|
||||
// Intl.DurationFormat localizes units ("1m 5s", "1 Min., 5 Sek."); runtimes without it get the same
|
||||
// English shape. Past an hour the minutes always show ("1h 0m 5s"), so a glance never misreads it.
|
||||
function durationFormatter(locale: string, withHours: boolean): DurationFormatter | null {
|
||||
const key = `${locale}|${withHours}`
|
||||
if (!durationFormatters.has(key)) {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the TS lib lacks Intl.DurationFormat; it is read as optional and only constructed when present.
|
||||
const DurationFormat = (Intl as unknown as { DurationFormat?: DurationFormatConstructor })
|
||||
.DurationFormat
|
||||
let formatter: DurationFormatter | null = null
|
||||
try {
|
||||
formatter = DurationFormat
|
||||
? new DurationFormat(locale, {
|
||||
style: 'narrow',
|
||||
secondsDisplay: 'always',
|
||||
minutesDisplay: withHours ? 'always' : 'auto'
|
||||
})
|
||||
: null
|
||||
} catch {
|
||||
// An unsupported locale tag falls back to the English shape below.
|
||||
}
|
||||
durationFormatters.set(key, formatter)
|
||||
}
|
||||
return durationFormatters.get(key) ?? null
|
||||
}
|
||||
|
||||
export function formatElapsedTime(elapsedMs: number, locale: string = getIntlLocale()): string {
|
||||
const totalSeconds = Math.max(0, Math.floor(elapsedMs / 1000))
|
||||
const minutes = Math.floor(totalSeconds / 60)
|
||||
const hours = Math.floor(totalSeconds / 3600)
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60)
|
||||
const seconds = totalSeconds % 60
|
||||
if (minutes === 0) {
|
||||
return `${seconds}s`
|
||||
const formatter = durationFormatter(locale, hours > 0)
|
||||
if (formatter) {
|
||||
return formatter.format({ hours, minutes, seconds })
|
||||
}
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes}m ${seconds}s`
|
||||
}
|
||||
return `${minutes}m ${seconds.toString().padStart(2, '0')}s`
|
||||
return minutes > 0 ? `${minutes}m ${seconds}s` : `${seconds}s`
|
||||
}
|
||||
|
||||
@@ -4102,7 +4102,6 @@
|
||||
"outOfHoursBodyMember": "Your team has used this month’s sandbox hours. Ask an owner or admin to add hours. Nothing was created, and you weren’t charged.",
|
||||
"spendCapReachedTitle": "Spend cap reached",
|
||||
"spendCapReachedBody": "Your team has reached this month’s spend cap. Raise it to keep starting sandboxes. Nothing was created, and you weren’t charged.",
|
||||
"spendCapReachedBodyMember": "Your team has reached this month’s spend cap. Ask an owner or admin to add hours. Nothing was created, and you weren’t charged.",
|
||||
"paymentFailedTitle": "Payment failed",
|
||||
"paymentFailedBody": "New sandboxes can’t start until the payment goes through. Nothing was created, and you weren’t charged.",
|
||||
"paymentFailedBodyMember": "New sandboxes can’t start until the payment goes through. Ask an owner or admin to update the payment method. Nothing was created, and you weren’t charged.",
|
||||
@@ -4120,7 +4119,8 @@
|
||||
"snapshotBody3": "The sandbox starts from a saved copy of your environment, and that copy couldn’t be found. Nothing was created, and you weren’t charged. Try again, or contact support if it keeps happening.",
|
||||
"connectGitHubFailed": "Couldn’t open GitHub. Try again in a moment.",
|
||||
"noGithubRemoteBody2": "Orca Cloud clones your code from GitHub, and this repository has no GitHub remote. Push it to GitHub, then try again. Nothing was created, and you weren’t charged.",
|
||||
"howToPushToGitHub": "How to push to GitHub"
|
||||
"howToPushToGitHub": "How to push to GitHub",
|
||||
"spendCapReachedBodyMember2": "Your team has reached this month’s spend cap. Ask an owner or admin to raise the spend cap. Nothing was created, and you weren’t charged."
|
||||
},
|
||||
"VmBuildLog": {
|
||||
"jumpToError": "Jump to first error",
|
||||
@@ -4139,7 +4139,6 @@
|
||||
"connecting": "Connecting to the sandbox…",
|
||||
"resuming": "Waking the sandbox…",
|
||||
"suspending": "Putting the sandbox to sleep…",
|
||||
"rollingOver": "Starting a fresh session…",
|
||||
"destroying": "Deleting the sandbox…",
|
||||
"denyNoPlan": "Your account has no active Team plan.",
|
||||
"denySpendCap": "This month’s spend cap is reached.",
|
||||
@@ -4157,7 +4156,8 @@
|
||||
"denyConcurrency2": "You’re at your team’s limit of running sandboxes.",
|
||||
"denyMemberConcurrency2": "You’re at your limit of running sandboxes.",
|
||||
"denyMachineSize2": "This sandbox size isn’t part of your plan.",
|
||||
"refusedProvider2": "Orca Cloud is unavailable right now."
|
||||
"refusedProvider2": "Orca Cloud is unavailable right now.",
|
||||
"rollingOver2": "Restarting the sandbox…"
|
||||
},
|
||||
"environmentCheckCopy": {
|
||||
"gitMissing": "git isn’t installed.",
|
||||
@@ -20914,6 +20914,13 @@
|
||||
"title": "Orca refused this browser",
|
||||
"retry": "Try again"
|
||||
}
|
||||
},
|
||||
"app": {
|
||||
"shell": {
|
||||
"TitlebarMainStrip": {
|
||||
"sidebarAfterBuild": "Available when the workspace is ready"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
|
||||
@@ -70,6 +70,20 @@ async function runVmEnvironmentCheck(
|
||||
* build sheet; it resolves true to continue (passed, unsupported, or "Open anyway") and false to cancel.
|
||||
* Nothing here tears the VM down: only cancelling does, so a degraded open keeps the VM that provisioned.
|
||||
*/
|
||||
/** The user decided: fold the wait into pausedMs so the clock resumes where it stopped. */
|
||||
export function resumeBuildClock(creationId: string, now = Date.now()): void {
|
||||
const state = useAppStore.getState()
|
||||
const entry = state.pendingWorktreeCreations[creationId]
|
||||
if (entry?.waitingSince === undefined) {
|
||||
return
|
||||
}
|
||||
state.updatePendingWorktreeCreation(creationId, {
|
||||
pausedMs: (entry.pausedMs ?? 0) + Math.max(0, now - entry.waitingSince),
|
||||
waitingSince: undefined,
|
||||
resumedAt: now
|
||||
})
|
||||
}
|
||||
|
||||
export async function gateVmCreateOnEnvironmentCheck(
|
||||
creationId: string,
|
||||
environmentId: string,
|
||||
@@ -105,13 +119,15 @@ export async function gateVmCreateOnEnvironmentCheck(
|
||||
})
|
||||
return true
|
||||
}
|
||||
const failedAt = Date.now()
|
||||
update(creationId, {
|
||||
environmentCheck: {
|
||||
status: 'failed',
|
||||
checks: result.checks,
|
||||
startedAt,
|
||||
finishedAt: Date.now()
|
||||
}
|
||||
finishedAt: failedAt
|
||||
},
|
||||
waitingSince: failedAt
|
||||
})
|
||||
if (useAppStore.getState().activePendingCreationId !== creationId) {
|
||||
// The paused build keeps its VM running, so a background create must say it needs the user.
|
||||
@@ -126,6 +142,7 @@ export async function gateVmCreateOnEnvironmentCheck(
|
||||
if (decision === 'cancel' || !isPending()) {
|
||||
return false
|
||||
}
|
||||
resumeBuildClock(creationId)
|
||||
if (decision === 'open-anyway') {
|
||||
toast.warning(
|
||||
translate(
|
||||
|
||||
@@ -176,6 +176,12 @@ export type PendingWorktreeCreation = {
|
||||
provisioningLogStamps?: readonly number[]
|
||||
/** VM creates: the provisioned VM's environment checks, run before the workspace is created. */
|
||||
environmentCheck?: PendingVmEnvironmentCheck
|
||||
/** VM creates: since when failed checks have waited on the user; the build clock stops meanwhile. */
|
||||
waitingSince?: number
|
||||
/** VM creates: time already spent waiting on the user this run, left out of the build clock. */
|
||||
pausedMs?: number
|
||||
/** VM creates: when the user last resumed the build; the clock never reads earlier than this. */
|
||||
resumedAt?: number
|
||||
/** VM creates: the provisioned VM's runtime environment, known once it answers. */
|
||||
vmEnvironmentId?: string
|
||||
// Local git checkout meter; absent when git emits none or creation runs over SSH/runtime.
|
||||
|
||||
@@ -198,6 +198,9 @@ export function retryBackgroundWorktreeCreation(creationId: string): void {
|
||||
provisioningLog: undefined,
|
||||
provisioningLogStamps: undefined,
|
||||
environmentCheck: undefined,
|
||||
waitingSince: undefined,
|
||||
pausedMs: undefined,
|
||||
resumedAt: undefined,
|
||||
checkoutProgress: undefined,
|
||||
worktreeId: undefined
|
||||
})
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { selectVmBuildSurfaceShown } from '@/lib/workspace/creation/worktree-creation-surface'
|
||||
import type { EditorGet, EditorSet } from '../editor/types/editor-set-get'
|
||||
import type { ActivityBarPosition } from '../editor/types/open-file'
|
||||
import type {
|
||||
@@ -35,7 +36,7 @@ export type RightSidebarState = {
|
||||
setActivityBarPosition: (position: ActivityBarPosition) => void
|
||||
}
|
||||
|
||||
export function createRightSidebarState(set: EditorSet, _get: EditorGet): RightSidebarState {
|
||||
export function createRightSidebarState(set: EditorSet, get: EditorGet): RightSidebarState {
|
||||
return {
|
||||
rightSidebarOpen: false,
|
||||
rightSidebarWidth: 280,
|
||||
@@ -47,7 +48,13 @@ export function createRightSidebarState(set: EditorSet, _get: EditorGet): RightS
|
||||
rightSidebarExplorerViewByWorktree: {},
|
||||
rightSidebarEffectiveTab: null,
|
||||
activityBarPosition: 'top',
|
||||
toggleRightSidebar: () => set((s) => ({ rightSidebarOpen: !s.rightSidebarOpen })),
|
||||
toggleRightSidebar: () => {
|
||||
// A build sheet keeps the sidebar closed; flipping the hidden preference would surprise later.
|
||||
if (selectVmBuildSurfaceShown(get())) {
|
||||
return
|
||||
}
|
||||
set((s) => ({ rightSidebarOpen: !s.rightSidebarOpen }))
|
||||
},
|
||||
setRightSidebarOpen: (open) => set({ rightSidebarOpen: open }),
|
||||
setRightSidebarWidth: (width) => set({ rightSidebarWidth: width }),
|
||||
setRightSidebarTab: (tab) =>
|
||||
|
||||
@@ -182,6 +182,9 @@ export type WorktreeSlice = {
|
||||
provisioningLog?: string
|
||||
provisioningLogStamps?: PendingWorktreeCreation['provisioningLogStamps']
|
||||
environmentCheck?: PendingWorktreeCreation['environmentCheck']
|
||||
waitingSince?: number
|
||||
pausedMs?: number
|
||||
resumedAt?: number
|
||||
vmEnvironmentId?: string
|
||||
checkoutProgress?: PendingWorktreeCreation['checkoutProgress']
|
||||
worktreeId?: string
|
||||
|
||||
Reference in New Issue
Block a user