From 7cff2bdbccbd682a4d9916cffa413e28f49c83b5 Mon Sep 17 00:00:00 2001 From: Lambda Date: Fri, 31 Jul 2026 15:53:47 +0800 Subject: [PATCH] fix(issues): offer quick actions in the reply composer's / menu (MUL-5588) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The thread reply composer enabled the built-in `/` command menu but never passed `quickActionMenu` to its ContentEditor, so the menu listed only `/note` while the top-level comment composer listed the workspace's quick actions. A reply posts to the same issue, so `/` must offer the same catalog in both. Wires ReplyInput to the same `useQuickActionMenu(issueId)` hook CommentInput already uses — picking an action inserts the server-rendered body for the user to edit, exactly as in the top-level composer. Adds a parametrized test over both composers so the two cannot drift apart again. Co-authored-by: multica-agent --- .../components/comment-composers.test.tsx | 107 +++++++++++++++++- .../views/issues/components/reply-input.tsx | 6 + 2 files changed, 112 insertions(+), 1 deletion(-) diff --git a/packages/views/issues/components/comment-composers.test.tsx b/packages/views/issues/components/comment-composers.test.tsx index 98b38c9a4a..d2158d730e 100644 --- a/packages/views/issues/components/comment-composers.test.tsx +++ b/packages/views/issues/components/comment-composers.test.tsx @@ -5,18 +5,34 @@ import { act, fireEvent, screen, waitFor } from "@testing-library/react"; import type { UploadResult } from "@multica/core/hooks/use-file-upload"; import type { Attachment } from "@multica/core/types"; import { useCommentComposerStore, useCommentDraftStore } from "@multica/core/issues/stores"; +import { WorkspaceSlugProvider } from "@multica/core/paths"; import { renderWithI18n } from "../../test/i18n"; import { CommentInput } from "./comment-input"; import { ReplyInput } from "./reply-input"; +/** Shape of ContentEditor's `quickActionMenu` prop, as the composers pass it. */ +type QuickActionMenuProp = { + getQuickActions?: () => { id: string; name: string; description?: string }[]; + renderQuickAction?: (quickActionId: string) => Promise; + onRenderError?: (error: unknown) => void; +}; + // Uploads now flow through the module-level coordinator, which calls // `api.uploadFile(file, ctx, signal)` (MUL-5181). Tests drive uploads by // mocking that call directly rather than the old `uploadWithToast` hook. const apiUploadFile = vi.hoisted(() => vi.fn()); +const apiListWorkspaces = vi.hoisted(() => vi.fn()); +const apiListQuickActions = vi.hoisted(() => vi.fn()); +const apiRenderQuickAction = vi.hoisted(() => vi.fn()); const uploadWithToast = vi.hoisted(() => vi.fn()); const editorDefaultValues = vi.hoisted(() => ({ values: [] as Array, })); +// The `/` quick-action menu is wired through a ContentEditor prop, so the mock +// editor records it — that prop being absent is exactly the MUL-5588 bug. +const editorQuickActionMenu = vi.hoisted(() => ({ + last: undefined as QuickActionMenuProp | undefined, +})); // Observability + failure control for the write-back insert path (MUL-5181): // `insertMarkdownAtEnd` returns false while the (simulated) Tiptap instance // doesn't exist yet, exactly like the real handle. @@ -38,7 +54,12 @@ const editorUploadSignal = vi.hoisted( let mockUploadIdSeq = 0; vi.mock("@multica/core/api", () => ({ - api: { uploadFile: apiUploadFile }, + api: { + uploadFile: apiUploadFile, + listWorkspaces: apiListWorkspaces, + listQuickActions: apiListQuickActions, + renderQuickAction: apiRenderQuickAction, + }, })); vi.mock("@multica/core/hooks/use-file-upload", async () => ({ @@ -87,6 +108,7 @@ vi.mock("../../editor", async () => ({ onUploadingChange, onSubmit, onReady, + quickActionMenu, }: { defaultValue?: string; onUpdate?: (markdown: string) => void; @@ -95,10 +117,12 @@ vi.mock("../../editor", async () => ({ onUploadingChange?: (uploading: boolean) => void; onSubmit?: () => void; onReady?: () => void; + quickActionMenu?: QuickActionMenuProp; }, ref: Ref, ) { editorDefaultValues.values.push(defaultValue); + editorQuickActionMenu.last = quickActionMenu; editorUploadSignal.notify = onUploadingChange; const valueRef = useRef(defaultValue ?? ""); // Mirrors the real editor's `uploading` node attrs: the placeholder exists @@ -233,6 +257,9 @@ function getSubmitButton(container: HTMLElement): HTMLButtonElement { beforeEach(() => { uploadWithToast.mockReset(); apiUploadFile.mockReset(); + apiListWorkspaces.mockReset(); + apiListQuickActions.mockReset(); + apiRenderQuickAction.mockReset(); insertMarkdownSpy.mockReset(); insertPlaceholderSpy.mockReset(); insertMarkdownBehavior.succeed = true; @@ -243,10 +270,88 @@ beforeEach(() => { // path and hide the shell the next test expects. useCommentDraftStore.setState({ drafts: {} }); editorDefaultValues.values = []; + editorQuickActionMenu.last = undefined; focusCalls.focused = 0; focusCalls.blurred = 0; }); +// --------------------------------------------------------------------------- +// Quick action `/` menu (MUL-5588) +// --------------------------------------------------------------------------- + +describe("quick action `/` menu", () => { + const workspace = { id: "ws-1", slug: "acme", name: "Acme" }; + + function renderInWorkspace(ui: ReactNode) { + apiListWorkspaces.mockResolvedValue([workspace]); + apiListQuickActions.mockResolvedValue({ + quick_actions: [ + { id: "qa-1", name: "review", description: "Ask for a review", status: "active" }, + { id: "qa-2", name: "retired", description: "", status: "archived" }, + ], + }); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return renderWithI18n( + + {/* useQuickActionMenu resolves the workspace from the slug context; without + it the catalog query stays disabled and every menu reads as empty. */} + {ui} + , + ); + } + + // Both composers post to the same issue, so `/` must offer the same catalog in + // both. The reply box shipped without the prop entirely (MUL-5588): its menu + // listed only the built-in `/note` while the top-level composer listed the + // workspace's quick actions. + const composers = [ + { + name: "top-level comment composer", + shell: "comment-composer-shell" as const, + element: , + }, + { + name: "thread reply composer", + shell: "reply-composer-shell" as const, + element: ( + + ), + }, + ]; + + for (const composer of composers) { + it(`offers the workspace's active quick actions in the ${composer.name}`, async () => { + renderInWorkspace(composer.element); + activateComposer(composer.shell); + + await waitFor(() => { + expect(editorQuickActionMenu.last?.getQuickActions?.()).toEqual([ + { id: "qa-1", name: "review", description: "Ask for a review" }, + ]); + }); + }); + + it(`binds quick action rendering to this issue in the ${composer.name}`, async () => { + apiRenderQuickAction.mockResolvedValue("rendered body"); + renderInWorkspace(composer.element); + activateComposer(composer.shell); + + await waitFor(() => { + expect(editorQuickActionMenu.last?.renderQuickAction).toBeTypeOf("function"); + }); + await editorQuickActionMenu.last?.renderQuickAction?.("qa-1"); + + expect(apiRenderQuickAction).toHaveBeenCalledWith("issue-1", "qa-1"); + }); + } +}); + describe("comment composers", () => { it("renders the main comment composer without a manual expand control", () => { const { container } = renderCommentInput(); diff --git a/packages/views/issues/components/reply-input.tsx b/packages/views/issues/components/reply-input.tsx index 486413a074..d672c089eb 100644 --- a/packages/views/issues/components/reply-input.tsx +++ b/packages/views/issues/components/reply-input.tsx @@ -14,6 +14,7 @@ import { useT } from "../../i18n"; import { CommentTriggerChips } from "./comment-trigger-chips"; import { useCommentTriggerPreview } from "../hooks/use-comment-trigger-preview"; import { useCommentUploads } from "./use-comment-uploads"; +import { useQuickActionMenu } from "../hooks/use-quick-action-menu"; // --------------------------------------------------------------------------- // Types @@ -57,6 +58,10 @@ function ReplyInput({ const composerRef = useRef(null); // See CommentInput — replying mid-upload posts without the file. const uploadGate = useUploadGate(editorRef); + // Quick actions in the `/` menu — same catalog and same insert-don't-run + // behavior as the top-level composer. A reply posts to the same issue, so + // `/` has to offer the same thing here (MUL-5588). + const quickActionMenu = useQuickActionMenu(issueId); // If a draft key is provided, hydrate from store on mount (defaultValue is // the only injection point on ContentEditorRef) and flush on every onUpdate. const [initialDraft] = useState(() => @@ -246,6 +251,7 @@ function ReplyInput({ attachments={pendingAttachments} enableSlashCommands slashCommandMode="command" + quickActionMenu={quickActionMenu} /> )}