diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index 7a27ef3fc9..21927fa82f 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -245,12 +245,6 @@ export const ChannelPane = React.memo(function ChannelPane({ }, [onEdit, currentPubkey], ); - const handleEditLastOwnMainMessage = React.useCallback((): boolean => { - const target = findLastOwnEditable(messages); - if (!target || !onEdit) return false; - onEdit(target); - return true; - }, [findLastOwnEditable, messages, onEdit]); const timeoutState = useTimeoutState(); // A moderation DM (1:1 with the relay identity) is read-only for the member; @@ -496,6 +490,12 @@ export const ChannelPane = React.memo(function ChannelPane({ }, [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); @@ -561,6 +561,7 @@ export const ChannelPane = React.memo(function ChannelPane({ useFocusThreadDrawer ? ( diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx index 0b838a2e34..1ba9474f46 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.tsx +++ b/desktop/src/features/channels/ui/ChannelScreen.tsx @@ -68,7 +68,6 @@ import { useIsHuddleTranscript, } from "@/features/channels/ui/useHuddleChannelMessages"; import { useHuddleReadMarker } from "@/features/channels/ui/useHuddleReadMarker"; -import { useHuddleThreadIsolation } from "@/features/channels/ui/useHuddleThreadIsolation"; import { AgentSessionProvider } from "@/shared/context/AgentSessionContext"; import { ProfilePanelProvider } from "@/shared/context/ProfilePanelContext"; import { useMainInsetRef } from "@/shared/layout/MainInsetContext"; @@ -171,12 +170,8 @@ export function ChannelScreen({ const activeChannelId = activeChannel?.id ?? null; const isHuddleTranscript = useIsHuddleTranscript(activeChannelId); const relaySelfPubkey = useRelaySelfQuery(activeChannel !== null).data; - const effectiveOpenThreadHeadId = useHuddleThreadIsolation({ - closeThread: setOpenThreadHeadId, - isHuddleTranscript, - openThreadHeadId, - optimisticOpenThreadHeadId, - }); + const effectiveOpenThreadHeadId = + optimisticOpenThreadHeadId ?? openThreadHeadId; const isNotifiedForEffectiveThread = effectiveOpenThreadHeadId != null ? isNotifiedForThread(effectiveOpenThreadHeadId) @@ -510,6 +505,16 @@ export function ChannelScreen({ threadReplyTargetId, toggleReactionMutation, }); + React.useEffect(() => { + if (!isHuddleTranscript || openThreadHeadId === null) return; + if (!requireThreadEditResolution()) return; + setOpenThreadHeadId(null); + }, [ + isHuddleTranscript, + openThreadHeadId, + requireThreadEditResolution, + setOpenThreadHeadId, + ]); const effectiveToggleReaction = React.useMemo( () => activeChannel && !activeChannel.archivedAt && activeChannel.isMember @@ -662,6 +667,7 @@ export function ChannelScreen({ activeChannel, activeChannelId, closeAgentSession: handleCloseAgentSession, + requireThreadEditResolution, setEditTargetId, setExpandedThreadReplyIds, setOpenThreadHeadId, diff --git a/desktop/src/features/channels/ui/FocusThreadDrawer.tsx b/desktop/src/features/channels/ui/FocusThreadDrawer.tsx index 3a53983b55..410f2d8672 100644 --- a/desktop/src/features/channels/ui/FocusThreadDrawer.tsx +++ b/desktop/src/features/channels/ui/FocusThreadDrawer.tsx @@ -11,6 +11,7 @@ import { cn } from "@/shared/lib/cn"; type FocusThreadDrawerProps = { channelName: string; children: React.ReactNode; + hasActiveEdit: boolean; onClose: () => void; }; @@ -139,6 +140,7 @@ const REDUCED_MOTION_TRANSITION = { duration: 0.12, ease: "linear" } as const; export function FocusThreadDrawer({ channelName, children, + hasActiveEdit, onClose, }: FocusThreadDrawerProps) { const prefersReducedMotion = useReducedMotion(); @@ -151,9 +153,9 @@ export function FocusThreadDrawer({ if (event.key !== "Escape") return; const target = event.target; if ( + hasActiveEdit && target instanceof Node && - drawerRef.current?.contains(target) && - drawerRef.current.querySelector('[data-testid="edit-target"]') + drawerRef.current?.contains(target) ) { return; } @@ -166,7 +168,7 @@ export function FocusThreadDrawer({ return () => { window.removeEventListener("keydown", handleEscape, { capture: true }); }; - }, [onClose]); + }, [hasActiveEdit, onClose]); React.useLayoutEffect(() => { previousFocusRef.current = diff --git a/desktop/src/features/channels/ui/useChannelRouteTarget.ts b/desktop/src/features/channels/ui/useChannelRouteTarget.ts index 0dc4b0e4d6..71250461ca 100644 --- a/desktop/src/features/channels/ui/useChannelRouteTarget.ts +++ b/desktop/src/features/channels/ui/useChannelRouteTarget.ts @@ -56,6 +56,7 @@ export function useChannelRouteTarget({ activeChannel, activeChannelId, closeAgentSession, + requireThreadEditResolution, setEditTargetId, setExpandedThreadReplyIds, setOpenThreadHeadId, @@ -68,6 +69,7 @@ export function useChannelRouteTarget({ activeChannel: Channel | null; activeChannelId: string | null; closeAgentSession: () => void; + requireThreadEditResolution: () => boolean; setEditTargetId: React.Dispatch>; setExpandedThreadReplyIds: React.Dispatch>>; setOpenThreadHeadId: PanelValueSetter; @@ -115,6 +117,10 @@ export function useChannelRouteTarget({ } if (!targetMessage.parentId) { + if (!requireThreadEditResolution()) { + handledThreadRouteTargetRef.current = targetKey; + return; + } closeAgentSession(); // Root message links should open the reply panel for that root. The // timeline scroll/highlight target alone is not enough: root links have @@ -141,6 +147,10 @@ export function useChannelRouteTarget({ if (!routeTarget) { return; } + if (!requireThreadEditResolution()) { + handledThreadRouteTargetRef.current = targetKey; + return; + } closeAgentSession(); // Replace so the deep-link entry itself carries the opened thread — @@ -156,6 +166,7 @@ export function useChannelRouteTarget({ activeChannel, activeChannelId, closeAgentSession, + requireThreadEditResolution, setEditTargetId, setExpandedThreadReplyIds, setOpenThreadHeadId, diff --git a/desktop/src/features/channels/ui/useHuddleThreadIsolation.ts b/desktop/src/features/channels/ui/useHuddleThreadIsolation.ts deleted file mode 100644 index c32c809a88..0000000000 --- a/desktop/src/features/channels/ui/useHuddleThreadIsolation.ts +++ /dev/null @@ -1,24 +0,0 @@ -import * as React from "react"; - -type HuddleThreadIsolationOptions = { - closeThread: (threadId: string | null) => void; - isHuddleTranscript: boolean; - openThreadHeadId: string | null; - optimisticOpenThreadHeadId: string | null | undefined; -}; - -export function useHuddleThreadIsolation({ - closeThread, - isHuddleTranscript, - openThreadHeadId, - optimisticOpenThreadHeadId, -}: HuddleThreadIsolationOptions): string | null { - React.useEffect(() => { - if (!isHuddleTranscript || openThreadHeadId === null) return; - closeThread(null); - }, [closeThread, isHuddleTranscript, openThreadHeadId]); - if (isHuddleTranscript) return null; - return optimisticOpenThreadHeadId === undefined - ? openThreadHeadId - : optimisticOpenThreadHeadId; -} diff --git a/desktop/tests/e2e/messaging.spec.ts b/desktop/tests/e2e/messaging.spec.ts index 1ca6986c16..f7b745cb5b 100644 --- a/desktop/tests/e2e/messaging.spec.ts +++ b/desktop/tests/e2e/messaging.spec.ts @@ -3383,6 +3383,99 @@ test("closing a thread while editing a reply preserves the typed edit", async ({ ).toBeVisible(); }); +test("main ArrowUp ignores closed-thread replies and edits the visible timeline message", async ({ + page, +}) => { + const root = `Main ArrowUp root ${Date.now()}`; + const reply = `Main ArrowUp hidden reply ${Date.now()}`; + + await page.goto("/"); + await page.getByTestId("channel-general").click(); + const mainInput = page + .getByTestId("channel-composer-overlay") + .getByTestId("message-input"); + await mainInput.fill(root); + await mainInput.press("Enter"); + const timelineRoot = page + .getByTestId("message-timeline") + .getByTestId("message-row") + .last(); + await timelineRoot.hover(); + await timelineRoot + .getByRole("button", { name: "Reply" }) + .click({ force: true }); + + const threadPanel = page.getByTestId("message-thread-panel"); + const threadInput = threadPanel.getByTestId("message-input"); + await threadInput.fill(reply); + await threadInput.press("Enter"); + await expect(threadPanel).toContainText(reply); + await threadPanel.getByTestId("auxiliary-panel-close").click(); + await expect(threadPanel).toBeHidden(); + + await mainInput.click(); + await page.keyboard.press("ArrowUp"); + await expect(page.getByTestId("edit-target")).toBeVisible(); + await expect(mainInput).toHaveText(root); + + // No hidden reply edit may block reopening its thread. + await mainInput.press("Escape"); + await timelineRoot.hover(); + await timelineRoot + .getByRole("button", { name: "Reply" }) + .click({ force: true }); + await expect(threadPanel).toBeVisible(); +}); + +test("main ArrowUp refuses to replace a dirty thread edit", async ({ + page, +}) => { + const root = `Main ArrowUp refusal root ${Date.now()}`; + const reply = `Main ArrowUp refusal reply ${Date.now()}`; + const unsaved = `${reply} with unsaved text`; + + await page.goto("/"); + await page.getByTestId("channel-general").click(); + const mainInput = page + .getByTestId("channel-composer-overlay") + .getByTestId("message-input"); + await mainInput.fill(root); + await mainInput.press("Enter"); + const timelineRoot = page + .getByTestId("message-timeline") + .getByTestId("message-row") + .last(); + await timelineRoot.hover(); + await timelineRoot + .getByRole("button", { name: "Reply" }) + .click({ force: true }); + + const threadPanel = page.getByTestId("message-thread-panel"); + const threadInput = threadPanel.getByTestId("message-input"); + await threadInput.fill(reply); + await threadInput.press("Enter"); + const threadReply = threadPanel.getByTestId("message-row").last(); + await expect(threadReply).toContainText(reply); + await threadReply.hover(); + await threadReply.getByRole("button", { name: "More actions" }).click(); + await page.getByRole("menuitem", { name: "Edit message" }).click(); + await threadInput.fill(unsaved); + + await mainInput.click(); + await page.keyboard.press("ArrowUp"); + await expect( + page.getByText("Finish or cancel your edit first."), + ).toBeVisible(); + await expect(threadPanel.getByTestId("edit-target")).toBeVisible(); + await expect(threadInput).toHaveText(unsaved); + await expect(mainInput).toHaveText(""); + + // Refusal must not arm a deferred edit that appears after cancellation. + await threadInput.press("Escape"); + await expect(threadPanel.getByTestId("edit-target")).toHaveCount(0); + await expect(mainInput).toHaveText(""); +}); + test("ArrowUp in an empty composer edits your last message right after sending", async ({ page, }) => {