diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md index 24f3dcacf3e7..06f8a8305551 100644 --- a/docs/web/control-ui.md +++ b/docs/web/control-ui.md @@ -266,6 +266,8 @@ The folder defaults to the agent workspace. Write-scoped connections can browse, For a remote target, the Control UI creates the managed-worktree session with an empty initial message and no `execNode`, dispatches it by exact `deviceId`, `autoDevice: true`, or `profileId` (plus an optional cloud machine class), waits for active placement, and then sends the first message and attachments with the same idempotency key used by recovery. Explicit and automatic device dispatch require `operator.write`; cloud profile dispatch requires `operator.admin`. The composer footer chooses the new session's model and reasoning level. +Once the session is created, chat opens immediately. Remote startup uses the same transcript progress indicator and elapsed timer as GitHub workspace preparation, showing provisioning, workspace preparation, startup, and first-message delivery as they happen. The composer stays disabled until the first message is accepted; normal startup is not an error. Startup failures remain visible in the session, with **Retry** when recovery is available. + Canceling or recovering an interrupted remote-placement startup reclaims the placement by session key. Cleanup archives a newly created draft before deleting it with the write-scoped archived-only contract, and any cleanup error remains visible for recovery. Unsent text and staged attachments can be recovered only in the same browser profile and Gateway credential scope; they are never stored on the Gateway or synced across devices. The browser keeps the 20 most recently edited draft scopes per Gateway credential scope for up to seven days, with at most 25 MiB of attachment data per draft, but it can evict browser storage sooner. A successful send or New Session creation, explicit attachment removal, or confirmed session deletion retires the corresponding browser draft. If cleanup fails after deletion, clear site data for the Control UI origin to remove it. Clearing site data also removes every other browser draft. If a draft's attachments exceed the cap, the current tab keeps them and shows the existing storage warning, but only the text is restart-recoverable. OpenClaw **Incognito** drafts are never durable. In a private browser window, IndexedDB availability and lifetime are controlled by the browser and stored data is normally cleared when the private session ends. The **Incognito** toggle in the new-session page's top-right control rail retires that browser draft and creates a web-only thread whose session entry, transcript, and compaction state stay in memory until the Gateway restarts; OpenClaw also skips its automatic memory flush. The agent keeps its normal tools, so an explicit save request or tool-driven file write can still persist data. The model provider still processes messages, and content-free audit metadata is still recorded. Remote-placement starts persist their model and reasoning choices before dispatching the session to its worker. diff --git a/ui/src/e2e/new-session-page.cloud-dispatch.e2e.test.ts b/ui/src/e2e/new-session-page.cloud-dispatch.e2e.test.ts index 4d34ccad9b7b..3eff4fabbbe7 100644 --- a/ui/src/e2e/new-session-page.cloud-dispatch.e2e.test.ts +++ b/ui/src/e2e/new-session-page.cloud-dispatch.e2e.test.ts @@ -392,6 +392,7 @@ suite.define(() => { const publishPlacement = async ( state: "requested" | "provisioning" | "syncing" | "starting", generation: number, + label: string, includeNeutral = false, ) => { await gateway.setMethodResponse("sessions.list", { @@ -427,16 +428,16 @@ suite.define(() => { ts: Date.now(), }); await gateway.emitGatewayEvent("sessions.changed", { sessionKey, reason: "dispatch" }); - await pollLocatorText(startupStatus).toContain(`Placement: ${state}`); + await pollLocatorText(startupStatus).toContain(label); }; - for (const [state, generation] of [ - ["requested", 1], - ["provisioning", 2], - ["syncing", 3], - ["starting", 4], + for (const [state, generation, label] of [ + ["requested", 1, "Provisioning environment…"], + ["provisioning", 2, "Provisioning environment…"], + ["syncing", 3, "Preparing workspace…"], + ["starting", 4, "Starting…"], ] as const) { - await publishPlacement(state, generation, state === "starting"); + await publishPlacement(state, generation, label, state === "starting"); expect(await gateway.getRequests("sessions.send")).toHaveLength(0); } expect(await gateway.getRequests("sessions.describe")).toHaveLength( @@ -457,7 +458,7 @@ suite.define(() => { app.runtime?.context.navigate("chat", { pathname }); }, controlUiSessionPath(sessionKey)); await expect.poll(() => page.url()).toContain(controlUiSessionPath(sessionKey)); - await pollLocatorText(startupStatus).toContain("Placement: starting"); + await pollLocatorText(startupStatus).toContain("Starting…"); expect(await gateway.getRequests("sessions.abort")).toHaveLength(0); expect(await gateway.getRequests("environments.destroy")).toHaveLength(0); expect(await gateway.getRequests("sessions.delete")).toHaveLength(0); diff --git a/ui/src/e2e/new-session-page.cloud-startup-failure.e2e.test.ts b/ui/src/e2e/new-session-page.cloud-startup-failure.e2e.test.ts index bb6d15fb4e34..0ec432fc0ea6 100644 --- a/ui/src/e2e/new-session-page.cloud-startup-failure.e2e.test.ts +++ b/ui/src/e2e/new-session-page.cloud-startup-failure.e2e.test.ts @@ -12,69 +12,92 @@ import { const suite = createNewSessionPageE2eSuite(); suite.define(() => { - it("keeps a definitive cloud startup failure visible in the created session", async () => { - const context = await suite.browser.newContext({ locale: "en-US", serviceWorkers: "block" }); - const page = await context.newPage(); - const sessionKey = "agent:cloud:failed-startup-e2e"; - const gateway = await installMockGateway(page, { - defaultAgentId: "cloud", - deferredMethods: ["sessions.dispatch"], - featureMethods: ["sessions.create", "sessions.dispatch"], - workspaceGit: true, - methodResponses: { - "agents.list": { - agents: [ - { - id: "cloud", - identity: { name: "Cloud" }, - name: "Cloud", - workspace: WORKSPACE, - workspaceGit: true, - }, - ], - defaultId: "cloud", - mainKey: "main", - scope: "agent", + it.each([false, true])( + "keeps cloud startup visible through failure (history fails: %s)", + async (historyFails) => { + const context = await suite.browser.newContext({ locale: "en-US", serviceWorkers: "block" }); + const page = await context.newPage(); + const sessionKey = "agent:cloud:failed-startup-e2e"; + const gateway = await installMockGateway(page, { + defaultAgentId: "cloud", + deferredMethods: ["sessions.dispatch", ...(historyFails ? ["chat.startup"] : [])], + featureMethods: ["sessions.create", "sessions.dispatch", "chat.startup"], + workspaceGit: true, + methodResponses: { + "agents.list": { + agents: [ + { + id: "cloud", + identity: { name: "Cloud" }, + name: "Cloud", + workspace: WORKSPACE, + workspaceGit: true, + }, + ], + defaultId: "cloud", + mainKey: "main", + scope: "agent", + }, + "environments.list": { + environments: [], + profiles: [{ id: "aws", providerId: "crabbox" }], + }, + "worktrees.branches": { + branches: [{ kind: "local", name: "main" }], + defaultBranch: "main", + repositoryStatus: "git", + }, + "sessions.create": { key: sessionKey }, + "sessions.list": createdSessionListResult(sessionKey), + "sessions.describe": { session: {} }, }, - "environments.list": { - environments: [], - profiles: [{ id: "aws", providerId: "crabbox" }], - }, - "worktrees.branches": { - branches: [{ kind: "local", name: "main" }], - defaultBranch: "main", - repositoryStatus: "git", - }, - "sessions.create": { key: sessionKey }, - "sessions.list": createdSessionListResult(sessionKey), - "sessions.describe": { session: {} }, - }, - }); - - try { - await page.goto(`${suite.server.baseUrl}new`); - await gateway.waitForRequest("environments.list"); - await page.locator("#new-session-where-trigger").click(); - await page - .locator("wa-popover.new-session-page__where-popover") - .getByRole("button", { name: "Cloud · aws" }) - .click(); - await page.locator(".new-session-page__message").fill("surface the failed startup"); - await page.getByRole("button", { name: "Start session" }).click(); - await gateway.waitForRequest("sessions.dispatch"); - await waitForCommittedChatRoute(page); - await gateway.rejectDeferred("sessions.dispatch", { - code: "INVALID_REQUEST", - message: "cloud profile was removed", }); - const alert = page.locator('.chat-cloud-startup-error[role="alert"]'); - await pollLocatorText(alert).toContain("cloud profile was removed"); - expect(page.url()).toContain(controlUiSessionPath(sessionKey)); - expect(await gateway.getRequests("sessions.send")).toHaveLength(0); - expect(await gateway.getRequests("sessions.delete")).toHaveLength(0); - } finally { - await context.close(); - } - }); + try { + await page.goto(`${suite.server.baseUrl}new`); + await gateway.waitForRequest("environments.list"); + await page.locator("#new-session-where-trigger").click(); + await page + .locator("wa-popover.new-session-page__where-popover") + .getByRole("button", { name: "Cloud · aws" }) + .click(); + await page.locator(".new-session-page__message").fill("surface the failed startup"); + await page.getByRole("button", { name: "Start session" }).click(); + await gateway.waitForRequest("sessions.dispatch"); + await waitForCommittedChatRoute(page); + if (historyFails) { + await gateway.waitForRequest("chat.startup"); + await gateway.rejectDeferred("chat.startup", { + code: "UNAVAILABLE", + message: "History is temporarily unavailable", + }); + await pollLocatorText(page.locator(".chat-history-error--inline")).toContain( + "History is temporarily unavailable", + ); + } + const working = page.locator('.chat-thread .chat-working-indicator[role="status"]'); + await pollLocatorText(working).toContain("Provisioning environment…"); + expect(await working.locator(".chat-reading-indicator").count()).toBe(1); + expect( + await page + .locator('.chat-cloud-startup, .agent-chat__composer-status-band[role="alert"]') + .count(), + ).toBe(0); + expect(await page.locator(".chat-send-btn--stop").count()).toBe(0); + await gateway.rejectDeferred("sessions.dispatch", { + code: "INVALID_REQUEST", + message: "cloud profile was removed", + }); + + const alert = page.locator('.chat-cloud-startup-error[role="alert"]'); + await pollLocatorText(alert).toContain("cloud profile was removed"); + await expect.poll(() => working.count()).toBe(0); + expect(page.url()).toContain(controlUiSessionPath(sessionKey)); + expect(await gateway.getRequests("sessions.send")).toHaveLength(0); + expect(await gateway.getRequests("sessions.delete")).toHaveLength(0); + } finally { + await context.close(); + } + }, + ); }); diff --git a/ui/src/e2e/new-session-page.test-support.ts b/ui/src/e2e/new-session-page.test-support.ts index 4098abce2b32..546a35dde289 100644 --- a/ui/src/e2e/new-session-page.test-support.ts +++ b/ui/src/e2e/new-session-page.test-support.ts @@ -152,9 +152,12 @@ export async function expectPendingSessionPlacementStartupBeforeRuntime( ) { await waitForCommittedChatRoute(page); expect(page.url()).toContain(controlUiSessionPath(sessionKey)); - const startupStatus = page.locator('.chat-cloud-startup[role="status"]'); + const startupStatus = page.locator('.chat-thread .chat-working-indicator[role="status"]'); await expect.poll(() => startupStatus.count()).toBe(1); - await pollLocatorText(startupStatus).toContain("Starting…"); + await pollLocatorText(startupStatus).toContain("Provisioning environment…"); + expect(await page.locator(".chat-cloud-startup, .agent-chat__composer-status-band").count()).toBe( + 0, + ); await expect .poll(() => page.locator(".agent-chat__composer-combobox textarea").isDisabled()) .toBe(true); diff --git a/ui/src/pages/chat/chat-pane-render.ts b/ui/src/pages/chat/chat-pane-render.ts index 9be5ca0b1072..0c7a29231b35 100644 --- a/ui/src/pages/chat/chat-pane-render.ts +++ b/ui/src/pages/chat/chat-pane-render.ts @@ -198,15 +198,13 @@ export class ChatPane extends ChatPaneLayoutRender { selectedSession.sharingRole === "viewer" && isGatewayMethodAdvertised(gatewaySnapshot, "session.suggestions.add") === true && isGatewayMethodAdvertised(gatewaySnapshot, "session.suggestions.list") === true; - // Every composer-disabling gate needs a visible reason here or a banner in - // sessionDisabledBanner; a silently disabled composer is a silent failure. + // Placement progress already explains its gate in the transcript. Other + // gates need a reason here or a sessionDisabledBanner. const disabledReason = modelUnavailable ? `${t("modelSetup.failure.auth")}. ${t("modelSetup.failureGuidance.auth")}` : sessionParticipationBlocked && !suggestionViewer ? t("chat.sessionSharing.readOnlyNotice") - : placementStartupPending - ? t("newSession.starting") - : null; + : null; const typingEnabled = multiIdentity && hasOperatorWriteAccess(gatewaySnapshot.hello?.auth ?? null) && diff --git a/ui/src/pages/chat/chat-run-startup.ts b/ui/src/pages/chat/chat-run-startup.ts index 6b749a0a0213..ab74c27bf36b 100644 --- a/ui/src/pages/chat/chat-run-startup.ts +++ b/ui/src/pages/chat/chat-run-startup.ts @@ -1,4 +1,6 @@ import type { ChatRunStartupPhase } from "../../../../packages/gateway-protocol/src/index.js"; +import type { ApplicationPlacementStartupStatus } from "../../app/session-placement-startup.ts"; +import { t } from "../../i18n/index.ts"; export type { ChatRunStartupPhase } from "../../../../packages/gateway-protocol/src/index.js"; @@ -8,6 +10,37 @@ export type ChatRunStartupState = export type ChatRunStartupStatus = Extract; +const STARTUP_LABEL_KEYS = { + preparing_workspace: "chat.startupStatus.preparingWorkspace", + provisioning_environment: "chat.startupStatus.provisioningEnvironment", + preparing_context: "chat.startupStatus.preparingContext", + starting_model: "chat.startupStatus.startingModel", +} as const satisfies Record[0]>; + +export function chatStartupStatusLabel( + run: ChatRunStartupStatus | null | undefined, + placement: ApplicationPlacementStartupStatus | null | undefined, +): string | undefined { + if (run) { + return t(STARTUP_LABEL_KEYS[run.phase]); + } + switch (placement?.phase) { + case "pending": + case "requested": + case "provisioning": + return t("chat.startupStatus.provisioningEnvironment"); + case "syncing": + return t("chat.startupStatus.preparingWorkspace"); + case "starting": + return t("newSession.starting"); + case "active": + case "sending": + return t("chat.composer.sendingMessage"); + default: + return undefined; + } +} + export function activeChatRunStartupStatus( startup: ChatRunStartupState | null | undefined, ): ChatRunStartupStatus | null { diff --git a/ui/src/pages/chat/chat-view-notices.ts b/ui/src/pages/chat/chat-view-notices.ts index eb0bacef4199..205cb5a79e7c 100644 --- a/ui/src/pages/chat/chat-view-notices.ts +++ b/ui/src/pages/chat/chat-view-notices.ts @@ -4,7 +4,6 @@ import type { ApplicationPlacementStartupStatus } from "../../app/session-placem import { icons } from "../../components/icons.ts"; import { t } from "../../i18n/index.ts"; import { formatBytes } from "../../lib/agents/display.ts"; -import { renderPlacementStartupStatus } from "./components/chat-working-indicator.ts"; import { renderWorkspaceConflictNotice } from "./components/chat-workspace-conflict.ts"; import type { WorkspaceResultConflict } from "./workspace-conflict.ts"; @@ -123,6 +122,37 @@ export function renderChatComposerNotices(props: ChatComposerNoticesProps) { conflict: props.workspaceConflict ?? undefined, onDismiss: props.onDismissWorkspaceConflict, })} - ${renderPlacementStartupStatus(props.placementStartup, props.onRetrySessionPlacementStartup)} + ${renderPlacementStartupError(props.placementStartup, props.onRetrySessionPlacementStartup)} + `; +} + +function renderPlacementStartupError( + status: ApplicationPlacementStartupStatus | null | undefined, + onRetry?: () => void, +) { + if (status?.phase !== "failed") { + return nothing; + } + return html` + `; } diff --git a/ui/src/pages/chat/chat-view.ts b/ui/src/pages/chat/chat-view.ts index c5d332b4858a..9dabc6d8f5de 100644 --- a/ui/src/pages/chat/chat-view.ts +++ b/ui/src/pages/chat/chat-view.ts @@ -40,7 +40,7 @@ import type { ProviderUsageDisplayProps } from "../../lib/provider-quota-summary import type { SessionToolOverrides } from "../../lib/sessions/patch.ts"; import type { UiSessionDefaultsHost } from "../../lib/sessions/session-key.ts"; import { getChatHistoryLoadState, retryChatHistoryLoad } from "./chat-history.ts"; -import type { ChatRunStartupStatus } from "./chat-run-startup.ts"; +import { chatStartupStatusLabel, type ChatRunStartupStatus } from "./chat-run-startup.ts"; import type { ChatState } from "./chat-state-contract.ts"; import { type ChatPlacementStartupNoticeProps, @@ -321,20 +321,24 @@ export function renderChat(props: ChatProps) { ? (item: ImageLightboxItem) => openImage?.(item, props.onRequestOpenImage?.()) : undefined; const attachmentDropHandlers = createChatAttachmentDropHandlers({ ...props, canCompose }); + const placementStartup = + props.placementStartup?.phase === "failed" ? null : props.placementStartup; + // Placement is visible work, but does not own an abortable model run yet. + const runWorking = Boolean(placementStartup) || isChatRunWorking(props); let chatSection: HTMLElement | null = null; const thread = renderChatThread( { paneId: props.paneId, sessionKey: props.sessionKey, announceTranscript: props.announceTranscript, - loading: props.loading, + loading: props.loading && !placementStartup, historyLoading: props.historyPagination?.loading, messages: props.messages, toolMessages: props.toolMessages, guardianNotices: props.guardianNotices, streamSegments: props.streamSegments, stream: props.stream, - streamStartedAt: props.streamStartedAt, + streamStartedAt: placementStartup?.startedAt ?? props.streamStartedAt, runId: props.runId, runOutputTokens: props.runOutputTokens, runStatus: props.runStatus, @@ -343,8 +347,8 @@ export function renderChat(props: ChatProps) { showToolCalls: props.showToolCalls, persistCommentary: props.persistCommentary, runActive: Boolean(props.canAbort), - runWorking: isChatRunWorking(props), - startupStatus: props.startupStatus, + runWorking, + startupLabel: chatStartupStatusLabel(props.startupStatus, placementStartup), waitingApproval: props.waitingApproval, questionPrompts: props.gatewayQuestionPrompts, sessions: props.sessions, @@ -549,6 +553,7 @@ export function renderChat(props: ChatProps) { historyLoadState?.phase === "failed" && historyLoadState.sessionKey === props.sessionKey; const transcriptEmpty = + !runWorking && props.messages.length === 0 && props.toolMessages.length === 0 && props.streamSegments.length === 0 && diff --git a/ui/src/pages/chat/components/chat-message-stream.ts b/ui/src/pages/chat/components/chat-message-stream.ts index 70a4caacac1e..c7abee439b8e 100644 --- a/ui/src/pages/chat/components/chat-message-stream.ts +++ b/ui/src/pages/chat/components/chat-message-stream.ts @@ -6,7 +6,6 @@ import type { AssistantIdentity } from "../../../lib/assistant-identity.ts"; import type { ChatItem } from "../../../lib/chat/chat-types.ts"; import { formatDurationCompact } from "../../../lib/format.ts"; import { renderChatAvatar } from "../chat-avatar.ts"; -import type { ChatRunStartupPhase } from "../chat-run-startup.ts"; import { renderGroupedMessage } from "./chat-message-bubble.ts"; import { renderChatTimestamp } from "./chat-message-timestamp.ts"; import { renderChatQuestionSummary } from "./chat-question-card.ts"; @@ -46,7 +45,7 @@ export type StreamGroupOptions = StreamMessageOptions & { onOpenSidebar?: (content: SidebarContent) => void; assistant?: AssistantIdentity; showAssistantAvatar?: boolean; - startupPhase?: ChatRunStartupPhase; + startupLabel?: string; waitingApproval?: boolean; runOutputTokens?: number | null; questionPrompts?: ReadonlyMap; @@ -69,7 +68,7 @@ export function renderStreamGroupParts( part.kind === "reading-indicator" ? renderChatWorkingIndicator(part, { waitingApproval: opts.waitingApproval === true, - startupPhase: opts.startupPhase, + startupLabel: opts.startupLabel, outputTokens: opts.runOutputTokens, presentation, }) diff --git a/ui/src/pages/chat/components/chat-message.test.ts b/ui/src/pages/chat/components/chat-message.test.ts index ba6c1e35846f..63ac9b7339d7 100644 --- a/ui/src/pages/chat/components/chat-message.test.ts +++ b/ui/src/pages/chat/components/chat-message.test.ts @@ -7,6 +7,7 @@ import type { MessageGroup } from "../../../lib/chat/chat-types.ts"; import { setAvatarGatewayOrigin } from "../../../lib/identity-avatar.ts"; import * as localStorageModule from "../../../local-storage.ts"; import * as chatAvatar from "../chat-avatar.ts"; +import { chatStartupStatusLabel } from "../chat-run-startup.ts"; import { buildCachedChatItems } from "../chat-thread.ts"; import { agentEvent, createHost } from "../tool-stream.test-helpers.ts"; import { handleAgentEvent } from "../tool-stream.ts"; @@ -1695,6 +1696,7 @@ describe("grouped chat rendering", () => { }); it.each([ + ["preparing_workspace", "Preparing workspace…"], ["provisioning_environment", "Provisioning environment…"], ["preparing_context", "Preparing this turn…"], ["starting_model", "Waiting for a response…"], @@ -1703,7 +1705,10 @@ describe("grouped chat rendering", () => { render( renderStreamGroup([{ kind: "reading-indicator", key: "reading", startedAt: 1_000 }], { - startupPhase, + startupLabel: chatStartupStatusLabel( + { state: "status", runId: "startup-run", phase: startupPhase }, + null, + ), }), container, ); @@ -1763,7 +1768,7 @@ describe("grouped chat rendering", () => { render( renderStreamGroup([{ kind: "reading-indicator", key: "reading", startedAt: 1_000 }], { - startupPhase: "starting_model", + startupLabel: "Waiting for a response…", waitingApproval: true, runOutputTokens: 5_500, }), diff --git a/ui/src/pages/chat/components/chat-thread-interactions.ts b/ui/src/pages/chat/components/chat-thread-interactions.ts index e1d1f6e435b1..bf6459bb73cc 100644 --- a/ui/src/pages/chat/components/chat-thread-interactions.ts +++ b/ui/src/pages/chat/components/chat-thread-interactions.ts @@ -24,7 +24,6 @@ import { import type { EmbedSandboxMode } from "../../../lib/chat/tool-display.ts"; import { fnv1aUtf16 } from "../../../lib/fnv1a.ts"; import type { UiSessionDefaultsHost } from "../../../lib/sessions/session-key.ts"; -import type { ChatRunStartupStatus } from "../chat-run-startup.ts"; import { resetChatThreadState } from "../chat-thread.ts"; import type { LinkFaviconFetcher } from "../link-favicon-loader.ts"; import type { RealtimeTalkConversationEntry } from "../realtime-talk-conversation.ts"; @@ -84,7 +83,7 @@ export type ChatThreadProps = { persistCommentary?: boolean; runActive?: boolean; runWorking?: boolean; - startupStatus?: ChatRunStartupStatus | null; + startupLabel?: string; waitingApproval?: boolean; questionPrompts?: readonly QuestionPrompt[]; sessions: SessionsListResult | null; diff --git a/ui/src/pages/chat/components/chat-transcript-projection.ts b/ui/src/pages/chat/components/chat-transcript-projection.ts index b2f7d3184694..c59daf34ff02 100644 --- a/ui/src/pages/chat/components/chat-transcript-projection.ts +++ b/ui/src/pages/chat/components/chat-transcript-projection.ts @@ -427,7 +427,7 @@ export function projectChatTranscript( return renderStreamGroup(item.parts, { ...streamGroupOptions, questionPrompts, - startupPhase: props.startupStatus?.phase, + startupLabel: props.startupLabel, waitingApproval: props.waitingApproval, runOutputTokens: props.runOutputTokens, }); @@ -458,7 +458,7 @@ export function projectChatTranscript( streamOptions: { ...streamGroupOptions, questionPrompts, - startupPhase: props.startupStatus?.phase, + startupLabel: props.startupLabel, waitingApproval: props.waitingApproval, runOutputTokens: props.runOutputTokens, }, @@ -528,7 +528,7 @@ export function projectChatTranscript( parts: activeStatusParts, options: { ...streamGroupOptions, - startupPhase: props.startupStatus?.phase, + startupLabel: props.startupLabel, waitingApproval: props.waitingApproval, runOutputTokens: props.runOutputTokens, }, @@ -646,7 +646,7 @@ export function projectChatTranscript( props.showToolCalls, Boolean(props.runActive), Boolean(props.runWorking), - props.startupStatus?.phase, + props.startupLabel, Boolean(props.waitingApproval), props.questionPrompts, Boolean(props.autoExpandToolCalls), diff --git a/ui/src/pages/chat/components/chat-working-indicator.ts b/ui/src/pages/chat/components/chat-working-indicator.ts index 3a792c1a4eae..76da6de90e72 100644 --- a/ui/src/pages/chat/components/chat-working-indicator.ts +++ b/ui/src/pages/chat/components/chat-working-indicator.ts @@ -1,22 +1,13 @@ import { html, nothing } from "lit"; import "../../../components/elapsed-time.ts"; -import type { ApplicationPlacementStartupStatus } from "../../../app/session-placement-startup.ts"; import "../../../components/working-phrase.ts"; import { icons } from "../../../components/icons.ts"; import { i18n, t } from "../../../i18n/index.ts"; import type { ChatItem } from "../../../lib/chat/chat-types.ts"; import { formatCompactTokenCount } from "../../../lib/format.ts"; import type { TurnRecap } from "../chat-progress.ts"; -import type { ChatRunStartupPhase } from "../chat-run-startup.ts"; import { selectWorkingClawSurprise } from "./chat-working-indicator-surprise.ts"; -// Almost every run uses the default loop; an alternate move fires once, then yields back to it. -const STARTUP_STATUS_LABEL_KEYS = { - preparing_workspace: "chat.startupStatus.preparingWorkspace", - provisioning_environment: "chat.startupStatus.provisioningEnvironment", - preparing_context: "chat.startupStatus.preparingContext", - starting_model: "chat.startupStatus.startingModel", -} as const satisfies Record[0]>; const TURN_RECAP_DURATION_UNITS = [ { seconds: 86_400, unit: "day" }, { seconds: 3_600, unit: "hour" }, @@ -24,70 +15,6 @@ const TURN_RECAP_DURATION_UNITS = [ { seconds: 1, unit: "second" }, ] as const; -function startupStatusLabel(phase: ChatRunStartupPhase): string { - return t(STARTUP_STATUS_LABEL_KEYS[phase]); -} - -function placementStartupStatusLabel(status: ApplicationPlacementStartupStatus): string { - if (status.phase === "pending") { - return t("newSession.starting"); - } - return status.phase === "sending" || status.phase === "active" - ? t("chat.composer.sendingMessage") - : t("sessionsView.cloudWorkerPlacement", { state: status.phase }); -} - -export function renderPlacementStartupStatus( - status: ApplicationPlacementStartupStatus | null | undefined, - onRetry?: () => void, -) { - if (!status) { - return nothing; - } - if (status.phase === "failed") { - return html` - - `; - } - return html` -
- - - ${placementStartupStatusLabel(status)} - - -
- `; -} - function formatTurnRecapDuration(ms: number): string { let remainingSeconds = Math.max(1, Math.round(ms / 1_000)); const locale = i18n.getLocale(); @@ -133,7 +60,7 @@ export function renderChatWorkingIndicator( part: Extract, options: { waitingApproval?: boolean; - startupPhase?: ChatRunStartupPhase; + startupLabel?: string; outputTokens?: number | null; presentation?: "standalone" | "continuation"; } = {}, @@ -166,9 +93,9 @@ export function renderChatWorkingIndicator( ${waitingApproval ? html`${t("chat.waitingForApproval")}` - : options.startupPhase + : options.startupLabel ? html` - ${startupStatusLabel(options.startupPhase)} + ${options.startupLabel}