From 86bf4946b59fd7a958fbb695919653dc33e51350 Mon Sep 17 00:00:00 2001 From: Wrench <0eabe6ea5758c1e4c5b68cea4ac42b32c479072883cb28da8110b4a47c32b9a1@buzz.block.builderlab.xyz> Date: Mon, 24 Aug 2026 16:53:06 -0400 Subject: [PATCH] refactor(desktop): keep channel panes within size ratchet Co-authored-by: Wrench <0eabe6ea5758c1e4c5b68cea4ac42b32c479072883cb28da8110b4a47c32b9a1@buzz.block.builderlab.xyz> Signed-off-by: Wrench <0eabe6ea5758c1e4c5b68cea4ac42b32c479072883cb28da8110b4a47c32b9a1@buzz.block.builderlab.xyz> --- .../src/features/channels/ui/ChannelPane.tsx | 115 +++------------- .../features/channels/ui/ChannelScreen.tsx | 23 +--- .../channels/ui/useRoutedMessageEdit.ts | 123 ++++++++++++++++++ 3 files changed, 146 insertions(+), 115 deletions(-) create mode 100644 desktop/src/features/channels/ui/useRoutedMessageEdit.ts diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index 2672c44eab..817013392d 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -1,5 +1,4 @@ import * as React from "react"; -import { toast } from "sonner"; import { LogIn } from "lucide-react"; import { AnimatePresence } from "motion/react"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; @@ -25,7 +24,6 @@ import { hasOtherDmParticipant, } from "@/features/channels/lib/dmHuddleMembers"; import { buildVideoReviewPresentationByMessageId } from "@/features/messages/lib/videoReviewContext"; -import { isThreadReply } from "@/features/messages/lib/threading"; import { useComposerHeightPadding } from "@/features/messages/ui/useComposerHeightPadding"; import { UserProfilePanel } from "@/features/profile/ui/UserProfilePanel"; import { AgentSessionThreadPanel } from "@/features/channels/ui/AgentSessionThreadPanel"; @@ -59,11 +57,10 @@ 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 { useChannelPaneMessages } from "@/features/channels/ui/useChannelPaneMessages"; +import { useRoutedMessageEdit } from "@/features/channels/ui/useRoutedMessageEdit"; import { Button } from "@/shared/ui/button"; import { useRenderScopedReactionHydration } from "@/features/messages/lib/useRenderScopedReactionHydration"; -import type { TimelineMessage } from "@/features/messages/types"; import { isWelcomeExperienceChannel as isWelcomeExperience } from "@/features/onboarding/welcome"; -import { KIND_SYSTEM_MESSAGE } from "@/shared/constants/kinds"; import { useIsThreadPanelOverlay } from "@/shared/hooks/use-mobile"; import { channelChrome } from "@/shared/layout/chromeLayout"; import { cn } from "@/shared/lib/cn"; @@ -239,26 +236,6 @@ export const ChannelPane = React.memo(function ChannelPane({ const isEditInThread = editTarget?.isThreadReply === true; const mainEditTarget = editTarget && !isEditInThread ? editTarget : null; const threadEditTarget = editTarget && isEditInThread ? editTarget : null; - const findLastOwnEditable = React.useCallback( - (candidates: TimelineMessage[]): TimelineMessage | null => { - if (!onEdit || !currentPubkey) return null; - let best: TimelineMessage | null = null; - for (const message of candidates) { - if ( - message.kind === KIND_SYSTEM_MESSAGE || - message.pubkey !== currentPubkey || - message.pending - ) { - continue; - } - if (!best || message.createdAt >= best.createdAt) { - best = message; - } - } - return best; - }, - [onEdit, currentPubkey], - ); const timeoutState = useTimeoutState(); // A moderation DM (1:1 with the relay identity) is read-only for the member; @@ -486,81 +463,23 @@ export const ChannelPane = React.memo(function ChannelPane({ onExternalTargetResolved: onThreadScrollTargetResolved, onModeChange: markExitComplete, }); - const pendingMainEditRef = React.useRef(null); - const editTargetRef = React.useRef(editTarget); - editTargetRef.current = editTarget; - const pendingMainEditContextRef = React.useRef({ - channelId: activeChannel?.id ?? null, - threadId: threadHeadMessage?.id ?? null, - }); - const pendingMainEditContext = { - channelId: activeChannel?.id ?? null, - threadId: threadHeadMessage?.id ?? null, - }; - const previousPendingContext = pendingMainEditContextRef.current; - if ( - previousPendingContext.channelId !== pendingMainEditContext.channelId || - (previousPendingContext.threadId !== null && - pendingMainEditContext.threadId !== null && - previousPendingContext.threadId !== pendingMainEditContext.threadId) - ) { - pendingMainEditRef.current = null; - } - pendingMainEditContextRef.current = pendingMainEditContext; - const handleRoutedEdit = React.useCallback( - (message: TimelineMessage): boolean => { - const currentEditTarget = editTargetRef.current; - if ( - currentEditTarget && - currentEditTarget.id !== message.id && - currentEditTarget.isThreadReply !== isThreadReply(message.tags ?? []) - ) { - pendingMainEditRef.current = null; - toast.info("Finish or cancel your edit first."); - return false; - } - if (currentEditTarget?.id === message.id) { - pendingMainEditRef.current = null; - onEdit?.(message); - return true; - } - if ( - !isThreadReply(message.tags ?? []) && - (isSinglePanelView || useFocusThreadDrawer) - ) { - pendingMainEditRef.current = message; - onCloseThread(); - return true; - } - onEdit?.(message); - return Boolean(onEdit); - }, - [isSinglePanelView, onCloseThread, onEdit, useFocusThreadDrawer], - ); - const handleEditLastOwnMainMessage = React.useCallback((): boolean => { - const target = findLastOwnEditable( - mainTimelineEntries.map((entry) => entry.message), - ); - return target ? handleRoutedEdit(target) : false; - }, [findLastOwnEditable, handleRoutedEdit, mainTimelineEntries]); - const handleEditLastOwnThreadMessage = React.useCallback((): boolean => { - const scope: TimelineMessage[] = []; - if (threadHeadMessage) scope.push(threadHeadMessage); - for (const entry of threadMessages) scope.push(entry.message); - const target = findLastOwnEditable(scope); - return target ? handleRoutedEdit(target) : false; - }, [ - findLastOwnEditable, - handleRoutedEdit, + const { + handleEditLastOwnMainMessage, + handleEditLastOwnThreadMessage, + routeEdit: handleRoutedEdit, + } = useRoutedMessageEdit({ + activeChannelId, + channelIsCovered, + currentPubkey, + editTarget, + isSinglePanelView, + mainMessages: mainTimelineEntries.map((entry) => entry.message), + onCloseThread, + onEdit, threadHeadMessage, - threadMessages, - ]); - React.useEffect(() => { - const pendingMainEdit = pendingMainEditRef.current; - if (!pendingMainEdit || isSinglePanelView || channelIsCovered) return; - pendingMainEditRef.current = null; - onEdit?.(pendingMainEdit); - }, [channelIsCovered, isSinglePanelView, onEdit]); + threadMessages: threadMessages.map((entry) => entry.message), + useFocusThreadDrawer, + }); const hasSplitAuxiliaryPane = useSplitAuxiliaryPane && (channelManagementOpen || diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx index 9ff701963c..3b26e1459f 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.tsx +++ b/desktop/src/features/channels/ui/ChannelScreen.tsx @@ -569,14 +569,8 @@ export function ChannelScreen({ welcomeAgentCreate.openAddAgent(() => setIsAddBotOpen(true), options), [welcomeAgentCreate], ); - const handleOpenMembersSidebar = React.useCallback( - () => setIsMembersSidebarOpen(true), - [], - ); - const handleCloseChannelManagement = React.useCallback( - () => setChannelManagementOpen(false), - [setChannelManagementOpen], - ); + const handleOpenMembersSidebar = () => setIsMembersSidebarOpen(true); + const handleCloseChannelManagement = () => setChannelManagementOpen(false); const handleChannelManagementDeleted = React.useCallback(() => { setChannelManagementOpen(false); void goHome({ replace: true }); @@ -637,9 +631,6 @@ export function ChannelScreen({ isPlaceholderData: messagesQuery.isPlaceholderData, dataLength: messagesQuery.data?.length ?? null, }, - // A persisted head only counts as hydrated when it has rows to paint - // (channelHeadCache.ts), so this bypass never settles onto an empty - // placeholder while the authoritative refresh is still in flight. hasSettledThisChannel || (activeChannelId !== null && hasPersistedHydratedChannel(queryClient, activeChannelId)), @@ -769,8 +760,7 @@ export function ChannelScreen({ activeDmHeaderParticipants={activeDmHeaderParticipants} activeDmPresenceStatus={activeDmPresenceStatus} chromeWrapperRef={channelHeaderChromeRef} - currentPubkey={currentPubkey} - headerEndActions={headerEndActions} + {...{ currentPubkey, headerEndActions }} isAddBotOpen={isAddBotOpen} isJoining={joinChannelMutation.isPending} onAddBotOpenChange={setIsAddBotOpen} @@ -875,12 +865,11 @@ export function ChannelScreen({ canResetThreadPanelWidth={canResetThreadPanelWidth} fetchOlder={fetchOlder} header={channelHeader} - idleAuxiliaryPanel={idleAuxiliaryPanel} - idleAuxiliaryTitle={idleAuxiliaryTitle} + {...{ idleAuxiliaryPanel, idleAuxiliaryTitle }} hasOlderMessages={hasOlderMessages} historyExhausted={historyExhausted} + {...{ onAddFiles }} onAddAgent={handleOpenAddBot} - onAddFiles={onAddFiles} onBrowseChannels={openBrowseChannels} onCreateChannel={openCreateChannel} onOpenMembers={handleOpenMembersSidebar} @@ -934,8 +923,8 @@ export function ChannelScreen({ ? handleBackFromAgentSession : undefined } + {...{ onCloseIdleAuxiliaryPanel }} onCloseChannelManagement={handleCloseChannelManagement} - onCloseIdleAuxiliaryPanel={onCloseIdleAuxiliaryPanel} onCloseThread={handleCloseThread} onDelete={ activeChannel?.archivedAt ? undefined : handleDelete diff --git a/desktop/src/features/channels/ui/useRoutedMessageEdit.ts b/desktop/src/features/channels/ui/useRoutedMessageEdit.ts new file mode 100644 index 0000000000..426226a9fd --- /dev/null +++ b/desktop/src/features/channels/ui/useRoutedMessageEdit.ts @@ -0,0 +1,123 @@ +import * as React from "react"; +import { toast } from "sonner"; +import { isThreadReply } from "@/features/messages/lib/threading"; +import type { TimelineMessage } from "@/features/messages/types"; +import { KIND_SYSTEM_MESSAGE } from "@/shared/constants/kinds"; + +type Input = { + activeChannelId: string | null; + channelIsCovered: boolean; + currentPubkey?: string | null; + editTarget: { id: string; isThreadReply: boolean } | null; + isSinglePanelView: boolean; + mainMessages: TimelineMessage[]; + onCloseThread: () => void; + onEdit?: (message: TimelineMessage) => void; + threadHeadMessage: TimelineMessage | null; + threadMessages: TimelineMessage[]; + useFocusThreadDrawer: boolean; +}; + +export function useRoutedMessageEdit({ + activeChannelId, + channelIsCovered, + currentPubkey, + editTarget, + isSinglePanelView, + mainMessages, + onCloseThread, + onEdit, + threadHeadMessage, + threadMessages, + useFocusThreadDrawer, +}: Input) { + const pendingMainEditRef = React.useRef(null); + const editTargetRef = React.useRef(editTarget); + editTargetRef.current = editTarget; + const contextRef = React.useRef({ + channelId: activeChannelId, + threadId: threadHeadMessage?.id, + }); + const context = { + channelId: activeChannelId, + threadId: threadHeadMessage?.id, + }; + if ( + contextRef.current.channelId !== context.channelId || + (contextRef.current.threadId && + context.threadId && + contextRef.current.threadId !== context.threadId) + ) + pendingMainEditRef.current = null; + contextRef.current = context; + + const findLastOwnEditable = React.useCallback( + (messages: TimelineMessage[]) => { + if (!onEdit || !currentPubkey) return null; + return messages.reduce( + (best, message) => + message.kind === KIND_SYSTEM_MESSAGE || + message.pubkey !== currentPubkey || + message.pending || + (best && message.createdAt < best.createdAt) + ? best + : message, + null, + ); + }, + [currentPubkey, onEdit], + ); + const routeEdit = React.useCallback( + (message: TimelineMessage) => { + const current = editTargetRef.current; + if ( + current && + current.id !== message.id && + current.isThreadReply !== isThreadReply(message.tags ?? []) + ) { + pendingMainEditRef.current = null; + toast.info("Finish or cancel your edit first."); + return false; + } + if (current?.id === message.id) { + pendingMainEditRef.current = null; + onEdit?.(message); + return true; + } + if ( + !isThreadReply(message.tags ?? []) && + (isSinglePanelView || useFocusThreadDrawer) + ) { + pendingMainEditRef.current = message; + onCloseThread(); + return true; + } + onEdit?.(message); + return Boolean(onEdit); + }, + [isSinglePanelView, onCloseThread, onEdit, useFocusThreadDrawer], + ); + const handleEditLastOwnMainMessage = React.useCallback(() => { + const target = findLastOwnEditable(mainMessages); + return target ? routeEdit(target) : false; + }, [findLastOwnEditable, mainMessages, routeEdit]); + const handleEditLastOwnThreadMessage = React.useCallback(() => { + const target = findLastOwnEditable( + threadHeadMessage + ? [threadHeadMessage, ...threadMessages] + : threadMessages, + ); + return target ? routeEdit(target) : false; + }, [findLastOwnEditable, routeEdit, threadHeadMessage, threadMessages]); + React.useEffect(() => { + const pending = pendingMainEditRef.current; + if (!pending || isSinglePanelView || channelIsCovered) return; + pendingMainEditRef.current = null; + onEdit?.(pending); + }, [channelIsCovered, isSinglePanelView, onEdit]); + return { + handleEditLastOwnMainMessage, + handleEditLastOwnThreadMessage, + routeEdit, + }; +}