feat(vscode): show cloud sandbox provisioning as a progress card

2 hours ago

Starting a cloud task in the VS Code extension now shows a progress card while the sandbox comes up. It names the repo and branch, and ticks off each setup step as the sandbox reports it. Underneath, it's one new chat message type.

Ask about this presentation

Chapters

  1. 0:00Cloud sandbox setup gets its own progress card
  2. 0:16The old line looked like an assistant reply
  3. 0:34One row, rewritten as each phase lands
  4. 0:54Resume uses the card, then it folds to a header
  5. 1:11A new say type carries a JSON payload
  6. 1:32The coordinator re-emits the row per phase
  7. 1:55The row picks its steps from the phase
  8. 2:16Review caught Thinking showing beside the card
  9. 2:37A full-hook test pins the loader hidden
  10. 2:55A live run, measured from send
  11. 3:16Where to look next
Show transcript

Cloud sandbox setup gets its own progress card

Clinecline/cline
The changeThe wiringThe evidence
Setting up cloud sandbox card: gitscription/test, branch main, with Start sandbox spinning

Shown while a cloud task starts in the VS Code extension

Starting a cloud task in the VS Code extension now shows a progress card while the sandbox comes up. It names the repo and branch, and ticks off each setup step as the sandbox reports it. Underneath, it's one new chat message type.

The old line looked like an assistant reply

Clinecline/cline
The changeThe wiringThe evidence

The old setup line looked like an assistant reply

Before
Chat line: Starting a cloud sandbox for gitscription/test (main)… with Thinking… under it
From the pull request

“It looked like an assistant reply, and the shimmer was misleading because nothing was thinking yet.”

Here's what it replaced. Starting a cloud task used to post a plain chat line, with the Thinking shimmer under it. The author's point was that this read like an assistant reply, and nothing was thinking yet. The sandbox takes about half a minute to start, so that state stayed on screen a while.

One row, rewritten as each phase lands

Clinecline/cline
The changeThe wiringThe evidence

One chat row, rewritten in place as each phase lands

Start sandboxstarting
Clone repositorycloning_repo
Start agentagent_starting

The card is one chat row that the coordinator keeps rewriting. It starts with the phase starting, so the first step spins. When the sandbox reports it's cloning the repo, the same row comes back with the first step checked. Then once more when the agent starts. Each update keeps the row's timestamp, so the webview replaces it in place.

Resume uses the card, then it folds to a header

Clinecline/cline
The changeThe wiringThe evidence

Resuming reuses the card, and it folds to a header once work starts

Waking a suspended sandbox
Resuming cloud sandbox card with one step, Wake up sandbox
After the agent starts
Collapsed card: Cloud sandbox, gitscription/test, main

Resuming a suspended sandbox uses the same card, with a single step, wake up sandbox. Once anything follows the row, the agent starting or an error, the card collapses to its header. The conversation keeps a compact marker with the repo and the branch.

A new say type carries a JSON payload

Clinecline/cline
The changeThe wiringThe evidence

A new message type carries a JSON payload

Coordinator
src/sdk/sdk-cloud-session-coordinator.ts
{ phase, repo, branch } instead of prose
Message type
src/shared/ExtensionMessage.ts
say: "cloud_provisioning", ClineCloudProvisioningInfo
Proto enum
proto/cline/ui.proto, proto-conversions/cline-message.ts
CLOUD_PROVISIONING = 39
Webview row
components/chat/ChatRow.tsx
case "cloud_provisioning" renders CloudProvisioningRow

Here's the path that message takes. The cloud session coordinator now emits a JSON payload with the phase, repo and branch, instead of prose. The shared message types gain a cloud provisioning say value. The proto enum gets value 39, with both conversion entries. And ChatRow hands the message to the new row component.

The coordinator re-emits the row per phase

Clinecline/cline
The changeThe wiringThe evidence

The coordinator emits the same row again for each phase

sdk-cloud-session-coordinator.ts

