Skip to content

Commit aa2c476

Browse files
author
hclsys
committed
fix(ui): add guarded dashboard shortcuts
Fixes #81946
1 parent eb4e20c commit aa2c476

2 files changed

Lines changed: 196 additions & 1 deletion

File tree

ui/src/ui/app.ts

Lines changed: 91 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,30 @@ declare global {
137137
const bootAssistantIdentity = normalizeAssistantIdentity({});
138138
const bootLocalUserIdentity = loadLocalUserIdentity();
139139

140+
const DASHBOARD_SHORTCUT_EDITABLE_SELECTOR =
141+
'input, textarea, select, [contenteditable=""], [contenteditable="true"], [contenteditable="plaintext-only"], [role="textbox"], .cm-editor';
142+
143+
function isDashboardShortcutEditableElement(element: Element): boolean {
144+
if (element instanceof HTMLElement && element.isContentEditable) {
145+
return true;
146+
}
147+
return element.matches(DASHBOARD_SHORTCUT_EDITABLE_SELECTOR);
148+
}
149+
150+
function isDashboardShortcutEditableTarget(event: KeyboardEvent): boolean {
151+
const path = event.composedPath();
152+
for (const item of path) {
153+
if (!(item instanceof Element)) {
154+
continue;
155+
}
156+
if (isDashboardShortcutEditableElement(item)) {
157+
return true;
158+
}
159+
}
160+
const active = document.activeElement;
161+
return active instanceof Element && isDashboardShortcutEditableElement(active);
162+
}
163+
140164
function resolveOnboardingMode(): boolean {
141165
if (!window.location.search) {
142166
return false;
@@ -620,10 +644,62 @@ export class OpenClawApp extends LitElement {
620644
this.paletteQuery = "";
621645
this.paletteActiveIndex = 0;
622646
}
647+
return;
648+
}
649+
650+
if (isDashboardShortcutEditableTarget(e) || e.altKey || e.ctrlKey || e.metaKey) {
651+
return;
652+
}
653+
654+
if (e.key === "/") {
655+
e.preventDefault();
656+
this.focusChatComposer();
657+
return;
658+
}
659+
660+
if (e.key.toLowerCase() === "n" && this.tab === "chat" && this.chatNewMessagesBelow) {
661+
e.preventDefault();
662+
this.scrollToBottom();
663+
return;
664+
}
665+
666+
if (e.key !== "Escape") {
667+
return;
668+
}
669+
670+
if (this.paletteOpen) {
671+
e.preventDefault();
672+
this.paletteOpen = false;
673+
this.paletteQuery = "";
674+
return;
675+
}
676+
677+
if (this.chatMobileControlsOpen) {
678+
e.preventDefault();
679+
this.setChatMobileControlsOpen(false, { restoreFocus: true });
680+
return;
681+
}
682+
683+
if (this.sessionSwitchNotice) {
684+
e.preventDefault();
685+
this.sessionSwitchNotice = null;
686+
return;
687+
}
688+
689+
if (this.settings.chatFocusMode) {
690+
e.preventDefault();
691+
this.applySettings({
692+
...this.settings,
693+
chatFocusMode: false,
694+
});
623695
}
624696
};
625697
private chatMobileControlsKeydownHandler = (e: KeyboardEvent) => {
626-
if (e.key !== "Escape" || !this.chatMobileControlsOpen) {
698+
if (
699+
e.key !== "Escape" ||
700+
!this.chatMobileControlsOpen ||
701+
isDashboardShortcutEditableTarget(e)
702+
) {
627703
return;
628704
}
629705
e.preventDefault();
@@ -807,6 +883,20 @@ export class OpenClawApp extends LitElement {
807883
});
808884
}
809885

886+
private focusChatComposer() {
887+
if (this.tab !== "chat") {
888+
this.setTab("chat");
889+
}
890+
void this.updateComplete.then(() => {
891+
const composer = this.querySelector<HTMLTextAreaElement>(
892+
".agent-chat__composer-combobox textarea",
893+
);
894+
if (composer && !composer.disabled) {
895+
composer.focus();
896+
}
897+
});
898+
}
899+
810900
setTheme(next: ThemeName, context?: Parameters<typeof setThemeInternal>[2]) {
811901
setThemeInternal(this as unknown as Parameters<typeof setThemeInternal>[0], next, context);
812902
this.themeOrder = this.buildThemeOrder(next);

ui/src/ui/navigation.browser.test.ts

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -488,6 +488,111 @@ describe("control UI routing", () => {
488488
expect(app.chatMobileControlsOpen).toBe(false);
489489
});
490490

491+
it("dispatches guarded dashboard chat shortcuts", async () => {
492+
const app = mountApp("/overview");
493+
await app.updateComplete;
494+
495+
const scrollToBottom = vi.spyOn(app, "scrollToBottom");
496+
app.chatNewMessagesBelow = true;
497+
const overviewJump = new KeyboardEvent("keydown", {
498+
key: "N",
499+
bubbles: true,
500+
cancelable: true,
501+
});
502+
document.dispatchEvent(overviewJump);
503+
504+
expect(overviewJump.defaultPrevented).toBe(false);
505+
expect(scrollToBottom).not.toHaveBeenCalled();
506+
507+
const slash = new KeyboardEvent("keydown", {
508+
key: "/",
509+
bubbles: true,
510+
cancelable: true,
511+
});
512+
document.dispatchEvent(slash);
513+
await app.updateComplete;
514+
await nextFrame();
515+
516+
const composer = expectElement(
517+
app,
518+
".agent-chat__composer-combobox textarea",
519+
HTMLTextAreaElement,
520+
);
521+
expect(app.tab).toBe("chat");
522+
expect(slash.defaultPrevented).toBe(true);
523+
expect(document.activeElement).toBe(composer);
524+
525+
app.chatNewMessagesBelow = true;
526+
composer.blur();
527+
app.requestUpdate();
528+
await app.updateComplete;
529+
530+
const jump = new KeyboardEvent("keydown", {
531+
key: "N",
532+
bubbles: true,
533+
cancelable: true,
534+
});
535+
document.dispatchEvent(jump);
536+
537+
expect(jump.defaultPrevented).toBe(true);
538+
expect(scrollToBottom).toHaveBeenCalledOnce();
539+
540+
const editableJump = new KeyboardEvent("keydown", {
541+
key: "N",
542+
bubbles: true,
543+
cancelable: true,
544+
});
545+
composer.dispatchEvent(editableJump);
546+
547+
expect(editableJump.defaultPrevented).toBe(false);
548+
expect(scrollToBottom).toHaveBeenCalledOnce();
549+
550+
app.paletteOpen = true;
551+
const escape = new KeyboardEvent("keydown", {
552+
key: "Escape",
553+
bubbles: true,
554+
cancelable: true,
555+
});
556+
document.dispatchEvent(escape);
557+
558+
expect(escape.defaultPrevented).toBe(true);
559+
expect(app.paletteOpen).toBe(false);
560+
561+
app.chatMobileControlsOpen = true;
562+
composer.focus();
563+
const editableEscape = new KeyboardEvent("keydown", {
564+
key: "Escape",
565+
bubbles: true,
566+
cancelable: true,
567+
});
568+
composer.dispatchEvent(editableEscape);
569+
570+
expect(editableEscape.defaultPrevented).toBe(false);
571+
expect(app.chatMobileControlsOpen).toBe(true);
572+
573+
const plaintextOnly = document.createElement("div");
574+
plaintextOnly.setAttribute("contenteditable", "plaintext-only");
575+
plaintextOnly.tabIndex = 0;
576+
app.append(plaintextOnly);
577+
plaintextOnly.focus();
578+
app.paletteOpen = true;
579+
app.chatMobileControlsOpen = true;
580+
581+
for (const key of ["/", "N", "Escape"]) {
582+
const event = new KeyboardEvent("keydown", {
583+
key,
584+
bubbles: true,
585+
cancelable: true,
586+
});
587+
plaintextOnly.dispatchEvent(event);
588+
expect(event.defaultPrevented, key).toBe(false);
589+
}
590+
591+
expect(app.paletteOpen).toBe(true);
592+
expect(app.chatMobileControlsOpen).toBe(true);
593+
expect(scrollToBottom).toHaveBeenCalledOnce();
594+
});
595+
491596
it("preserves session navigation and keeps focus mode scoped to chat", async () => {
492597
const app = mountApp("/sessions?session=agent:main:subagent:task-123");
493598
await app.updateComplete;

0 commit comments

Comments
 (0)