Merge pull request #109 from nwparker/cloud/core-ui-finish-2

Build sheet: W10 round-9 follow-ups
This commit is contained in:
Neil
2026-11-09 17:41:28 -08:00
committed by GitHub
25 changed files with 350 additions and 60 deletions
@@ -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 () => {
+19 -8
View File
@@ -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`
}
+11 -4
View File
@@ -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