Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
193 changes: 193 additions & 0 deletions packages/app/src/agent-stream/scroll-keyboard-dismiss.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
import { describe, expect, it } from "vitest";
import {
beginDragSpeedSamples,
recordDragSpeedSample,
resolveDragReleaseSpeed,
resolveScrollEventTimeMs,
type DragSpeedSamples,
} from "./scroll-keyboard-dismiss";

const MIN_SAMPLE_MS = 30;
const DISMISS_VELOCITY = 1.5;

function dragThrough(
points: Array<{ ts: number; y: number }>,
start: { ts: number; y: number },
): DragSpeedSamples {
return points.reduce(
(samples, point) => recordDragSpeedSample(samples, point.ts, point.y, MIN_SAMPLE_MS),
beginDragSpeedSamples(start.ts, start.y),
);
}

describe("resolveDragReleaseSpeed", () => {
it("reports a slow read-scroll below the dismiss threshold", () => {
const samples = dragThrough(
[
{ ts: 1040, y: 8 },
{ ts: 1080, y: 16 },
{ ts: 1120, y: 24 },
],
{ ts: 1000, y: 0 },
);

const speed = resolveDragReleaseSpeed({
samples,
releaseTs: 1160,
releaseY: 32,
minSampleMs: MIN_SAMPLE_MS,
});

expect(speed).toBeCloseTo(0.2);
expect(speed > DISMISS_VELOCITY).toBe(false);
});

it("reports a flick above the dismiss threshold", () => {
const samples = dragThrough([{ ts: 1040, y: 120 }], { ts: 1000, y: 0 });

const speed = resolveDragReleaseSpeed({
samples,
releaseTs: 1080,
releaseY: 240,
minSampleMs: MIN_SAMPLE_MS,
});

expect(speed).toBeCloseTo(3);
expect(speed > DISMISS_VELOCITY).toBe(true);
});

it("stays negative when the inverted list moves back toward the newest messages", () => {
const samples = dragThrough([{ ts: 1040, y: -120 }], { ts: 1000, y: 0 });

const speed = resolveDragReleaseSpeed({
samples,
releaseTs: 1080,
releaseY: -240,
minSampleMs: MIN_SAMPLE_MS,
});

expect(speed).toBeCloseTo(-3);
expect(speed > DISMISS_VELOCITY).toBe(false);
});

it("keeps a fast-but-decelerating drag below the threshold it would pass on average", () => {
// 600 points over 300ms averages 2/ms, but the last stretch crawls.
const samples = dragThrough(
[
{ ts: 1100, y: 500 },
{ ts: 1200, y: 590 },
{ ts: 1270, y: 598 },
],
{ ts: 1000, y: 0 },
);

const speed = resolveDragReleaseSpeed({
samples,
releaseTs: 1300,
releaseY: 600,
minSampleMs: MIN_SAMPLE_MS,
});

expect(speed).toBeCloseTo(0.0666, 3);
expect(speed > DISMISS_VELOCITY).toBe(false);
});

it("measures the whole drag when the gesture was too short to sample", () => {
const samples = beginDragSpeedSamples(1000, 0);

const speed = resolveDragReleaseSpeed({
samples,
releaseTs: 1020,
releaseY: 60,
minSampleMs: MIN_SAMPLE_MS,
});

expect(speed).toBeCloseTo(3);
expect(speed > DISMISS_VELOCITY).toBe(true);
});

it("steps back a sample when the release lands right after one", () => {
const samples = dragThrough(
[
{ ts: 1040, y: 120 },
{ ts: 1075, y: 225 },
],
{ ts: 1000, y: 0 },
);

// Only 5ms since the last sample: too short to measure on its own, so the
// span reaches back to the previous one instead of collapsing to zero.
const speed = resolveDragReleaseSpeed({
samples,
releaseTs: 1080,
releaseY: 240,
minSampleMs: MIN_SAMPLE_MS,
});

expect(speed).toBeCloseTo(3);
expect(speed > DISMISS_VELOCITY).toBe(true);
});

it("reports no speed when the release carries no measurable span", () => {
const samples = beginDragSpeedSamples(1000, 0);

expect(
resolveDragReleaseSpeed({
samples,
releaseTs: 1000,
releaseY: 90,
minSampleMs: MIN_SAMPLE_MS,
}),
).toBe(0);
});
});

describe("resolveScrollEventTimeMs", () => {
it("prefers the native payload timestamp over the dispatch clock", () => {
expect(
resolveScrollEventTimeMs({
timeStamp: 1761000000000,
nativeEvent: { contentOffset: { y: 120 }, timestamp: 84231.5 },
}),
).toBe(84231.5);
});

it("falls back to the synthetic timestamp when the payload omits one", () => {
expect(
resolveScrollEventTimeMs({
timeStamp: 1761000000000,
nativeEvent: { contentOffset: { y: 120 } },
}),
).toBe(1761000000000);
});
});

