Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
4f378a3
L-E upscroll-jitter gate: RED metric isolating realization drift
Jul 8, 2026
164408c
T1.1 upscroll-jitter gate: reframe to motion-consistency (comp-sensit…
Jul 8, 2026
ae8f130
fix(desktop): compensate realization jitter on upscroll (T2 writer)
Jul 8, 2026
92fd119
T3 shrink timeline row height estimates
Jul 8, 2026
2b96e4f
T3 tune markdown reserve overshoot
Jul 8, 2026
f8adc9f
feat(desktop): wire measured column width into row estimates + anchor…
Jul 8, 2026
104f3cd
T1.2 upscroll-jitter gate: score a fetchOlder prepend per run
Jul 8, 2026
6b9203c
test(desktop): live-staging upscroll probes + WebKit perf project
Jul 8, 2026
6b04aa1
fix(desktop): make upscroll compensation engine-order-independent (We…
tlongwell-block Jul 8, 2026
7c25874
W3 trackpad-momentum upscroll GATE: merge-blocking, offline, both eng…
Jul 8, 2026
6ef1880
Merge W1: engine-order-independent upscroll compensation (dawn/webkit…
Jul 8, 2026
eaccc0f
Merge W3: offline trackpad-momentum upscroll gate (sami/webkit-comp-g…
Jul 8, 2026
c9e5bf3
fix(desktop): drop redundant post-correction re-baseline (single-writer)
tlongwell-block Jul 8, 2026
fe786ea
fix(desktop): hold reading row across WebKit realization (engine-orde…
tlongwell-block Jul 8, 2026
3d1e91e
Merge W1r2: drop redundant post-correction re-baseline (dawn/webkit-c…
Jul 8, 2026
b170680
Merge W1r3: hold reading row across WebKit realization via aboveShift…
Jul 8, 2026
8239284
test(desktop): slow-trackpad mismatch probe (per-frame rect.top traje…
Jul 8, 2026
e729784
fix(desktop): gate WebKit upscroll fallback on prev-rAF anchor agreement
Jul 8, 2026
f196ff7
Merge W1r4: R_obs agreement gate — Shape-B fabricated-lurch fix (dawn…
Jul 9, 2026
45500ed
W4 felt gate: jerk metric rewrite + whole-run cumulative drift instru…
Jul 9, 2026
dfa9b0c
Merge W4 felt-gate instrument: three-leg jerk metric + whole-run cumu…
Jul 9, 2026
2c422c1
docs(desktop): correct totalDrift comment — summed-magnitude of all a…
Jul 9, 2026
e724388
fix(desktop): correct realizing-upscroll reading-row reversal on both…
Jul 9, 2026
26418e9
test(desktop): slow-scroll characterization leg (2-4px/frame momentum…
Jul 9, 2026
a4fdc35
test(desktop): grow/shrink/skip reversal classifier + signedShift pro…
Jul 9, 2026
9df6d92
test(desktop): widen classifier attribution window to +/-1 frame
Jul 9, 2026
44d9edc
chore(desktop): raise useAnchoredScroll size limit 1140->1180 for W4a…
Jul 9, 2026
2bd2076
fix(desktop): rekey upscroll momentum gate onto rendered scroll (w4a-…
Jul 9, 2026
b0005df
test(desktop): media-corpus classifier — media reflow is absorbed-cla…
Jul 9, 2026
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
23 changes: 23 additions & 0 deletions desktop/playwright.perf-webkit.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { defineConfig, devices } from "@playwright/test";

export default defineConfig({
testDir: "./tests/e2e",
timeout: 900_000,
retries: 0,
workers: 1,
reporter: [["list"]],
use: { baseURL: "http://127.0.0.1:4173" },
projects: [
{
name: "perf-webkit",
testMatch: ["**/*.perf.ts"],
use: { ...devices["Desktop Safari"] },
},
],
webServer: {
command: "python3 -m http.server 4173 -d dist",
cwd: ".",
reuseExistingServer: true,
url: "http://127.0.0.1:4173",
},
});
24 changes: 24 additions & 0 deletions desktop/scripts/check-file-sizes.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,30 @@ const overrides = new Map([
// overage from load-bearing per-message plumbing, not generic debt growth.
// Approved override; still queued to split with the rest of this list.
["src/features/messages/ui/MessageThreadPanel.tsx", 1006],
// W4a realizing-upscroll reversal fix (design-of-record (c), targets #1662):
// the hook is the sole cross-engine reading-anchor scroll writer, and the
// fix's correctness lives in WHY each observer wins per engine — so the
// ungated-RO mid-history corrector, the safe-margin straddler-guard invariant
// (Quinn's merge-bar checklist #4), the one-clock/single-writer reasoning, and
// the build-stamp/probe contract are all documented AT their sites. The file
// was 888 lines at HEAD; this fix's code + load-bearing provenance comments
// crossed 1000. The 1140→1210 bump is the W4a chase instrumentation inflating
// a load-bearing hook mid-investigation: the signed-shift grow/shrink probe
// (classifier arm, 1140→1156) and the renderedScroll gate-clock rekey
// (w4a-gate-1 arm, ~1156→1206). The gate-arm growth is the deferred-refresh
// commit path + the momentum-gate rationale + the `renderedScroll` field
// threaded through the shared result type and all four returns so the
// classifier fixture can PROVE the scroll/reflow decomposition (Eva required
// this in the arm's first run); biome reflows each 4-prop return object to
// multi-line, which is most of the line cost. FLAGGED to Eva: this is heavy
// for a diagnostic — if the decomposition proof moves behind a lighter
// gate-only probe, this drops back toward 1185. EXIT CONDITION: once #1662's
// fate is decided, the diagnostic emits get stripped or gated and the
// mid-history corrector + band walk split into a sibling module — Eva's ruling
// holds the structural split until the arms in flight settle, so it is NOT
// done mid-chase. Comment-dominated overage on a correctness fix, not generic
// debt growth. Approved override; queued to split.
["src/features/messages/ui/useAnchoredScroll.ts", 1210],
// AgentConfigPanel footer fold into ProfileFieldGroup for the config-bridge
// panel — a small overage from load-bearing UI plumbing, not generic debt
// growth. Approved override; still queued to split with the rest of this list.
Expand Down
76 changes: 75 additions & 1 deletion desktop/src/features/messages/lib/rowHeightEstimate.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -84,21 +84,95 @@ test("estimateRowHeight: imeta dim is not double-counted with its body url", ()
assert.ok(both < 400, `expected single media reserve, got ${both}`);
});

test("estimateRowHeight: bare URL line adds a preview card", () => {
test("estimateRowHeight: unsupported bare URL line does not add a preview card", () => {
const withUrl = estimateRowHeight(msg({ body: "https://example.com/x" }));
const withoutUrl = estimateRowHeight(msg({ body: "example" }));
assert.ok(withUrl < withoutUrl + 25, `url ${withUrl} vs ${withoutUrl}`);
});

test("estimateRowHeight: supported GitHub URL line adds a preview card", () => {
const withUrl = estimateRowHeight(
msg({ body: "https://github.com/block/buzz/pull/1641" }),
);
const withoutUrl = estimateRowHeight(msg({ body: "example" }));
assert.ok(withUrl > withoutUrl + 50, `url ${withUrl} vs ${withoutUrl}`);
});

test("estimateRowHeight: supported Linear and Google URLs add preview cards", () => {
const base = estimateRowHeight(msg({ body: "example" }));
const linear = estimateRowHeight(
msg({ body: "https://linear.app/block/issue/BUZZ-123/fix-scroll" }),
);
const google = estimateRowHeight(
msg({ body: "https://docs.google.com/document/d/abc123/edit" }),
);
assert.ok(linear > base + 50, `linear ${linear} vs ${base}`);
assert.ok(google > base + 50, `google ${google} vs ${base}`);
});

test("estimateRowHeight: column width option changes prose wrapping", () => {
const body = "x".repeat(160);
const narrow = estimateRowHeight(msg({ body }), { columnWidthPx: 320 });
const fallback = estimateRowHeight(msg({ body }));
assert.ok(narrow > fallback + 20, `narrow ${narrow} vs fallback ${fallback}`);
});

test("estimateRowHeight: markdown structures reserve extra chrome", () => {
const table = estimateRowHeight(
msg({ body: "| A | B |\n| - | - |\n| 1 | 2 |" }),
);
const plain = estimateRowHeight(msg({ body: "A B\n- -\n1 2" }));
assert.ok(table > plain + 20, `table ${table} vs plain ${plain}`);
});

test("timelineRowReserveStyle: message item yields containIntrinsicSize", () => {
const style = timelineRowReserveStyle({
kind: "message",
key: "k",
entry: { message: msg({ body: "hi" }), summary: null },
isContinuation: false,
isFollowedByContinuation: false,
});
assert.match(String(style.containIntrinsicSize), /^auto \d+px$/);
});

test("timelineRowReserveStyle: summary rows add summary chrome", () => {
const item = {
kind: "message",
key: "k",
entry: {
message: msg({ body: "hi" }),
summary: {
threadHeadId: "m1",
replyCount: 3,
lastReplyAt: 1,
participants: [],
},
},
isContinuation: false,
isFollowedByContinuation: false,
};
const withSummary = Number.parseInt(
String(timelineRowReserveStyle(item).containIntrinsicSize).match(
/auto (\d+)px/,
)?.[1] ?? "0",
10,
);
const withoutSummary = Number.parseInt(
String(
timelineRowReserveStyle({
...item,
entry: { ...item.entry, summary: null },
}).containIntrinsicSize,
).match(/auto (\d+)px/)?.[1] ?? "0",
10,
);
assert.ok(
withSummary > withoutSummary + 25,
`${withSummary} vs ${withoutSummary}`,
);
});

test("timelineRowReserveStyle: divider is short fixed height", () => {
const style = timelineRowReserveStyle({
kind: "day-divider",
Expand Down
109 changes: 92 additions & 17 deletions desktop/src/features/messages/lib/rowHeightEstimate.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type * as React from "react";

import { extractSupportedLinkPreviews } from "@/shared/lib/linkPreview";
import { dimensionsFromDim } from "@/shared/ui/markdown/utils";
import type { TimelineItem } from "./timelineItems";
import type { TimelineMessage } from "../types";
Expand All @@ -24,16 +25,33 @@ const MEDIA_MAX_WIDTH = 384; // max-w-[min(24rem,100%)]
const MEDIA_MAX_HEIGHT = 256; // max-h-64
const TEXT_LINE_HEIGHT = 20;
const CODE_LINE_HEIGHT = 19;
const CHARS_PER_LINE = 64; // rough wrap width at the timeline column
const FALLBACK_CHARS_PER_LINE = 64; // rough wrap width at the timeline column
const AVERAGE_TEXT_CHAR_WIDTH = 7.2; // text-sm, biased toward common prose
const ROW_HORIZONTAL_CHROME = 64; // avatar + row gap + inline padding
const MIN_CHARS_PER_LINE = 32;
const MAX_CHARS_PER_LINE = 96;
const ROW_CHROME = 26; // author/time header + denser row padding
const CONTINUATION_ROW_CHROME = 8; // dense row padding only; header/avatar are hidden
const MEDIA_BLOCK_MARGIN_TOP = 4; // image/video blocks use mt-1 in markdown
const REACTION_ROW = 24;
const PREVIEW_CARD = 70;
const THREAD_SUMMARY_ROW = 38;
const FOOTER_ROW = 32;
const MESSAGE_ITEM_BOTTOM_PADDING = 10; // TimelineMessageList pb-2.5
const MIN_ESTIMATE = 60; // never reserve less than the old flat floor
const CONTINUATION_MIN_ESTIMATE = 34;

export type TimelineRowReserveOptions = {
/** Timeline column width measured once by the caller; absent keeps the old 64-char estimate. */
columnWidthPx?: number;
/** Optional row footer chrome. The main channel timeline currently leaves this unset. */
hasFooter?: boolean;
};

type EstimateRowHeightOptions = TimelineRowReserveOptions & {
isContinuation?: boolean;
};

function mediaHeightFromDim(dim: string | undefined): number {
const dimensions = dimensionsFromDim(dim);
if (!dimensions) return MEDIA_MAX_HEIGHT; // unknown shape: reserve full box
Expand All @@ -49,10 +67,27 @@ function mediaReserveHeight(dim: string | undefined): number {
return MEDIA_BLOCK_MARGIN_TOP + mediaHeightFromDim(dim);
}

function wrappedLineCount(text: string): number {
function charsPerLineFromColumnWidth(
columnWidthPx: number | undefined,
): number {
if (columnWidthPx == null || !Number.isFinite(columnWidthPx)) {
return FALLBACK_CHARS_PER_LINE;
}

const textWidth = Math.max(0, columnWidthPx - ROW_HORIZONTAL_CHROME);
return Math.max(
MIN_CHARS_PER_LINE,
Math.min(
MAX_CHARS_PER_LINE,
Math.floor(textWidth / AVERAGE_TEXT_CHAR_WIDTH),
),
);
}

function wrappedLineCount(text: string, charsPerLine: number): number {
let lines = 0;
for (const raw of text.split("\n")) {
lines += Math.max(1, Math.ceil(raw.length / CHARS_PER_LINE));
lines += Math.max(1, Math.ceil(raw.length / charsPerLine));
}
return lines;
}
Expand All @@ -61,7 +96,10 @@ function wrappedLineCount(text: string): number {
* Strip fenced code blocks from the body, returning the prose remainder and the
* total number of code lines (for separate mono line-height accounting).
*/
function splitFencedCode(body: string): { prose: string; codeLines: number } {
function splitFencedCode(body: string): {
prose: string;
codeLines: number;
} {
const parts = body.split(/```/);
// Even indices are prose, odd indices are inside a fence.
let prose = "";
Expand Down Expand Up @@ -108,19 +146,55 @@ function stripMediaOnlyLines(text: string): string {
.join("\n");
}

function markdownStructureExtraHeight(text: string): number {
let extra = 0;
let tableRunLines = 0;

const flushTableRun = () => {
if (tableRunLines >= 2) {
// GFM tables have cell padding/borders, so they are taller than the same
// raw markdown counted as plain 20px text lines.
extra += 14 + tableRunLines * 6;
}
tableRunLines = 0;
};

for (const line of text.split("\n")) {
const trimmed = line.trim();
const isTableLine =
/^\|.+\|$/.test(trimmed) || /\S\s+\|\s+\S/.test(trimmed);

if (isTableLine) tableRunLines += 1;
else flushTableRun();

if (/^(?:---+|\*\*\*+|___+)\s*$/.test(trimmed)) extra += 12;
}

flushTableRun();
return extra;
}

export function estimateRowHeight(
message: TimelineMessage,
{ isContinuation = false }: { isContinuation?: boolean } = {},
{
columnWidthPx,
hasFooter = false,
isContinuation = false,
}: EstimateRowHeightOptions = {},
): number {
const body = message.body ?? "";
const { prose, codeLines } = splitFencedCode(body);
const proseForLineCount = stripMediaOnlyLines(prose);
const charsPerLine = charsPerLineFromColumnWidth(columnWidthPx);

let height = isContinuation ? CONTINUATION_ROW_CHROME : ROW_CHROME;
height +=
wrappedLineCount(proseForLineCount.trim() === "" ? "" : proseForLineCount) *
TEXT_LINE_HEIGHT;
wrappedLineCount(
proseForLineCount.trim() === "" ? "" : proseForLineCount,
charsPerLine,
) * TEXT_LINE_HEIGHT;
height += codeLines * CODE_LINE_HEIGHT;
height += markdownStructureExtraHeight(proseForLineCount);

const imetaUrls = new Set<string>();
if (message.tags && message.tags.length > 0) {
Expand All @@ -137,16 +211,13 @@ export function estimateRowHeight(
height += mediaReserveHeight(undefined);
}

// A bare non-media URL on its own line usually renders a link-preview card.
const hasPreviewUrlLine = body
.split("\n")
.some(
(line) =>
/^\s*https?:\/\/\S+\s*$/.test(line) && !MEDIA_URL_RE.test(line.trim()),
);
if (hasPreviewUrlLine) height += PREVIEW_CARD;
// Reserve only cards the renderer can actually produce. Generic bare URLs do
// not render preview cards, so keeping the old blanket reserve overestimated
// unsupported links by about one card height during first realization.
height += extractSupportedLinkPreviews(body).length * PREVIEW_CARD;

if (message.reactions && message.reactions.length > 0) height += REACTION_ROW;
if (hasFooter) height += FOOTER_ROW;

return Math.max(
isContinuation ? CONTINUATION_MIN_ESTIMATE : MIN_ESTIMATE,
Expand All @@ -166,14 +237,18 @@ const DIVIDER_HEIGHT = 32;
*/
export function timelineRowReserveStyle(
item: TimelineItem,
opts: TimelineRowReserveOptions = {},
): React.CSSProperties {
const height =
item.kind === "message"
? estimateRowHeight(item.entry.message, {
...opts,
isContinuation: item.isContinuation,
}) + (item.isFollowedByContinuation ? 0 : MESSAGE_ITEM_BOTTOM_PADDING)
}) +
(item.entry.summary ? THREAD_SUMMARY_ROW : 0) +
(item.isFollowedByContinuation ? 0 : MESSAGE_ITEM_BOTTOM_PADDING)
: item.kind === "system"
? estimateRowHeight(item.entry.message)
? estimateRowHeight(item.entry.message, opts)
: DIVIDER_HEIGHT;
return { containIntrinsicSize: `auto ${height}px` };
}
15 changes: 13 additions & 2 deletions desktop/src/features/messages/ui/TimelineMessageList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import type { TimelineMessage } from "@/features/messages/types";
import { canManageMessageForCurrentUser } from "@/features/messages/lib/canManageMessage";
import type { UserProfileLookup } from "@/features/profile/lib/identity";
import type { ChannelType } from "@/shared/api/types";
import { useElementWidth } from "@/shared/hooks/use-mobile";
import { cn } from "@/shared/lib/cn";
import { DayDivider } from "./DayDivider";
import { MessageRow } from "./MessageRow";
Expand Down Expand Up @@ -178,6 +179,14 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
[itemsResult.items],
);

// Measure the row column once so row-height estimates reserve credible space
// for the *actual* wrap width instead of the 64-char fallback. The rows are
// `w-full` inside this wrapper, so its width is the row box the estimator
// subtracts chrome from. Zero (pre-measure) passes `undefined` to preserve
// the estimator's own fallback rather than tripping its min-chars floor.
const [columnRef, columnWidthPx] = useElementWidth<HTMLDivElement>();
const reserveColumnWidthPx = columnWidthPx > 0 ? columnWidthPx : undefined;

const renderItem = React.useCallback(
(item: TimelineNonDayItem) => {
switch (item.kind) {
Expand Down Expand Up @@ -256,7 +265,7 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
);

return (
<div className="flex flex-col">
<div className="flex flex-col" ref={columnRef}>
{dayGroups.map((group) => (
<section
className={cn(
Expand All @@ -279,7 +288,9 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
<div
className="timeline-row-cv"
key={getTimelineItemKey(item)}
style={timelineRowReserveStyle(item)}
style={timelineRowReserveStyle(item, {
columnWidthPx: reserveColumnWidthPx,
})}
>
{renderItem(item)}
</div>
Expand Down
Loading
Loading