fix(desktop): restore virtual timeline prepend anchors

Co-authored-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf
2026-07-09 18:23:25 -04:00
parent 2fb1da561a
commit 24f4383e07
4 changed files with 213 additions and 39 deletions

View File

@@ -13,6 +13,7 @@ import type { MainTimelineEntry } from "@/features/messages/lib/threadPanel";
import type { ChannelWindowThreadSummary } from "@/features/messages/lib/channelWindowStore";
import type { UserProfileLookup } from "@/features/profile/lib/identity";
import type { ChannelType } from "@/shared/api/types";
import { useFeatureEnabled } from "@/shared/features";
import { cn } from "@/shared/lib/cn";
import { channelChrome } from "@/shared/layout/chromeLayout";
import { Spinner } from "@/shared/ui/spinner";
@@ -198,7 +199,7 @@ const MessageTimelineBase = React.forwardRef<
React.useReducer((version: number) => version + 1, 0);
const [timelineVirtualizerApi, setTimelineVirtualizerApi] =
React.useState<TimelineVirtualizerApi | null>(null);
const useTimelineVirtualizer = true;
const useTimelineVirtualizer = useFeatureEnabled("virtuosoTimeline");
const activeScrollContainerRef = React.useMemo(
() => ({
get current() {
@@ -246,6 +247,7 @@ const MessageTimelineBase = React.forwardRef<
// painted at a stale offset until the user's next scroll event forces layout.
const scrollContainerDomKey = channelId ?? "none";
// biome-ignore lint/correctness/useExhaustiveDependencies: this effect is scoped to scroll DOM swaps; when the Experiments flag hydrates, the virtualizer child registers its own scroller/API via callbacks, and re-running this reset on the flag transition can briefly clear that API during navigation tests.
React.useLayoutEffect(() => {
// Re-read after `scrollContainerDomKey` swaps the keyed scroll DOM node.
void scrollContainerDomKey;
@@ -390,6 +392,18 @@ const MessageTimelineBase = React.forwardRef<
React.useEffect(() => {
const target = pendingSearchTargetRef.current;
if (!target || showTimelineSkeleton) return;
if (
useTimelineVirtualizer &&
!activeScrollContainerRef.current?.querySelector(
`[data-message-id="${CSS.escape(target)}"]`,
)
) {
// Phase 1: ask Virtuoso to realize the match's index. The retry effect
// runs again on range change and the DOM-visible path does the actual
// center + highlight once the row exists.
void jumpToMessage(target, { behavior: "auto" });
return;
}
if (jumpToMessage(target, { behavior: "auto" })) {
pendingSearchTargetRef.current = null;
}
@@ -419,6 +433,11 @@ const MessageTimelineBase = React.forwardRef<
messages: showTimelineSkeleton ? EMPTY_MESSAGES : deferredMessages,
});
const handleVirtualizerRangeChanged = React.useCallback(() => {
bumpVirtualizerRenderVersion();
onScroll();
}, [onScroll]);
const timelineList = showMessageList ? (
<TimelineMessageList
key={scrollContainerDomKey}
@@ -448,7 +467,8 @@ const MessageTimelineBase = React.forwardRef<
onStartReached={loadOlderViaVirtualizer}
onToggleReaction={onToggleReaction}
onVirtualizerApiChange={setTimelineVirtualizerApi}
onVirtualizerRangeChanged={bumpVirtualizerRenderVersion}
onVirtualizerRangeChanged={handleVirtualizerRangeChanged}
onVirtualizerScroll={onScroll}
onVirtualizerScrollerChange={setVirtualizerScrollParent}
onAtBottomStateChange={onVirtualizerAtBottomStateChange}
personaLookup={personaLookup}

View File

@@ -103,6 +103,7 @@ type TimelineMessageListProps = {
onAtBottomStateChange?: (atBottom: boolean) => void;
onVirtualizerApiChange?: (api: TimelineVirtualizerApi | null) => void;
onVirtualizerRangeChanged?: () => void;
onVirtualizerScroll?: () => void;
onVirtualizerScrollerChange?: (element: HTMLDivElement | null) => void;
};
@@ -142,6 +143,7 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
onAtBottomStateChange,
onVirtualizerApiChange,
onVirtualizerRangeChanged,
onVirtualizerScroll,
onVirtualizerScrollerChange,
}: TimelineMessageListProps) {
const entries = React.useMemo(
@@ -292,6 +294,7 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
onStartReached={onStartReached}
onVirtualizerApiChange={onVirtualizerApiChange}
onVirtualizerRangeChanged={onVirtualizerRangeChanged}
onVirtualizerScroll={onVirtualizerScroll}
onVirtualizerScrollerChange={onVirtualizerScrollerChange}
renderItem={renderItem}
/>
@@ -331,12 +334,68 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
const FIRST_ITEM_INDEX_BASE = 1_000_000;
function timelineItemMessageId(item: TimelineNonDayItem): string | null {
return item.kind === "message" || item.kind === "system"
? item.entry.message.id
: null;
}
function groupedInternalIndexForItem(
groupCounts: readonly number[],
itemIndex: number,
): number {
let itemCount = 0;
for (let groupIndex = 0; groupIndex < groupCounts.length; groupIndex += 1) {
if (itemIndex < itemCount + groupCounts[groupIndex]) {
return itemIndex + groupIndex + 1;
}
itemCount += groupCounts[groupIndex];
}
return itemIndex + groupCounts.length;
}
function buildGroupedInternalKeyByIndex(
firstItemIndex: number,
dayGroups: readonly TimelineDayGroup[],
): Map<number, string> {
const keyByIndex = new Map<number, string>();
let internalIndex = 0;
for (const group of dayGroups) {
keyByIndex.set(firstItemIndex + internalIndex, `day-${group.key}`);
internalIndex += 1;
for (const item of group.items) {
keyByIndex.set(firstItemIndex + internalIndex, getTimelineItemKey(item));
internalIndex += 1;
}
}
return keyByIndex;
}
function buildGroupedInternalData(
dayGroups: readonly TimelineDayGroup[],
): Array<TimelineNonDayItem | undefined> {
const data: Array<TimelineNonDayItem | undefined> = [];
for (const group of dayGroups) {
data.push(undefined);
data.push(...group.items);
}
return data;
}
type VirtualizedTimelineRowsProps = {
dayGroups: TimelineDayGroup[];
onAtBottomStateChange?: (atBottom: boolean) => void;
onStartReached?: () => void;
onVirtualizerApiChange?: (api: TimelineVirtualizerApi | null) => void;
onVirtualizerRangeChanged?: () => void;
onVirtualizerScroll?: () => void;
onVirtualizerScrollerChange?: (element: HTMLDivElement | null) => void;
renderItem: (item: TimelineNonDayItem) => React.ReactNode;
};
@@ -347,10 +406,13 @@ function VirtualizedTimelineRows({
onStartReached,
onVirtualizerApiChange,
onVirtualizerRangeChanged,
onVirtualizerScroll,
onVirtualizerScrollerChange,
renderItem,
}: VirtualizedTimelineRowsProps) {
const virtuosoRef = React.useRef<GroupedVirtuosoHandle>(null);
const [scrollerElement, setScrollerElement] =
React.useState<HTMLDivElement | null>(null);
const firstItemIndexStateRef = React.useRef<StableFirstItemIndexState>({
firstItemIndex: FIRST_ITEM_INDEX_BASE,
keys: [],
@@ -367,15 +429,56 @@ function VirtualizedTimelineRows({
};
}, [dayGroups]);
const { firstItemIndex, flattenedItems, groupCounts } = timelineModel;
const messageVirtualIndexById = React.useMemo(() => {
const groupedInternalKeyByIndex = React.useMemo(
() => buildGroupedInternalKeyByIndex(firstItemIndex, dayGroups),
[dayGroups, firstItemIndex],
);
const groupedInternalData = React.useMemo(
() => buildGroupedInternalData(dayGroups),
[dayGroups],
);
const virtualizerModelRef = React.useRef<{
firstItemIndex: number;
flattenedCount: number;
groupCounts: readonly number[];
messageItemIndexById: Map<string, number>;
}>({
firstItemIndex,
flattenedCount: flattenedItems.length,
groupCounts,
messageItemIndexById: new Map(),
});
const deferredScrollFrameRef = React.useRef<number | null>(null);
React.useEffect(
() => () => {
if (deferredScrollFrameRef.current !== null) {
window.cancelAnimationFrame(deferredScrollFrameRef.current);
deferredScrollFrameRef.current = null;
}
},
[],
);
const messageItemIndexById = React.useMemo(() => {
const byId = new Map<string, number>();
flattenedItems.forEach((item, index) => {
if (item.kind === "message" || item.kind === "system") {
byId.set(item.entry.message.id, index);
}
const messageId = timelineItemMessageId(item);
if (messageId) byId.set(messageId, index);
});
return byId;
}, [flattenedItems]);
React.useLayoutEffect(() => {
virtualizerModelRef.current = {
firstItemIndex,
flattenedCount: flattenedItems.length,
groupCounts,
messageItemIndexById,
};
}, [
firstItemIndex,
flattenedItems.length,
groupCounts,
messageItemIndexById,
]);
React.useLayoutEffect(() => {
if (!onVirtualizerApiChange) return;
@@ -388,27 +491,67 @@ function VirtualizedTimelineRows({
});
},
scrollToMessage(messageId, options = {}) {
const index = messageVirtualIndexById.get(messageId);
if (index === undefined) return false;
virtuosoRef.current?.scrollToIndex({
align: "center",
behavior: options.behavior === "smooth" ? "smooth" : "auto",
index,
const itemIndex = messageItemIndexById.get(messageId);
if (itemIndex === undefined) return false;
if (deferredScrollFrameRef.current !== null) {
window.cancelAnimationFrame(deferredScrollFrameRef.current);
}
const requested = {
count: flattenedItems.length,
firstItemIndex,
itemIndex,
messageId,
};
deferredScrollFrameRef.current = window.requestAnimationFrame(() => {
deferredScrollFrameRef.current = null;
const current = virtualizerModelRef.current;
const currentIndex = current.messageItemIndexById.get(messageId);
if (
currentIndex !== requested.itemIndex ||
current.firstItemIndex !== requested.firstItemIndex ||
current.flattenedCount !== requested.count
) {
return;
}
virtuosoRef.current?.scrollToIndex({
align: "center",
behavior: options.behavior === "smooth" ? "smooth" : "auto",
index: groupedInternalIndexForItem(
current.groupCounts,
requested.itemIndex,
),
});
});
return true;
},
};
onVirtualizerApiChange(api);
return () => onVirtualizerApiChange(null);
}, [messageVirtualIndexById, onVirtualizerApiChange]);
}, [
flattenedItems.length,
firstItemIndex,
messageItemIndexById,
onVirtualizerApiChange,
]);
const handleScrollerRef = React.useCallback(
(ref: HTMLElement | Window | null) => {
onVirtualizerScrollerChange?.(ref instanceof HTMLDivElement ? ref : null);
const scroller = ref instanceof HTMLDivElement ? ref : null;
onVirtualizerScrollerChange?.(scroller);
setScrollerElement(scroller);
},
[onVirtualizerScrollerChange],
);
React.useEffect(() => {
if (!scrollerElement || !onVirtualizerScroll) return;
scrollerElement.addEventListener("scroll", onVirtualizerScroll, {
passive: true,
});
return () =>
scrollerElement.removeEventListener("scroll", onVirtualizerScroll);
}, [onVirtualizerScroll, scrollerElement]);
const handleRangeChanged = React.useCallback(
(_range: ListRange) => {
onVirtualizerRangeChanged?.();
@@ -417,16 +560,17 @@ function VirtualizedTimelineRows({
);
return (
<GroupedVirtuoso<TimelineNonDayItem>
<GroupedVirtuoso<TimelineNonDayItem | undefined>
ref={virtuosoRef}
className="h-full min-h-0 w-full"
components={virtuosoComponents}
computeItemKey={(index, item) =>
item === undefined
? `timeline-missing-${index}`
? (groupedInternalKeyByIndex.get(index) ??
`timeline-missing-${index}`)
: getTimelineItemKey(item)
}
data={flattenedItems}
data={groupedInternalData}
defaultItemHeight={72}
firstItemIndex={firstItemIndex}
followOutput="auto"
@@ -455,9 +599,14 @@ function VirtualizedTimelineRows({
);
}}
groupCounts={groupCounts}
itemContent={(_index, _groupIndex, item) => (
<TimelineRowShell item={item}>{renderItem(item)}</TimelineRowShell>
)}
itemContent={(_index, _groupIndex, item) => {
if (!item) return null;
return (
<TimelineRowShell item={item} useContentVisibility={false}>
{renderItem(item)}
</TimelineRowShell>
);
}}
rangeChanged={handleRangeChanged}
scrollerRef={handleScrollerRef}
startReached={onStartReached ? () => onStartReached() : undefined}
@@ -465,7 +614,7 @@ function VirtualizedTimelineRows({
);
}
const virtuosoComponents: Components<TimelineNonDayItem> = {
const virtuosoComponents: Components<TimelineNonDayItem | undefined> = {
Scroller: React.forwardRef<
HTMLDivElement,
ScrollerProps & { className?: string }
@@ -496,12 +645,17 @@ const virtuosoComponents: Components<TimelineNonDayItem> = {
function TimelineRowShell({
children,
item,
useContentVisibility = true,
}: {
children: React.ReactNode;
item: TimelineNonDayItem;
useContentVisibility?: boolean;
}) {
return (
<div className="timeline-row-cv" style={timelineRowReserveStyle(item)}>
<div
className={useContentVisibility ? "timeline-row-cv" : undefined}
style={useContentVisibility ? timelineRowReserveStyle(item) : undefined}
>
{children}
</div>
);

View File

@@ -286,7 +286,10 @@ export function useAnchoredScroll({
if (!el) {
if (
virtualScrollToMessage?.(messageId, {
behavior: options.behavior ?? "auto",
// Phase 1 only asks the virtualizer to realize an off-DOM row.
// Keep this jump synchronous; the DOM-visible phase below applies
// the caller's smooth/auto behavior once the target can be measured.
behavior: "auto",
})
) {
anchorRef.current = { kind: "message", messageId, topOffset: 0 };
@@ -547,7 +550,7 @@ export function useAnchoredScroll({
// *without* marking the target handled until its row actually exists — each
// subsequent message commit re-runs the effect and retries the centering.
// ---------------------------------------------------------------------------
// biome-ignore lint/correctness/useExhaustiveDependencies: `messages` is an intentional trigger, not a read — the effect reads the DOM (querySelector), and we need it to re-run each time the rendered row set changes so a target spliced into older history gets centered once its row commits.
// biome-ignore lint/correctness/useExhaustiveDependencies: `messages` and `virtualizerRenderVersion` are intentional retry triggers, not values read by the effect body — the effect reads the DOM (querySelector), and we need it to re-run each time the message list or virtualized rendered range changes so a target spliced into older history gets centered once its row commits.
React.useEffect(() => {
if (!targetMessageId) {
handledTargetIdRef.current = null;
@@ -556,6 +559,7 @@ export function useAnchoredScroll({
if (handledTargetIdRef.current === targetMessageId || isLoading) return;
if (!hasInitializedRef.current) return; // initial-mount path will handle.
void virtualizerRenderVersion;
const container = scrollContainerRef.current;
if (!container) return;
const el = container.querySelector<HTMLElement>(

View File

@@ -5,41 +5,37 @@
"id": "workflows",
"name": "Workflows",
"description": "YAML-defined automations with approval gates",
"platforms": [
"desktop"
]
"platforms": ["desktop"]
},
{
"id": "projects",
"name": "Projects",
"description": "Git repository browser and collaboration",
"platforms": [
"desktop"
]
"platforms": ["desktop"]
},
{
"id": "pulse",
"name": "Pulse",
"description": "Activity feed with notes, social posts, and agent activity",
"platforms": [
"desktop"
]
"platforms": ["desktop"]
},
{
"id": "workspaceRail",
"name": "Workspace Rail",
"description": "Far-left rail for switching relays with cross-relay unread indicators",
"platforms": [
"desktop"
]
"platforms": ["desktop"]
},
{
"id": "forum",
"name": "Forum Channels",
"description": "Forum-style threaded channels for long-form discussions",
"platforms": [
"desktop"
]
"platforms": ["desktop"]
},
{
"id": "virtuosoTimeline",
"name": "Virtuoso Timeline",
"description": "Virtualized channel timeline using react-virtuoso",
"platforms": ["desktop"]
}
]
}