fix(messages): preserve guarded edit transitions

Co-authored-by: Rizz <302abe414ca6e3134763d2539bfcf145aea2a63fe5f8455204ed602fd40cf381@buzz.block.builderlab.xyz>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
Taylor Ho
2026-08-22 14:25:28 -07:00
committed by Carl
parent 6467a36724
commit c02caeafef
6 changed files with 214 additions and 15 deletions

View File

@@ -467,7 +467,10 @@ export const ChannelPane = React.memo(function ChannelPane({
const handleRoutedEdit = React.useCallback(
(message: TimelineMessage): boolean => {
const currentEditTarget = editTargetRef.current;
if (currentEditTarget && currentEditTarget.id !== message.id) {
if (
currentEditTarget?.isThreadReply === true &&
currentEditTarget.id !== message.id
) {
pendingMainEditRef.current = null;
toast.info("Finish or cancel your edit first.");
return false;

View File

@@ -68,6 +68,7 @@ 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";
@@ -170,8 +171,16 @@ export function ChannelScreen({
const activeChannelId = activeChannel?.id ?? null;
const isHuddleTranscript = useIsHuddleTranscript(activeChannelId);
const relaySelfPubkey = useRelaySelfQuery(activeChannel !== null).data;
const effectiveOpenThreadHeadId =
optimisticOpenThreadHeadId ?? openThreadHeadId;
const requireThreadEditResolutionRef = React.useRef<() => boolean>(
() => true,
);
const effectiveOpenThreadHeadId = useHuddleThreadIsolation({
closeThread: setOpenThreadHeadId,
isHuddleTranscript,
openThreadHeadId,
optimisticOpenThreadHeadId,
requireThreadEditResolutionRef,
});
const isNotifiedForEffectiveThread =
effectiveOpenThreadHeadId != null
? isNotifiedForThread(effectiveOpenThreadHeadId)
@@ -505,16 +514,7 @@ export function ChannelScreen({
threadReplyTargetId,
toggleReactionMutation,
});
React.useEffect(() => {
if (!isHuddleTranscript || openThreadHeadId === null) return;
if (!requireThreadEditResolution()) return;
setOpenThreadHeadId(null);
}, [
isHuddleTranscript,
openThreadHeadId,
requireThreadEditResolution,
setOpenThreadHeadId,
]);
requireThreadEditResolutionRef.current = requireThreadEditResolution;
const effectiveToggleReaction = React.useMemo(
() =>
activeChannel && !activeChannel.archivedAt && activeChannel.isMember

View File

@@ -118,7 +118,6 @@ export function useChannelRouteTarget({
if (!targetMessage.parentId) {
if (!requireThreadEditResolution()) {
handledThreadRouteTargetRef.current = targetKey;
return;
}
closeAgentSession();
@@ -148,7 +147,6 @@ export function useChannelRouteTarget({
return;
}
if (!requireThreadEditResolution()) {
handledThreadRouteTargetRef.current = targetKey;
return;
}

View File

@@ -0,0 +1,34 @@
import assert from "node:assert/strict";
import test from "node:test";
import { resolveHuddleOpenThreadHeadId } from "./useHuddleThreadIsolation.ts";
test("huddle transcripts synchronously hide URL thread state", () => {
assert.equal(
resolveHuddleOpenThreadHeadId({
isHuddleTranscript: true,
openThreadHeadId: "url-thread",
optimisticOpenThreadHeadId: undefined,
}),
null,
);
});
test("an optimistic null overrides the URL thread until navigation settles", () => {
assert.equal(
resolveHuddleOpenThreadHeadId({
isHuddleTranscript: false,
openThreadHeadId: "url-thread",
optimisticOpenThreadHeadId: null,
}),
null,
);
assert.equal(
resolveHuddleOpenThreadHeadId({
isHuddleTranscript: false,
openThreadHeadId: "url-thread",
optimisticOpenThreadHeadId: undefined,
}),
"url-thread",
);
});

View File

@@ -0,0 +1,47 @@
import * as React from "react";
type HuddleThreadIsolationOptions = {
closeThread: (threadId: string | null) => void;
isHuddleTranscript: boolean;
openThreadHeadId: string | null;
optimisticOpenThreadHeadId: string | null | undefined;
requireThreadEditResolutionRef: React.RefObject<() => boolean>;
};
export function resolveHuddleOpenThreadHeadId({
isHuddleTranscript,
openThreadHeadId,
optimisticOpenThreadHeadId,
}: Pick<
HuddleThreadIsolationOptions,
"isHuddleTranscript" | "openThreadHeadId" | "optimisticOpenThreadHeadId"
>): string | null {
if (isHuddleTranscript) return null;
return optimisticOpenThreadHeadId === undefined
? openThreadHeadId
: optimisticOpenThreadHeadId;
}
export function useHuddleThreadIsolation({
closeThread,
isHuddleTranscript,
openThreadHeadId,
optimisticOpenThreadHeadId,
requireThreadEditResolutionRef,
}: HuddleThreadIsolationOptions): string | null {
React.useEffect(() => {
if (!isHuddleTranscript || openThreadHeadId === null) return;
if (!requireThreadEditResolutionRef.current()) return;
closeThread(null);
}, [
closeThread,
isHuddleTranscript,
openThreadHeadId,
requireThreadEditResolutionRef,
]);
return resolveHuddleOpenThreadHeadId({
isHuddleTranscript,
openThreadHeadId,
optimisticOpenThreadHeadId,
});
}

View File

@@ -3476,6 +3476,123 @@ test("main ArrowUp refuses to replace a dirty thread edit", async ({
await expect(mainInput).toHaveText("");
});
test("main composer switches directly between visible message edits", async ({
page,
}) => {
const first = `Main edit switch first ${Date.now()}`;
const second = `Main edit switch second ${Date.now()}`;
await page.goto("/");
await page.getByTestId("channel-general").click();
const mainInput = page
.getByTestId("channel-composer-overlay")
.getByTestId("message-input");
await mainInput.fill(first);
await mainInput.press("Enter");
await expect(
page
.getByTestId("message-timeline")
.getByTestId("message-row")
.filter({ hasText: first }),
).toBeVisible();
await page.waitForTimeout(1_100);
await mainInput.fill(second);
await mainInput.press("Enter");
await expect(
page
.getByTestId("message-timeline")
.getByTestId("message-row")
.filter({ hasText: second }),
).toBeVisible();
await mainInput.click();
await page.keyboard.press("ArrowUp");
await expect(mainInput).toHaveText(second);
const firstMessage = page
.getByTestId("message-timeline")
.getByTestId("message-row")
.filter({ hasText: first })
.last();
await firstMessage.hover();
await firstMessage.getByRole("button", { name: "More actions" }).click();
await page.getByRole("menuitem", { name: "Edit message" }).click();
await expect(mainInput).toHaveText(first);
await expect(mainInput).toBeFocused();
await expect(page.getByText("Finish or cancel your edit first.")).toHaveCount(
0,
);
});
test("a refused message deep link retries after the thread edit is canceled", async ({
page,
}) => {
const sourceRoot = `Deep link retry source ${Date.now()}`;
const reply = `Deep link retry reply ${Date.now()}`;
const destinationRoot = `Deep link retry destination ${Date.now()}`;
await page.goto("/");
await page.getByTestId("channel-general").click();
const mainInput = page
.getByTestId("channel-composer-overlay")
.getByTestId("message-input");
await mainInput.fill(destinationRoot);
await mainInput.press("Enter");
const destination = page
.getByTestId("message-timeline")
.getByTestId("message-row")
.filter({ hasText: destinationRoot })
.last();
const destinationId = await destination.getAttribute("data-message-id");
expect(destinationId).not.toBeNull();
await mainInput.fill(
`Retry link buzz://message?channel=9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50&id=${destinationId}`,
);
await mainInput.press("Enter");
const destinationLink = page
.getByTestId("message-row")
.filter({ hasText: "Retry link" })
.last()
.getByRole("button", { name: "Open message in channel general" });
await expect(destinationLink).toBeVisible();
await mainInput.fill(sourceRoot);
await mainInput.press("Enter");
const source = page
.getByTestId("message-timeline")
.getByTestId("message-row")
.filter({ hasText: sourceRoot })
.last();
await source.hover();
await source.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")
.filter({ hasText: reply })
.last();
await threadReply.hover();
await threadReply.getByRole("button", { name: "More actions" }).click();
await page.getByRole("menuitem", { name: "Edit message" }).click();
await threadInput.fill(`${reply} unsaved`);
await destinationLink.click();
await expect(
page.getByText("Finish or cancel your edit before leaving the thread."),
).toBeVisible();
await page.keyboard.press("Escape");
await destinationLink.click();
const routedDestination = page
.getByTestId("message-timeline")
.locator(`[data-message-id="${destinationId}"]`);
await expect(threadPanel).toBeHidden();
await expect(routedDestination).toBeVisible();
await expect(routedDestination).toHaveClass(/route-target-highlight-fade/);
});
test("ArrowUp in an empty composer edits your last message right after sending", async ({
page,
}) => {