feat(desktop): instant channel switching via data-layer caching (#133)

This commit is contained in:
Wes
2026-03-20 11:23:50 -07:00
committed by GitHub
parent e70d31eabf
commit 0035ab7cd2
7 changed files with 45 additions and 14 deletions

View File

@@ -33,7 +33,7 @@ const overrides = new Map([
["src/app/AppShell.tsx", 750],
["src/features/channels/hooks.ts", 525], // canvas query + mutation hooks
["src/features/channels/ui/ChannelManagementSheet.tsx", 800],
["src/features/messages/ui/MessageComposer.tsx", 650], // media upload handlers (paste, drop, dialog)
["src/features/messages/ui/MessageComposer.tsx", 665], // media upload handlers (paste, drop, dialog) + channelId reset effect
["src/features/settings/ui/SettingsView.tsx", 600],
["src/features/sidebar/ui/AppSidebar.tsx", 850], // channels + forums creation forms
["src/features/tokens/ui/TokenSettingsCard.tsx", 800],

View File

@@ -217,14 +217,6 @@ export function AppShell() {
.filter((value) => value && value.trim().length > 0)
.join(" ") || "Channel details and activity."
: "Connect to the relay to browse channels and read messages.";
const contentPaneKey =
selectedView === "home"
? "home"
: selectedView === "agents"
? "agents"
: selectedView === "settings"
? "settings"
: `channel:${activeChannel?.id ?? "none"}`;
const shouldLoadTimeline =
activeChannel !== null && activeChannel.channelType !== "forum";
const isTimelineLoading =
@@ -589,10 +581,7 @@ export function AppShell() {
unreadChannelIds={unreadChannelIds}
/>
<SidebarInset
className="min-h-0 min-w-0 overflow-hidden"
key={contentPaneKey}
>
<SidebarInset className="min-h-0 min-w-0 overflow-hidden">
{selectedView === "home" ? (
<ChatHeader
description="Personalized feed for mentions, reminders, channel activity, and agent work."

View File

@@ -51,8 +51,9 @@ export function ChannelPane({
typingPubkeys,
}: ChannelPaneProps) {
return (
<React.Fragment key={activeChannel?.id ?? "no-channel"}>
<React.Fragment>
<MessageTimeline
channelId={activeChannel?.id}
activeReplyTargetId={replyTargetId}
currentPubkey={currentPubkey}
profiles={profiles}

View File

@@ -78,6 +78,20 @@ export function MessageComposer({
}>({ status: "idle" });
const [pendingImeta, setPendingImeta] = React.useState<BlobDescriptor[]>([]);
// biome-ignore lint/correctness/useExhaustiveDependencies: channelId is the sole trigger — reset all composer state on channel switch to prevent draft/upload/mention leaks
React.useEffect(() => {
setContent("");
setPendingImeta([]);
setUploadState({ status: "idle" });
setMentionQuery(null);
setMentionStartIndex(0);
setMentionSelectedIndex(0);
setIsEmojiPickerOpen(false);
mentionMapRef.current.clear();
draftSelectionRef.current = { end: 0, start: 0 };
pendingSelectionRef.current = null;
}, [channelId]);
const membersQuery = useChannelMembersQuery(channelId);
const members = membersQuery.data ?? [];
const managedAgentsQuery = useManagedAgentsQuery();

View File

@@ -11,6 +11,7 @@ import { TimelineSkeleton } from "./TimelineSkeleton";
import { useTimelineScrollManager } from "./useTimelineScrollManager";
type MessageTimelineProps = {
channelId?: string | null;
messages: TimelineMessage[];
isLoading?: boolean;
emptyTitle?: string;
@@ -29,6 +30,7 @@ type MessageTimelineProps = {
};
export function MessageTimeline({
channelId,
messages,
isLoading = false,
emptyTitle = "No messages yet",
@@ -51,6 +53,7 @@ export function MessageTimeline({
syncScrollState,
timelineRef,
} = useTimelineScrollManager({
channelId,
isLoading,
messages,
onTargetReached,

View File

@@ -4,11 +4,13 @@ import type { TimelineMessage } from "@/features/messages/types";
import { isNearBottom } from "./messageTimelineUtils";
export function useTimelineScrollManager({
channelId,
isLoading,
messages,
onTargetReached,
targetMessageId,
}: {
channelId?: string | null;
isLoading: boolean;
messages: TimelineMessage[];
onTargetReached?: (messageId: string) => void;
@@ -32,6 +34,24 @@ export function useTimelineScrollManager({
string | null
>(null);
const [newMessageCount, setNewMessageCount] = React.useState(0);
// biome-ignore lint/correctness/useExhaustiveDependencies: channelId is intentionally the sole trigger — we reset all scroll state when the channel changes
React.useLayoutEffect(() => {
hasInitializedRef.current = false;
shouldStickToBottomRef.current = true;
isAtBottomRef.current = true;
isProgrammaticBottomScrollRef.current = false;
previousTimelineHeightRef.current = null;
previousScrollTopRef.current = 0;
lockedScrollTopRef.current = null;
previousLastMessageIdRef.current = undefined;
previousMessageCountRef.current = 0;
handledTargetMessageIdRef.current = null;
setIsAtBottom(true);
setHighlightedMessageId(null);
setNewMessageCount(0);
}, [channelId]);
const latestMessage =
messages.length > 0 ? messages[messages.length - 1] : undefined;

View File

@@ -13,6 +13,10 @@ const queryClient = new QueryClient({
queries: {
retry: 1,
refetchOnWindowFocus: false,
networkMode: "always",
},
mutations: {
networkMode: "always",
},
},
});