mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 12:56:01 -06:00
fix(ui): show cloud session startup progress in chat (#130669)
This commit is contained in:
committed by
GitHub
parent
110eb787a1
commit
8f237b6a97
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) &&
|
||||
|
||||
@@ -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<ChatRunStartupState, { state: "status" }>;
|
||||
|
||||
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<ChatRunStartupPhase, Parameters<typeof t>[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 {
|
||||
|
||||
@@ -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`
|
||||
<div
|
||||
class="chat-composer-neighbor-card chat-composer-neighbor-card--danger chat-cloud-startup-error"
|
||||
role="alert"
|
||||
>
|
||||
<span class="chat-composer-neighbor-card__icon" aria-hidden="true"
|
||||
>${icons.alertTriangle}</span
|
||||
>
|
||||
<span class="chat-composer-neighbor-card__copy"
|
||||
><strong
|
||||
>${t("newSession.placementStartFailed", {
|
||||
error: status.error ?? t("newSession.createFailed"),
|
||||
})}</strong
|
||||
></span
|
||||
>
|
||||
${status.retryable && onRetry
|
||||
? html`<button class="btn btn--sm" type="button" @click=${onRetry}>
|
||||
${t("common.retry")}
|
||||
</button>`
|
||||
: nothing}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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<string, QuestionPrompt>;
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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<ChatRunStartupPhase, Parameters<typeof t>[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`
|
||||
<div
|
||||
class="chat-composer-neighbor-card chat-composer-neighbor-card--danger chat-cloud-startup-error"
|
||||
role="alert"
|
||||
>
|
||||
<span class="chat-composer-neighbor-card__icon" aria-hidden="true"
|
||||
>${icons.alertTriangle}</span
|
||||
>
|
||||
<span class="chat-composer-neighbor-card__copy"
|
||||
><strong
|
||||
>${t("newSession.placementStartFailed", {
|
||||
error: status.error ?? t("newSession.createFailed"),
|
||||
})}</strong
|
||||
></span
|
||||
>
|
||||
${status.retryable && onRetry
|
||||
? html`<button class="btn btn--sm" type="button" @click=${onRetry}>
|
||||
${t("common.retry")}
|
||||
</button>`
|
||||
: nothing}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
return html`
|
||||
<div
|
||||
class="chat-composer-neighbor-card chat-composer-neighbor-card--info chat-cloud-startup"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<span class="chat-composer-neighbor-card__icon chat-cloud-startup__spinner" aria-hidden="true"
|
||||
>${icons.loader}</span
|
||||
>
|
||||
<span class="chat-composer-neighbor-card__copy">
|
||||
<strong>${placementStartupStatusLabel(status)}</strong>
|
||||
<openclaw-elapsed-time
|
||||
class="chat-working-indicator__elapsed"
|
||||
.startMs=${status.startedAt}
|
||||
></openclaw-elapsed-time>
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
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<ChatItem, { kind: "reading-indicator" }>,
|
||||
options: {
|
||||
waitingApproval?: boolean;
|
||||
startupPhase?: ChatRunStartupPhase;
|
||||
startupLabel?: string;
|
||||
outputTokens?: number | null;
|
||||
presentation?: "standalone" | "continuation";
|
||||
} = {},
|
||||
@@ -166,9 +93,9 @@ export function renderChatWorkingIndicator(
|
||||
<span class="chat-working-indicator__status">
|
||||
${waitingApproval
|
||||
? html`<span>${t("chat.waitingForApproval")}</span>`
|
||||
: options.startupPhase
|
||||
: options.startupLabel
|
||||
? html`
|
||||
<span>${startupStatusLabel(options.startupPhase)}</span>
|
||||
<span>${options.startupLabel}</span>
|
||||
<openclaw-elapsed-time
|
||||
class="chat-working-indicator__elapsed"
|
||||
.startMs=${part.startedAt}
|
||||
|
||||
@@ -507,10 +507,6 @@ openclaw-chat-page {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.chat-cloud-startup__spinner svg {
|
||||
animation: compaction-spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.chat-composer-neighbor-card.chat-workspace-conflict-notice {
|
||||
width: calc(100% - 8px);
|
||||
|
||||
Reference in New Issue
Block a user