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: 1 addition & 1 deletion desktop/scripts/check-file-sizes.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ const overrides = new Map([
// is test-only content; the override covers the test growth accumulated
// across the local-archive + agent-metric-archive PR series. store_tests.rs
// (~731 lines) is under 1000 so needs no override.
["src-tauri/src/archive/mod_tests.rs", 1208],
["src-tauri/src/archive/mod_tests.rs", 1304],
// unified-agent-model 1A.1: profile reconcile split to agents_profile.rs,
// ratcheting 1443 -> 1295. Queued to split further in the A2 fold.
// global-agent-config: resolve_deploy_model_provider + visibility exports
Expand Down
20 changes: 9 additions & 11 deletions desktop/src/features/channels/ui/ChannelPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ import type { ChannelPaneProps } from "@/features/channels/ui/ChannelPane.types"
import * as agentSessionSelection from "@/features/channels/ui/agentSessionSelection";
import { usePrepareDmSendChannel } from "@/features/channels/ui/usePrepareDmSendChannel";
import { Button } from "@/shared/ui/button";
import { buildMainTimelineEntries } from "@/features/messages/lib/threadPanel";
import { useChannelMainTimelineEntries } from "@/features/messages/lib/channelMainTimeline";
import { useRenderScopedReactionHydration } from "@/features/messages/lib/useRenderScopedReactionHydration";
import type { TimelineMessage } from "@/features/messages/types";
import { isWelcomeExperienceChannel as isWelcomeExperience } from "@/features/onboarding/welcome";
Expand Down Expand Up @@ -462,16 +462,13 @@ export const ChannelPane = React.memo(function ChannelPane({

return messages.filter((message) => !isWelcomeSetupSystemMessage(message));
}, [activeChannel, messages]);
const mainTimelineEntries = React.useMemo(
() =>
buildMainTimelineEntries(
visibleMessages,
new Set(),
threadSummaries,
profiles,
),
[profiles, threadSummaries, visibleMessages],
);
const { entries: mainTimelineEntries, flattenReplies } =
useChannelMainTimelineEntries(
activeChannel,
visibleMessages,
threadSummaries,
profiles,
);
useRenderScopedReactionHydration({
activeChannel,
mainTimelineEntries,
Expand Down Expand Up @@ -674,6 +671,7 @@ export const ChannelPane = React.memo(function ChannelPane({
entranceMessageId={entranceMessageId}
onEntranceMessageComplete={onEntranceMessageComplete}
mainEntries={mainTimelineEntries}
flattenReplies={flattenReplies}
threadSummaries={threadSummaries}
messages={visibleMessages}
firstUnreadMessageId={firstUnreadMessageId}
Expand Down
46 changes: 44 additions & 2 deletions desktop/src/features/messages/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,18 @@ import {
isBroadcastReply,
normalizeMentionPubkeys,
resolveReplyRootId,
shouldFlattenChannelTimeline,
} from "@/features/messages/lib/threading";
import {
projectChannelWindowMessages,
refreshChannelWindowMessages,
} from "@/features/messages/lib/projectChannelWindow";
import { reconcileChannelWindowMessages } from "@/features/messages/lib/channelWindowReconciliation";
import {
fetchFlattenTimelineReplies,
flattenTimelineRootIds,
mergeFlattenTimelineReplies,
} from "@/features/messages/lib/flattenChannelTimeline";
import {
mergeMessages,
mergeTimelineCacheMessages,
Expand Down Expand Up @@ -226,6 +232,31 @@ export function useChannelWindowQuery(channel: Channel | null) {
});
}

async function withFlattenedTimelineReplies(
channel: Channel,
window: ChannelWindowStore,
messages: RelayEvent[],
): Promise<RelayEvent[]> {
if (!shouldFlattenChannelTimeline(channel)) {
return messages;
}
const rootIds = flattenTimelineRootIds(window);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Include recent replies whose roots are off-window

In a private/DM channel where a new reply lands on an older thread root that is not in the newest top-level window, this only hydrates replies for roots that already have a 39005 summary in the loaded window. I checked the relay window path: top_level rows exclude non-broadcast replies and only include depth-null/depth-0/broadcast rows, so the recent reply itself is absent; after a cold load or reconnect replaceNewestChannelWindow also clears the live overlay and this code never fetches that root. The visible flattened timeline can therefore drop the newest DM/private messages until the user pages far enough back to load the old root.

Useful? React with 👍 / 👎.

if (rootIds.length === 0) {
return messages;
}
try {
const replies = await fetchFlattenTimelineReplies(channel.id, rootIds);
return mergeFlattenTimelineReplies(messages, replies);
} catch (error) {
console.error(
"Failed to hydrate flattened timeline replies for channel",
channel.id,
error,
);
return messages;
}
}

export function useChannelMessagesQuery(channel: Channel | null) {
const queryClient = useQueryClient();
const queryKey = channelMessagesKey(channel?.id ?? "none");
Expand All @@ -245,7 +276,15 @@ export function useChannelMessagesQuery(channel: Channel | null) {
emptyChannelWindowStore();
const next = replaceNewestChannelWindow(current, page);
queryClient.setQueryData(windowKey, next);
return reconcileChannelWindowMessages(next, previousMessages);
const reconciled = reconcileChannelWindowMessages(
next,
previousMessages,
{
retainNonBroadcastThreadReplies:
!shouldFlattenChannelTimeline(channel),
},
);
return withFlattenedTimelineReplies(channel, next, reconciled);
},
staleTime: 5 * 60 * 1_000,
gcTime: 60 * 60 * 1_000,
Expand All @@ -256,6 +295,7 @@ export function useChannelSubscription(channel: Channel | null) {
const queryClient = useQueryClient();
const channelId = channel?.id ?? null;
const channelType = channel?.channelType ?? null;
const flattenTimeline = shouldFlattenChannelTimeline(channel);
const refreshNewestWindow = useEffectEvent(async () => {
if (!channelId) return;
await refreshChannelWindowMessages(queryClient, channelId);
Expand Down Expand Up @@ -288,7 +328,9 @@ export function useChannelSubscription(channel: Channel | null) {
(current = []) => mergeMessages(current, event),
);
}
if (!isBroadcastReply(event.tags)) return;
// Private/DM flatten: keep NIP-10 tags, but still admit the event into
// the live window overlay so it renders as a normal timeline row.
if (!isBroadcastReply(event.tags) && !flattenTimeline) return;
}
if (!isTimelineRow && !CHANNEL_AUX_KINDS.has(event.kind)) return;
if (!isTimelineRow) {
Expand Down
51 changes: 51 additions & 0 deletions desktop/src/features/messages/lib/channelMainTimeline.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { useMemo } from "react";
import type { Channel } from "@/shared/api/types";
import type { UserProfileLookup } from "@/features/profile/lib/identity";
import type { ChannelWindowThreadSummary } from "@/features/messages/lib/channelWindowStore";
import {
buildMainTimelineEntries,
type MainTimelineEntry,
} from "@/features/messages/lib/threadPanel";
import { shouldFlattenChannelTimeline } from "@/features/messages/lib/threading";
import type { TimelineMessage } from "@/features/messages/types";

/** Main-timeline entries with private/DM reply flatten applied when appropriate. */
export function buildChannelMainTimelineEntries(
channel: Pick<Channel, "channelType" | "visibility"> | null | undefined,
messages: TimelineMessage[],
threadSummaries: ReadonlyMap<string, ChannelWindowThreadSummary> | undefined,
profiles?: UserProfileLookup,
): { entries: MainTimelineEntry[]; flattenReplies: boolean } {
const flattenReplies = shouldFlattenChannelTimeline(channel);
return {
flattenReplies,
entries: buildMainTimelineEntries(
messages,
new Set(),
threadSummaries ?? new Map(),
profiles,
{
flattenReplies,
},
),
};
}

/** Memoized private/DM timeline projection for channel panes. */
export function useChannelMainTimelineEntries(
channel: Pick<Channel, "channelType" | "visibility"> | null | undefined,
messages: TimelineMessage[],
threadSummaries: ReadonlyMap<string, ChannelWindowThreadSummary> | undefined,
profiles?: UserProfileLookup,
): { entries: MainTimelineEntry[]; flattenReplies: boolean } {
return useMemo(
() =>
buildChannelMainTimelineEntries(
channel,
messages,
threadSummaries,
profiles,
),
[channel, messages, profiles, threadSummaries],
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,28 +10,43 @@ import { getThreadReference, isBroadcastReply } from "./threading";

const CHANNEL_TIMELINE_KINDS = new Set<number>(CHANNEL_TIMELINE_CONTENT_KINDS);

function retainRefetchReconciliationEvents(events: RelayEvent[]) {
function retainRefetchReconciliationEvents(
events: RelayEvent[],
retainNonBroadcastThreadReplies: boolean,
) {
return events.filter((event) => {
if (!CHANNEL_TIMELINE_KINDS.has(event.kind)) return false;
if (event.pending) return true;
if (!retainNonBroadcastThreadReplies) return false;
const thread = getThreadReference(event.tags);
return thread.parentId !== null && !isBroadcastReply(event.tags);
});
}

export type ReconcileChannelWindowMessagesOptions = {
/**
* Preserve cache-only non-broadcast replies that the authoritative channel
* window omits. Flattened private/DM refetches disable this before
* rehydrating replies for the roots in the replacement window.
*/
retainNonBroadcastThreadReplies?: boolean;
};

/**
* Project the timeline from the authoritative window while retaining local
* pending sends and non-broadcast thread replies the window does not contain.
*/
export function reconcileChannelWindowMessages(
window: ChannelWindowStore,
messages: RelayEvent[],
options?: ReconcileChannelWindowMessagesOptions,
) {
const windowEvents = flattenChannelWindowEvents(window);
const authoritativeIds = new Set(windowEvents.map((event) => event.id));
const retained = retainRefetchReconciliationEvents(messages).filter(
(event) => !authoritativeIds.has(event.id),
);
const retained = retainRefetchReconciliationEvents(
messages,
options?.retainNonBroadcastThreadReplies !== false,
).filter((event) => !authoritativeIds.has(event.id));

// Reconcile acknowledgements against cache-only rows without changing the
// authoritative window's order. The render key moves from an optimistic row
Expand Down
Loading