mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-05 21:39:54 +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>
74 lines
2.2 KiB
TypeScript
74 lines
2.2 KiB
TypeScript
import { beforeEach, describe, expect, it } from "vitest";
|
|
import { createChatStore, newSessionDraftKey } from "./store";
|
|
import type { StorageAdapter } from "../types";
|
|
import type { Attachment } from "../types";
|
|
|
|
function memStorage(): StorageAdapter {
|
|
const m = new Map<string, string>();
|
|
return {
|
|
getItem: (k) => m.get(k) ?? null,
|
|
setItem: (k, v) => {
|
|
m.set(k, v);
|
|
},
|
|
removeItem: (k) => {
|
|
m.delete(k);
|
|
},
|
|
};
|
|
}
|
|
|
|
function makeAttachment(id: string): Attachment {
|
|
return {
|
|
id,
|
|
workspace_id: "ws-1",
|
|
issue_id: null,
|
|
comment_id: null,
|
|
chat_session_id: null,
|
|
chat_message_id: null,
|
|
uploader_type: "member",
|
|
uploader_id: "user-1",
|
|
filename: `${id}.png`,
|
|
url: `/uploads/${id}.png`,
|
|
download_url: `/api/attachments/${id}/download`,
|
|
markdown_url: `/api/attachments/${id}/download`,
|
|
content_type: "image/png",
|
|
size_bytes: 1,
|
|
created_at: new Date(0).toISOString(),
|
|
};
|
|
}
|
|
|
|
describe("newSessionDraftKey", () => {
|
|
it("derives a stable per-agent slot for an uncreated chat", () => {
|
|
expect(newSessionDraftKey("agent-1")).toBe("__new__:agent-1");
|
|
expect(newSessionDraftKey(null)).toBe("__new__:");
|
|
});
|
|
});
|
|
|
|
describe("chat store — draft attachments", () => {
|
|
let store: ReturnType<typeof createChatStore>;
|
|
|
|
beforeEach(() => {
|
|
store = createChatStore({ storage: memStorage() });
|
|
});
|
|
|
|
it("deduplicates attachment drafts by id", () => {
|
|
store.getState().addInputDraftAttachment("draft-1", makeAttachment("att-1"));
|
|
store.getState().addInputDraftAttachment("draft-1", {
|
|
...makeAttachment("att-1"),
|
|
filename: "updated.png",
|
|
});
|
|
|
|
expect(store.getState().inputDraftAttachments["draft-1"]).toHaveLength(1);
|
|
expect(store.getState().inputDraftAttachments["draft-1"]?.[0]?.filename).toBe("updated.png");
|
|
});
|
|
|
|
it("clearInputDraft clears both text and attachment records", () => {
|
|
store.getState().setInputDraft("draft-1", "hello");
|
|
store.getState().addInputDraftAttachment("draft-1", makeAttachment("att-1"));
|
|
|
|
store.getState().clearInputDraft("draft-1");
|
|
|
|
expect(store.getState().inputDrafts["draft-1"]).toBeUndefined();
|
|
expect(store.getState().inputDraftAttachments["draft-1"]).toBeUndefined();
|
|
});
|
|
});
|