mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
+23
-9
@@ -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) {
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user