mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-12 19:06:06 +02:00
* feat(chat): workspace-scoped attachment binding + fire-and-forget send Uploads are now workspace-scoped: the chat session is created and attachments are bound to the message at send time, so a paste/drop no longer creates an empty session the user never sends. - LinkAttachmentsToChatMessage returns the ids it actually bound; the client diffs requested-vs-bound and warns on partial bind, replacing an extra listChatMessagesPage fetch. - Cancelling an empty chat task detaches attachments before deleting the user message (attachment FK is ON DELETE CASCADE) and returns them via cancelled_chat_message.attachments, so a restored draft can re-bind. - SendChatMessageResponse.attachment_ids has no omitempty: "requested but bound zero" serializes [] so the client can tell it apart from an older server and still warn. - Send is fire-and-forget: it no longer steals focus when the user has navigated to another session (guarded on the live store + new-chat agent id); the reply surfaces via the unread dot. commitInput gets clearEditor so a navigated-away commit doesn't wipe the editor now showing another session, while still clearing the sent draft's data. - Draft restore is session-aware so a failed fire-and-forget send restores into the session it was sent from, never the one the user moved to. - Removed the now-unreferenced migrateInputDraft store action. Verified: core/views typecheck, chat-input (15) / store (3) / api client (24) unit tests, go build + vet, handler SendChatMessage + CancelTaskByUser DB tests. Full make check / E2E left to CI. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(chat): guard attachment survival on empty-chat cancel Cancelling an empty chat task deletes the user message, and attachment.chat_message_id is ON DELETE CASCADE (migration 083), so the detach-before-delete in finalizeCancelledChatMessage is the only thing keeping the user's attachment from being silently destroyed. Nothing covered it. Add a DB regression test that binds an attachment to the cancelled user message and asserts: the row survives the cascade (chat_message_id NULL, chat_session_id retained), the cancel response returns it via cancelled_chat_message.attachments, and a resend re-binds it to the new message. Verified red when the detach step is removed. Related issue: MUL-3364 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai> * fix(comment): pessimistic submit for comment/reply composers The comment and reply composers cleared the editor after `await onSubmit` returned, with no in-flight lock. On a slow send the WS `comment:created` event already dropped the real comment into the timeline while the box still held the same text + spinner, so it read as two comments. And because `submitComment`/`submitReply` swallow errors (toast, no rethrow), a failed send still reached `clearContent` and silently discarded the user's draft. Recover the comment/reply portion of the closed #4236: make the submit callback resolve a success boolean (true on success, false on the caught failure), lock the editor while in flight (pointer-events-none + dimmed wrapper + aria-busy, since ContentEditor can't toggle Tiptap `editable` post-mount), keep the button spinning, and clear only on success — a failed send keeps the draft. Chat composer is out of scope (already reworked on this branch); attachment binding is untouched. Adds two view tests (in-flight lock then clear-on-success; failed send keeps the draft); both verified red against the un-fixed code. Related issue: MUL-3364 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai>
226 lines
7.3 KiB
TypeScript
226 lines
7.3 KiB
TypeScript
import { forwardRef, useImperativeHandle, useRef, type ReactNode, type Ref } from "react";
|
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
import { fireEvent, screen, waitFor } from "@testing-library/react";
|
|
import type { UploadResult } from "@multica/core/hooks/use-file-upload";
|
|
import { renderWithI18n } from "../../test/i18n";
|
|
import { CommentInput } from "./comment-input";
|
|
import { ReplyInput } from "./reply-input";
|
|
|
|
const uploadWithToast = vi.hoisted(() => vi.fn());
|
|
|
|
vi.mock("@multica/core/api", () => ({
|
|
api: {},
|
|
}));
|
|
|
|
vi.mock("@multica/core/hooks/use-file-upload", () => ({
|
|
useFileUpload: () => ({ uploadWithToast }),
|
|
}));
|
|
|
|
vi.mock("../../common/actor-avatar", () => ({
|
|
ActorAvatar: ({ actorType, actorId }: { actorType: string; actorId: string }) => (
|
|
<span data-testid="actor-avatar">
|
|
{actorType}:{actorId}
|
|
</span>
|
|
),
|
|
}));
|
|
|
|
vi.mock("../../editor", () => ({
|
|
useFileDropZone: () => ({
|
|
isDragOver: false,
|
|
dropZoneProps: { "data-testid": "drop-zone" },
|
|
}),
|
|
FileDropOverlay: () => null,
|
|
ContentEditor: forwardRef(function MockContentEditor(
|
|
{
|
|
defaultValue,
|
|
onUpdate,
|
|
placeholder,
|
|
onUploadFile,
|
|
}: {
|
|
defaultValue?: string;
|
|
onUpdate?: (markdown: string) => void;
|
|
placeholder?: string;
|
|
onUploadFile?: (file: File) => Promise<UploadResult | null>;
|
|
},
|
|
ref: Ref<unknown>,
|
|
) {
|
|
const valueRef = useRef(defaultValue ?? "");
|
|
|
|
useImperativeHandle(ref, () => ({
|
|
getMarkdown: () => valueRef.current,
|
|
clearContent: () => {
|
|
valueRef.current = "";
|
|
},
|
|
focus: () => {},
|
|
blur: () => {},
|
|
uploadFile: async (file: File) => {
|
|
const result = await onUploadFile?.(file);
|
|
if (!result) return;
|
|
valueRef.current = `${valueRef.current}\n${result.url}`.trim();
|
|
onUpdate?.(valueRef.current);
|
|
},
|
|
hasActiveUploads: () => false,
|
|
}));
|
|
|
|
return (
|
|
<textarea
|
|
data-testid="editor"
|
|
defaultValue={defaultValue}
|
|
placeholder={placeholder}
|
|
onChange={(event) => {
|
|
valueRef.current = event.target.value;
|
|
onUpdate?.(event.target.value);
|
|
}}
|
|
/>
|
|
);
|
|
}),
|
|
}));
|
|
|
|
function renderWithProviders(ui: ReactNode) {
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: {
|
|
queries: { retry: false },
|
|
},
|
|
});
|
|
return renderWithI18n(
|
|
<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>,
|
|
);
|
|
}
|
|
|
|
function renderCommentInput(onSubmit = vi.fn().mockResolvedValue(true)) {
|
|
const view = renderWithProviders(<CommentInput issueId="issue-1" onSubmit={onSubmit} />);
|
|
return { ...view, onSubmit };
|
|
}
|
|
|
|
function renderReplyInput({
|
|
onSubmit = vi.fn().mockResolvedValue(true),
|
|
size = "sm",
|
|
}: {
|
|
onSubmit?: (content: string, attachmentIds?: string[], suppressAgentIds?: string[]) => Promise<boolean>;
|
|
size?: "sm" | "default";
|
|
} = {}) {
|
|
const view = renderWithProviders(
|
|
<ReplyInput
|
|
issueId="issue-1"
|
|
parentId="comment-1"
|
|
avatarType="member"
|
|
avatarId="user-1"
|
|
onSubmit={onSubmit}
|
|
size={size}
|
|
/>,
|
|
);
|
|
return { ...view, onSubmit };
|
|
}
|
|
|
|
function getSubmitButton(container: HTMLElement): HTMLButtonElement {
|
|
const button = container.querySelectorAll("button")[1];
|
|
if (!button) throw new Error("Expected submit button to render");
|
|
return button;
|
|
}
|
|
|
|
beforeEach(() => {
|
|
uploadWithToast.mockReset();
|
|
localStorage.clear();
|
|
});
|
|
|
|
describe("comment composers", () => {
|
|
it("renders the main comment composer without a manual expand control", () => {
|
|
const { container } = renderCommentInput();
|
|
|
|
expect(screen.getByPlaceholderText("Leave a comment...")).toBeInTheDocument();
|
|
expect(screen.getByRole("button", { name: "Attach file" })).toBeInTheDocument();
|
|
expect(container.querySelectorAll("button")).toHaveLength(2);
|
|
|
|
const shell = screen.getByTestId("drop-zone");
|
|
expect(shell.className).not.toMatch(/max-h-/);
|
|
expect(shell.className).not.toContain("h-[70vh]");
|
|
});
|
|
|
|
it("renders reply composer without a manual expand control", () => {
|
|
const { container } = renderReplyInput();
|
|
|
|
expect(screen.getByPlaceholderText("Leave a reply...")).toBeInTheDocument();
|
|
expect(screen.getByRole("button", { name: "Attach file" })).toBeInTheDocument();
|
|
expect(container.querySelectorAll("button")).toHaveLength(2);
|
|
|
|
const shell = screen.getByTestId("drop-zone");
|
|
expect(shell.className).not.toMatch(/max-h-/);
|
|
expect(shell.className).not.toContain("h-[60vh]");
|
|
});
|
|
|
|
it("lets default-size replies grow without a height cap", () => {
|
|
const { container } = renderReplyInput({ size: "default" });
|
|
|
|
expect(screen.getByPlaceholderText("Leave a reply...")).toBeInTheDocument();
|
|
expect(container.querySelectorAll("button")).toHaveLength(2);
|
|
|
|
const shell = screen.getByTestId("drop-zone");
|
|
expect(shell.className).not.toMatch(/max-h-/);
|
|
});
|
|
|
|
it("keeps main comment submission wired after removing expand", async () => {
|
|
const { container, onSubmit } = renderCommentInput();
|
|
|
|
fireEvent.change(screen.getByTestId("editor"), {
|
|
target: { value: "hello from composer" },
|
|
});
|
|
fireEvent.click(getSubmitButton(container));
|
|
|
|
await waitFor(() => {
|
|
expect(onSubmit).toHaveBeenCalledWith("hello from composer", undefined, undefined);
|
|
});
|
|
});
|
|
|
|
it("keeps reply submission wired after removing expand", async () => {
|
|
const { container, onSubmit } = renderReplyInput();
|
|
|
|
fireEvent.change(screen.getByTestId("editor"), {
|
|
target: { value: "thread reply" },
|
|
});
|
|
fireEvent.click(getSubmitButton(container));
|
|
|
|
await waitFor(() => {
|
|
expect(onSubmit).toHaveBeenCalledWith("thread reply", undefined, undefined);
|
|
});
|
|
});
|
|
|
|
it("locks the editor while the send is in flight, then clears on success", async () => {
|
|
let resolveSubmit: (ok: boolean) => void = () => {};
|
|
const onSubmit = vi.fn(
|
|
() => new Promise<boolean>((resolve) => { resolveSubmit = resolve; }),
|
|
);
|
|
const { container } = renderCommentInput(onSubmit);
|
|
|
|
fireEvent.change(screen.getByTestId("editor"), { target: { value: "sending" } });
|
|
fireEvent.click(getSubmitButton(container));
|
|
|
|
// In flight: text kept, editor wrapper locked (aria-busy), not cleared yet.
|
|
await waitFor(() =>
|
|
expect(screen.getByTestId("editor").closest("[aria-busy]")).toHaveAttribute(
|
|
"aria-busy",
|
|
"true",
|
|
),
|
|
);
|
|
expect(onSubmit).toHaveBeenCalledWith("sending", undefined, undefined);
|
|
|
|
resolveSubmit(true);
|
|
|
|
// Success: the composer clears (now empty → submit disabled, lock released).
|
|
await waitFor(() => expect(getSubmitButton(container)).toBeDisabled());
|
|
expect(screen.getByTestId("editor").closest("[aria-busy]")).toBeNull();
|
|
});
|
|
|
|
it("keeps the draft when the send fails (no optimistic clear)", async () => {
|
|
const onSubmit = vi.fn().mockResolvedValue(false);
|
|
const { container } = renderCommentInput(onSubmit);
|
|
|
|
fireEvent.change(screen.getByTestId("editor"), { target: { value: "will fail" } });
|
|
fireEvent.click(getSubmitButton(container));
|
|
|
|
await waitFor(() => expect(onSubmit).toHaveBeenCalled());
|
|
// Failed send must NOT clear — the box still has content, submit stays live.
|
|
await waitFor(() => expect(getSubmitButton(container)).not.toBeDisabled());
|
|
});
|
|
});
|