fix(cloud-create): repository step reads 'Get repository' until the boot plan answers, then Clone/Update in place; no placeholder bar

design-squad via punchlist-lead: live, the skeleton bar was nearly invisible, so while 'Start sandbox ·
Checking your plan…' ran the second step read as an empty, unlabeled row. The row now names itself
neutrally (the N-12 string) until the mode is known, with its empty circle; the 150 ms delay and the
waitedMs/stopped props go.
This commit is contained in:
Neil
2026-11-12 03:53:53 -08:00
parent 981b415e0d
commit 8a307e276c
4 changed files with 21 additions and 109 deletions
@@ -293,11 +293,7 @@ export function VmBuildSheet({
: null
}
timing={timings[step.id] ?? null}
stopped={failure !== null}
t={t}
waitedMs={
steps.find((candidate) => candidate.id === 'provision')?.elapsedMs ?? 0
}
/>
))}
{/* Orca Cloud: agent setup happens after create (the setup page and the workspace's
@@ -1,15 +1,12 @@
// @vitest-environment happy-dom
import { act, cleanup, render } from '@testing-library/react'
import { afterEach, expect, it, vi } from 'vitest'
import { cleanup, render } from '@testing-library/react'
import { afterEach, expect, it } from 'vitest'
import type { Translate } from '@/i18n/use-translate'
import { VmBuildStepRow } from './VmBuildStepRow'
import type { VmBuildStep } from './vm-build-steps'
afterEach(() => {
cleanup()
vi.useRealTimers()
})
afterEach(cleanup)
const t: Translate = (_key, fallback) => fallback
const pendingClone = (cloneMode?: 'clone' | 'update'): VmBuildStep => ({
@@ -22,50 +19,19 @@ const pendingClone = (cloneMode?: 'clone' | 'update'): VmBuildStep => ({
...(cloneMode ? { cloneMode } : {})
})
it('names the repository row once: no label guessed, a placeholder only after 150 ms (PC-4)', () => {
vi.useFakeTimers()
const row = (step: VmBuildStep) => (
<ol>
<VmBuildStepRow step={step} cloud detail={null} timing={null} t={t} />
</ol>
)
const { container, rerender } = render(row(pendingClone()))
// Accessible name only; no visible label, no placeholder yet (the plan usually answers by now).
expect(container.textContent).toContain('Repository')
expect(container.textContent).not.toContain('Clone repository')
expect(container.querySelector('[data-slot="skeleton"]')).toBeNull()
act(() => {
vi.advanceTimersByTime(160)
})
expect(container.querySelector('[data-slot="skeleton"]')).not.toBeNull()
// The plan answers: the one label, never changed afterwards.
rerender(row(pendingClone('update')))
expect(container.querySelector('[data-slot="skeleton"]')).toBeNull()
expect(container.textContent).toContain('Update repository')
})
const row = (step: VmBuildStep) => (
<ol>
<VmBuildStepRow step={step} cloud detail={null} timing={null} t={t} />
</ol>
)
it('names an undecided repository step neutrally once the start has failed (N-12)', () => {
const { container } = render(
<ol>
<VmBuildStepRow step={pendingClone()} cloud detail={null} timing={null} stopped t={t} />
</ol>
)
it('reads "Get repository" until the boot plan answers, then switches in place, no placeholder bar', () => {
const { container, rerender } = render(row(pendingClone()))
expect(container.textContent).toContain('Get repository')
expect(container.querySelector('[data-slot="skeleton"]')).toBeNull()
})
it('shows the placeholder at once when the create has already waited past 150 ms (no blank row)', () => {
const { container } = render(
<ol>
<VmBuildStepRow
step={pendingClone()}
cloud
detail={null}
timing={null}
t={t}
waitedMs={12_000}
/>
</ol>
)
expect(container.querySelector('[data-slot="skeleton"]')).not.toBeNull()
rerender(row(pendingClone('update')))
expect(container.textContent).toContain('Update repository')
expect(container.textContent).not.toContain('Get repository')
rerender(row(pendingClone('clone')))
expect(container.textContent).toContain('Clone repository')
})
@@ -3,7 +3,6 @@ import { CircleSlash } from 'lucide-react'
import { cn } from '@/lib/utils'
import type { Translate } from '@/i18n/use-translate'
import { Progress } from '@/components/ui/progress'
import { Skeleton } from '@/components/ui/skeleton'
import { StepIcon } from './WorktreeSetupSteps'
import type { VmBuildStep } from './vm-build-steps'
import {
@@ -14,47 +13,21 @@ import {
import { formatElapsedTime } from './worktree-creation-clock'
import { stepLabel, stepStateLabel } from './vm-build-sheet-copy'
/** The boot plan usually answers within this; only a slower answer shows the placeholder. */
const PLACEHOLDER_DELAY_MS = 150
/** True once `active` has held for `delayMs` (the row's mode is unknown only once per create). */
function useDelayedFlag(active: boolean, delayMs: number): boolean {
const [due, setDue] = React.useState(false)
React.useEffect(() => {
if (!active) {
return
}
const timer = window.setTimeout(() => setDue(true), delayMs)
return () => window.clearTimeout(timer)
}, [active, delayMs])
return active && due
}
/** One timed row of the build checklist; the failure card, not the row, carries the red. */
export function VmBuildStepRow({
step,
cloud,
detail,
timing,
stopped = false,
t,
waitedMs = 0
t
}: {
step: VmBuildStep
cloud: boolean
/** The build stopped (a failure card shows): an undecided clone/update never will be decided. */
stopped?: boolean
/** The step's measured duration, when there is one. */
timing: VmBuildStepTiming | null
/** What the running step is doing right now, in plain words. */
detail: string | null
t: Translate
/**
* How long the create has been running (the sandbox step's time). Past PLACEHOLDER_DELAY_MS the
* boot plan had its chance to answer, so a still-unknown row shows its placeholder at once: no
* blank row, also not in a fixture or a remount.
*/
waitedMs?: number
}): React.JSX.Element {
const labelId = React.useId()
// A live percent already says how far along the step is.
@@ -65,11 +38,9 @@ export function VmBuildStepRow({
timing?.p95Ms ?? (cloud && step.id === 'provision' ? SLOW_WITHOUT_MEASURE_MS : null)
const slow =
measuring && slowAfterMs !== null && step.elapsedMs !== null && step.elapsedMs > slowAfterMs
// Clone or update is known once the boot plan answers; until then (or for good, if the start
// failed first) the row reads "Get repository" with its empty circle, then switches in place.
const undecided = cloud && step.id === 'clone' && step.state === 'pending' && !step.cloneMode
// N-12: once the start has failed, the mode stays unknown for good: name the step neutrally.
const modeUnknown = undecided && !stopped
const placeholderDelayed = useDelayedFlag(modeUnknown, PLACEHOLDER_DELAY_MS)
const placeholderDue = modeUnknown && (placeholderDelayed || waitedMs >= PLACEHOLDER_DELAY_MS)
return (
<li
@@ -89,29 +60,9 @@ export function VmBuildStepRow({
(step.state === 'pending' || step.skipped) && 'text-muted-foreground'
)}
>
{modeUnknown ? (
// Clone or update is decided in a moment (boot plan): a placeholder, never a guessed label.
// Usually decided within ~150 ms, so the placeholder shows only after that (no blink).
<>
<span className="sr-only">
{t(
'auto.components.worktree.creation.VmBuildSheet.repositoryPending',
'Repository'
)}
</span>
{placeholderDue ? (
<Skeleton className="inline-block h-3 w-28 align-middle" />
) : (
<span aria-hidden className="invisible">
{'\u00a0'}
</span>
)}
</>
) : undecided ? (
t('auto.components.worktree.creation.VmBuildSheet.getRepository', 'Get repository')
) : (
stepLabel(step, cloud, t)
)}
{undecided
? t('auto.components.worktree.creation.VmBuildSheet.getRepository', 'Get repository')
: stepLabel(step, cloud, t)}
</span>
<span className="sr-only">
{step.skipped
-1
View File
@@ -4258,7 +4258,6 @@
"cloneCounting": "Counting objects…",
"updateCloud": "Update repository",
"updateFetching": "Fetching changes…",
"repositoryPending": "Repository",
"getRepository": "Get repository"
},
"VmBuildFailureCard": {