Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
67 commits
Select commit Hold shift + click to select a range
8dba0b5
Virtualize channel timeline with Virtua
Jul 9, 2026
49afc50
Polish virtualized channel viewport
Jul 9, 2026
a276ea6
fix(desktop): include overlay clearance in virtual timeline
Jul 10, 2026
0b3782c
fix(desktop): clear Virtua prepend shift after render
Jul 10, 2026
045727f
fix(desktop): anchor stationary timeline reflows
Jul 10, 2026
536db13
fix(desktop): bound timeline window and preserve live threads
Jul 10, 2026
1b82f20
fix(desktop): prerender timeline beyond viewport
Jul 10, 2026
8c8c7a9
fix(desktop): anchor timeline measurements at center
Jul 10, 2026
1dab5e8
fix(desktop): let Virtua own timeline anchoring
Jul 10, 2026
59ff2c5
Replace timeline virtualization with retained windows
Jul 10, 2026
d3df9a6
Restore smooth virtualized timeline scrolling
Jul 10, 2026
3efefa1
Preload loaded timeline images
Jul 10, 2026
d03b724
fix(desktop): preserve timeline anchor across prepends
Jul 10, 2026
2584720
Revert "fix(desktop): preserve timeline anchor across prepends"
Jul 10, 2026
767cb27
fix(desktop): stabilize deep timeline prepends
Jul 10, 2026
3968991
fix(desktop): prevent transient prepend jumps
Jul 10, 2026
6660bda
fix(desktop): cancel upward momentum at history boundary
Jul 10, 2026
8cfba9e
fix(desktop): premeasure timeline rows before viewport
Jul 10, 2026
5b0e25d
fix(desktop): mount loaded timeline rows
Jul 10, 2026
f58fd1a
fix(desktop): stop boundary scroll rollback
Jul 10, 2026
b2d1c6d
fix(desktop): retire stale prepend anchors
Jul 10, 2026
0d16233
fix(desktop): gate older-page render commit on scroller rest
Jul 10, 2026
e03fc68
fix(desktop): preserve Virtua prepend cache alignment
Jul 10, 2026
a3728fd
fix(desktop): stabilize virtual timeline lifecycle
Jul 10, 2026
40fb7dc
fix(desktop): preserve measured rows during history scroll
Jul 10, 2026
8b1be65
Merge origin/main into wren/virtua-timeline
Jul 10, 2026
936e96b
Fix virtual timeline CI regressions
Jul 10, 2026
f2bada8
Restore viewport overscroll boundary lock
Jul 11, 2026
a590536
Remove stale content visibility assertion
Jul 11, 2026
21515de
Update retained timeline test contracts
Jul 11, 2026
dfa7f4a
Measure visual timeline tail in integration tests
Jul 11, 2026
8e2b9ec
Keep visual timeline tail pinned as composer grows
Jul 11, 2026
c93b9c1
Bound virtual timeline row retention
Jul 11, 2026
8a094b8
Defer timeline retention refresh through prepend guard
Jul 11, 2026
01095f3
Remember dim-less video aspect ratios across remount
Jul 11, 2026
7263642
Preserve timeline anchor through virtualizer corrections
Jul 11, 2026
a1ee4a6
Retire timeline anchors for explicit navigation
Jul 11, 2026
c207fc8
fix(timeline): scope upward momentum suppression
Jul 11, 2026
81b8084
Stabilize virtualized live-tail settling
Jul 11, 2026
f13614e
Stabilize virtualized head geometry
Jul 11, 2026
ba0aae1
Gate cascade-08 anchor baseline on wheel-input settle
Jul 11, 2026
1ce9560
Externalize day dividers as proven-boundary standalone items
Jul 11, 2026
babc8b7
Fix visual scrollability threshold in stream E2E
Jul 11, 2026
78bdc73
Merge origin/main into wren/virtua-timeline
Jul 11, 2026
dd1b41f
perf(desktop): warm relay connections sooner
Jul 11, 2026
73754d6
test(desktop): pin shared HTTP pool settings
Jul 11, 2026
7eee2b0
perf(desktop): streamline cold channel opens
Jul 11, 2026
8d8d99e
perf(desktop): prefetch channel heads on intent
Jul 11, 2026
c05af28
perf(desktop): stage older channel windows
Jul 11, 2026
31b0273
perf(desktop): remove predictive channel prefetch
Jul 11, 2026
c4544b8
revert(desktop): restore proven channel timeline
Jul 11, 2026
1b3d20a
feat(desktop): progressively load message images
Jul 11, 2026
d755c22
fix(desktop): pin progressive image layout
Jul 11, 2026
61b0f23
fix(desktop): skip duplicate progressive image
Jul 11, 2026
8171974
test(desktop): align virtualized smoke coverage
Jul 11, 2026
3899b38
Merge origin/main into wren/virtua-timeline
Jul 12, 2026
86fd5c7
fix(desktop): hydrate thread root reactions
Jul 12, 2026
d0b7240
fix(desktop): settle channel open at bottom
Jul 12, 2026
bdf76aa
fix(desktop): preserve pinned timeline floor
Jul 12, 2026
f42eb78
Keep bottom pin when split thread opens
Jul 12, 2026
0ef648c
fix(desktop): preserve reader position on reactions
Jul 12, 2026
f9eb093
Merge origin/main into wren/virtua-timeline
Jul 12, 2026
66b3697
Fix progressive image mosaic lightbox geometry
Jul 12, 2026
114427a
Stabilize relay-backed scroll tests
Jul 12, 2026
e6418a3
Stabilize settled prepend anchor correction
Jul 13, 2026
22d8fbd
Preserve timeline suffix across membership prepends
Jul 13, 2026
0e225a9
Anchor prepends to system timeline rows
Jul 13, 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
1 change: 1 addition & 0 deletions desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@
"tailwind-merge": "^3.5.0",
"tiptap-markdown": "^0.9.0",
"upng-js": "^2.1.0",
"virtua": "0.49.2",
"yaml": "^2.8.3",
"zod": "^4.4.3"
},
Expand Down
10 changes: 8 additions & 2 deletions desktop/src/features/channels/ui/ChannelPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ export const ChannelPane = React.memo(function ChannelPane({
fetchOlder,
header,
hasOlderMessages,
historyExhausted,
isFetchingOlder,
followThreadById,
isFollowingThread,
Expand Down Expand Up @@ -191,6 +192,7 @@ export const ChannelPane = React.memo(function ChannelPane({
timelineScrollRef,
composerWrapperRef,
`${activeChannelId}:${isSinglePanelView}:${hasMainComposerOverlay}`,
"css-variable",
);
const clearWelcomeComposerDismissTimer = React.useCallback(() => {
if (welcomeComposerDismissTimerRef.current !== null) {
Expand Down Expand Up @@ -617,6 +619,7 @@ export const ChannelPane = React.memo(function ChannelPane({
followThreadById={followThreadById}
hasComposerOverlay={hasMainComposerOverlay}
hasOlderMessages={hasOlderMessages}
historyExhausted={historyExhausted}
huddleMemberPubkeys={huddleMemberPubkeys}
huddleMemberPubkeysPending={huddleMemberPubkeysPending}
isFetchingOlder={isFetchingOlder}
Expand Down Expand Up @@ -660,6 +663,9 @@ export const ChannelPane = React.memo(function ChannelPane({
searchMatchingMessageIds={channelFind.matchingMessageIds}
searchQuery={channelFind.query}
targetMessageId={targetMessageId}
splitThreadPanelOpen={
useSplitAuxiliaryPane && Boolean(openThreadHeadId)
}
threadUnreadCounts={threadUnreadCounts}
/>
{isNonMemberView ? (
Expand Down Expand Up @@ -690,7 +696,7 @@ export const ChannelPane = React.memo(function ChannelPane({
</div>
) : (
<div
className="pointer-events-none absolute inset-x-0 bottom-0 z-40"
className="pointer-events-none absolute inset-x-0 bottom-0 z-40 bg-background/70 backdrop-blur-md supports-[backdrop-filter]:bg-background/55"
data-testid="channel-composer-overlay"
ref={composerWrapperRef}
>
Expand Down Expand Up @@ -736,7 +742,7 @@ export const ChannelPane = React.memo(function ChannelPane({
}
showTopBorder={false}
/>
<div className="min-h-8 overflow-visible bg-background px-5 pb-1.5 pt-0">
<div className="min-h-8 overflow-visible bg-transparent px-5 pb-1.5 pt-0">
<div className="flex h-full w-full items-center gap-2 overflow-visible">
{hasComposerBotActivity ? (
<div className="flex min-w-0 flex-1 overflow-visible">
Expand Down
2 changes: 2 additions & 0 deletions desktop/src/features/channels/ui/ChannelPane.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ export type ChannelPaneProps = {
fetchOlder?: () => Promise<void>;
header?: React.ReactNode;
hasOlderMessages?: boolean;
/** True when the loaded window provably starts at the channel's beginning. */
historyExhausted?: boolean;
isFetchingOlder?: boolean;
isJoining?: boolean;
isSinglePanelView?: boolean;
Expand Down
4 changes: 2 additions & 2 deletions desktop/src/features/channels/ui/ChannelScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,6 @@ import { useChannelProfilePanel } from "./useChannelProfilePanel";
import { useChannelRouteTarget } from "./useChannelRouteTarget";
import { useChannelUnreadState } from "./useChannelUnreadState";
import type { ChannelScreenProps } from "./ChannelScreen.types";

const HEADER_ACTIONS_COMPACT_BREAKPOINT_PX = 760,
EMPTY_RELAY_EVENTS: RelayEvent[] = [];
export function ChannelScreen({
Expand Down Expand Up @@ -190,7 +189,7 @@ export function ChannelScreen({
effectiveOpenThreadHeadId,
);
useChannelSubscription(activeChannel);
const { fetchOlder, hasOlderMessages, isFetchingOlder } =
const { fetchOlder, hasOlderMessages, historyExhausted, isFetchingOlder } =
useFetchOlderMessages(activeChannel);
const latestActiveMessage = React.useMemo(() => {
const messages = messagesQuery.data;
Expand Down Expand Up @@ -878,6 +877,7 @@ export function ChannelScreen({
fetchOlder={fetchOlder}
header={channelHeader}
hasOlderMessages={hasOlderMessages}
historyExhausted={historyExhausted}
onAddAgent={handleOpenAddBot}
onCreateChannel={openCreateChannel}
onOpenMembers={handleOpenMembersSidebar}
Expand Down
25 changes: 25 additions & 0 deletions desktop/src/features/messages/lib/channelWindowStore.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import test from "node:test";
import {
appendOlderChannelWindow,
mapChannelWindowEvents,
channelWindowHasMore,
channelWindowHistoryExhausted,
channelWindowThreadSummaries,
emptyChannelWindowStore,
flattenChannelWindowEvents,
Expand Down Expand Up @@ -401,3 +403,26 @@ test("mapChannelWindowEvents rewrites live overlay events", () => {
"edited-live",
);
});

test("exhaustion is unresolved (false) on an empty store, unlike hasMore's default", () => {
const store = emptyChannelWindowStore();
assert.equal(channelWindowHasMore(store), false);
// Both read "no more history", but exhaustion must NOT claim the boundary
// is proven before any page resolves — an unloaded window is unknown.
assert.equal(channelWindowHistoryExhausted(store), false);
});

test("exhaustion tracks only a resolved tail page's hasMore", () => {
const open = replaceNewestChannelWindow(
emptyChannelWindowStore(),
page(null, [event("newest", 100)], { hasMore: true }),
);
assert.equal(channelWindowHistoryExhausted(open), false);

const closed = appendOlderChannelWindow(
open,
page(open.pages[0].nextCursor, [event("oldest", 99)], { hasMore: false }),
);
assert.equal(channelWindowHistoryExhausted(closed), true);
assert.equal(channelWindowHasMore(closed), false);
});
11 changes: 11 additions & 0 deletions desktop/src/features/messages/lib/channelWindowStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,17 @@ export function channelWindowHasMore(store: ChannelWindowStore) {
return tail?.hasMore ?? false;
}

/**
* Whether the loaded window PROVABLY starts at the channel's beginning. This
* is not `!channelWindowHasMore`: an empty store also reports "no more", but
* that means the boundary is unresolved (nothing has loaded), not exhausted.
* Only a resolved tail page saying `hasMore: false` proves the start.
*/
export function channelWindowHistoryExhausted(store: ChannelWindowStore) {
const tail = store.pages[store.pages.length - 1];
return tail !== undefined && !tail.hasMore;
}

/**
* Per-root thread summaries for badge rendering: authoritative page summaries
* overlaid with any fresher relay-pushed live summaries. The live overlay also
Expand Down
98 changes: 98 additions & 0 deletions desktop/src/features/messages/lib/timelineImagePreload.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import assert from "node:assert/strict";
import { test } from "node:test";

import { timelineImageUrls } from "./timelineImagePreload.ts";

function message(over = {}) {
return {
id: "m1",
createdAt: 0,
author: "a",
time: "now",
body: "",
depth: 0,
...over,
};
}

test("timelineImageUrls warms chrome images but leaves attachments lazy", () => {
const urls = timelineImageUrls(
message({
avatarUrl: "https://example.com/avatar.jpg",
body: [
"![first](https://example.com/one.png)",
'![second](https://example.com/two.webp "caption")',
].join("\n"),
tags: [
["imeta", "url https://example.com/three.jpg", "m image/jpeg"],
[
"imeta",
"url https://example.com/movie.mp4",
"m video/mp4",
"image https://example.com/poster.jpg",
"thumb https://example.com/thumb.jpg",
],
["emoji", "party", "https://example.com/party.png"],
],
reactions: [
{
emoji: ":party:",
emojiUrl: "https://example.com/reaction.png",
count: 1,
users: [],
},
],
}),
);

assert.deepEqual(
new Set(urls),
new Set([
"https://example.com/avatar.jpg",
"https://example.com/poster.jpg",
"https://example.com/thumb.jpg",
"https://example.com/party.png",
"https://example.com/reaction.png",
]),
);
assert.ok(!urls.includes("https://example.com/one.png"));
assert.ok(!urls.includes("https://example.com/three.jpg"));
});

test("timelineImageUrls deduplicates chrome image URLs", () => {
const url = "https://example.com/same.png";
assert.deepEqual(
timelineImageUrls(
message({
avatarUrl: url,
tags: [["emoji", "same", url]],
}),
),
[url],
);
});

test("preloadTimelineImages requests URLs once and keeps requests alive", async () => {
const { preloadTimelineImages } = await import("./timelineImagePreload.ts");
const previousImage = globalThis.Image;
const requested = [];
class FakeImage extends EventTarget {
set src(url) {
requested.push(url);
}
}
globalThis.Image = FakeImage;
try {
const state = { activeImages: new Set(), requestedUrls: new Set() };
const messages = [message({ avatarUrl: "https://example.com/one.png" })];
preloadTimelineImages(messages, state);
preloadTimelineImages(messages, state);

assert.deepEqual(requested, ["https://example.com/one.png"]);
assert.equal(state.activeImages.size, 1);
state.activeImages.values().next().value.dispatchEvent(new Event("load"));
assert.equal(state.activeImages.size, 0);
} finally {
globalThis.Image = previousImage;
}
});
57 changes: 57 additions & 0 deletions desktop/src/features/messages/lib/timelineImagePreload.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { rewriteRelayUrl } from "@/shared/lib/mediaUrl";
import type { TimelineMessage } from "../types";
import { parseImetaTags } from "./parseImeta";

/**
* Return non-message-media image URLs worth warming before a virtualized row
* mounts. Inline image attachments deliberately stay out of this projection:
* their native-lazy thumbnail must load before the full-resolution request.
*/
export function timelineImageUrls(message: TimelineMessage): string[] {
const urls = new Set<string>();
const add = (url: string | null | undefined) => {
if (url) urls.add(rewriteRelayUrl(url));
};

add(message.avatarUrl);

if (message.tags) {
for (const entry of parseImetaTags(message.tags).values()) {
// Video poster frames are not part of the progressive image path.
if (entry.m?.startsWith("video/")) {
add(entry.image);
add(entry.thumb);
}
}
for (const tag of message.tags) {
if (tag[0] === "emoji") add(tag[2]);
}
}

for (const reaction of message.reactions ?? []) add(reaction.emojiUrl);
return [...urls];
}

export type TimelineImagePreloadState = {
activeImages: Set<HTMLImageElement>;
requestedUrls: Set<string>;
};

/** Start all image requests now, independently of Virtua row mounting. */
export function preloadTimelineImages(
messages: readonly TimelineMessage[],
state: TimelineImagePreloadState,
): void {
for (const message of messages) {
for (const url of timelineImageUrls(message)) {
if (state.requestedUrls.has(url)) continue;
state.requestedUrls.add(url);
const image = new Image();
state.activeImages.add(image);
const release = () => state.activeImages.delete(image);
image.addEventListener("load", release, { once: true });
image.addEventListener("error", release, { once: true });
image.src = url;
}
}
}
28 changes: 25 additions & 3 deletions desktop/src/features/messages/lib/timelineItems.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ test("buildTimelineItems: member additions by one actor group within five minute
group?.entries.map((groupEntry) => groupEntry.message.id),
["a", "b", "c"],
);
assert.equal(group?.key, "a");
assert.equal(group?.key, "c");
});

test("buildTimelineItems: self-joins group across different members within five minutes", () => {
Expand Down Expand Up @@ -146,7 +146,29 @@ test("buildTimelineItems: self-joins group across different members within five
);
});

test("buildTimelineItems: member-add window is fixed from the first addition", () => {
test("buildTimelineItems: prepending membership history preserves the loaded suffix", () => {
const start = dayAt(2026, 6, 14);
const loaded = [
memberAddedEntry({ id: "b", target: "target-b", createdAt: start + 240 }),
memberAddedEntry({ id: "c", target: "target-c", createdAt: start + 360 }),
entry({ id: "message", createdAt: start + 600 }),
];
const prepended = [
memberAddedEntry({ id: "a", target: "target-a", createdAt: start }),
...loaded,
];

const loadedItems = buildTimelineItems(loaded, null).items;
const prependedItems = buildTimelineItems(prepended, null).items;
const loadedKeys = loadedItems.slice(1).map((item) => item.key);
const prependedKeys = prependedItems.slice(1).map((item) => item.key);

assert.deepEqual(loadedKeys, ["c", "message"]);
assert.deepEqual(prependedKeys, ["a", "c", "message"]);
assert.deepEqual(prependedKeys.slice(-loadedKeys.length), loadedKeys);
});

test("buildTimelineItems: member-add window is fixed from the newest addition", () => {
const start = dayAt(2026, 6, 14);
const entries = [
memberAddedEntry({ id: "a", target: "target-a", createdAt: start }),
Expand All @@ -155,7 +177,7 @@ test("buildTimelineItems: member-add window is fixed from the first addition", (
];

const { items } = buildTimelineItems(entries, null);
assert.deepEqual(kinds(items), ["day-divider", "system-group", "system"]);
assert.deepEqual(kinds(items), ["day-divider", "system", "system-group"]);
});

test("buildTimelineItems: actor changes and intervening rows break member-add groups", () => {
Expand Down
Loading