mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-06 17:57:43 +02:00
[pierre] fix: scroll focused card horizontally with Left/Right arrow keys
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type Dispatch, type SetStateAction } from 'react';
|
||||
import { report } from '@/shared/serviceClient';
|
||||
import { scrollCardContentX } from '@/shared/cardContentScroll';
|
||||
import { useAppDispatch } from '@/shared/hooks';
|
||||
import { expandSession } from '@/shared/state/agentsSlice';
|
||||
import { bringToFront, viewCardKey } from '@/shared/state/dashboardLayoutSlice';
|
||||
@@ -108,6 +109,8 @@ export function useArrowNav({
|
||||
focusedCardIdRef.current = focusedCardId;
|
||||
const canvasZoomRef = useRef(zoom);
|
||||
canvasZoomRef.current = zoom;
|
||||
// Set while we're waiting to hear whether the focused card's content absorbed a Left/Right; see the handler for why a held key must not stack these.
|
||||
const scrollProbeRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Helper: is the currently-focused element a text-entry field the user is actively editing? We only want to suppress dashboard navigation when the user is genuinely typing, not just because an input somewhere happens to have focus from a click long ago.
|
||||
@@ -124,6 +127,35 @@ export function useArrowNav({
|
||||
return true;
|
||||
};
|
||||
|
||||
const navigateToNeighbor = (fromCardId: string, direction: Direction) => {
|
||||
const target = findNearestCard(fromCardId, direction);
|
||||
|
||||
if (!target) {
|
||||
// No card in that direction, shake
|
||||
if (shakeTimerRef.current) clearTimeout(shakeTimerRef.current);
|
||||
setShakeDirection(direction);
|
||||
shakeTimerRef.current = setTimeout(() => {
|
||||
setShakeDirection(null);
|
||||
shakeTimerRef.current = null;
|
||||
}, 400);
|
||||
return;
|
||||
}
|
||||
|
||||
// Expand + navigate to target + bring to front
|
||||
report('dashboard', 'arrow_navigated', { direction, from_card: fromCardId, to_card: target.id });
|
||||
if (target.type === 'agent') {
|
||||
dispatch(expandSession(target.id));
|
||||
}
|
||||
dispatch(bringToFront({ id: target.id, type: target.type }));
|
||||
setFocusedCardId(target.id);
|
||||
|
||||
setTimeout(() => {
|
||||
const rect = getCardRect(target.id, target.type);
|
||||
if (rect) canvasActions.fitToCards([rect], 1.15, true);
|
||||
setTimeout(() => (document.activeElement as HTMLElement)?.blur?.(), 150);
|
||||
}, 100);
|
||||
};
|
||||
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
if (!isActive) return; // Don't fire shortcuts when dashboard is hidden
|
||||
|
||||
@@ -162,32 +194,22 @@ export function useArrowNav({
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
const target = findNearestCard(currentFocused, direction);
|
||||
|
||||
if (!target) {
|
||||
// No card in that direction, shake
|
||||
if (shakeTimerRef.current) clearTimeout(shakeTimerRef.current);
|
||||
setShakeDirection(direction);
|
||||
shakeTimerRef.current = setTimeout(() => {
|
||||
setShakeDirection(null);
|
||||
shakeTimerRef.current = null;
|
||||
}, 400);
|
||||
// Left/Right belong to the focused card's own content first: while it can still scroll that way it eats the key, and only once it's at its horizontal boundary (or has nothing to scroll sideways) does the arrow go back to meaning card-to-card navigation. Same hand-off the wheel already does in useCanvasControls, so a Sheets card behaves the same under the trackpad and under the keyboard. Up/Down are untouched: most cards scroll vertically, so applying this rule to them would quietly take away vertical nav across the whole canvas.
|
||||
const fromCardId = currentFocused;
|
||||
if (direction === 'left' || direction === 'right') {
|
||||
// A webview card's content lives in another renderer, so the answer can't arrive before this handler returns. Drop repeats while a probe is in flight instead of stacking round-trips: a held key would otherwise queue several, and the ones that land after the card hits its boundary would all navigate.
|
||||
if (scrollProbeRef.current) return;
|
||||
scrollProbeRef.current = true;
|
||||
scrollCardContentX(fromCardId, direction)
|
||||
.then((scrolled) => {
|
||||
if (!scrolled) navigateToNeighbor(fromCardId, direction);
|
||||
})
|
||||
.finally(() => { scrollProbeRef.current = false; });
|
||||
return;
|
||||
}
|
||||
|
||||
// Expand + navigate to target + bring to front
|
||||
report('dashboard', 'arrow_navigated', { direction, from_card: currentFocused, to_card: target.id });
|
||||
if (target.type === 'agent') {
|
||||
dispatch(expandSession(target.id));
|
||||
}
|
||||
dispatch(bringToFront({ id: target.id, type: target.type }));
|
||||
setFocusedCardId(target.id);
|
||||
|
||||
setTimeout(() => {
|
||||
const rect = getCardRect(target.id, target.type);
|
||||
if (rect) canvasActions.fitToCards([rect], 1.15, true);
|
||||
setTimeout(() => (document.activeElement as HTMLElement)?.blur?.(), 150);
|
||||
}, 100);
|
||||
navigateToNeighbor(fromCardId, direction);
|
||||
};
|
||||
|
||||
// Capture phase so we beat MUI Menus/Selects that also listen for arrows. We still bail early on isActivelyEditing, so this doesn't interfere with typing.
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useIframeElementSelector } from './useIframeElementSelector';
|
||||
import { getAuthToken, ensureAuthToken } from '@/shared/config';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
import { registerViewWebview, unregisterViewWebview, type ViewWebview } from '@/shared/viewWebviewRegistry';
|
||||
import { registerViewFrame, unregisterViewFrame } from '@/shared/viewFrameRegistry';
|
||||
import RunInDesktopMessage from '@/app/components/RunInDesktopMessage';
|
||||
import { registerWebview, unregisterWebview, setActiveTab, type BrowserWebview } from '@/shared/browserRegistry';
|
||||
|
||||
@@ -324,6 +325,15 @@ const ViewPreview = forwardRef<ViewPreviewHandle, Props>(({
|
||||
return () => unregisterViewWebview(registryId);
|
||||
}, [useWebview, registryId, iframeSrc]);
|
||||
|
||||
// Same registration for the srcdoc path, so the dashboard's arrow keys can reach a non-webview app card's content. Re-runs on reloadKey because a reload swaps the element.
|
||||
useEffect(() => {
|
||||
if (useWebview || !registryId) return;
|
||||
const frame = iframeRef.current;
|
||||
if (!frame) return;
|
||||
registerViewFrame(registryId, frame);
|
||||
return () => unregisterViewFrame(registryId);
|
||||
}, [useWebview, registryId, iframeSrc, reloadKey]);
|
||||
|
||||
// Mirror `interactive` into a ref so the once-per-load did-finish-load listener can read the latest value when it pushes initial state.
|
||||
const interactiveRef = useRef(interactive);
|
||||
interactiveRef.current = interactive;
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { getWebview } from './browserRegistry';
|
||||
import { getViewWebview } from './viewWebviewRegistry';
|
||||
import { getViewFrame } from './viewFrameRegistry';
|
||||
|
||||
// One arrow press moves the content about a wheel notch, so a held key and a trackpad flick cover ground at a comparable rate.
|
||||
const ARROW_STEP_PX = 120;
|
||||
|
||||
// Walks up from whatever sits at the middle of the view (a key press has no cursor to aim with) to the first ancestor that can still scroll horizontally the way dx points, nudges it, and reports whether anything actually moved. The boundary test is the same one the wheel path uses in useCanvasControls, so keys and trackpad hand the gesture back to the canvas at the same moment.
|
||||
// This runs in two worlds: stringified into a <webview> guest renderer, and called directly on a same-origin srcdoc iframe. Keep it self-contained - no imports, no closure references - or the stringified copy lands in the guest with dangling names.
|
||||
function scrollContentX(doc: Document, win: Window, dx: number): boolean {
|
||||
const nudge = (node: Element | null): boolean => {
|
||||
if (!node) return false;
|
||||
const el = node as HTMLElement;
|
||||
if (el.scrollWidth <= el.clientWidth) return false;
|
||||
// The document's own scroller reports overflowX 'visible' yet still scrolls, so it skips the overflow test the way a real browser does.
|
||||
const isViewport = el === doc.scrollingElement;
|
||||
const overflowX = win.getComputedStyle(el).overflowX;
|
||||
if (!isViewport && overflowX !== 'auto' && overflowX !== 'scroll') return false;
|
||||
const atRight = el.scrollLeft + el.clientWidth >= el.scrollWidth - 1;
|
||||
const atLeft = el.scrollLeft <= 1;
|
||||
if ((dx > 0 && atRight) || (dx < 0 && atLeft)) return false;
|
||||
// Instant, not smooth: a page with scroll-behavior smooth would otherwise still be animating when the next key repeat arrives.
|
||||
el.scrollBy({ left: dx, behavior: 'instant' });
|
||||
return true;
|
||||
};
|
||||
|
||||
let node: Element | null = doc.elementFromPoint(
|
||||
Math.floor(win.innerWidth / 2),
|
||||
Math.floor(win.innerHeight / 2),
|
||||
);
|
||||
while (node) {
|
||||
if (nudge(node)) return true;
|
||||
node = node.parentElement;
|
||||
}
|
||||
return nudge(doc.scrollingElement);
|
||||
}
|
||||
|
||||
// Present on real Electron webviews; a browser card falls back to a plain iframe on locked-out Windows builds, which has none of this.
|
||||
interface GuestWebview {
|
||||
executeJavaScript?: (code: string) => Promise<unknown>;
|
||||
}
|
||||
|
||||
/** Scrolls a card's own content sideways. True means the card absorbed the arrow, so the dashboard must not also navigate to a neighbor. */
|
||||
export async function scrollCardContentX(cardId: string, direction: 'left' | 'right'): Promise<boolean> {
|
||||
const dx = direction === 'right' ? ARROW_STEP_PX : -ARROW_STEP_PX;
|
||||
|
||||
const guest = (getWebview(cardId) ?? getViewWebview(cardId)) as GuestWebview | undefined;
|
||||
if (guest?.executeJavaScript) {
|
||||
// A guest is a separate renderer: the host can't read its scrollLeft, so the whole scroll-or-boundary decision has to be made over there and come back as a yes/no.
|
||||
try {
|
||||
const scrolled = await guest.executeJavaScript(`(${scrollContentX})(document, window, ${dx})`);
|
||||
return scrolled === true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Srcdoc app card: same-origin, so the host can walk the frame's DOM directly. A cross-origin frame throws on contentWindow access; treat that as "didn't scroll" and let the arrow navigate.
|
||||
const frame = getViewFrame(cardId);
|
||||
try {
|
||||
const win = frame?.contentWindow;
|
||||
if (!win) return false;
|
||||
return scrollContentX(win.document, win, dx);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// Srcdoc app-card iframes keyed by card key. Mirror of viewWebviewRegistry for the outputs that render as an iframe instead of a <webview> (no serve URL): the dashboard's arrow-key handler needs a handle on the card's content to scroll it, and a srcdoc frame is same-origin, so no IPC is involved.
|
||||
const registry = new Map<string, HTMLIFrameElement>();
|
||||
|
||||
export function registerViewFrame(cardKey: string, frame: HTMLIFrameElement): void {
|
||||
registry.set(cardKey, frame);
|
||||
}
|
||||
|
||||
export function unregisterViewFrame(cardKey: string): void {
|
||||
registry.delete(cardKey);
|
||||
}
|
||||
|
||||
export function getViewFrame(cardKey: string): HTMLIFrameElement | undefined {
|
||||
return registry.get(cardKey);
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
// Live app-card preview webviews keyed by output id. The delete path looks a card's <webview> up here to quiesce its GPU surface BEFORE React rips the element out; without it, deleting a couple of large app cards at once tears down several live SharedImage surfaces in one frame, which piles up "non-existent mailbox" errors and kills the GPU process (taking the whole app down with no dump). Mirror of browserRegistry, for the non-CDP preview webviews.
|
||||
export interface ViewWebview extends HTMLElement {
|
||||
loadURL: (url: string) => Promise<void>;
|
||||
// Optional: present on real Electron webviews, absent on any non-Electron stand-in, so callers must ?.() it.
|
||||
executeJavaScript?: (code: string) => Promise<unknown>;
|
||||
}
|
||||
|
||||
const registry = new Map<string, ViewWebview>();
|
||||
|
||||
Reference in New Issue
Block a user