Files
multica/packages/views/editor/use-composer-submit.test.tsx
Naiyuan Qing b5f19adbab feat(composer): post-send caret policy per surface (afterAccepted) (#6014)
* feat(composer): post-send caret policy per surface (afterAccepted)

Where the caret goes after a send was hand-rolled per composer: chat blurred,
quick-create hand-wrote its own requestAnimationFrame focus, and comment/reply
did nothing at all. The mechanics are identical everywhere and easy to get
wrong (must run after the clear, must survive a dialog focus trap, must not
steal focus the user moved elsewhere mid-flight), so they now live once in the
shared send contract.

`useComposerSubmit` gains `afterAccepted` ("refocus" | "blur" | "none",
default "none") plus an optional `containerRef` that bounds focus reclaim to
the composer that sent. The mode may be a function so a surface can decide at
accept time — chat only reclaims focus when the commit actually scrubbed the
shared editor, never when a fire-and-forget send left another session's draft
on screen.

Per-surface policy:
- Chat refocuses (one file, three mount points: chat page, floating window,
  agent creation studio). Replaces the deliberate blur.
- Thread replies refocus — the user is mid-conversation.
- A top-level comment blurs, and IssueDetail reveals what was posted instead:
  submitComment now returns the created id, and the page scrolls to that row
  and flashes it with the same jumpToThread the inbox deep-link uses.
- Quick create's keep-open mode drops its hand-written rAF for the option.
- Inline comment edit and Create Issue keep "none": both close on save.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor(composer): drop the scroll-to-posted-comment reveal

The top-level composer is sticky at the bottom of the timeline, so a comment
posted from it lands directly above the box and is almost always already on
screen — the scroll was a no-op and the flash re-announced something the user
had just deliberately done. The flash earns its keep for inbox deep-links,
where the user did not choose the landing spot.

`submitComment` goes back to returning a boolean; it only carried the created
id to feed the reveal. The composer still blurs after posting: the turn is
over, so the caret is dropped rather than kept.

The shared contract is untouched — `afterAccepted` never knew about comments,
which is why removing this costs nothing outside IssueDetail.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(composer): drop stale references to the removed comment reveal

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(composer): bind the post-send caret policy to an actually-cleared editor

Review blocker: CommentInput passed a literal `afterAccepted: "blur"`, but its
stale-submit guard declines to clear when the user typed during the request.
Posting comment A on a slow connection while typing comment B therefore
dropped the caret out of B mid-sentence — the guard kept the text, and the
blur fired anyway.

Both issue composers now resolve the mode from a ref set only on the branch
that really wipes the editor, matching what ChatInput and quick-create already
do. ReplyInput gets the same treatment: refocusing a box the user is typing in
happens to be harmless, but leaving one surface on the unsafe shape invites the
next one to copy it.

The rule is now stated on the option itself: a surface whose `onAccepted` can
decline to clear must pass a function and resolve to "none" on those paths.

Both regressions are mutation-verified — reverting either binding fails the new
assertions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 13:15:43 +08:00

265 lines
8.9 KiB
TypeScript

import { describe, it, expect, vi } from "vitest";
import { renderHook, act } from "@testing-library/react";
import { useComposerSubmit } from "./use-composer-submit";
import type { ContentEditorRef } from "./content-editor";
import type { UploadGate } from "./use-upload-gate";
function editorWith(markdown: string) {
return {
current: { getMarkdown: () => markdown } as unknown as ContentEditorRef,
};
}
/** Editor stub that records the focus calls `afterAccepted` makes. */
function focusTrackingEditor(markdown = "hello") {
const calls = { focused: 0, blurred: 0 };
const ref = {
current: {
getMarkdown: () => markdown,
focus: () => { calls.focused += 1; },
blur: () => { calls.blurred += 1; },
} as unknown as ContentEditorRef,
};
return { ref, calls };
}
/** `afterAccepted` defers a frame; flush it. */
function nextFrame() {
return new Promise((resolve) => requestAnimationFrame(() => resolve(null)));
}
const openGate: UploadGate = {
uploading: false,
onUploadingChange: () => {},
isBlocked: () => false,
};
const blockedGate: UploadGate = { ...openGate, isBlocked: () => true };
describe("useComposerSubmit", () => {
it("does not submit empty content", async () => {
const onSubmit = vi.fn().mockResolvedValue(true);
const { result } = renderHook(() =>
useComposerSubmit({ editorRef: editorWith(" \n"), uploadGate: openGate, onSubmit }),
);
await act(async () => { await result.current.submit(); });
expect(onSubmit).not.toHaveBeenCalled();
});
it("clears (onAccepted) only when the server accepts", async () => {
const onAccepted = vi.fn();
const { result } = renderHook(() =>
useComposerSubmit({
editorRef: editorWith("hello"),
uploadGate: openGate,
onSubmit: vi.fn().mockResolvedValue(true),
onAccepted,
}),
);
await act(async () => { await result.current.submit(); });
expect(onAccepted).toHaveBeenCalledTimes(1);
});
it("keeps the draft when the server rejects", async () => {
const onAccepted = vi.fn();
const { result } = renderHook(() =>
useComposerSubmit({
editorRef: editorWith("hello"),
uploadGate: openGate,
onSubmit: vi.fn().mockResolvedValue(false),
onAccepted,
}),
);
await act(async () => { await result.current.submit(); });
expect(onAccepted).not.toHaveBeenCalled();
});
it("keeps the draft when the send throws", async () => {
const onAccepted = vi.fn();
const { result } = renderHook(() =>
useComposerSubmit({
editorRef: editorWith("hello"),
uploadGate: openGate,
onSubmit: vi.fn().mockRejectedValue(new Error("network")),
onAccepted,
}),
);
await act(async () => { await result.current.submit(); });
expect(onAccepted).not.toHaveBeenCalled();
});
it("blocks while an upload is in flight", async () => {
const onSubmit = vi.fn().mockResolvedValue(true);
const { result } = renderHook(() =>
useComposerSubmit({ editorRef: editorWith("hello"), uploadGate: blockedGate, onSubmit }),
);
await act(async () => { await result.current.submit(); });
expect(onSubmit).not.toHaveBeenCalled();
});
it("single-flights concurrent submits", async () => {
let resolve!: (v: boolean) => void;
const onSubmit = vi.fn().mockImplementation(() => new Promise<boolean>((r) => { resolve = r; }));
const { result } = renderHook(() =>
useComposerSubmit({ editorRef: editorWith("hello"), uploadGate: openGate, onSubmit }),
);
await act(async () => {
const a = result.current.submit();
const b = result.current.submit();
resolve(true);
await Promise.all([a, b]);
});
expect(onSubmit).toHaveBeenCalledTimes(1);
});
});
// `afterAccepted` — where the caret goes once a send is accepted. Each surface
// picks its own policy; the mechanics (defer a frame, respect unmount, don't
// steal focus the user moved elsewhere) live in the hook.
describe("useComposerSubmit afterAccepted", () => {
it("defaults to leaving focus alone", async () => {
const { ref, calls } = focusTrackingEditor();
const { result } = renderHook(() =>
useComposerSubmit({
editorRef: ref,
uploadGate: openGate,
onSubmit: vi.fn().mockResolvedValue(true),
}),
);
await act(async () => { await result.current.submit(); });
await act(async () => { await nextFrame(); });
expect(calls.focused).toBe(0);
expect(calls.blurred).toBe(0);
});
it("refocuses the editor after an accepted submit", async () => {
const { ref, calls } = focusTrackingEditor();
const { result } = renderHook(() =>
useComposerSubmit({
editorRef: ref,
uploadGate: openGate,
onSubmit: vi.fn().mockResolvedValue(true),
afterAccepted: "refocus",
}),
);
await act(async () => { await result.current.submit(); });
// Deferred on purpose: a synchronous grab loses to a dialog's focus trap.
expect(calls.focused).toBe(0);
await act(async () => { await nextFrame(); });
expect(calls.focused).toBe(1);
});
it("blurs the editor when the surface hands attention elsewhere", async () => {
const { ref, calls } = focusTrackingEditor();
const { result } = renderHook(() =>
useComposerSubmit({
editorRef: ref,
uploadGate: openGate,
onSubmit: vi.fn().mockResolvedValue(true),
afterAccepted: "blur",
}),
);
await act(async () => { await result.current.submit(); });
await act(async () => { await nextFrame(); });
expect(calls.blurred).toBe(1);
expect(calls.focused).toBe(0);
});
it("does nothing when the server rejects", async () => {
const { ref, calls } = focusTrackingEditor();
const { result } = renderHook(() =>
useComposerSubmit({
editorRef: ref,
uploadGate: openGate,
onSubmit: vi.fn().mockResolvedValue(false),
afterAccepted: "refocus",
}),
);
await act(async () => { await result.current.submit(); });
await act(async () => { await nextFrame(); });
expect(calls.focused).toBe(0);
});
it("resolves a function mode at accept time, not a frame later", async () => {
const { ref, calls } = focusTrackingEditor();
let allow = true;
const { result } = renderHook(() =>
useComposerSubmit({
editorRef: ref,
uploadGate: openGate,
onSubmit: vi.fn().mockResolvedValue(true),
afterAccepted: () => (allow ? "refocus" : "none"),
}),
);
await act(async () => { await result.current.submit(); });
// Flipping after acceptance must not retroactively cancel the decision —
// chat's flag is only true for the instant around the commit.
allow = false;
await act(async () => { await nextFrame(); });
expect(calls.focused).toBe(1);
});
it("does not steal focus the user moved outside the composer", async () => {
const { ref, calls } = focusTrackingEditor();
const container = document.createElement("div");
const outside = document.createElement("input");
document.body.append(container, outside);
outside.focus();
const { result } = renderHook(() =>
useComposerSubmit({
editorRef: ref,
uploadGate: openGate,
onSubmit: vi.fn().mockResolvedValue(true),
afterAccepted: "refocus",
containerRef: { current: container },
}),
);
await act(async () => { await result.current.submit(); });
await act(async () => { await nextFrame(); });
expect(calls.focused).toBe(0);
container.remove();
outside.remove();
});
it("reclaims focus that is still inside the composer", async () => {
const { ref, calls } = focusTrackingEditor();
const container = document.createElement("div");
const sendButton = document.createElement("button");
container.append(sendButton);
document.body.append(container);
sendButton.focus();
const { result } = renderHook(() =>
useComposerSubmit({
editorRef: ref,
uploadGate: openGate,
onSubmit: vi.fn().mockResolvedValue(true),
afterAccepted: "refocus",
containerRef: { current: container },
}),
);
await act(async () => { await result.current.submit(); });
await act(async () => { await nextFrame(); });
expect(calls.focused).toBe(1);
container.remove();
});
it("does not touch an editor whose composer unmounted mid-flight", async () => {
const { ref, calls } = focusTrackingEditor();
let resolve!: (v: boolean) => void;
const { result, unmount } = renderHook(() =>
useComposerSubmit({
editorRef: ref,
uploadGate: openGate,
onSubmit: () => new Promise<boolean>((r) => { resolve = r; }),
afterAccepted: "refocus",
}),
);
let pending!: Promise<void>;
act(() => { pending = result.current.submit(); });
await act(async () => { resolve(true); await pending; });
unmount();
await act(async () => { await nextFrame(); });
expect(calls.focused).toBe(0);
});
});