mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-06 05:49:12 +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>
118 lines
3.6 KiB
SQL
118 lines
3.6 KiB
SQL
-- name: CreateAttachment :one
|
|
INSERT INTO attachment (
|
|
id, workspace_id, issue_id, comment_id, chat_session_id,
|
|
uploader_type, uploader_id, filename, url, content_type, size_bytes
|
|
)
|
|
VALUES (
|
|
$1, $2, sqlc.narg(issue_id), sqlc.narg(comment_id), sqlc.narg(chat_session_id),
|
|
$3, $4, $5, $6, $7, $8
|
|
)
|
|
RETURNING *;
|
|
|
|
-- name: ListAttachmentsByIssue :many
|
|
SELECT * FROM attachment
|
|
WHERE issue_id = $1 AND workspace_id = $2
|
|
ORDER BY created_at ASC;
|
|
|
|
-- name: ListAttachmentsByComment :many
|
|
SELECT * FROM attachment
|
|
WHERE comment_id = $1 AND workspace_id = $2
|
|
ORDER BY created_at ASC;
|
|
|
|
-- name: GetAttachment :one
|
|
SELECT * FROM attachment
|
|
WHERE id = $1 AND workspace_id = $2;
|
|
|
|
-- name: GetAttachmentByIDOnly :one
|
|
-- Used by the download endpoint, which derives workspace context from the
|
|
-- attachment row itself rather than from request headers/query params. The
|
|
-- caller still has to verify the requester is a member of the returned
|
|
-- workspace_id before serving the bytes — this query is access-neutral on
|
|
-- purpose so a self-contained URL like /api/attachments/{id}/download can
|
|
-- work as a native <img>/<video> resource load (no header attachment).
|
|
SELECT * FROM attachment
|
|
WHERE id = $1;
|
|
|
|
-- name: ListAttachmentsByCommentIDs :many
|
|
SELECT * FROM attachment
|
|
WHERE comment_id = ANY($1::uuid[]) AND workspace_id = $2
|
|
ORDER BY created_at ASC;
|
|
|
|
-- name: ListAttachmentURLsByIssueOrComments :many
|
|
SELECT a.url FROM attachment a
|
|
WHERE a.issue_id = $1
|
|
OR a.comment_id IN (SELECT c.id FROM comment c WHERE c.issue_id = $1);
|
|
|
|
-- name: ListAttachmentURLsByCommentID :many
|
|
SELECT url FROM attachment
|
|
WHERE comment_id = $1;
|
|
|
|
-- name: LinkAttachmentsToComment :exec
|
|
UPDATE attachment
|
|
SET comment_id = $1
|
|
WHERE issue_id = $2
|
|
AND comment_id IS NULL
|
|
AND id = ANY($3::uuid[]);
|
|
|
|
-- name: ReplaceCommentAttachments :exec
|
|
UPDATE attachment
|
|
SET comment_id = CASE
|
|
WHEN id = ANY(sqlc.arg(attachment_ids)::uuid[]) THEN $1
|
|
ELSE NULL
|
|
END
|
|
WHERE issue_id = $2
|
|
AND (
|
|
comment_id = $1
|
|
OR (comment_id IS NULL AND id = ANY(sqlc.arg(attachment_ids)::uuid[]))
|
|
);
|
|
|
|
-- name: LinkAttachmentsToChatMessage :many
|
|
UPDATE attachment
|
|
SET chat_message_id = sqlc.arg(chat_message_id),
|
|
chat_session_id = sqlc.arg(chat_session_id)
|
|
WHERE workspace_id = sqlc.arg(workspace_id)
|
|
AND issue_id IS NULL
|
|
AND comment_id IS NULL
|
|
AND chat_message_id IS NULL
|
|
AND (
|
|
chat_session_id IS NULL
|
|
OR chat_session_id = sqlc.arg(chat_session_id)
|
|
)
|
|
AND uploader_type = sqlc.arg(uploader_type)
|
|
AND uploader_id = sqlc.arg(uploader_id)
|
|
AND id = ANY(sqlc.arg(attachment_ids)::uuid[])
|
|
RETURNING id;
|
|
|
|
-- name: DetachAttachmentsFromUserChatMessageByTask :many
|
|
-- When an empty chat task is cancelled, its user message is deleted. The
|
|
-- attachment FK is ON DELETE CASCADE, so without this the bound rows would be
|
|
-- destroyed and a restored draft could never re-bind them. Detach first
|
|
-- (chat_message_id -> NULL, keep chat_session_id) so the rows survive as
|
|
-- workspace/session-scoped unattached attachments and re-send can re-link them.
|
|
UPDATE attachment
|
|
SET chat_message_id = NULL
|
|
WHERE chat_message_id IN (
|
|
SELECT id FROM chat_message WHERE task_id = $1 AND role = 'user'
|
|
)
|
|
RETURNING *;
|
|
|
|
-- name: ListAttachmentsByChatMessage :many
|
|
SELECT * FROM attachment
|
|
WHERE chat_message_id = $1 AND workspace_id = $2
|
|
ORDER BY created_at ASC;
|
|
|
|
-- name: ListAttachmentsByChatMessageIDs :many
|
|
SELECT * FROM attachment
|
|
WHERE chat_message_id = ANY($1::uuid[]) AND workspace_id = $2
|
|
ORDER BY created_at ASC;
|
|
|
|
-- name: LinkAttachmentsToIssue :exec
|
|
UPDATE attachment
|
|
SET issue_id = $1
|
|
WHERE workspace_id = $2
|
|
AND issue_id IS NULL
|
|
AND id = ANY($3::uuid[]);
|
|
|
|
-- name: DeleteAttachment :exec
|
|
DELETE FROM attachment WHERE id = $1 AND workspace_id = $2;
|