function cloudProvisioningRow(ts: number, phase: …, repoContext: …): ClineMessage {
  const info: ClineCloudProvisioningInfo = {
    phase,
    repo: repoContext.repoUrl?.replace(/^https:\/\/github\.com\//, ""),
    branch: repoContext.branch,
  }
  return { ts, type: "say", say: "cloud_provisioning", text: JSON.stringify(info), partial: false }
}
if (phase === shownPhase || (phase !== "cloning_repo" && phase !== "agent_starting") || isStale()) return
shownPhase = phase
// Same ts: the progress row is updated in place, not appended.
this.options.messages.appendAndEmit([provisioningRow(phase)], provisioningEvent)

A small helper builds the row. It strips the GitHub prefix from the repo URL and packs the phase, repo and branch together. It returns a say row of the new type, with that as JSON text. When the sandbox reports a phase, the callback skips a repeat, and any phase other than cloning or agent start, then emits the row with the same timestamp.

The row picks its steps from the phase

Clinecline/cline
The changeThe wiringThe evidence

The row takes its steps from the phase

components/cloud/CloudProvisioningRow.tsx

const START_STEPS: Step[] = [
  { phase: "starting", label: "Start sandbox" },
  { phase: "cloning_repo", label: "Clone repository" },
  { phase: "agent_starting", label: "Start agent" },
]
const RESUME_STEPS: Step[] = [{ phase: "resuming", label: "Wake up sandbox" }]
const steps = info.phase === "resuming" ? RESUME_STEPS : START_STEPS
const current = steps.findIndex((step) => step.phase === info.phase)

components/chat/ChatRow.tsx

case "cloud_provisioning":
  return <CloudProvisioningRow
    active={lastModifiedMessage?.ts === message.ts}
    message={message} />
If the JSON won't parse

The row renders a 1 px spacer. “Virtuoso cannot handle zero-height items.”

In the webview, the row parses that payload and picks a step list. Starting a sandbox gets three steps, and resuming gets one. The current phase decides which step spins. ChatRow marks the row active only while it's the last modified message, and an inactive row shows just its header. Unparseable text renders a one pixel spacer.

Review caught Thinking showing beside the card

Clinecline/cline
The changeThe wiringThe evidence

Review caught “Thinking…” rendering next to the card

dominiccooney · review comment · P2

“During this handoff, forceShow is still true and the provisioning message has partial: false, so optimisticShow below overrides this false and renders ‘Thinking…’ alongside the progress card.”

useThinkingLoaderRow.ts, after the fix

+ const cloudProvisioning = lastRawMessage?.type === "say" && lastRawMessage.say === "cloud_provisioning"
  return useDebouncedLoaderVisibility(
-   optimisticShow || isWaitingForResponse || handoffToReasoningPending,
+   !cloudProvisioning && (optimisticShow || isWaitingForResponse || handoffToReasoningPending),

One review comment reshaped the fix. The reviewer noticed that on a new cloud send, the optimistic loader is still forced on until the full state arrives through a 50 millisecond debounce. So Thinking would render right next to the card. The author moved the provisioning check onto the combined condition, and kept the hook call unconditional.

A full-hook test pins the loader hidden

Clinecline/cline
The changeThe wiringThe evidence

A full-hook test keeps the loader hidden while the sandbox provisions

useThinkingLoaderRow.test.tsx · “stays hidden while a cloud sandbox provisions, even before the optimistic send is acknowledged”

const provisioning = say(1, "cloud_provisioning", false, JSON.stringify({ phase: "starting" }))
const idle: TurnState = { phase: "idle", seq: 1 }
const { result, rerender } = renderLoader({ ...inputsFor([provisioning], idle), forceShow: true })
expect(result.current).toBe(false)

rerender({ ...inputsFor([provisioning, say(2, "api_req_started", false)], idle), forceShow: true })
expect(result.current).toBe(true)
On the code before the fix

expected true to be false

Also updated

The coordinator test expects three JSON phases on one timestamp

The new test runs the whole hook. With a provisioning row as the tail, a stale idle turn state, and the optimistic flag forced on, the loader stays hidden. Once an API request starts, it comes back. Against the code before the fix, it fails with expected true to be false.

A live run, measured from send

Clinecline/cline
The changeThe wiringThe evidence

In a live run, the card collapsed at about 35 seconds

Start sandbox, about 30 s
Then start agent, about 5 s
0 s
15 s
30 s
45 s
60 s
Card collapsesabout 35 s
First agent outputabout 60 s

Cloud task on gitscription/test in a VS Code dev host, measured from send. Every card state also rendered in Storybook, dark and light themes.

Then a live run. On a real cloud task, measured from send, the card sat about 30 seconds on start sandbox, and about 5 seconds on start agent. It collapsed at about 35 seconds, and the first agent output arrived at about a minute. Every card state was also rendered in Storybook, in dark and light themes.

Where to look next

Clinecline/cline
The changeThe wiringThe evidence

Checked in Storybook and in one live run, signed in through a local auth shim that isn't committed.

Cloud sandbox setup gets its own progress card

Starting a cloud sandbox for gitscription/test (main)…

Collapsed card: Cloud sandbox, gitscription/test, main
Adding a phase touches

ClineCloudProvisioningInfo["phase"]
START_STEPS in CloudProvisioningRow
the coordinator's phase filter

One limit. The end-to-end check is a single live run, signed in through a local auth shim that isn't committed. It replaces the prose line that read like a reply. If you add a sandbox phase, three places change together: the phase type, the step list in the row, and the coordinator's phase filter. Cloud sandbox setup now has its own progress card.

What happened in AI today, in your inbox

One short email each evening. Read it and you’re caught up.

Built in New York with ❤️