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>
This commit is contained in:
Wrench
2026-08-24 16:53:06 -04:00
parent 06dcc1807a
commit 86bf4946b5
3 changed files with 146 additions and 115 deletions

View File

@@ -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<TimelineMessage | null>(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 ||

View File

@@ -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

View File

@@ -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<TimelineMessage | null>(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<TimelineMessage | null>(
(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,
};
}