diff --git a/src/main/hosted-sandbox/hosted-account-ipc.test.ts b/src/main/hosted-sandbox/hosted-account-ipc.test.ts index c2258530261..7e9fa6e3011 100644 --- a/src/main/hosted-sandbox/hosted-account-ipc.test.ts +++ b/src/main/hosted-sandbox/hosted-account-ipc.test.ts @@ -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 () => { diff --git a/src/main/hosted-sandbox/hosted-account-ipc.ts b/src/main/hosted-sandbox/hosted-account-ipc.ts index fcb9c52db80..5c704ce9152 100644 --- a/src/main/hosted-sandbox/hosted-account-ipc.ts +++ b/src/main/hosted-sandbox/hosted-account-ipc.ts @@ -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 ): void { - // Timings move slowly and the Worker caches them for 5 minutes; one read per app session is plenty. - let config: Promise | 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 } | 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 () => { diff --git a/src/renderer/src/app-shell/TitlebarMainStrip.tsx b/src/renderer/src/app-shell/TitlebarMainStrip.tsx index bf9b5576350..4c85673d892 100644 --- a/src/renderer/src/app-shell/TitlebarMainStrip.tsx +++ b/src/renderer/src/app-shell/TitlebarMainStrip.tsx @@ -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 ( - {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 + })} ) diff --git a/src/renderer/src/app-shell/right-sidebar-toggle-build.test.tsx b/src/renderer/src/app-shell/right-sidebar-toggle-build.test.tsx new file mode 100644 index 00000000000..a41c50d28c5 --- /dev/null +++ b/src/renderer/src/app-shell/right-sidebar-toggle-build.test.tsx @@ -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( + + + + ) + 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') +}) diff --git a/src/renderer/src/components/cloud-environment/build-log-model.test.ts b/src/renderer/src/components/cloud-environment/build-log-model.test.ts index a4ca69afa54..5f34ee60e27 100644 --- a/src/renderer/src/components/cloud-environment/build-log-model.test.ts +++ b/src/renderer/src/components/cloud-environment/build-log-model.test.ts @@ -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') }) }) diff --git a/src/renderer/src/components/settings/cloud-api/cloud-api-format.test.ts b/src/renderer/src/components/settings/cloud-api/cloud-api-format.test.ts index 7b46a14fb26..1312784f4ab 100644 --- a/src/renderer/src/components/settings/cloud-api/cloud-api-format.test.ts +++ b/src/renderer/src/components/settings/cloud-api/cloud-api-format.test.ts @@ -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') }) diff --git a/src/renderer/src/components/worktree-creation/VmBuildAgentSignInRow.tsx b/src/renderer/src/components/worktree-creation/VmBuildAgentSignInRow.tsx index 177f18441ff..a108d170c0f 100644 --- a/src/renderer/src/components/worktree-creation/VmBuildAgentSignInRow.tsx +++ b/src/renderer/src/components/worktree-creation/VmBuildAgentSignInRow.tsx @@ -155,7 +155,8 @@ export function VmBuildAgentSignInRowView({
  • {icon} -
    + {/* Sign-in progresses on its own (waiting, code shown, done); screen readers hear each step. */} +
    {t('vmBuildAgentSignIn.title', 'Agent sign-in')} {tag ? · {tag} : null} diff --git a/src/renderer/src/components/worktree-creation/VmBuildFailureActions.tsx b/src/renderer/src/components/worktree-creation/VmBuildFailureActions.tsx index b77b0e63c48..49ce22447c9 100644 --- a/src/renderer/src/components/worktree-creation/VmBuildFailureActions.tsx +++ b/src/renderer/src/components/worktree-creation/VmBuildFailureActions.tsx @@ -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 ? : , + icon: signingIn ? : , onClick: actions.onSignIn, pending: signingIn } @@ -216,7 +216,7 @@ function failureActions( 'auto.components.worktree.creation.VmBuildFailureCard.connectGitHub', 'Connect GitHub' ), - icon: connectingGitHub ? : , + icon: connectingGitHub ? : , onClick: actions.onConnectGitHub, pending: connectingGitHub }, diff --git a/src/renderer/src/components/worktree-creation/VmBuildFailureCard.tsx b/src/renderer/src/components/worktree-creation/VmBuildFailureCard.tsx index fd5245ced1b..80d170a416e 100644 --- a/src/renderer/src/components/worktree-creation/VmBuildFailureCard.tsx +++ b/src/renderer/src/components/worktree-creation/VmBuildFailureCard.tsx @@ -69,7 +69,8 @@ export function VmBuildFailureCard({ const headingRef = React.useRef(null) const cardRef = React.useRef(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 . + React.useLayoutEffect(() => { const card = cardRef.current const sheet = card?.closest('[data-vm-build-sheet]') const active = document.activeElement diff --git a/src/renderer/src/components/worktree-creation/VmBuildSheet.tsx b/src/renderer/src/components/worktree-creation/VmBuildSheet.tsx index a16219c229a..652cb0bdb16 100644 --- a/src/renderer/src/components/worktree-creation/VmBuildSheet.tsx +++ b/src/renderer/src/components/worktree-creation/VmBuildSheet.tsx @@ -173,10 +173,7 @@ export function VmBuildSheet({
    {building ? ( - + ) : state === 'ready' ? ( ) : gated ? ( diff --git a/src/renderer/src/components/worktree-creation/VmBuildStepRow.tsx b/src/renderer/src/components/worktree-creation/VmBuildStepRow.tsx index d3ba56c50b0..a2ed7037ef1 100644 --- a/src/renderer/src/components/worktree-creation/VmBuildStepRow.tsx +++ b/src/renderer/src/components/worktree-creation/VmBuildStepRow.tsx @@ -77,7 +77,7 @@ export function VmBuildStepRow({ ) : null} {step.elapsedMs !== null && step.state !== 'pending' ? ( - + {formatElapsedTime(step.elapsedMs)} ) : null} diff --git a/src/renderer/src/components/worktree-creation/WorktreeCreationPanel.tsx b/src/renderer/src/components/worktree-creation/WorktreeCreationPanel.tsx index 51033961eba..54dfb17f755 100644 --- a/src/renderer/src/components/worktree-creation/WorktreeCreationPanel.tsx +++ b/src/renderer/src/components/worktree-creation/WorktreeCreationPanel.tsx @@ -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 ( diff --git a/src/renderer/src/components/worktree-creation/WorktreeSetupSteps.tsx b/src/renderer/src/components/worktree-creation/WorktreeSetupSteps.tsx index a1575b6951a..48717932fc2 100644 --- a/src/renderer/src/components/worktree-creation/WorktreeSetupSteps.tsx +++ b/src/renderer/src/components/worktree-creation/WorktreeSetupSteps.tsx @@ -32,10 +32,7 @@ export function StepIcon({ state }: { state: WorktreeSetupStep['state'] }): Reac switch (state) { case 'running': return ( - + ) case 'done': return @@ -85,7 +82,7 @@ export function WorktreeSetupSteps({ {step.percent}% ) : null} {step.elapsedMs !== null ? ( - + {formatElapsedTime(step.elapsedMs)} ) : null} @@ -102,7 +99,10 @@ export function WorktreeSetupSteps({ {error !== null ? (
    -

    {error}

    + {/* The failure is the one thing that changed; announce it like the VM sheet's card does. */} +

    + {error} +