fix: support touch history loading

This commit is contained in:
Shakker
2026-07-15 02:27:06 +01:00
committed by Shakker
parent dcb1de9b87
commit aa9997b756
4 changed files with 82 additions and 2 deletions
+24
View File
@@ -695,6 +695,30 @@ describe("chat pane catalog session lifecycle", () => {
expect(pane.loadOlderMessages).toHaveBeenCalledOnce();
expect(pane.historyAutoLoadBlocked).toBe(false);
});
it("loads a blocked unscrollable transcript from a downward touch pull", async () => {
const client = { request: vi.fn() } as unknown as GatewayBrowserClient;
const { pane } = createTestChatPane({ client, sessions: {} as SessionCapability });
pane.historyAutoLoadBlocked = true;
pane.hasOlderMessages = vi.fn(() => true);
pane.loadOlderMessages = vi.fn(async () => undefined);
vi.stubGlobal("IntersectionObserver", undefined);
const thread = document.createElement("div");
const touchEvent = (type: string, clientY: number) => {
const event = new TouchEvent(type);
Object.defineProperty(event, "currentTarget", { value: thread });
Object.defineProperty(event, "touches", { value: [{ clientY }] });
return event;
};
pane.handleTranscriptHistoryIntent(touchEvent("touchstart", 100));
pane.handleTranscriptHistoryIntent(touchEvent("touchmove", 106));
pane.handleTranscriptHistoryIntent(touchEvent("touchmove", 112));
await Promise.resolve();
expect(pane.loadOlderMessages).toHaveBeenCalledOnce();
expect(pane.historyAutoLoadBlocked).toBe(false);
});
});
describe("chat pane native history pagination", () => {
+22 -1
View File
@@ -153,6 +153,7 @@ type ChatHistoryAnchor = {
const CATALOG_TOOL_RESULT_PREVIEW_MAX_CHARS = 500;
const CHAT_HISTORY_INTENT_EDGE_PX = 300;
const CHAT_HISTORY_INTENT_IDLE_MS = 200;
const CHAT_HISTORY_TOUCH_INTENT_PX = 8;
const CHAT_HISTORY_UPWARD_KEYS = new Set(["ArrowUp", "PageUp", "Home"]);
function catalogRawString(raw: unknown, keys: readonly string[]): string | null {
@@ -314,6 +315,7 @@ class ChatPane extends OpenClawLightDomElement {
private historyBootstrapPagesLoaded = 0;
private historyIntentConsumed = false;
private historyIntentTimer: number | null = null;
private historyTouchY: number | null = null;
private transcriptScrollTop: number | null = null;
private pendingHistoryAnchor: ChatHistoryAnchor | null = null;
private nativePaginationSnapshot: ChatHistoryPagination | null = null;
@@ -939,6 +941,7 @@ class ChatPane extends OpenClawLightDomElement {
this.historyAutoLoadBlocked = false;
this.historyBootstrapPagesLoaded = 0;
this.historyIntentConsumed = false;
this.historyTouchY = null;
if (this.historyIntentTimer !== null) {
window.clearTimeout(this.historyIntentTimer);
this.historyIntentTimer = null;
@@ -1104,9 +1107,27 @@ class ChatPane extends OpenClawLightDomElement {
private handleTranscriptHistoryIntent(event: Event): void {
const root = event.currentTarget instanceof HTMLElement ? event.currentTarget : null;
const upward =
let upward =
(event instanceof WheelEvent && event.deltaY < 0) ||
(event instanceof KeyboardEvent && CHAT_HISTORY_UPWARD_KEYS.has(event.key));
if (event instanceof TouchEvent) {
const touchY = event.touches[0]?.clientY ?? null;
if (event.type === "touchstart") {
this.historyTouchY = touchY;
return;
}
if (event.type === "touchend" || event.type === "touchcancel") {
this.historyTouchY = null;
return;
}
const previousTouchY = this.historyTouchY;
if (touchY !== null && previousTouchY !== null) {
upward = touchY - previousTouchY >= CHAT_HISTORY_TOUCH_INTENT_PX;
if (upward || touchY < previousTouchY) {
this.historyTouchY = touchY;
}
}
}
if (
!root ||
!upward ||
+27
View File
@@ -725,6 +725,33 @@ describe("chat history pagination", () => {
thread.dispatchEvent(new KeyboardEvent("keydown", { key: "PageUp", bubbles: true }));
expect(onHistoryIntent).toHaveBeenCalledTimes(2);
});
it("keeps wheel and touch history intent listeners passive", () => {
const addEventListener = vi.spyOn(EventTarget.prototype, "addEventListener");
try {
renderChatView({
historyPagination: {
loading: false,
},
onHistoryIntent: vi.fn(),
});
for (const eventName of ["wheel", "touchstart", "touchmove"]) {
expect(
addEventListener.mock.calls.some(
([type, , options]) =>
type === eventName &&
typeof options === "object" &&
options !== null &&
"passive" in options &&
options.passive === true,
),
).toBe(true);
}
} finally {
addEventListener.mockRestore();
}
});
});
describe("direct thread avatar mode", () => {
+9 -1
View File
@@ -653,8 +653,16 @@ export function renderChatThread(props: ChatThreadProps) {
aria-live="polite"
tabindex="0"
@scroll=${props.onChatScroll}
@wheel=${props.onHistoryIntent}
@wheel=${props.onHistoryIntent ? { handleEvent: props.onHistoryIntent, passive: true } : null}
@keydown=${props.onHistoryIntent}
@touchstart=${props.onHistoryIntent
? { handleEvent: props.onHistoryIntent, passive: true }
: null}
@touchmove=${props.onHistoryIntent
? { handleEvent: props.onHistoryIntent, passive: true }
: null}
@touchend=${props.onHistoryIntent}
@touchcancel=${props.onHistoryIntent}
@mousedown=${beginNativeWindowDragFromTopInset}
@click=${(event: Event) => {
handleMarkdownCodeBlockCopy(event);