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
2 changes: 2 additions & 0 deletions desktop/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,8 @@ export default defineConfig({
"**/cold-switch-longtask.perf.ts",
"**/switch-settle-after-paint.spec.ts",
"**/timeline-no-shift.spec.ts",
"**/thread-summary-stability.spec.ts",
"**/channel-revisit-no-skeleton.spec.ts",
"**/human-edit-agent-content.spec.ts",
"**/empty-edit-delete.spec.ts",
"**/reaction-order.spec.ts",
Expand Down
10 changes: 9 additions & 1 deletion desktop/src/features/channels/ui/ChannelScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ import {
resolveTimelineLoadingLatch,
selectTimelineLoadingState,
} from "@/features/messages/lib/timelineLoadingState";
import { hasTimelineSettledThisSession } from "@/features/messages/lib/settledTimelineChannels";
import { useFetchOlderMessages } from "@/features/messages/useFetchOlderMessages";
import { useIndependentThreadPanel } from "@/features/messages/useIndependentThreadPanel";
import { useThreadReplies } from "@/features/messages/useThreadReplies";
Expand Down Expand Up @@ -596,8 +597,15 @@ export function ChannelScreen({
setThreadScrollTargetId,
});
const settledChannelIdRef = React.useRef<string | null>(null);
// A channel that settled at any point this session keeps its settled
// status across switches: its cache holds an authoritative window (live
// updates merge into it while away), so a stale revisit renders those rows
// stale-while-revalidate instead of flashing the skeleton for the whole
// refetch round-trip.
const hasSettledThisChannel =
activeChannelId !== null && settledChannelIdRef.current === activeChannelId;
activeChannelId !== null &&
(settledChannelIdRef.current === activeChannelId ||
hasTimelineSettledThisSession(activeChannelId));
const timelineLoadingNow =
activeChannel !== null &&
activeChannel.channelType !== "forum" &&
Expand Down
2 changes: 2 additions & 0 deletions desktop/src/features/channels/useChannelSwitchTraceMarks.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import * as React from "react";

import { markTimelineSettledThisSession } from "@/features/messages/lib/settledTimelineChannels";
import {
abandonChannelSwitchTrace,
markChannelSwitchRouteCommit,
Expand Down Expand Up @@ -48,6 +49,7 @@ export function useChannelSwitchTraceMarks({
return;
}
if (!isTimelineLoading) {
markTimelineSettledThisSession(activeChannelId);
settleChannelSwitchTrace(activeChannelId);
}
}, [activeChannelId, activeChannelType, isTimelineLoading]);
Expand Down
3 changes: 3 additions & 0 deletions desktop/src/features/communities/useCommunityInit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import { resetAgentObserverStore } from "@/features/agents/observerRelayStore";
import { resetAvatarPresentations } from "@/features/profile/avatarPresentationStore";
import { resetAvatarProfileSync } from "@/features/profile/avatarProfileSync";
import { resetSidebarRelayConnectionCardState } from "@/features/sidebar/ui/useSidebarRelayConnectionCard";
import { resetSettledTimelineChannels } from "@/features/messages/lib/settledTimelineChannels";
import { resetChannelSwitchTrace } from "@/shared/lib/channelSwitchPerf";
import { clearMarkdownNodeCache } from "@/shared/ui/markdown/nodeCache";
import { resetMessageLinkMetadataCache } from "@/shared/ui/markdown/useMessageLinkMetadata";
Expand Down Expand Up @@ -86,6 +87,8 @@ async function resetCommunityState({
clearSearchHitEventCache();
clearMarkdownNodeCache();
resetMessageLinkMetadataCache();
// resetChannelSwitchTrace() runs earlier, before the first await.
resetSettledTimelineChannels();
}

type CommunityInitResult =
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import assert from "node:assert/strict";
import test from "node:test";

import {
hasTimelineSettledThisSession,
markTimelineSettledThisSession,
resetSettledTimelineChannels,
} from "./settledTimelineChannels.ts";

test("settled channels are remembered for the session", () => {
resetSettledTimelineChannels();
assert.equal(hasTimelineSettledThisSession("ch-a"), false);
markTimelineSettledThisSession("ch-a");
assert.equal(hasTimelineSettledThisSession("ch-a"), true);
assert.equal(hasTimelineSettledThisSession("ch-b"), false);
});

test("community reset clears every settled channel", () => {
resetSettledTimelineChannels();
markTimelineSettledThisSession("ch-a");
markTimelineSettledThisSession("ch-b");
resetSettledTimelineChannels();
assert.equal(hasTimelineSettledThisSession("ch-a"), false);
assert.equal(hasTimelineSettledThisSession("ch-b"), false);
});
28 changes: 28 additions & 0 deletions desktop/src/features/messages/lib/settledTimelineChannels.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/**
* Session registry of channels whose timeline has settled at least once.
*
* The per-mount loading latch (resolveTimelineLoadingLatch) resets on every
* channel switch, so a revisit past the messages query's staleTime used to
* refetch on mount with the "unsettled" skeleton branch active — flashing a
* skeleton over a fully cached timeline for the whole relay round-trip. A
* channel that settled once this session has an authoritative window in the
* query cache (live updates merge into it while away), so revisits render
* stale-while-revalidate instead.
*
* Community-scoped module state: reset via resetCommunityState()
* (useCommunityInit.ts), like every other community-scoped singleton.
*/

const settledChannelIds = new Set<string>();

export function markTimelineSettledThisSession(channelId: string): void {
settledChannelIds.add(channelId);
}

export function hasTimelineSettledThisSession(channelId: string): boolean {
return settledChannelIds.has(channelId);
}

export function resetSettledTimelineChannels(): void {
settledChannelIds.clear();
}
38 changes: 31 additions & 7 deletions desktop/src/features/messages/lib/timelineLoadingState.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -33,14 +33,20 @@ test("stale placeholder while refetching is loading", () => {

test("subscription-seeded empty cache while fetching is loading", () => {
// The live subscription's setQueryData seeds [] before history settles, so
// data is defined but empty and a fetch is still in flight.
// data is defined but empty and a fetch is still in flight. This is a
// cold-load shape: the channel has not settled this session (a channel
// that HAS settled owns an authoritative — possibly empty — window and
// paints it instead; see the settled-empty-revisit test below).
assert.equal(
selectTimelineLoadingState({
...settled,
isFetching: true,
isPlaceholderData: false,
dataLength: 0,
}),
selectTimelineLoadingState(
{
...settled,
isFetching: true,
isPlaceholderData: false,
dataLength: 0,
},
false,
),
true,
);
});
Expand Down Expand Up @@ -158,3 +164,21 @@ test("latch: no active channel passes loadingNow through untouched", () => {
false,
);
});

test("settled empty channel revisit paints the empty state, not a skeleton", () => {
// The channel settled this session with zero rows: its cache holds an
// authoritative empty window, so a stale-revisit refetch must render the
// empty state stale-while-revalidate — same contract as populated rows.
assert.equal(
selectTimelineLoadingState(
{
isPending: false,
isFetching: true,
isPlaceholderData: false,
dataLength: 0,
},
true,
),
false,
);
});
9 changes: 5 additions & 4 deletions desktop/src/features/messages/lib/timelineLoadingState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,11 @@ export function selectTimelineLoadingState(
// painting those as if loaded flashes a near-empty timeline.
return status.isFetching;
}
return (
status.isFetching &&
(status.isPlaceholderData || (status.dataLength ?? 0) === 0)
);
// Settled this session: the cache holds an authoritative window — possibly
// authoritatively EMPTY — so refetches paint cached content (or the empty
// state) stale-while-revalidate. Only placeholder data still skeletons: it
// is not this channel's own settled window.
return status.isFetching && status.isPlaceholderData;
}

/**
Expand Down
37 changes: 34 additions & 3 deletions desktop/src/features/messages/lib/timelineSnapshot.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -489,7 +489,11 @@ test("deferred-snapshot: fresh when channel ids match", () => {
);
});

test("timeline-body-surface: stale deferred channel snapshot paints skeleton instead of old list", () => {
test("timeline-body-surface: stale deferred channel snapshot paints blank instead of old list", () => {
// Production wiring (MessageTimeline) routes deferred-snapshot divergence
// through isSwitchGap, not isLoading: the gap is a render-pipeline
// artifact, so it paints the blank surface — never the previous channel's
// rows, and not a false skeleton.
const isStale = isDeferredTimelineSnapshotStale({
deferredSnapshot: { channelId: "chan-a" },
liveSnapshot: { channelId: "chan-b" },
Expand All @@ -498,10 +502,11 @@ test("timeline-body-surface: stale deferred channel snapshot paints skeleton ins
assert.equal(
selectTimelineBodySurface({
deferredCount: 4,
isLoading: isStale,
isLoading: false,
isSwitchGap: isStale,
liveCount: 0,
}),
"skeleton",
"blank",
);
});

Expand Down Expand Up @@ -610,3 +615,29 @@ test("isRenderedTimelineBehindHistoryPrepend: false when rendered oldest already
// not behind an older-history prepend.
assert.equal(isRenderedTimelineBehindHistoryPrepend([a], [a, b]), false);
});

test("timeline-body-surface: the channel-switch gap is blank, never a skeleton flash", () => {
// Deferred snapshot still holds the previous channel while the live one
// moved on. Without an authoritative load in flight this is a 1-2 frame
// render-pipeline gap — paint background, not flashing skeleton bars.
assert.equal(
selectTimelineBodySurface({
deferredCount: 5,
isLoading: false,
isSwitchGap: true,
liveCount: 3,
}),
"blank",
);
// A genuine authoritative load during the gap keeps the skeleton: the cold
// switch is a real loading state, not a pipeline artifact.
assert.equal(
selectTimelineBodySurface({
deferredCount: 5,
isLoading: true,
isSwitchGap: true,
liveCount: 0,
}),
"skeleton",
);
});
14 changes: 13 additions & 1 deletion desktop/src/features/messages/lib/timelineSnapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,22 +181,34 @@ export function selectDeferredListRenderState(
return "pending";
}

export type TimelineBodySurface = "skeleton" | "empty" | "list";
export type TimelineBodySurface = "skeleton" | "blank" | "empty" | "list";

export function selectTimelineBodySurface({
deferredCount,
preserveSettledEmptyIntro = false,
isLoading,
isSwitchGap = false,
liveCount,
}: {
deferredCount: number;
preserveSettledEmptyIntro?: boolean;
isLoading: boolean;
/** Deferred snapshot still holds another channel (channel-switch gap). */
isSwitchGap?: boolean;
liveCount: number;
}): TimelineBodySurface {
if (isLoading) {
return "skeleton";
}
if (isSwitchGap) {
// The deferred snapshot lags the live one for a frame or two on every
// channel switch. Without an authoritative load in flight that gap is a
// render-pipeline artifact, not a loading state: paint plain background
// instead of flashing skeleton bars on every switch. (Painting the
// previous channel's lagging rows here is also wrong — the header and
// sidebar have already moved on.)
return "blank";
}

const renderState = selectDeferredListRenderState(deferredCount, liveCount);
if (renderState === "pending") {
Expand Down
12 changes: 11 additions & 1 deletion desktop/src/features/messages/ui/MessageThreadSummaryRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,10 @@ function ParticipantAvatar({
avatarUrl={participant.avatarUrl}
className="h-6 w-6 text-2xs"
displayName={participant.author}
// Facepile avatars start with no image URL until profiles hydrate;
// the default fallback delay would leave a blank hole for 200ms and
// then pop the initials in. Render the fallback immediately.
fallbackDelayMs={0}
size="sm"
/>
</div>
Expand Down Expand Up @@ -210,7 +214,13 @@ export function MessageThreadSummaryRow({

<button
aria-label={summaryAriaLabel}
className="group relative isolate inline-flex h-[1.875rem] w-fit max-w-full cursor-pointer items-center gap-1.5 rounded-full py-0 pr-3 text-left text-xs font-medium text-muted-foreground transition-[color,opacity] hover:text-foreground hover:opacity-90 focus-visible:outline-hidden"
// `flex`, not `inline-flex`: an inline-level button participates in
// its wrapper's line box via its baseline, and that baseline moves
// when the avatar's content resolves (fallback text or image landing
// after profiles hydrate) — the row visibly collapsed ~3px per
// summary a beat after channel entry. A block-level flex row keeps
// the wrapper at padding + button height regardless of avatar state.
className="group relative isolate flex h-[1.875rem] w-fit max-w-full cursor-pointer items-center gap-1.5 rounded-full py-0 pr-3 text-left text-xs font-medium text-muted-foreground transition-[color,opacity] hover:text-foreground hover:opacity-90 focus-visible:outline-hidden"
data-thread-head-id={message.id}
data-testid="message-thread-summary"
onClick={() => onOpenThread(message)}
Expand Down
17 changes: 14 additions & 3 deletions desktop/src/features/messages/ui/MessageTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -299,10 +299,17 @@ const MessageTimelineBase = React.forwardRef<
const timelineBodySurface = selectTimelineBodySurface({
deferredCount: deferredMessages.length,
preserveSettledEmptyIntro,
isLoading: timelineIsLoading,
isLoading,
isSwitchGap: isDeferredSnapshotStale,
liveCount: messages.length,
});
const showTimelineSkeleton = timelineBodySurface === "skeleton";
// "blank" (the 1-2 frame channel-switch gap) blocks the same behaviors as
// the skeleton — no autoscroll, jumps, pills, or scrollback while the
// deferred snapshot catches up — but renders plain background instead of
// flashing skeleton bars on every switch.
const showTimelineSkeleton =
timelineBodySurface === "skeleton" || timelineBodySurface === "blank";
const showSkeletonVisual = timelineBodySurface === "skeleton";
const [isSemanticallyAtBottom, setIsSemanticallyAtBottom] =
React.useState(true);
// biome-ignore lint/correctness/useExhaustiveDependencies: reset semantic tail state when the active channel changes
Expand Down Expand Up @@ -601,6 +608,9 @@ const MessageTimelineBase = React.forwardRef<
sentinelRef: topSentinelRef,
});

// Blocked (skeleton OR blank switch gap) uses EMPTY_MESSAGES: during the
// gap the deferred rows still belong to the previous channel, and shape
// caching must never write them under this channel's key.
const timelineSkeletonRows = useTimelineSkeletonRows({
channelId,
isLoading: showTimelineSkeleton,
Expand Down Expand Up @@ -749,6 +759,7 @@ const MessageTimelineBase = React.forwardRef<
data-buzz-conversation-scroll={
useTimelineVirtualizer && showMessageList ? undefined : "true"
}
data-render-pending={isRenderPending ? "true" : undefined}
data-scroll-restoration-id={scrollRestorationId}
data-testid={
useTimelineVirtualizer && showMessageList
Expand Down Expand Up @@ -794,7 +805,7 @@ const MessageTimelineBase = React.forwardRef<
"mt-auto",
)}
>
{showTimelineSkeleton ? (
{showSkeletonVisual ? (
<TimelineSkeleton rows={timelineSkeletonRows} />
) : null}
{activeDirectMessageIntro ? (
Expand Down
1 change: 1 addition & 0 deletions desktop/src/features/messages/ui/TimelineSkeleton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,7 @@ export function TimelineSkeleton({ rows }: TimelineSkeletonProps) {
{skeletonRows.map((row) => (
<article
className="relative mx-1 flex items-start gap-2.5 rounded-2xl px-2 py-2"
data-testid="timeline-skeleton-row"
key={row.key}
>
<Skeleton className="h-9 w-9 shrink-0 rounded-full" />
Expand Down
10 changes: 10 additions & 0 deletions desktop/src/testing/e2eBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -374,6 +374,9 @@ type E2eConfig = {
/** Delay (ms) applied to continuation channel-window requests so e2e
* tests can observe the in-flight prepend window. 0/undefined = instant. */
channelWindowDelayMs?: number;
/** Delay (ms) for head (cursorless) channel-window requests, so specs can
* observe what paints while a refetch is in flight. 0/undefined = instant. */
channelWindowHeadDelayMs?: number;
profileReadDelayMs?: number;
profileReadError?: string;
/** Override whether get_profile reports a real kind:0 event. */
Expand Down Expand Up @@ -5579,6 +5582,13 @@ async function handleGetChannelWindow(
};

if (!args.cursor) {
// TEST-ONLY: head (cursorless) window fetches are the cold-load and
// revisit-refetch path; delaying them creates the observation window
// the stale-revisit spec asserts over.
const headDelayMs = getConfig()?.mock?.channelWindowHeadDelayMs ?? 0;
if (headDelayMs > 0) {
await new Promise((resolve) => window.setTimeout(resolve, headDelayMs));
}
return execute();
}

Expand Down
Loading
Loading