From a1ed982cd72dd1729fb0d4c8d855b20a98b3453e Mon Sep 17 00:00:00 2001 From: Jacob Tomlinson Date: Sat, 1 Aug 2026 22:02:38 +0100 Subject: [PATCH] fix(ui): focus capability prompt (#117279) * fix(ui): focus capability prompt * fix(ui): honor reduced motion for composer cue * fix(ui): strengthen composer prefill cue * fix(ui): preserve composer focus after menu close --- .../app-sidebar-session-navigation.ts | 4 +- ui/src/e2e/sidebar-interactions.e2e.test.ts | 81 +++++++++++ ui/src/lib/sessions/route-navigation.ts | 5 + ui/src/pages/chat/chat-page.test.ts | 70 +++++++++- ui/src/pages/chat/chat-page.ts | 30 ++-- ui/src/pages/chat/chat-pane-base.ts | 3 + ui/src/pages/chat/chat-pane-lifecycle.test.ts | 50 +++++++ ui/src/pages/chat/chat-pane-lifecycle.ts | 37 ++++- .../pages/chat/route-draft-focus-handoff.ts | 132 ++++++++++++++++++ ui/src/pages/chat/route-draft.ts | 42 ++++++ ui/src/pages/chat/route-loader.ts | 28 ++-- ui/src/pages/chat/route.test.ts | 11 +- ui/src/styles/chat/layout.css | 31 ++++ .../app-sidebar-cases/agent-menu.ts | 34 ++++- 14 files changed, 514 insertions(+), 44 deletions(-) create mode 100644 ui/src/pages/chat/route-draft-focus-handoff.ts create mode 100644 ui/src/pages/chat/route-draft.ts diff --git a/ui/src/components/app-sidebar-session-navigation.ts b/ui/src/components/app-sidebar-session-navigation.ts index c7a7a3722350..0e8154281399 100644 --- a/ui/src/components/app-sidebar-session-navigation.ts +++ b/ui/src/components/app-sidebar-session-navigation.ts @@ -13,6 +13,7 @@ import { sessionMatchesArchivedFilter, } from "../lib/sessions/index.ts"; import { + composerDraftSearch, resolveSessionPreferredFace, sessionNavigationTarget, } from "../lib/sessions/route-navigation.ts"; @@ -547,7 +548,6 @@ export class AppSidebarSessionNavigationElement extends AppSidebarBase { return; } const key = this.agentResumeKey(agentId); - const draft = encodeURIComponent(t("chat.welcome.suggestions.whatCanYouDo")); const target = sessionNavigationTarget({ face: "chat", sessionKey: key, @@ -559,7 +559,7 @@ export class AppSidebarSessionNavigationElement extends AppSidebarBase { this.setApplicationSession(key, this.selectedAgentIdForSessions()); this.onNavigate?.("chat", { ...target.options, - search: `?draft=${draft}`, + search: composerDraftSearch(t("chat.welcome.suggestions.whatCanYouDo")), }); } diff --git a/ui/src/e2e/sidebar-interactions.e2e.test.ts b/ui/src/e2e/sidebar-interactions.e2e.test.ts index d12077d66f99..bbb962b4dfac 100644 --- a/ui/src/e2e/sidebar-interactions.e2e.test.ts +++ b/ui/src/e2e/sidebar-interactions.e2e.test.ts @@ -9,6 +9,87 @@ import { const suite = createSidebarCustomizationSuite("Control UI sidebar interactions mocked Gateway E2E"); suite.define(() => { + async function openCapabilitiesPrompt(reducedMotion: "no-preference" | "reduce") { + const context = await suite.newBrowserContext({ + locale: "en-US", + reducedMotion, + serviceWorkers: "block", + viewport: { height: 900, width: 1440 }, + }); + const page = await context.newPage(); + await installMockGateway(page); + await page.goto(`${suite.server.baseUrl}chat`); + + const sidebar = page.locator("openclaw-app-sidebar"); + await sidebar.locator(".sidebar-agent-card__main").click(); + await sidebar + .locator('wa-dropdown.sidebar-agent-menu wa-dropdown-item[value="command:capabilities"]') + .click(); + + const textarea = page.locator(".agent-chat__composer-combobox > textarea"); + await expect.poll(() => textarea.inputValue()).toBe("What can you do?"); + await expect + .poll(() => textarea.evaluate((element) => element === document.activeElement)) + .toBe(true); + const input = textarea.locator("xpath=ancestor::*[contains(@class, 'agent-chat__input')][1]"); + await expect + .poll(() => input.getAttribute("class")) + .toContain("agent-chat__input--prefill-attention"); + return { context, input, page }; + } + + it("focuses and highlights the composer from the agent capabilities action", async () => { + const { context, input, page } = await openCapabilitiesPrompt("no-preference"); + + try { + await expect + .poll(() => input.evaluate((element) => getComputedStyle(element).animationName)) + .toBe("chat-composer-prefill-attention"); + const highlightedBackground = await input.evaluate( + (element) => getComputedStyle(element).backgroundColor, + ); + await captureSidebarUiProof(page, "capabilities-composer-focus.png"); + await expect + .poll(() => input.getAttribute("class")) + .not.toContain("agent-chat__input--prefill-attention"); + expect(await input.evaluate((element) => getComputedStyle(element).backgroundColor)).not.toBe( + highlightedBackground, + ); + } finally { + await suite.closeBrowserContext(context); + } + }); + + it("keeps a static composer cue when reduced motion is requested", async () => { + const { context, input, page } = await openCapabilitiesPrompt("reduce"); + + try { + await expect + .poll(() => + input.evaluate((element) => { + const style = getComputedStyle(element); + return { animationName: style.animationName, boxShadow: style.boxShadow }; + }), + ) + .toEqual(expect.objectContaining({ animationName: "none" })); + await expect + .poll(() => input.evaluate((element) => getComputedStyle(element).boxShadow)) + .not.toBe("none"); + const highlightedBackground = await input.evaluate( + (element) => getComputedStyle(element).backgroundColor, + ); + await captureSidebarUiProof(page, "capabilities-composer-reduced-motion.png"); + await expect + .poll(() => input.getAttribute("class")) + .not.toContain("agent-chat__input--prefill-attention"); + expect(await input.evaluate((element) => getComputedStyle(element).backgroundColor)).not.toBe( + highlightedBackground, + ); + } finally { + await suite.closeBrowserContext(context); + } + }); + it("restores focus to the Pages edit button after closing the pin editor with Escape", async () => { const { context, page } = await openSidebarCustomizationPage(suite); diff --git a/ui/src/lib/sessions/route-navigation.ts b/ui/src/lib/sessions/route-navigation.ts index 7b82bdc6a0a2..ad117cbd42c2 100644 --- a/ui/src/lib/sessions/route-navigation.ts +++ b/ui/src/lib/sessions/route-navigation.ts @@ -17,6 +17,11 @@ import { export const SESSION_FACE_PREFERENCE_PARAM = "__openclawSessionFacePreference"; export const SESSION_NAVIGATION_KEY_PARAM = "__openclawSessionKey"; +export const SESSION_COMPOSER_FOCUS_PARAM = "__openclawComposerFocus"; + +export function composerDraftSearch(draft: string): string { + return `?${new URLSearchParams({ draft, [SESSION_COMPOSER_FOCUS_PARAM]: "1" }).toString()}`; +} const SESSION_KEY_UUID_SUFFIX_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu; diff --git a/ui/src/pages/chat/chat-page.test.ts b/ui/src/pages/chat/chat-page.test.ts index ce45c0be3f3c..177c74a9e661 100644 --- a/ui/src/pages/chat/chat-page.test.ts +++ b/ui/src/pages/chat/chat-page.test.ts @@ -32,7 +32,8 @@ import { SESSION_DRAG_MIME } from "../../lib/sessions/drag.ts"; import { sessionNavigationTarget } from "../../lib/sessions/route-navigation.ts"; import { createStorageMock } from "../../test-helpers/storage.ts"; import { ChatPage } from "./chat-page.ts"; -import { loadChatRoute } from "./route-loader.ts"; +import { routeDraft } from "./route-draft.ts"; +import { loadChatRoute, type SessionChatRouteData } from "./route-loader.ts"; const WORK_SESSION_KEY = "agent:main:dashboard:12345678-90ab-cdef-1234-567890abcdef"; const SESSION_VIEWERS_SET_METHOD = "sessions.viewers.set"; @@ -50,6 +51,7 @@ import { insertPane, type ChatSplitLayout } from "./split-layout.ts"; type RenderedPane = HTMLElement & { paneId: string; + focusComposer: boolean; chatMessagesBySession: ChatMessageCache; sessionKey: string; active: boolean; @@ -100,11 +102,11 @@ function setNarrow(page: ChatPage, narrow: boolean) { } function getRouteDraftForActivePane(page: ChatPage): string | undefined { - return ( - page as unknown as { - routeDraftForActivePane: () => string | undefined; - } - ).routeDraftForActivePane(); + const state = page as unknown as { + data: SessionChatRouteData; + consumedDraftData: SessionChatRouteData | null; + }; + return routeDraft(state.data, state.consumedDraftData); } function applySessionDrop(page: ChatPage, sessionKey: string, paneId: string, zone: SplitDropZone) { @@ -257,6 +259,62 @@ describe("chat page split layout host", () => { expect(typeof itemAt(panes, 0, "rendered pane").onOpenSplitView).toBe("function"); }); + it("hands route-owned focus to the final page across pane replacement", async () => { + const sourcePage = new ChatPage(); + setNavigationContext(sourcePage); + sourcePage.data = { + sessionKey: "main", + draft: "What can you do?", + focusComposer: true, + }; + const page = new ChatPage(); + setNavigationContext(page); + page.data = { sessionKey: "main" }; + + vi.useFakeTimers(); + try { + document.body.append(sourcePage); + await sourcePage.updateComplete; + await Promise.resolve(); + + document.body.append(page); + await page.updateComplete; + const pane = itemAt(page.querySelectorAll("openclaw-chat-pane"), 0, "pane"); + expect(pane.focusComposer).toBe(true); + + const combobox = document.createElement("div"); + combobox.className = "agent-chat__composer-combobox"; + const textarea = document.createElement("textarea"); + combobox.append(textarea); + pane.append(combobox); + vi.advanceTimersByTime(250); + expect(document.activeElement).toBe(textarea); + + const replacementPane = document.createElement("openclaw-chat-pane") as RenderedPane; + replacementPane.active = true; + replacementPane.sessionKey = "main"; + const replacementCombobox = document.createElement("div"); + replacementCombobox.className = "agent-chat__composer-combobox"; + const replacementTextarea = document.createElement("textarea"); + replacementCombobox.append(replacementTextarea); + replacementPane.append(replacementCombobox); + pane.replaceWith(replacementPane); + + vi.advanceTimersByTime(250); + expect(document.activeElement).toBe(replacementTextarea); + + const userTarget = document.createElement("button"); + document.body.append(userTarget); + userTarget.focus(); + vi.advanceTimersByTime(250); + expect(document.activeElement).toBe(userTarget); + } finally { + sourcePage.remove(); + page.remove(); + vi.useRealTimers(); + } + }); + it("passes the chat-owned gateway capability only to the rightmost pane", async () => { const gatewaySnapshot: NativeGatewaysSnapshot = { gateways: [], diff --git a/ui/src/pages/chat/chat-page.ts b/ui/src/pages/chat/chat-page.ts index c6f5f6c8bf4a..05f2d698d21b 100644 --- a/ui/src/pages/chat/chat-page.ts +++ b/ui/src/pages/chat/chat-page.ts @@ -23,6 +23,8 @@ import { stillOwnsCanonicalLocation } from "./chat-canonical-location.ts"; import { ChatViewerPresenceController } from "./chat-viewer-presence.ts"; import "../../styles/chat.css"; import "./chat-pane.ts"; +import { RouteDraftComposerFocus, type ChatPaneElement } from "./route-draft-focus-handoff.ts"; +import { routeDraft } from "./route-draft.ts"; import { locationWithoutDraft, type SessionChatRouteData } from "./route-loader.ts"; import type { ChatMessageCache } from "./session-message-cache.ts"; import { @@ -50,8 +52,6 @@ import { } from "./split-layout.ts"; type DropIndicator = { paneId: string; zone: SplitDropZone; rect: SplitDropRect }; -type ChatPaneElement = HTMLElement & { paneId?: string; sessionKey?: string }; - export class ChatPage extends OpenClawLightDomElement { @consume({ context: applicationContext, subscribe: true }) private context!: ApplicationContext; @@ -77,6 +77,7 @@ export class ChatPage extends OpenClawLightDomElement { private dragFrame = 0; private pendingDragOver: { pane: ChatPaneElement; x: number; y: number } | null = null; private consumedDraftData: SessionChatRouteData | null = null; + private readonly draftFocus = new RouteDraftComposerFocus(this); private readonly chatMessagesBySession: ChatMessageCache = new Map(); private classicColumnId = "c1"; private classicPaneId = "p1"; @@ -128,10 +129,8 @@ export class ChatPage extends OpenClawLightDomElement { } const data = this.data; const activePane = this.layout ? findPane(this.layout, this.layout.activePaneId)?.pane : null; - const routeDraftWasRendered = - Boolean(data?.draft) && - this.consumedDraftData !== data && - (!this.layout || activePane?.sessionKey === data.sessionKey); + const activeSessionKey = this.layout ? (activePane?.sessionKey ?? null) : undefined; + const draftRendered = this.draftFocus.rendered(data, activeSessionKey, this.consumedDraftData); if (changedProperties.has("data")) { if ( data?.canonicalLocation && @@ -160,10 +159,11 @@ export class ChatPage extends OpenClawLightDomElement { this.syncRouteAgent(); this.syncRouteToActivePane(); } - if (data && routeDraftWasRendered) { + if (data && draftRendered) { // Process the route draft once so later focus changes cannot hand it to another pane. queueMicrotask(() => { if (this.isConnected && this.data === data && this.consumedDraftData !== data) { + this.draftFocus.beforeDraftCleanup(data); this.consumedDraftData = data; // Remove the one-shot draft from history once the matching pane owns it. this.updateRoute(data.sessionKey, true, data.face ?? "chat"); @@ -540,15 +540,6 @@ export class ChatPage extends OpenClawLightDomElement { } }; - private routeDraftForActivePane(sessionKey = this.data?.sessionKey): string | undefined { - const data = this.data; - // Never hand a new route's draft to the old pane while the split layout catches up. - if (!data || sessionKey !== data.sessionKey || this.consumedDraftData === data) { - return undefined; - } - return data.draft; - } - private renderPaneCell( pane: ChatSplitPane, active: boolean, @@ -559,6 +550,10 @@ export class ChatPage extends OpenClawLightDomElement { ) { const sessions = this.context?.sessions?.state.result?.sessions ?? []; const nativeGateways = nativeGatewaysCapability(); + const draft = active + ? routeDraft(this.data, this.consumedDraftData, pane.sessionKey) + : undefined; + const focus = this.draftFocus.shouldFocusPane(active, draft, pane.sessionKey, this.data); // Resolve aliases like the pane does so renamed sessions keep their display title. const resolvedKey = resolveSessionKey(pane.sessionKey, this.context?.gateway?.snapshot?.hello) || pane.sessionKey; @@ -580,7 +575,8 @@ export class ChatPage extends OpenClawLightDomElement { .chatMessagesBySession=${this.chatMessagesBySession} .sessionKey=${pane.sessionKey} .active=${active} - .draft=${active ? this.routeDraftForActivePane(pane.sessionKey) : undefined} + .draft=${draft} + .focusComposer=${focus} .routeFace=${this.data?.face ?? "chat"} .paneTitle=${title} .narrow=${this.narrow} diff --git a/ui/src/pages/chat/chat-pane-base.ts b/ui/src/pages/chat/chat-pane-base.ts index b9505024fcb6..50a675d141cc 100644 --- a/ui/src/pages/chat/chat-pane-base.ts +++ b/ui/src/pages/chat/chat-pane-base.ts @@ -82,6 +82,7 @@ export abstract class ChatPaneBase extends OpenClawLightDomElement { @property({ attribute: false }) sessionKey = ""; @property({ attribute: false }) active = false; @property({ attribute: false }) draft?: string; + @property({ attribute: false }) focusComposer = false; @property({ attribute: false }) routeFace: BoardFace = "chat"; @property({ attribute: false }) onFaceChange?: (face: BoardFace) => void; @property({ attribute: false }) onFocusPane?: (paneId: string) => void; @@ -254,6 +255,8 @@ export abstract class ChatPaneBase extends OpenClawLightDomElement { protected headerRenameInitialValue = ""; protected headerRenameSessionKey = ""; protected headerCopiedTimer: number | null = null; + protected composerPrefillAttentionTimer: number | null = null; + protected composerPrefillAttentionTarget: HTMLElement | null = null; /** Checkout paths keyed by worktree id — stable for a worktree's lifetime, * so reused session keys can never inherit another checkout's path. */ diff --git a/ui/src/pages/chat/chat-pane-lifecycle.test.ts b/ui/src/pages/chat/chat-pane-lifecycle.test.ts index 98a74e93ca88..76c19e697b4d 100644 --- a/ui/src/pages/chat/chat-pane-lifecycle.test.ts +++ b/ui/src/pages/chat/chat-pane-lifecycle.test.ts @@ -35,6 +35,55 @@ function createDeferred() { return { promise, reject, resolve }; } +describe("chat pane composer prefill attention", () => { + function createComposerAttentionFixture() { + const { pane } = createTestChatPane({ + client: {} as GatewayBrowserClient, + sessions: {} as SessionCapability, + }); + const input = document.createElement("div"); + input.className = "agent-chat__input"; + const textarea = document.createElement("textarea"); + input.append(textarea); + document.body.append(input); + vi.spyOn(pane, "querySelector").mockReturnValue(textarea); + const lifecycle = pane as TestChatPane & { + focusComposer: boolean; + updated: (changedProperties?: Map) => void; + }; + lifecycle.focusComposer = true; + return { input, lifecycle, textarea }; + } + + it("focuses and clears the one-shot composer cue for an explicit route hint", () => { + vi.useFakeTimers(); + const { input, lifecycle, textarea } = createComposerAttentionFixture(); + + lifecycle.updated(new Map([["focusComposer", false]])); + + expect(document.activeElement).toBe(textarea); + expect(input.classList.contains("agent-chat__input--prefill-attention")).toBe(true); + vi.advanceTimersByTime(1_200); + expect(input.classList.contains("agent-chat__input--prefill-attention")).toBe(false); + input.remove(); + }); + + it("restarts the cue without letting the prior timer clear it", () => { + vi.useFakeTimers(); + const { input, lifecycle } = createComposerAttentionFixture(); + + lifecycle.updated(new Map([["focusComposer", false]])); + vi.advanceTimersByTime(600); + lifecycle.updated(new Map([["focusComposer", false]])); + vi.advanceTimersByTime(600); + + expect(input.classList.contains("agent-chat__input--prefill-attention")).toBe(true); + vi.advanceTimersByTime(600); + expect(input.classList.contains("agent-chat__input--prefill-attention")).toBe(false); + input.remove(); + }); +}); + describe("chat pane first-turn attachment lifecycle", () => { it("claims the connected client's first message before attaching the pane", () => { const pane = document.createElement("openclaw-chat-pane") as unknown as TestChatPane; @@ -658,6 +707,7 @@ function createConfirmationOwner() { } afterEach(() => { + vi.useRealTimers(); vi.restoreAllMocks(); for (const owner of confirmationOwners) { dismissConfirmedActionPopovers(owner); diff --git a/ui/src/pages/chat/chat-pane-lifecycle.ts b/ui/src/pages/chat/chat-pane-lifecycle.ts index 1eb7d1760407..4f420cc0b68e 100644 --- a/ui/src/pages/chat/chat-pane-lifecycle.ts +++ b/ui/src/pages/chat/chat-pane-lifecycle.ts @@ -55,7 +55,33 @@ import { exportChatMarkdown } from "./export.ts"; import { admitInitialTurnHandoff, admitInitialUserMessageHandoff } from "./initial-turn-handoff.ts"; import { readChatSessionSnapshot } from "./session-message-cache.ts"; +const COMPOSER_PREFILL_ATTENTION_DURATION_MS = 1_200; +const COMPOSER_PREFILL_ATTENTION_CLASS = "agent-chat__input--prefill-attention"; + export abstract class ChatPaneLifecycle extends ChatPaneBoard { + private clearComposerPrefillAttention(): void { + if (this.composerPrefillAttentionTimer !== null) { + window.clearTimeout(this.composerPrefillAttentionTimer); + this.composerPrefillAttentionTimer = null; + } + this.composerPrefillAttentionTarget?.classList.remove(COMPOSER_PREFILL_ATTENTION_CLASS); + this.composerPrefillAttentionTarget = null; + } + + private showComposerPrefillAttention(input: HTMLElement): void { + this.clearComposerPrefillAttention(); + // Force a fresh animation frame when the same mounted composer is prompted again. + void input.offsetWidth; + input.classList.add(COMPOSER_PREFILL_ATTENTION_CLASS); + this.composerPrefillAttentionTarget = input; + // Reduced motion disables animation events, so timer cleanup owns both modes. + this.composerPrefillAttentionTimer = window.setTimeout(() => { + if (this.composerPrefillAttentionTarget === input) { + this.clearComposerPrefillAttention(); + } + }, COMPOSER_PREFILL_ATTENTION_DURATION_MS); + } + protected confirmConversationReset(): Promise { const board = this.resolveBoardView(); const sessionKey = this.resolveBoardSessionKey(board.snapshot.sessionKey); @@ -609,7 +635,15 @@ export abstract class ChatPaneLifecycle extends ChatPaneBoard { } } - override updated() { + override updated(changedProperties: Map = new Map()) { + if (changedProperties.has("focusComposer") && this.focusComposer) { + const textarea = this.querySelector(CHAT_COMPOSER_TEXTAREA_SELECTOR); + const input = textarea?.closest(".agent-chat__input"); + textarea?.focus({ preventScroll: true }); + if (input) { + this.showComposerPrefillAttention(input); + } + } this.cancelResetConfirmationForSessionChange(); this.syncHistoryObserver(); const board = this.resolveBoardView(); @@ -641,6 +675,7 @@ export abstract class ChatPaneLifecycle extends ChatPaneBoard { } override disconnectedCallback() { + this.clearComposerPrefillAttention(); this.boardProviderLifecycleConnected = false; this.releaseBoardProviderLease(); this.settleResetConfirmation(false); diff --git a/ui/src/pages/chat/route-draft-focus-handoff.ts b/ui/src/pages/chat/route-draft-focus-handoff.ts new file mode 100644 index 000000000000..2033df4b377d --- /dev/null +++ b/ui/src/pages/chat/route-draft-focus-handoff.ts @@ -0,0 +1,132 @@ +import { areUiSessionKeysEquivalent } from "../../lib/sessions/session-key.ts"; +import type { SessionChatRouteData } from "./route-loader.ts"; + +const HANDOFF_TTL_MS = 30_000; +const MAINTENANCE_MS = 15_000; +const RETRY_MS = 250; +const COMPOSER_SELECTOR = ".agent-chat__composer-combobox textarea"; + +type PendingHandoff = { + expiresAt: number; + sessionKey: string; + sourceData: SessionChatRouteData; +}; + +export type ChatPaneElement = HTMLElement & { + active?: boolean; + paneId?: string; + sessionKey?: string; +}; + +let pendingHandoff: PendingHandoff | undefined; + +function pendingMatches(sessionKey: string, data: SessionChatRouteData): boolean { + if (!pendingHandoff) { + return false; + } + if (pendingHandoff.expiresAt <= Date.now()) { + pendingHandoff = undefined; + return false; + } + return ( + pendingHandoff.sourceData !== data && + areUiSessionKeysEquivalent(pendingHandoff.sessionKey, sessionKey) + ); +} + +export class RouteDraftComposerFocus { + private timer: number | undefined; + private readonly host: HTMLElement; + + constructor(host: HTMLElement) { + this.host = host; + } + + rendered( + data: SessionChatRouteData | undefined, + activeSessionKey: string | null | undefined, + consumedData: SessionChatRouteData | null, + ): boolean { + const matchesActivePane = Boolean( + data && + (activeSessionKey === undefined || + (activeSessionKey !== null && + areUiSessionKeysEquivalent(activeSessionKey, data.sessionKey))), + ); + if (data && !data.draft && matchesActivePane && pendingMatches(data.sessionKey, data)) { + pendingHandoff = undefined; + this.maintain(data.sessionKey); + } + return Boolean(data?.draft && consumedData !== data && matchesActivePane); + } + + shouldFocusPane( + active: boolean, + hasDraft: unknown, + sessionKey: string, + data?: SessionChatRouteData, + ): boolean { + return Boolean( + active && + data && + ((hasDraft && data.focusComposer) || (!hasDraft && pendingMatches(sessionKey, data))), + ); + } + + beforeDraftCleanup(data: SessionChatRouteData): void { + if (!data.focusComposer) { + return; + } + // Draft cleanup remounts the route page; carry the one-shot focus fact + // to that final owner so focus does not die with this page instance. + pendingHandoff = { + expiresAt: Date.now() + HANDOFF_TTL_MS, + sessionKey: data.sessionKey, + sourceData: data, + }; + } + + private maintain(sessionKey: string): void { + if (this.timer !== undefined) { + window.clearTimeout(this.timer); + this.timer = undefined; + } + const deadline = Date.now() + MAINTENANCE_MS; + let ownedComposer: HTMLTextAreaElement | undefined; + + const focusCurrentComposer = () => { + if (!this.host.isConnected) { + this.timer = undefined; + return; + } + const pane = [...this.host.querySelectorAll("openclaw-chat-pane")].find( + (candidate) => + candidate.active && + candidate.sessionKey !== undefined && + areUiSessionKeysEquivalent(candidate.sessionKey, sessionKey), + ); + const composer = pane?.querySelector(COMPOSER_SELECTOR); + const activeElement = document.activeElement; + const focusStillOwned = + activeElement === ownedComposer || + activeElement === document.body || + (activeElement instanceof HTMLElement && !activeElement.isConnected); + + if (composer && (!ownedComposer || focusStillOwned)) { + composer.focus({ preventScroll: true }); + ownedComposer = composer; + } else if (ownedComposer && !focusStillOwned) { + this.timer = undefined; + return; + } + + if (Date.now() < deadline) { + this.timer = window.setTimeout(focusCurrentComposer, RETRY_MS); + } else { + this.timer = undefined; + } + }; + + focusCurrentComposer(); + } +} diff --git a/ui/src/pages/chat/route-draft.ts b/ui/src/pages/chat/route-draft.ts new file mode 100644 index 000000000000..d75086c0f028 --- /dev/null +++ b/ui/src/pages/chat/route-draft.ts @@ -0,0 +1,42 @@ +import type { RouteLocation } from "@openclaw/uirouter"; +import { SESSION_COMPOSER_FOCUS_PARAM } from "../../lib/sessions/route-navigation.ts"; + +type RouteDraftHint = { draft?: string; focusComposer?: boolean }; +type RouteDraftData = { sessionKey: string; draft?: string }; + +function draftFromLocation(location: RouteLocation): string | undefined { + return new URLSearchParams(location.search).get("draft") || undefined; +} + +function focusComposerFromLocation(location: RouteLocation): boolean { + return new URLSearchParams(location.search).get(SESSION_COMPOSER_FOCUS_PARAM) === "1"; +} + +export function draftRouteDataFromLocation(location: RouteLocation): RouteDraftHint { + const draft = draftFromLocation(location); + return { + draft, + ...(draft && focusComposerFromLocation(location) ? { focusComposer: true } : {}), + }; +} + +export function draftSearchFromLocation(location: RouteLocation): string { + const search = new URLSearchParams(); + const draft = draftFromLocation(location); + if (draft) { + search.set("draft", draft); + } + if (draft && focusComposerFromLocation(location)) { + search.set(SESSION_COMPOSER_FOCUS_PARAM, "1"); + } + return search.size > 0 ? "?" + search.toString() : ""; +} + +// A one-shot route draft belongs only to its matching pane until the page consumes it. +export function routeDraft( + data: RouteDraftData | null | undefined, + consumed: RouteDraftData | null, + sessionKey = data?.sessionKey, +): string | undefined { + return !data || sessionKey !== data.sessionKey || consumed === data ? undefined : data.draft; +} diff --git a/ui/src/pages/chat/route-loader.ts b/ui/src/pages/chat/route-loader.ts index 53bda5fccecd..3cfd8469bec4 100644 --- a/ui/src/pages/chat/route-loader.ts +++ b/ui/src/pages/chat/route-loader.ts @@ -15,6 +15,7 @@ import { } from "../../lib/sessions/catalog-key.ts"; import { findUiSessionRow, + SESSION_COMPOSER_FOCUS_PARAM, SESSION_FACE_PREFERENCE_PARAM, SESSION_NAVIGATION_KEY_PARAM, } from "../../lib/sessions/route-navigation.ts"; @@ -29,6 +30,7 @@ import { resolveUiConfiguredMainKey, resolveUiGlobalAliasAgentId, } from "../../lib/sessions/session-key.ts"; +import { draftRouteDataFromLocation, draftSearchFromLocation } from "./route-draft.ts"; import { findCachedShortSession, incompleteShortSessionResolution, @@ -56,6 +58,7 @@ export type ChatRouteData = sessionKey: string; agentId?: string; draft?: string; + focusComposer?: boolean; face: BoardFace; shortId?: string; canonicalLocation?: RouteLocation; @@ -81,6 +84,7 @@ export type SessionChatRouteData = Omit< export function locationWithoutDraft(location: RouteLocation): RouteLocation { const params = new URLSearchParams(location.search); params.delete("draft"); + params.delete(SESSION_COMPOSER_FOCUS_PARAM); const search = params.toString(); return { ...location, search: search ? `?${search}` : "" }; } @@ -300,10 +304,6 @@ async function querySessionReferencePages( } } -function draftFromLocation(location: RouteLocation): string | undefined { - return new URLSearchParams(location.search).get("draft") || undefined; -} - function isPreferenceDerivedFace(location: RouteLocation): boolean { return new URLSearchParams(location.search).get(SESSION_FACE_PREFERENCE_PARAM) === "1"; } @@ -420,7 +420,7 @@ function candidatesForResolution( context: ApplicationContext, face: BoardFace, resolution: Extract, - draft: string | undefined, + location: RouteLocation, preferenceDerived: boolean, ): SessionCandidate[] { const resolvedRows = resolution.sessions.flatMap((row) => { @@ -445,7 +445,7 @@ function candidatesForResolution( { agentId, displayName: row.displayName?.trim() || row.key, - href: `${href}${draft ? `?${new URLSearchParams({ draft }).toString()}` : ""}`, + href: `${href}${draftSearchFromLocation(location)}`, idPrefix: prefix, }, ] @@ -478,7 +478,7 @@ function resolvedSessionRouteData(params: { return { kind: "session", sessionKey: params.row.key, - draft: draftFromLocation(params.location), + ...draftRouteDataFromLocation(params.location), face, ...(params.shortId && params.shortId.length > 8 ? { shortId: params.shortId } : {}), ...(canonicalLocation ? { canonicalLocation, canonicalLocationSource: params.location } : {}), @@ -516,7 +516,7 @@ function resolvedMainSessionRouteData(params: { kind: "session", sessionKey: params.row.key, agentId: params.target.agentId, - draft: draftFromLocation(params.location), + ...draftRouteDataFromLocation(params.location), face, ...(canonicalLocation ? { canonicalLocation, canonicalLocationSource: params.location } : {}), }; @@ -566,7 +566,7 @@ export async function loadChatRoute( kind: "session", sessionKey, agentId: target.agentId, - draft: draftFromLocation(routeLocation), + ...draftRouteDataFromLocation(routeLocation), face: resolvedFace, // Non-null only on a preference-derived open, where it always at least drops the // marker from the URL. @@ -600,7 +600,7 @@ export async function loadChatRoute( return { kind: "session", sessionKey, - draft: draftFromLocation(routeLocation), + ...draftRouteDataFromLocation(routeLocation), face, ...(canonicalLocation && canonicalLocation.search !== routeLocation.search ? { canonicalLocation, canonicalLocationSource: routeLocation } @@ -659,7 +659,7 @@ export async function loadChatRoute( context, face, slugResolution, - draftFromLocation(routeLocation), + routeLocation, preferenceDerived, ), truncated: slugResolution.truncated, @@ -698,7 +698,7 @@ export async function loadChatRoute( return { kind: "session", sessionKey: target.sessionKey, - draft: draftFromLocation(routeLocation), + ...draftRouteDataFromLocation(routeLocation), face, ...(canonicalLocation ? { canonicalLocation, canonicalLocationSource: routeLocation } @@ -717,7 +717,7 @@ export async function loadChatRoute( return { kind: "session", sessionKey: cached.sessionKey, - draft: draftFromLocation(routeLocation), + ...draftRouteDataFromLocation(routeLocation), face, ...(target.shortId.length > 8 ? { shortId: target.shortId } : {}), ...(canonicalLocationChanged @@ -748,7 +748,7 @@ export async function loadChatRoute( context, face, resolution, - draftFromLocation(routeLocation), + routeLocation, preferenceDerived, ), truncated: resolution.truncated, diff --git a/ui/src/pages/chat/route.test.ts b/ui/src/pages/chat/route.test.ts index 2b23ff9d694e..2dd84f5823a7 100644 --- a/ui/src/pages/chat/route.test.ts +++ b/ui/src/pages/chat/route.test.ts @@ -293,7 +293,11 @@ describe("loadChatRoute", () => { const { context } = contextFor(result(rows)); const ambiguous = await loadChatRoute( context, - { pathname: "/dashboard/ignored/deploy-12345678", search: "?draft=ship", hash: "" }, + { + pathname: "/dashboard/ignored/deploy-12345678", + search: "?draft=ship&__openclawComposerFocus=1", + hash: "", + }, "dashboard", new AbortController().signal, ); @@ -302,8 +306,8 @@ describe("loadChatRoute", () => { throw new Error("expected an ambiguous route"); } expect(ambiguous.candidates.map((candidate) => candidate.href)).toEqual([ - "/dashboard/main/deploy-monitor-123456780a?draft=ship", - "/dashboard/work/deploy-monitor-two-123456780b?draft=ship", + "/dashboard/main/deploy-monitor-123456780a?draft=ship&__openclawComposerFocus=1", + "/dashboard/work/deploy-monitor-two-123456780b?draft=ship&__openclawComposerFocus=1", ]); for (const [candidate, expectedRow] of ambiguous.candidates.map( @@ -324,6 +328,7 @@ describe("loadChatRoute", () => { kind: "session", sessionKey: expectedRow?.key, draft: "ship", + focusComposer: true, face: "dashboard", shortId: candidate.idPrefix, }); diff --git a/ui/src/styles/chat/layout.css b/ui/src/styles/chat/layout.css index 3d9e532cb769..efd2a362bf5b 100644 --- a/ui/src/styles/chat/layout.css +++ b/ui/src/styles/chat/layout.css @@ -2283,6 +2283,37 @@ openclaw-chat-video-player { box-shadow: 0 0 0 3px var(--accent-subtle); } +.agent-chat__input--prefill-attention { + animation: chat-composer-prefill-attention 1200ms ease-out; +} + +@keyframes chat-composer-prefill-attention { + 0%, + 30% { + background: color-mix(in srgb, var(--accent) 14%, var(--card)); + border-color: color-mix(in srgb, var(--accent) 88%, var(--border)); + box-shadow: + 0 0 0 5px color-mix(in srgb, var(--accent) 32%, transparent), + 0 0 28px color-mix(in srgb, var(--accent) 42%, transparent); + } + 100% { + background: var(--card); + border-color: color-mix(in srgb, var(--accent) 30%, var(--border)); + box-shadow: 0 0 0 3px var(--accent-subtle); + } +} + +@media (prefers-reduced-motion: reduce) { + .agent-chat__input--prefill-attention { + animation: none; + background: color-mix(in srgb, var(--accent) 14%, var(--card)); + border-color: color-mix(in srgb, var(--accent) 88%, var(--border)); + box-shadow: + 0 0 0 5px color-mix(in srgb, var(--accent) 32%, transparent), + 0 0 28px color-mix(in srgb, var(--accent) 42%, transparent); + } +} + .agent-chat__input--offline, .agent-chat__input--offline:focus-within { border-color: color-mix(in srgb, var(--warn) 38%, var(--border)); diff --git a/ui/src/test-helpers/app-sidebar-cases/agent-menu.ts b/ui/src/test-helpers/app-sidebar-cases/agent-menu.ts index e1d81ed6fa3d..3d249e71b7c6 100644 --- a/ui/src/test-helpers/app-sidebar-cases/agent-menu.ts +++ b/ui/src/test-helpers/app-sidebar-cases/agent-menu.ts @@ -2,7 +2,10 @@ import { describe, expect, it, vi } from "vitest"; import type { GatewayBrowserClient } from "../../api/gateway.ts"; import type { AgentsListResult } from "../../api/types.ts"; import { createAgentIdentityCapability } from "../../lib/agents/identity.ts"; -import { SESSION_FACE_PREFERENCE_PARAM } from "../../lib/sessions/route-navigation.ts"; +import { + SESSION_COMPOSER_FOCUS_PARAM, + SESSION_FACE_PREFERENCE_PARAM, +} from "../../lib/sessions/route-navigation.ts"; import { createGateway, createGatewayHarness, @@ -163,6 +166,35 @@ describe("AppSidebar agent chip", () => { expect(sidebar.querySelector(".sidebar-agent-menu")).toBeNull(); }); + it("requests composer focus and highlighting from the capabilities action", async () => { + const gateway = createGateway({} as GatewayBrowserClient); + const { sidebar } = await mountSidebar( + gateway, + createSessions("main", ["agent:main:main"]), + "panel", + TWO_AGENTS, + ); + const onNavigate = vi.fn(); + sidebar.connected = true; + sidebar.onNavigate = onNavigate; + await sidebar.updateComplete; + + sidebar.querySelector(".sidebar-agent-card__main")?.click(); + await sidebar.updateComplete; + const item = sidebar.querySelector( + 'wa-dropdown-item[value="command:capabilities"]', + ); + sidebar + .querySelector(".sidebar-agent-menu") + ?.dispatchEvent(new CustomEvent("wa-select", { detail: { item }, bubbles: true })); + + expect(onNavigate).toHaveBeenCalledOnce(); + const options = onNavigate.mock.calls[0]?.[1] as { search: string }; + const search = new URLSearchParams(options.search); + expect(search.get("draft")).toBe("What can you do?"); + expect(search.get(SESSION_COMPOSER_FOCUS_PARAM)).toBe("1"); + }); + it("drops the menu below the agent card instead of covering it", async () => { const gateway = createGateway({} as GatewayBrowserClient); const { sidebar } = await mountSidebar(