describe("recordDragSpeedSample", () => {
it("ignores events that arrive before the minimum span", () => {
const samples = beginDragSpeedSamples(1000, 0);

expect(recordDragSpeedSample(samples, 1029, 400, MIN_SAMPLE_MS)).toBe(samples);
});

it("does not turn a burst of delayed callbacks into a flick", () => {
// A calm drag whose callbacks are delivered late and bunched together: the
// event timestamps still describe the real gesture, so the speed stays low.
const samples = dragThrough(
[
{ ts: 1200, y: 40 },
{ ts: 1400, y: 80 },
],
{ ts: 1000, y: 0 },
);

const speed = resolveDragReleaseSpeed({
samples,
releaseTs: 1600,
releaseY: 120,
minSampleMs: MIN_SAMPLE_MS,
});

expect(speed).toBeCloseTo(0.2);
expect(speed > DISMISS_VELOCITY).toBe(false);
});
});
122 changes: 122 additions & 0 deletions packages/app/src/agent-stream/scroll-keyboard-dismiss.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
/**
* Speed measurement for the chat history's flick-to-dismiss gesture.
*
* All timestamps must describe the native gesture, never callback dispatch:
* when the JS thread is busy the callbacks arrive in a burst long after the
* gesture, and dispatch spacing then reads a calm scroll as a flick.
*/

/**
* The parts of a scroll event this module reads. `timestamp` is absent from
* `NativeScrollEvent` even though every platform sends it, so it is declared
* here as optional alongside a field the platform type does declare.
*/
interface ScrollEventTiming {
timeStamp: number;
nativeEvent: {
contentOffset: { y: number };
timestamp?: number;
};
}

/**
* Milliseconds from the native scroll payload, which every platform stamps when
* it creates the event (`CACurrentMediaTime` on iOS, `uptimeMillis` on Android,
* the touch time in Fabric). The synthetic `timeStamp` reads a differently
* spelled key, so it silently falls back to the dispatch clock — only usable
* when the native value is missing.
*/
export function resolveScrollEventTimeMs(event: ScrollEventTiming): number {
const nativeTimestamp = event.nativeEvent.timestamp;
if (typeof nativeTimestamp === "number" && nativeTimestamp > 0) {
return nativeTimestamp;
}
return event.timeStamp;
}
export interface DragSpeedSamples {
startTs: number;
startY: number;
sampleTs: number;
sampleY: number;
previousSampleTs: number;
previousSampleY: number;
}

/** Shareable idle value: these helpers never mutate a sample set in place. */
export const IDLE_DRAG_SPEED_SAMPLES: DragSpeedSamples = Object.freeze({
startTs: 0,
startY: 0,
sampleTs: 0,
sampleY: 0,
previousSampleTs: 0,
previousSampleY: 0,
});

export function beginDragSpeedSamples(ts: number, y: number): DragSpeedSamples {
return {
startTs: ts,
startY: y,
sampleTs: ts,
sampleY: y,
previousSampleTs: 0,
previousSampleY: 0,
};
}

/**
* Keeps the last two samples that are at least `minSampleMs` apart, so the
* gesture's speed near release can be read without trusting a single event.
*/
export function recordDragSpeedSample(
samples: DragSpeedSamples,
ts: number,
y: number,
minSampleMs: number,
): DragSpeedSamples {
if (ts - samples.sampleTs < minSampleMs) {
return samples;
}
return {
startTs: samples.startTs,
startY: samples.startY,
sampleTs: ts,
sampleY: y,
previousSampleTs: samples.sampleTs,
previousSampleY: samples.sampleY,
};
}

/**
* Signed speed over the drag's final stretch, in points per millisecond.
* Positive means the inverted list moved toward older history.
*
* Measuring at release rather than averaging the whole drag is what separates a
* flick (finger lifted mid-motion) from a fast but controlled read-scroll, which
* decelerates before lifting. Prefers the most recent span long enough to be
* reliable, steps back one sample when the release landed right after a sample,
* and falls back to the whole drag for gestures too short to have sampled.
*/
export function resolveDragReleaseSpeed(input: {
samples: DragSpeedSamples;
releaseTs: number;
releaseY: number;
minSampleMs: number;
}): number {
const { samples, releaseTs, releaseY, minSampleMs } = input;

let spanStartTs = samples.startTs;
let spanStartY = samples.startY;
if (releaseTs - samples.sampleTs >= minSampleMs) {
spanStartTs = samples.sampleTs;
spanStartY = samples.sampleY;
} else if (samples.previousSampleTs > 0) {
spanStartTs = samples.previousSampleTs;
spanStartY = samples.previousSampleY;
}

const spanDurationMs = releaseTs - spanStartTs;
if (spanDurationMs <= 0) {
return 0;
}
return (releaseY - spanStartY) / spanDurationMs;
}
Loading
Loading