mirror of
https://github.com/cline/cline.git
synced 2026-10-24 02:02:27 +08:00
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:
@@ -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>
|
||||
|
||||
+9
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user