Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
54 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
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
7 changes: 5 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 @@ -190,6 +191,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 @@ -616,6 +618,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 @@ -689,7 +692,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 @@ -735,7 +738,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
3 changes: 2 additions & 1 deletion desktop/src/features/channels/ui/ChannelScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,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 +878,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;
}
}
}
Loading