diff --git a/apps/vscode/proto/cline/ui.proto b/apps/vscode/proto/cline/ui.proto index f9046813ad..53fa893eff 100644 --- a/apps/vscode/proto/cline/ui.proto +++ b/apps/vscode/proto/cline/ui.proto @@ -77,6 +77,7 @@ enum ClineSay { SUBAGENT_USAGE = 36; COMPACTION = 37; PLAN_COMPLETION_RESULT = 38; + CLOUD_PROVISIONING = 39; } // Enum for ClineSayTool tool types diff --git a/apps/vscode/src/sdk/sdk-cloud-session-coordinator.test.ts b/apps/vscode/src/sdk/sdk-cloud-session-coordinator.test.ts index 95f46f850a..76e8b3a52a 100644 --- a/apps/vscode/src/sdk/sdk-cloud-session-coordinator.test.ts +++ b/apps/vscode/src/sdk/sdk-cloud-session-coordinator.test.ts @@ -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() }) diff --git a/apps/vscode/src/sdk/sdk-cloud-session-coordinator.ts b/apps/vscode/src/sdk/sdk-cloud-session-coordinator.ts index 997fd7263b..4014430e76 100644 --- a/apps/vscode/src/sdk/sdk-cloud-session-coordinator.ts +++ b/apps/vscode/src/sdk/sdk-cloud-session-coordinator.ts @@ -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(promise: Promise, timeoutMs: number): Promise { @@ -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 diff --git a/apps/vscode/src/shared/ExtensionMessage.ts b/apps/vscode/src/shared/ExtensionMessage.ts index 0569fcc7fe..c82d2acb76 100644 --- a/apps/vscode/src/shared/ExtensionMessage.ts +++ b/apps/vscode/src/shared/ExtensionMessage.ts @@ -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 diff --git a/apps/vscode/src/shared/proto-conversions/cline-message.ts b/apps/vscode/src/shared/proto-conversions/cline-message.ts index 7498fed3fd..66ac917aa5 100644 --- a/apps/vscode/src/shared/proto-conversions/cline-message.ts +++ b/apps/vscode/src/shared/proto-conversions/cline-message.ts @@ -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] diff --git a/apps/vscode/src/test/cloud/cloud-session-resume-boundary.test.ts b/apps/vscode/src/test/cloud/cloud-session-resume-boundary.test.ts index a61aa8d650..978e18f3f1 100644 --- a/apps/vscode/src/test/cloud/cloud-session-resume-boundary.test.ts +++ b/apps/vscode/src/test/cloud/cloud-session-resume-boundary.test.ts @@ -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) diff --git a/apps/vscode/webview-ui/src/components/chat/ChatRow.tsx b/apps/vscode/webview-ui/src/components/chat/ChatRow.tsx index 730d59f013..1e9bfc331f 100644 --- a/apps/vscode/webview-ui/src/components/chat/ChatRow.tsx +++ b/apps/vscode/webview-ui/src/components/chat/ChatRow.tsx @@ -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 + case "cloud_provisioning": + return default: return (
diff --git a/apps/vscode/webview-ui/src/components/chat/chat-view/hooks/useThinkingLoaderRow.test.tsx b/apps/vscode/webview-ui/src/components/chat/chat-view/hooks/useThinkingLoaderRow.test.tsx index 98336bcb1b..93fc33ff81 100644 --- a/apps/vscode/webview-ui/src/components/chat/chat-view/hooks/useThinkingLoaderRow.test.tsx +++ b/apps/vscode/webview-ui/src/components/chat/chat-view/hooks/useThinkingLoaderRow.test.tsx @@ -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)", () => { diff --git a/apps/vscode/webview-ui/src/components/chat/chat-view/hooks/useThinkingLoaderRow.ts b/apps/vscode/webview-ui/src/components/chat/chat-view/hooks/useThinkingLoaderRow.ts index 9b6f37a63f..46e09d3250 100644 --- a/apps/vscode/webview-ui/src/components/chat/chat-view/hooks/useThinkingLoaderRow.ts +++ b/apps/vscode/webview-ui/src/components/chat/chat-view/hooks/useThinkingLoaderRow.ts @@ -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 diff --git a/apps/vscode/webview-ui/src/components/cloud/CloudProvisioningRow.tsx b/apps/vscode/webview-ui/src/components/cloud/CloudProvisioningRow.tsx new file mode 100644 index 0000000000..d2dc188083 --- /dev/null +++ b/apps/vscode/webview-ui/src/components/cloud/CloudProvisioningRow.tsx @@ -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
+ } + + 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 ( +
+
+ + + +
+ {title} + {info.repo && ( + + {info.repo} + {info.branch && ( + + + {info.branch} + + )} + + )} +
+
+ {active && ( +
    + {steps.map((step, index) => { + const done = index < current + const running = index === current + return ( +
  1. + + {done ? ( + + ) : running ? ( + + ) : ( + + )} + + {running ? ( + + {step.label} + + ) : ( + {step.label} + )} +
  2. + ) + })} +
+ )} +
+ ) +}