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
This commit is contained in:
Jacob Tomlinson
2026-08-01 22:02:38 +01:00
committed by GitHub
parent cf1afe26a1
commit a1ed982cd7
14 changed files with 514 additions and 44 deletions

View File

@@ -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")),
});
}

View File

@@ -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);

View File

@@ -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;

View File

@@ -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<RenderedPane>("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: [],

View File

@@ -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}

View File

@@ -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. */

View File

@@ -35,6 +35,55 @@ function createDeferred<T>() {
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<PropertyKey, unknown>) => 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);

View File

@@ -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<boolean> {
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<PropertyKey, unknown> = new Map()) {
if (changedProperties.has("focusComposer") && this.focusComposer) {
const textarea = this.querySelector<HTMLTextAreaElement>(CHAT_COMPOSER_TEXTAREA_SELECTOR);
const input = textarea?.closest<HTMLElement>(".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);

View File

@@ -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<ChatPaneElement>("openclaw-chat-pane")].find(
(candidate) =>
candidate.active &&
candidate.sessionKey !== undefined &&
areUiSessionKeysEquivalent(candidate.sessionKey, sessionKey),
);
const composer = pane?.querySelector<HTMLTextAreaElement>(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();
}
}

View File

@@ -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;
}

View File

@@ -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<SessionReferenceResolution, { kind: "ambiguous" }>,
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,

View File

@@ -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,
});

View File

@@ -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));

View File

@@ -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<HTMLButtonElement>(".sidebar-agent-card__main")?.click();
await sidebar.updateComplete;
const item = sidebar.querySelector<HTMLElement>(
'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(