mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-01 23:21:42 +00:00
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:
@@ -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")),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
132
ui/src/pages/chat/route-draft-focus-handoff.ts
Normal file
132
ui/src/pages/chat/route-draft-focus-handoff.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
42
ui/src/pages/chat/route-draft.ts
Normal file
42
ui/src/pages/chat/route-draft.ts
Normal 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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user