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

Replace the plain 'Starting a cloud sandbox for owner/repo (branch)…' text row
with a dedicated cloud_provisioning row: a card with the repo and branch and a
step list (start sandbox, clone repository, start agent) that advances in
place. Resuming a suspended sandbox uses the same card. The card collapses to
its header once the agent starts, and the Thinking loader no longer shows
underneath it while the sandbox provisions.
This commit is contained in:
Saoud Rizwan
2026-10-16 16:45:58 +00:00
parent 01b8a9724d
commit 185628d5a8
10 changed files with 146 additions and 32 deletions
+1
View File
@@ -77,6 +77,7 @@ enum ClineSay {
SUBAGENT_USAGE = 36;
COMPACTION = 37;
PLAN_COMPLETION_RESULT = 38;
CLOUD_PROVISIONING = 39;
}
// Enum for ClineSayTool tool types
@@ -190,12 +190,10 @@ describe("SdkCloudSessionCoordinator ownership", () => {
const rows = vi
.mocked(options.messages.appendAndEmit)
.mock.calls.flatMap(([messages]) => messages.filter((message) => message.say === "text"))
expect(rows.map((row) => row.text)).toEqual([
"Starting a cloud sandbox for cline/fixture (main)…",
"Cloning cline/fixture (main) into the cloud sandbox…",
"Starting the agent in the cloud sandbox…",
])
.mock.calls.flatMap(([messages]) => messages.filter((message) => message.say === "cloud_provisioning"))
expect(rows.map((row) => JSON.parse(row.text!))).toEqual(
["starting", "cloning_repo", "agent_starting"].map((phase) => ({ phase, repo: "cline/fixture", branch: "main" })),
)
expect(new Set(rows.map((row) => row.ts)).size).toBe(1)
await coordinator.dispose()
})
@@ -29,7 +29,7 @@ import {
isPersistedCloudSessionId,
type RememberedCloudStatuses,
} from "@shared/cloud/cloud-sessions"
import type { ClineMessage, TurnPhase } from "@shared/ExtensionMessage"
import type { ClineCloudProvisioningInfo, ClineMessage, TurnPhase } from "@shared/ExtensionMessage"
import type { HistoryItem } from "@shared/HistoryItem"
import { ShowMessageType } from "@shared/proto/host/window"
import {
@@ -109,15 +109,17 @@ const STATUS_RESOLUTION_RETRY_MS = 30_000
const STATUS_RESOLUTION_CONCURRENCY = 4
const RECOMMENDED_MODELS_RETRY_MS = 30_000
function provisioningPhaseText(phase: CloudProvisioningPhase, sandboxLabel: string): string | undefined {
switch (phase) {
case "cloning_repo":
return `Cloning ${sandboxLabel} into the cloud sandbox…`
case "agent_starting":
return "Starting the agent in the cloud sandbox…"
default:
return undefined
function cloudProvisioningRow(
ts: number,
phase: ClineCloudProvisioningInfo["phase"],
repoContext: { repoUrl?: string; branch?: string },
): 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 }
}
function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T | undefined> {
@@ -1085,15 +1087,7 @@ export class SdkCloudSessionCoordinator {
return true
}
const title = input.prompt.trim().split("\n")[0]?.trim().slice(0, 120) || input.prompt.trim()
const repoLabel = input.repoUrl.replace(/^https:\/\/github\.com\//, "")
const sandboxLabel = `${repoLabel}${input.branch ? ` (${input.branch})` : ""}`
const provisioningRow = (text: string): ClineMessage => ({
ts: startedAt + 1,
type: "say",
say: "text",
text,
partial: false,
})
const provisioningRow = (phase: ClineCloudProvisioningInfo["phase"]) => cloudProvisioningRow(startedAt + 1, phase, input)
const provisioningEvent = { type: "status", payload: { sessionId: provisionalId, status: "running" } } as const
this.options.messages.appendAndEmit(
@@ -1106,7 +1100,7 @@ export class SdkCloudSessionCoordinator {
...(input.images?.length ? { images: input.images } : {}),
partial: false,
},
provisioningRow(`Starting a cloud sandbox for ${sandboxLabel}…`),
provisioningRow("starting"),
],
provisioningEvent,
)
@@ -1131,11 +1125,10 @@ export class SdkCloudSessionCoordinator {
},
cancelSignal,
(phase) => {
const text = phase === shownPhase ? undefined : provisioningPhaseText(phase, sandboxLabel)
if (!text || isStale()) return
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(text)], provisioningEvent)
this.options.messages.appendAndEmit([provisioningRow(phase)], provisioningEvent)
},
)
sessionId = record.id
@@ -1428,7 +1421,7 @@ export class SdkCloudSessionCoordinator {
const startedAt = Date.now()
this.installTask(suspended.id).messageStateHandler.addMessages([
{ ts: startedAt, type: "say", say: "task", text: title, partial: false },
{ ts: startedAt + 1, type: "say", say: "text", text: "Resuming the cloud sandbox…", partial: false },
cloudProvisioningRow(startedAt + 1, "resuming", suspended.repoContext),
])
await this.options.postStateToWebview()
// Opened from History, the chat view (and this notice) stays covered until
@@ -277,6 +277,7 @@ export type ClineSay =
| "subagent_usage"
| "conditional_rules_applied"
| "compaction" // context compaction progress/result divider
| "cloud_provisioning" // cloud sandbox start/resume progress
export interface ClineSayTool {
tool:
@@ -404,6 +405,16 @@ export interface ClineCompactionInfo {
messagesAfter?: number
}
/**
* JSON payload of a say:"cloud_provisioning" message. The row is updated in
* place (same ts) as the sandbox advances through its phases.
*/
export interface ClineCloudProvisioningInfo {
phase: "starting" | "cloning_repo" | "agent_starting" | "resuming"
repo?: string
branch?: string
}
export interface ClineSubagentUsageInfo {
source: "subagents"
tokensIn: number
@@ -107,6 +107,7 @@ function convertClineSayToProtoEnum(say: AppClineSay | undefined): ClineSay | un
use_subagents: ClineSay.USE_SUBAGENTS_SAY,
subagent_usage: ClineSay.SUBAGENT_USAGE,
compaction: ClineSay.COMPACTION,
cloud_provisioning: ClineSay.CLOUD_PROVISIONING,
}
const result = mapping[say]
@@ -156,6 +157,7 @@ function convertProtoEnumToClineSay(say: ClineSay): AppClineSay | undefined {
[ClineSay.USE_SUBAGENTS_SAY]: "use_subagents",
[ClineSay.SUBAGENT_USAGE]: "subagent_usage",
[ClineSay.COMPACTION]: "compaction",
[ClineSay.CLOUD_PROVISIONING]: "cloud_provisioning",
}
return mapping[say]
@@ -118,8 +118,8 @@ describe("resuming a suspended cloud sandbox through the real Hub", () => {
resetMessageTranslator: vi.fn(() => renders.push("new epoch")),
setTurnPhase: vi.fn(),
postStateToWebview: vi.fn(async () => {
const text = task?.messageStateHandler.getClineMessages().at(-1)?.text
if (text !== undefined) renders.push(`post: ${text}`)
const say = task?.messageStateHandler.getClineMessages().at(-1)?.say
if (say !== undefined) renders.push(`post: ${say}`)
}),
showChatView: vi.fn(async () => renders.push("chat view shown")),
invalidateHistoryCache: vi.fn(),
@@ -140,7 +140,7 @@ describe("resuming a suspended cloud sandbox through the real Hub", () => {
await coordinator.openCloudTask(record.id)
// The transcript starts a new epoch after the notice, so the webview replaces the notice.
const noticePosted = renders.indexOf("post: Resuming the cloud sandbox…")
const noticePosted = renders.indexOf("post: cloud_provisioning")
expect(noticePosted).toBeGreaterThan(-1)
// The chat view is brought forward while the notice is showing, before the conversation replaces it.
expect(renders.indexOf("chat view shown")).toBeGreaterThan(noticePosted)
@@ -37,6 +37,7 @@ import { useSize } from "react-use"
import { isOptionsAskActive } from "@/components/chat/chat-view/shared/buttonConfig"
import { canRestoreWorkspaceFromMessage } from "@/components/chat/chat-view/utils/messageUtils"
import { OptionsButtons } from "@/components/chat/OptionsButtons"
import { CloudProvisioningRow } from "@/components/cloud/CloudProvisioningRow"
import { WithCopyButton } from "@/components/common/CopyButton"
import Thumbnails from "@/components/common/Thumbnails"
import McpResponseDisplay from "@/components/mcp/chat-display/McpResponseDisplay"
@@ -1042,6 +1043,8 @@ export const ChatRowContent = memo(
)
case "compaction":
return <CompactionRow message={message} />
case "cloud_provisioning":
return <CloudProvisioningRow active={lastModifiedMessage?.ts === message.ts} message={message} />
default:
return (
<div>
@@ -53,6 +53,15 @@ describe("computeIsWaitingForResponse (turnState path)", () => {
// phase to "completed"; the loader must not flash during that gap.
expect(computeIsWaitingForResponse(inputsFor([say(1, "completion_result", false)], streaming()))).toBe(false)
})
it("does not wait while a cloud sandbox is provisioning, which shows its own progress", () => {
expect(computeIsWaitingForResponse(inputsFor([say(1, "cloud_provisioning", false)], streaming()))).toBe(false)
expect(
computeIsWaitingForResponse(
inputsFor([say(1, "cloud_provisioning", false), say(2, "api_req_started", false)], streaming()),
),
).toBe(true)
})
})
describe("computeIsWaitingForResponse (legacy path)", () => {
@@ -52,6 +52,11 @@ export function computeIsWaitingForResponse({
}: ThinkingLoaderInputs): boolean {
const lastMsg = modifiedMessages[modifiedMessages.length - 1]
// A provisioning cloud sandbox shows its own progress card.
if (lastRawMessage?.type === "say" && lastRawMessage.say === "cloud_provisioning") {
return false
}
// AUTHORITATIVE PATH: when the backend provides a TurnState, the agent is only "thinking"
// while phase === "streaming". Any other phase (awaiting_approval/followup, completed,
// error, resumable, idle) is never a thinking state — this is what makes the footer
@@ -0,0 +1,92 @@
import type { ClineCloudProvisioningInfo, ClineMessage } from "@shared/ExtensionMessage"
import { CheckIcon, CloudIcon, GitBranchIcon, LoaderCircleIcon } from "lucide-react"
import { cn } from "@/lib/utils"
type Step = { phase: ClineCloudProvisioningInfo["phase"]; label: string }
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" }]
function parseInfo(text: string | undefined): ClineCloudProvisioningInfo | undefined {
try {
const parsed = JSON.parse(text || "")
return typeof parsed?.phase === "string" ? parsed : undefined
} catch {
return undefined
}
}
/**
* Progress card for a cloud sandbox that is starting or resuming. The same
* message (same ts) is updated in place as the phase advances; once anything
* follows it, the card collapses to its header.
*/
export const CloudProvisioningRow = ({ message, active }: { message: ClineMessage; active: boolean }) => {
const info = parseInfo(message.text)
if (!info) {
// Virtuoso cannot handle zero-height items; render a spacer instead of null.
return <div aria-hidden className="h-px" />
}
const steps = info.phase === "resuming" ? RESUME_STEPS : START_STEPS
const current = steps.findIndex((step) => step.phase === info.phase)
const title = !active ? "Cloud sandbox" : info.phase === "resuming" ? "Resuming cloud sandbox" : "Setting up cloud sandbox"
return (
<div className="flex flex-col gap-2.5 rounded-sm border border-description/20 bg-text-block-background/40 px-3 py-2.5">
<div className="flex min-w-0 items-center gap-2.5">
<span className="inline-flex size-7 shrink-0 items-center justify-center rounded-sm bg-badge-background/60 text-badge-foreground">
<CloudIcon className="size-3.5" />
</span>
<div className="flex min-w-0 flex-col gap-0.5">
<span className="truncate font-medium text-foreground">{title}</span>
{info.repo && (
<span className="flex min-w-0 items-center gap-1.5 text-xs text-description">
<span className="truncate">{info.repo}</span>
{info.branch && (
<span className="inline-flex min-w-0 shrink items-center gap-1 rounded-xs bg-description/10 px-1 py-px">
<GitBranchIcon className="size-2.5 shrink-0" />
<span className="truncate">{info.branch}</span>
</span>
)}
</span>
)}
</div>
</div>
{active && (
<ol className="m-0 flex list-none flex-col gap-1.5 p-0 pl-1 text-xs">
{steps.map((step, index) => {
const done = index < current
const running = index === current
return (
<li className="flex items-center gap-2" key={step.phase}>
<span className="inline-flex size-4 shrink-0 items-center justify-center">
{done ? (
<CheckIcon className="size-3 text-success" />
) : running ? (
<LoaderCircleIcon className="size-3 animate-spin text-foreground" />
) : (
<span className="size-1.5 rounded-full bg-description/40" />
)}
</span>
{running ? (
<span
aria-live="polite"
className="animate-shimmer bg-linear-90 from-foreground to-description bg-[length:200%_100%] bg-clip-text text-transparent">
{step.label}
</span>
) : (
<span className={cn(done ? "text-description" : "text-description/60")}>{step.label}</span>
)}
</li>
)
})}
</ol>
)}
</div>
)
}