diff --git a/ui/src/app/bootstrap.ts b/ui/src/app/bootstrap.ts index 678a6cff2180..baa2ed75861d 100644 --- a/ui/src/app/bootstrap.ts +++ b/ui/src/app/bootstrap.ts @@ -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 = { 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) { diff --git a/ui/src/app/context.ts b/ui/src/app/context.ts index e276b45cc210..8c38d747b0b4 100644 --- a/ui/src/app/context.ts +++ b/ui/src/app/context.ts @@ -116,6 +116,11 @@ export type ApplicationContext = { 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; readonly replace: (routeId: TRouteId, options?: ApplicationNavigationOptions) => void; readonly revalidate: (routeId?: TRouteId) => Promise; readonly preload: (routeId: TRouteId) => Promise; diff --git a/ui/src/app/route-transition.test.ts b/ui/src/app/route-transition.test.ts new file mode 100644 index 000000000000..e9123262cc10 --- /dev/null +++ b/ui/src/app/route-transition.test.ts @@ -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; + }; + 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((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((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(); + }); +}); diff --git a/ui/src/app/route-transition.ts b/ui/src/app/route-transition.ts new file mode 100644 index 000000000000..6b930f33ae55 --- /dev/null +++ b/ui/src/app/route-transition.ts @@ -0,0 +1,76 @@ +import type { RouteId } from "../app-routes.ts"; + +type RouteTransitionOptions = { + document: Document; + from: RouteId | undefined; + navigate: () => Promise; + prepare?: () => Promise; + 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((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, + prefersReducedMotion: boolean, +) { + const outlet = document.querySelector }>( + "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 { + 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); +} diff --git a/ui/src/e2e/new-session-page.transition.e2e.test.ts b/ui/src/e2e/new-session-page.transition.e2e.test.ts new file mode 100644 index 000000000000..4ea6610eb67c --- /dev/null +++ b/ui/src/e2e/new-session-page.transition.e2e.test.ts @@ -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((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(); + } + }); +}); diff --git a/ui/src/lib/sessions/route-navigation.ts b/ui/src/lib/sessions/route-navigation.ts index 670f3d3604bb..e9676a139584 100644 --- a/ui/src/lib/sessions/route-navigation.ts +++ b/ui/src/lib/sessions/route-navigation.ts @@ -36,6 +36,7 @@ type ContextSessionNavigationTargetParams = { 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( 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 diff --git a/ui/src/pages/chat/chat-page.ts b/ui/src/pages/chat/chat-page.ts index 3379ec14a303..5478b43a5f1e 100644 --- a/ui/src/pages/chat/chat-page.ts +++ b/ui/src/pages/chat/chat-page.ts @@ -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({ diff --git a/ui/src/pages/chat/chat-pane-lifecycle.ts b/ui/src/pages/chat/chat-pane-lifecycle.ts index 1a3a6a28ca1a..23ce7ffd1740 100644 --- a/ui/src/pages/chat/chat-pane-lifecycle.ts +++ b/ui/src/pages/chat/chat-pane-lifecycle.ts @@ -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 = 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(CHAT_COMPOSER_TEXTAREA_SELECTOR); const input = textarea?.closest(".agent-chat__input"); diff --git a/ui/src/pages/chat/route-draft-focus-handoff.ts b/ui/src/pages/chat/route-draft-focus-handoff.ts index 24493c1462f9..6afa54ca6b32 100644 --- a/ui/src/pages/chat/route-draft-focus-handoff.ts +++ b/ui/src/pages/chat/route-draft-focus-handoff.ts @@ -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( diff --git a/ui/src/pages/chat/route-draft.ts b/ui/src/pages/chat/route-draft.ts index 095f332a6949..000d32237ac3 100644 --- a/ui/src/pages/chat/route-draft.ts +++ b/ui/src/pages/chat/route-draft.ts @@ -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() : ""; diff --git a/ui/src/pages/new-session/composer.test.ts b/ui/src/pages/new-session/composer.test.ts index 4f9095522f6b..bc3ee6f0d250 100644 --- a/ui/src/pages/new-session/composer.test.ts +++ b/ui/src/pages/new-session/composer.test.ts @@ -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(".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({ diff --git a/ui/src/pages/new-session/composer.ts b/ui/src/pages/new-session/composer.ts index 889c25c393d2..02027d9070c2 100644 --- a/ui/src/pages/new-session/composer.ts +++ b/ui/src/pages/new-session/composer.ts @@ -56,8 +56,9 @@ function renderStartControl(options: NewSessionComposerOptions) {