fix(ui): show progress during new session startup (#122713)

* fix(ui): show progress during new session startup

Refs #122703

* oc-1fa: keep new-session progress through handoff

* fix(ui): keep new-session handoff live

* fix(ui): preserve navigation callback contract
This commit is contained in:
Josh Lehman
2026-08-12 17:17:12 -07:00
committed by GitHub
parent 800328f14e
commit a3207b574c
15 changed files with 478 additions and 24 deletions
+23 -9
View File
@@ -44,6 +44,7 @@ import { createNativeChatDrafts } from "./native-bridge.ts";
import { startNativeLinkRouting } from "./native-link-routing.ts";
import { createNativeNotificationsCapability } from "./native-notifications.ts";
import { createApplicationOverlays } from "./overlays.ts";
import { navigateWithRouteTransition } from "./route-transition.ts";
import {
loadSettings,
patchSettings,
@@ -450,6 +451,26 @@ export function bootstrapApplication(
const cancelPendingGatewayConnection = () => {
pendingGatewayConnection = null;
};
const navigateAndWait = (routeId: RouteId, options?: ApplicationNavigationOptions) => {
const location = routeLocation(routeId, options);
// Preserve pre-start navigation exactly as the fire-and-forget entry point does.
if (!routerStarted) {
pendingRouterStartNavigation = { routeId, location, mode: "push" };
}
// New-session submission awaits this promise so its live progress remains
// visible until the destination route has completed the UI handoff.
return navigateWithRouteTransition({
document,
from: router.getState().matches[0]?.routeId,
to: routeId,
prefersReducedMotion:
globalThis.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false,
prepare: () => router.preloadLocation(location, context),
navigate: () => router.navigate(routeId, context, { history: "push" }, location),
}).catch((error: unknown) => {
console.error("[openclaw] route navigation failed", error);
});
};
const context: ApplicationContext<RouteId> = {
basePath,
gateway,
@@ -472,16 +493,9 @@ export function bootstrapApplication(
initialUserMessage,
chatAttachmentHandoff,
navigate: (routeId, options) => {
const location = routeLocation(routeId, options);
if (!routerStarted) {
pendingRouterStartNavigation = { routeId, location, mode: "push" };
}
void router
.navigate(routeId, context, { history: "push" }, location)
.catch((error: unknown) => {
console.error("[openclaw] route navigation failed", error);
});
void navigateAndWait(routeId, options);
},
navigateAndWait,
replace: (routeId, options) => {
const location = routeLocation(routeId, options);
if (!routerStarted) {
+5
View File
@@ -116,6 +116,11 @@ export type ApplicationContext<TRouteId extends string = string> = {
readonly initialUserMessage: ApplicationInitialUserMessageHandoff;
readonly chatAttachmentHandoff: ApplicationChatAttachmentHandoff;
readonly navigate: (routeId: TRouteId, options?: ApplicationNavigationOptions) => void;
/** Navigates and resolves after any route-specific handoff completes. */
readonly navigateAndWait: (
routeId: TRouteId,
options?: ApplicationNavigationOptions,
) => Promise<void>;
readonly replace: (routeId: TRouteId, options?: ApplicationNavigationOptions) => void;
readonly revalidate: (routeId?: TRouteId) => Promise<void>;
readonly preload: (routeId: TRouteId) => Promise<void>;
+139
View File
@@ -0,0 +1,139 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { CHAT_ROUTE_READY_EVENT, navigateWithRouteTransition } from "./route-transition.ts";
function testDocumentWithOutlet(animate = vi.fn()) {
const outlet = document.createElement("openclaw-router-outlet") as HTMLElement & {
updateComplete: Promise<void>;
};
outlet.updateComplete = Promise.resolve();
outlet.animate = animate;
document.body.append(outlet);
return {
animate,
document,
outlet,
};
}
afterEach(() => document.body.replaceChildren());
describe("navigateWithRouteTransition", () => {
it("keeps the outgoing view live until the destination is prepared", async () => {
let finishPreparation!: () => void;
const prepare = vi.fn(
() =>
new Promise<void>((resolve) => {
finishPreparation = resolve;
}),
);
const navigate = vi.fn(async () => undefined);
const test = testDocumentWithOutlet();
const transition = navigateWithRouteTransition({
document: test.document,
from: "new-session",
to: "chat",
navigate,
prepare,
prefersReducedMotion: false,
});
await Promise.resolve();
expect(prepare).toHaveBeenCalledOnce();
expect(navigate).not.toHaveBeenCalled();
finishPreparation();
await vi.waitFor(() => expect(navigate).toHaveBeenCalledOnce());
document.dispatchEvent(new Event(CHAT_ROUTE_READY_EVENT));
await transition;
expect(navigate).toHaveBeenCalledOnce();
});
it("animates the rendered chat pane without freezing the outgoing document", async () => {
const finished = Promise.resolve({} as Animation);
const animate = vi.fn(() => ({ finished }) as Animation);
const test = testDocumentWithOutlet(animate);
let finishNavigation!: () => void;
const navigate = vi.fn(
() =>
new Promise<void>((resolve) => {
finishNavigation = resolve;
}),
);
const transition = navigateWithRouteTransition({
document: test.document,
from: "new-session",
to: "chat",
navigate,
prefersReducedMotion: false,
});
await vi.waitFor(() => expect(navigate).toHaveBeenCalledOnce());
expect(animate).not.toHaveBeenCalled();
finishNavigation();
document.dispatchEvent(new Event(CHAT_ROUTE_READY_EVENT));
await transition;
expect(navigate).toHaveBeenCalledOnce();
expect(animate).toHaveBeenCalledWith(
[{ transform: "translateY(5px) scale(0.997)" }, { transform: "none" }],
{ duration: 180, easing: "cubic-bezier(0.16, 1, 0.3, 1)" },
);
});
it("navigates directly when destination preparation fails", async () => {
const test = testDocumentWithOutlet();
const navigate = vi.fn(async () => undefined);
await navigateWithRouteTransition({
document: test.document,
from: "new-session",
to: "chat",
navigate,
prepare: async () => {
throw new Error("preload failed");
},
prefersReducedMotion: false,
});
expect(navigate).toHaveBeenCalledOnce();
expect(test.animate).not.toHaveBeenCalled();
});
it.each([
{ from: "about" as const, to: "chat" as const, prefersReducedMotion: false },
{ from: "new-session" as const, to: "about" as const, prefersReducedMotion: false },
])("navigates directly for $from to $to", async ({ from, to, prefersReducedMotion }) => {
const test = testDocumentWithOutlet();
const navigate = vi.fn(async () => undefined);
await navigateWithRouteTransition({
document: test.document,
from,
to,
navigate,
prefersReducedMotion,
});
expect(navigate).toHaveBeenCalledOnce();
expect(test.animate).not.toHaveBeenCalled();
});
it("waits for the chat route without animating when motion is reduced", async () => {
const test = testDocumentWithOutlet();
const navigate = vi.fn(async () => undefined);
const transition = navigateWithRouteTransition({
document: test.document,
from: "new-session",
to: "chat",
navigate,
prefersReducedMotion: true,
});
await vi.waitFor(() => expect(navigate).toHaveBeenCalledOnce());
document.dispatchEvent(new Event(CHAT_ROUTE_READY_EVENT));
await transition;
expect(test.animate).not.toHaveBeenCalled();
});
});
+76
View File
@@ -0,0 +1,76 @@
import type { RouteId } from "../app-routes.ts";
type RouteTransitionOptions = {
document: Document;
from: RouteId | undefined;
navigate: () => Promise<void>;
prepare?: () => Promise<void>;
prefersReducedMotion: boolean;
to: RouteId;
};
export const CHAT_ROUTE_READY_EVENT = "openclaw-chat-route-ready";
const SESSION_ROUTE_ENTER_KEYFRAMES: Keyframe[] = [
{ transform: "translateY(5px) scale(0.997)" },
{ transform: "none" },
];
const SESSION_ROUTE_ENTER_OPTIONS: KeyframeAnimationOptions = {
duration: 180,
easing: "cubic-bezier(0.16, 1, 0.3, 1)",
};
function waitForChatRouteReady(document: Document) {
if (document.querySelector(".agent-chat__composer-combobox")) {
return { cancel: () => undefined, ready: Promise.resolve() };
}
let resolve!: () => void;
const ready = new Promise<void>((next) => {
resolve = next;
});
const handleReady = () => resolve();
document.addEventListener(CHAT_ROUTE_READY_EVENT, handleReady, { once: true });
return {
cancel: () => document.removeEventListener(CHAT_ROUTE_READY_EVENT, handleReady),
ready,
};
}
async function navigateAndAnimate(
document: Document,
navigate: () => Promise<void>,
prefersReducedMotion: boolean,
) {
const outlet = document.querySelector<HTMLElement & { updateComplete?: Promise<unknown> }>(
"openclaw-router-outlet",
);
const chatReady = waitForChatRouteReady(document);
try {
await navigate();
await outlet?.updateComplete;
await chatReady.ready;
} finally {
chatReady.cancel();
}
if (prefersReducedMotion) {
return;
}
const animation = outlet?.animate?.(SESSION_ROUTE_ENTER_KEYFRAMES, SESSION_ROUTE_ENTER_OPTIONS);
await animation?.finished.catch(() => undefined);
}
export async function navigateWithRouteTransition(options: RouteTransitionOptions): Promise<void> {
const { document, from, navigate, prepare, prefersReducedMotion, to } = options;
if (from !== "new-session" || to !== "chat") {
return navigate();
}
try {
await prepare?.();
} catch {
// Preparation is an enhancement. Preserve direct navigation so its normal
// route error handling remains authoritative when preloading fails.
return navigate();
}
return navigateAndAnimate(document, navigate, prefersReducedMotion);
}
@@ -0,0 +1,153 @@
import { mkdir } from "node:fs/promises";
import path from "node:path";
import { expect, it } from "vitest";
import {
createNewSessionPageE2eSuite,
createdSessionListResult,
installMockGateway,
waitForCommittedChatRoute,
} from "./new-session-page.test-support.ts";
const suite = createNewSessionPageE2eSuite();
const SESSION_KEY = "agent:main:transition-proof-0f403cb8-3920-4cf1-8eb7-79f2f00ce488";
const RUN_ID = "transition-proof-run";
const proofDir = path.join(process.cwd(), ".artifacts", "control-ui-e2e", "new-session-transition");
const captureProofEnabled = process.env.OPENCLAW_CAPTURE_UI_PROOF === "1";
async function captureProof(page: import("playwright").Page, fileName: string) {
if (!captureProofEnabled) {
return;
}
await mkdir(proofDir, { recursive: true });
await page.screenshot({ fullPage: true, path: path.join(proofDir, fileName) });
}
suite.define(() => {
it("keeps the new-session view live until the focused chat is ready", async () => {
const context = await suite.browser.newContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
});
const page = await context.newPage();
let releaseChatModule!: () => void;
let chatModuleRequested = false;
const chatModuleBlocked = new Promise<void>((resolve) => {
releaseChatModule = resolve;
});
await page.route("**/assets/chat-page-*.js*", async (route) => {
chatModuleRequested = true;
await chatModuleBlocked;
await route.continue();
});
const gateway = await installMockGateway(page, {
methodResponses: {
"sessions.create": {
key: SESSION_KEY,
messageSeq: 1,
runId: RUN_ID,
runStarted: true,
},
"sessions.list": createdSessionListResult(SESSION_KEY),
},
});
try {
await page.goto(`${suite.server.baseUrl}new`);
const message = page.locator(".new-session-page__message");
const start = page.locator(".new-session-page__start-submit");
await message.fill("keep progress moving");
await expect.poll(() => start.isEnabled()).toBe(true);
await gateway.deferNext("sessions.create");
await start.click();
await gateway.waitForRequest("sessions.create");
await gateway.resolveDeferred("sessions.create", {
key: SESSION_KEY,
messageSeq: 1,
runId: RUN_ID,
runStarted: true,
});
await expect.poll(() => chatModuleRequested).toBe(true);
await expect.poll(() => start.getAttribute("aria-busy")).toBe("true");
const spinner = start.locator("svg");
const initialSpinnerTransform = await spinner.evaluate(
(element) => getComputedStyle(element).transform,
);
await expect
.poll(() => spinner.evaluate((element) => getComputedStyle(element).transform))
.not.toBe(initialSpinnerTransform);
await captureProof(page, "01-chat-route-preparing.png");
await page.evaluate(() => {
const frames = { invalid: 0, running: true };
Reflect.set(globalThis, "__openclawSessionTransitionFrames", frames);
const sample = () => {
const outlet = document.querySelector("openclaw-router-outlet");
const handoffCover = outlet?.classList.contains("session-route-handoff") === true;
const newSessionVisible = Boolean(
document.querySelector(".new-session-page__start-submit")?.getClientRects().length,
);
const chatVisible = Boolean(
document.querySelector(".agent-chat__composer-combobox")?.getClientRects().length,
);
if (handoffCover || (!newSessionVisible && !chatVisible)) {
frames.invalid += 1;
}
if (frames.running) {
requestAnimationFrame(sample);
}
};
requestAnimationFrame(sample);
});
await gateway.deferNext("chat.startup");
releaseChatModule();
await gateway.waitForRequest("chat.startup");
await expect
.poll(() =>
page.evaluate(() => ({
activeViewTransition: Boolean(document.activeViewTransition),
chatSurfaceReady: Boolean(document.querySelector(".agent-chat__composer-combobox")),
routeAnimation: document.getAnimations().some((animation) => {
const effect = animation.effect as KeyframeEffect | null;
return (
effect?.target instanceof HTMLElement &&
effect.target.tagName === "OPENCLAW-ROUTER-OUTLET" &&
effect.getKeyframes().every((keyframe) => keyframe.opacity === undefined)
);
}),
})),
)
.toEqual({ activeViewTransition: false, chatSurfaceReady: true, routeAnimation: true });
await expect
.poll(() => page.getByText("keep progress moving", { exact: true }).count())
.toBe(1);
const invalidFrames = await page.evaluate(() => {
const frames = Reflect.get(globalThis, "__openclawSessionTransitionFrames") as {
invalid: number;
running: boolean;
};
frames.running = false;
return frames.invalid;
});
expect(invalidFrames).toBe(0);
await captureProof(page, "02-session-route-transition.png");
await gateway.resolveDeferred("chat.startup");
await waitForCommittedChatRoute(page);
await page.locator("openclaw-chat-page").waitFor();
await expect
.poll(() =>
page.evaluate(
() =>
document.activeElement?.matches(".agent-chat__composer-combobox textarea") === true,
),
)
.toBe(true);
await captureProof(page, "03-chat-route-ready.png");
} finally {
releaseChatModule();
await context.close();
}
});
});
+5
View File
@@ -36,6 +36,7 @@ type ContextSessionNavigationTargetParams<TRouteId extends string> = {
mainKey?: never;
shortIdLength?: number;
preferenceDerivedFace?: boolean;
focusComposer?: boolean;
navigationKey?: string;
};
@@ -50,6 +51,7 @@ type ExplicitSessionNavigationTargetParams = {
shortIdLength?: number;
agentId?: never;
preferenceDerivedFace?: boolean;
focusComposer?: boolean;
navigationKey?: string;
};
@@ -183,6 +185,9 @@ export function sessionNavigationTarget<TRouteId extends string>(
if (params.preferenceDerivedFace && !row) {
navigationParams.set(SESSION_FACE_PREFERENCE_PARAM, "1");
}
if (params.focusComposer) {
navigationParams.set(SESSION_COMPOSER_FOCUS_PARAM, "1");
}
const navigationKey = params.navigationKey?.trim() || row?.key;
if (navigationKey && SESSION_KEY_UUID_SUFFIX_RE.test(navigationKey)) {
// Sidebar navigation already owns the full row. Carry its key only through the
+12 -3
View File
@@ -142,7 +142,11 @@ export class ChatPage extends OpenClawLightDomElement {
const data = this.data;
const activePane = this.layout ? findPane(this.layout, this.layout.activePaneId)?.pane : null;
const activeSessionKey = this.layout ? (activePane?.sessionKey ?? null) : undefined;
const draftRendered = this.draftFocus.rendered(data, activeSessionKey, this.consumedDraftData);
const routeHandoffRendered = this.draftFocus.rendered(
data,
activeSessionKey,
this.consumedDraftData,
);
if (changedProperties.has("data")) {
this.routeHref = window.location.href;
if (
@@ -171,7 +175,7 @@ export class ChatPage extends OpenClawLightDomElement {
this.syncRouteToActivePane();
this.retainedSessions.settleRoute(data.sessionKey);
}
if (data && draftRendered) {
if (data && routeHandoffRendered) {
queueMicrotask(() => {
if (this.isConnected && this.data === data && this.consumedDraftData !== data) {
this.draftFocus.beforeDraftCleanup(data);
@@ -388,7 +392,12 @@ export class ChatPage extends OpenClawLightDomElement {
private updateRoute(sessionKey: string, replace = false, face = this.data.face ?? "chat") {
const data = this.data;
if (data?.sessionKey === sessionKey && (data.face ?? "chat") === face && !data.draft) {
if (
data?.sessionKey === sessionKey &&
(data.face ?? "chat") === face &&
!data.draft &&
!data.focusComposer
) {
return;
}
const options = sessionNavigationTarget({
+8
View File
@@ -10,6 +10,7 @@ import {
disposeQuestionPromptState,
handleQuestionPromptEvent,
} from "../../app/question-prompt.ts";
import { CHAT_ROUTE_READY_EVENT } from "../../app/route-transition.ts";
import { readPresenceEntries } from "../../app/user-profile.ts";
import { BROWSER_ANNOTATION_EVENT } from "../../components/browser/browser-annotation.ts";
import { t } from "../../i18n/index.ts";
@@ -62,6 +63,7 @@ const COMPOSER_PREFILL_ATTENTION_DURATION_MS = 1_200;
const COMPOSER_PREFILL_ATTENTION_CLASS = "agent-chat__input--prefill-attention";
export abstract class ChatPaneLifecycle extends ChatPaneSessionCreation {
private chatRouteReadyReported = false;
private stagedAttachmentGatewayOwner: ChatAttachmentGatewayOwner = null;
private suppressStagedAttachmentHandoffOnDisconnect = false;
@@ -555,6 +557,12 @@ export abstract class ChatPaneLifecycle extends ChatPaneSessionCreation {
}
override updated(changedProperties: Map<PropertyKey, unknown> = new Map()) {
if (!this.chatRouteReadyReported && this.querySelector(CHAT_COMPOSER_TEXTAREA_SELECTOR)) {
// The outer router commit is not a meaningful chat paint. Keep the
// handoff cover until this pane has committed its usable composer.
this.chatRouteReadyReported = true;
this.dispatchEvent(new Event(CHAT_ROUTE_READY_EVENT, { bubbles: true, composed: true }));
}
if (changedProperties.has("focusComposer") && this.focusComposer) {
const textarea = this.querySelector<HTMLTextAreaElement>(CHAT_COMPOSER_TEXTAREA_SELECTOR);
const input = textarea?.closest<HTMLElement>(".agent-chat__input");
@@ -60,7 +60,12 @@ export class RouteDraftComposerFocus {
pendingHandoff = undefined;
this.maintain(data.sessionKey);
}
return Boolean(data?.draft && consumedData !== data && matchesActivePane);
return Boolean(
data &&
consumedData !== data &&
matchesActivePane &&
(data.draft !== undefined || data.focusComposer),
);
}
shouldFocusPane(
+3 -2
View File
@@ -22,9 +22,10 @@ export function locationWithoutDraft(location: RouteLocation): RouteLocation {
export function draftRouteDataFromLocation(location: RouteLocation): RouteDraftHint {
const draft = draftFromLocation(location);
const focusComposer = focusComposerFromLocation(location);
return {
draft,
...(draft && focusComposerFromLocation(location) ? { focusComposer: true } : {}),
...(focusComposer ? { focusComposer: true } : {}),
};
}
@@ -34,7 +35,7 @@ export function draftSearchFromLocation(location: RouteLocation): string {
if (draft) {
search.set("draft", draft);
}
if (draft && focusComposerFromLocation(location)) {
if (focusComposerFromLocation(location)) {
search.set(SESSION_COMPOSER_FOCUS_PARAM, "1");
}
return search.size > 0 ? "?" + search.toString() : "";
@@ -163,6 +163,14 @@ describe("new-session composer start control", () => {
expect(composer.querySelector("wa-dropdown-item[value='start-terminal']")).toBeNull();
});
it("marks the Start button busy while the session is starting", () => {
const { composer } = renderComposer({ submitting: true });
const start = composer.querySelector<HTMLButtonElement>(".new-session-page__start-submit");
expect(start?.getAttribute("aria-busy")).toBe("true");
expect(start?.getAttribute("aria-label")).toBe("Starting…");
});
it("renders the terminal action as a secondary split-button menu item", () => {
const onStart = vi.fn();
const { composer } = renderComposer({
+4 -2
View File
@@ -56,8 +56,9 @@ function renderStartControl(options: NewSessionComposerOptions) {
<openclaw-tooltip content=${options.submitDisabledReason ?? t("newSession.start")}>
<button
type="button"
class="chat-send-btn"
class="chat-send-btn new-session-page__start-submit"
?disabled=${!options.canSubmit}
aria-busy=${String(options.submitting)}
aria-label=${startLabel}
@click=${options.onSubmit}
>
@@ -72,8 +73,9 @@ function renderStartControl(options: NewSessionComposerOptions) {
<openclaw-tooltip content=${options.submitDisabledReason ?? t("newSession.start")}>
<button
type="button"
class="chat-send-btn new-session-page__start-primary"
class="chat-send-btn new-session-page__start-submit new-session-page__start-primary"
?disabled=${!options.canSubmit}
aria-busy=${String(options.submitting)}
aria-label=${startLabel}
@click=${options.onSubmit}
>
@@ -19,7 +19,7 @@ afterEach(() => {
});
describe("DraftSubmissionFlow", () => {
it("hands cloud startup to the application owner and navigates immediately", async () => {
it("keeps startup progress active through the navigation handoff", async () => {
const createResult = vi.fn(async (params: Record<string, unknown>) => ({
key: String(params.key),
initialRun: { status: "idle" as const },
@@ -30,7 +30,14 @@ describe("DraftSubmissionFlow", () => {
// Application-owned startup intentionally outlives this route.
}),
);
const navigate = vi.fn();
let finishNavigation!: () => void;
const navigateAndWait = vi.fn(
() =>
new Promise<void>((resolve) => {
finishNavigation = resolve;
}),
);
const preload = vi.fn(async () => undefined);
const setSessionKey = vi.fn();
const selectAgent = vi.fn();
const client = {
@@ -78,7 +85,8 @@ describe("DraftSubmissionFlow", () => {
sessions: { state: { result: null }, createResult },
cloudStartup: { start },
config: { current: {} },
navigate,
navigateAndWait,
preload,
} as unknown as ApplicationContext;
const host = new ControllerHost();
const gateway = new DraftGatewayState(
@@ -186,7 +194,12 @@ describe("DraftSubmissionFlow", () => {
},
]);
await flow.submit();
const submission = flow.submit();
await vi.waitFor(() => expect(navigateAndWait).toHaveBeenCalledOnce());
expect(flow.submitting).toBe(true);
finishNavigation();
await submission;
expect(start).toHaveBeenCalledOnce();
expect(start.mock.calls[0]?.[0].recovery).toMatchObject({
@@ -200,6 +213,6 @@ describe("DraftSubmissionFlow", () => {
expect(createResult).toHaveBeenCalledOnce();
expect(setSessionKey).toHaveBeenCalledWith(start.mock.calls[0]?.[0].recovery.sessionKey);
expect(selectAgent).toHaveBeenCalledWith("cloud");
expect(navigate).toHaveBeenCalledOnce();
expect(preload).not.toHaveBeenCalled();
});
});
@@ -528,13 +528,14 @@ export class DraftSubmissionFlow {
sessionKey: result.key,
agentId: submissionAgentId,
});
context.navigate(
await context.navigateAndWait(
"chat",
sessionNavigationTarget({
context,
face: "chat",
sessionKey: result.key,
agentId: this.place.agentId,
focusComposer: true,
}).options,
);
return;
@@ -571,13 +572,14 @@ export class DraftSubmissionFlow {
sessionKey: result.key,
agentId: submissionAgentId,
});
context.navigate(
await context.navigateAndWait(
"chat",
sessionNavigationTarget({
context,
face: "chat",
sessionKey: result.key,
agentId: this.place.agentId,
focusComposer: true,
}).options,
);
} finally {
+14
View File
@@ -429,6 +429,16 @@ wa-popover.new-session-page__place-popover::part(body) {
align-items: center;
}
.new-session-page__start-submit[aria-busy="true"] svg {
animation: new-session-start-spin 0.75s linear infinite;
}
@keyframes new-session-start-spin {
to {
transform: rotate(1turn);
}
}
.new-session-page__start-primary {
width: 34px;
min-width: 34px;
@@ -492,6 +502,10 @@ wa-dropdown.new-session-page__start-menu {
}
@media (prefers-reduced-motion: reduce) {
.new-session-page__start-submit[aria-busy="true"] svg {
animation: none;
}
.new-session-page__composer::after,
.new-session-page__composer > *,
.new-session-page__visibility {