mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-02 18:13:27 +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>
443 lines
13 KiB
TypeScript
443 lines
13 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useRef, useCallback, useMemo } from "react";
|
|
import {
|
|
useQuery,
|
|
useQueryClient,
|
|
useMutationState,
|
|
} from "@tanstack/react-query";
|
|
import type {
|
|
Comment,
|
|
TimelineEntry,
|
|
Reaction,
|
|
} from "@multica/core/types";
|
|
import type {
|
|
CommentCreatedPayload,
|
|
CommentUpdatedPayload,
|
|
CommentDeletedPayload,
|
|
CommentResolvedPayload,
|
|
CommentUnresolvedPayload,
|
|
ActivityCreatedPayload,
|
|
ReactionAddedPayload,
|
|
ReactionRemovedPayload,
|
|
} from "@multica/core/types";
|
|
import {
|
|
issueTimelineOptions,
|
|
issueKeys,
|
|
} from "@multica/core/issues/queries";
|
|
import {
|
|
useCreateComment,
|
|
useUpdateComment,
|
|
useDeleteComment,
|
|
useResolveComment,
|
|
useToggleCommentReaction,
|
|
type ToggleCommentReactionVars,
|
|
} from "@multica/core/issues/mutations";
|
|
import { sortTimelineEntriesAsc } from "@multica/core/issues/timeline-sort";
|
|
import { useWSEvent, useWSReconnect } from "@multica/core/realtime";
|
|
import { toast } from "sonner";
|
|
import { useT } from "../../i18n";
|
|
|
|
type TLCache = TimelineEntry[];
|
|
|
|
function commentToTimelineEntry(c: Comment): TimelineEntry {
|
|
return {
|
|
type: "comment",
|
|
id: c.id,
|
|
actor_type: c.author_type,
|
|
actor_id: c.author_id,
|
|
content: c.content,
|
|
parent_id: c.parent_id,
|
|
created_at: c.created_at,
|
|
updated_at: c.updated_at,
|
|
comment_type: c.type,
|
|
reactions: c.reactions ?? [],
|
|
attachments: c.attachments ?? [],
|
|
resolved_at: c.resolved_at,
|
|
resolved_by_type: c.resolved_by_type,
|
|
resolved_by_id: c.resolved_by_id,
|
|
source_task_id: c.source_task_id,
|
|
};
|
|
}
|
|
|
|
export function useIssueTimeline(issueId: string, userId?: string) {
|
|
const { t } = useT("issues");
|
|
const qc = useQueryClient();
|
|
|
|
const query = useQuery(issueTimelineOptions(issueId));
|
|
const { data, isLoading: loading } = query;
|
|
|
|
const timeline = useMemo<TimelineEntry[]>(() => data ?? [], [data]);
|
|
|
|
// Stable mutation handles. TanStack v5 returns a fresh result wrapper from
|
|
// useMutation per render, but the inner mutateAsync / mutate functions are
|
|
// stable. Pull just those so the useCallback identities downstream don't
|
|
// flip on every parent re-render — listing the whole mutation object would
|
|
// defeat React.memo on CommentCard.
|
|
const { mutateAsync: createComment } = useCreateComment(issueId);
|
|
const { mutateAsync: updateComment } = useUpdateComment(issueId);
|
|
const { mutateAsync: deleteCommentAsync } = useDeleteComment(issueId);
|
|
const { mutateAsync: resolveCommentAsync } = useResolveComment(issueId);
|
|
const { mutate: toggleCommentReaction } = useToggleCommentReaction(issueId);
|
|
|
|
// Reconnect recovery: invalidate so the next render refetches the full
|
|
// timeline. Cheaper than diffing across a possibly-long disconnect.
|
|
useWSReconnect(
|
|
useCallback(() => {
|
|
qc.invalidateQueries({ queryKey: issueKeys.timeline(issueId) });
|
|
}, [qc, issueId]),
|
|
);
|
|
|
|
// --- WS event handlers ---
|
|
|
|
useWSEvent(
|
|
"comment:created",
|
|
useCallback(
|
|
(payload: unknown) => {
|
|
const { comment } = payload as CommentCreatedPayload;
|
|
if (comment.issue_id !== issueId) return;
|
|
qc.setQueryData<TLCache>(issueKeys.timeline(issueId), (old) => {
|
|
const entry = commentToTimelineEntry(comment);
|
|
if (!old) return [entry];
|
|
if (old.some((e) => e.id === comment.id)) return old;
|
|
return sortTimelineEntriesAsc([...old, entry]);
|
|
});
|
|
},
|
|
[qc, issueId],
|
|
),
|
|
);
|
|
|
|
useWSEvent(
|
|
"comment:updated",
|
|
useCallback(
|
|
(payload: unknown) => {
|
|
const { comment } = payload as CommentUpdatedPayload;
|
|
if (comment.issue_id !== issueId) return;
|
|
qc.setQueryData<TLCache>(issueKeys.timeline(issueId), (old) =>
|
|
old?.map((e) =>
|
|
e.id === comment.id ? commentToTimelineEntry(comment) : e,
|
|
),
|
|
);
|
|
},
|
|
[qc, issueId],
|
|
),
|
|
);
|
|
|
|
// Granular handlers for comment:resolved / comment:unresolved. The payload
|
|
// carries the full Comment with the new resolved_at/resolved_by_* fields,
|
|
// which `commentToTimelineEntry` already preserves, so the existing
|
|
// entry can simply be replaced in place. Without these handlers the only
|
|
// path that updated the cache was `useRealtimeSync`'s global invalidate,
|
|
// which forces a full timeline refetch and busts every CommentCard memo.
|
|
useWSEvent(
|
|
"comment:resolved",
|
|
useCallback(
|
|
(payload: unknown) => {
|
|
const { comment } = payload as CommentResolvedPayload;
|
|
if (comment.issue_id !== issueId) return;
|
|
qc.setQueryData<TLCache>(issueKeys.timeline(issueId), (old) =>
|
|
old?.map((e) =>
|
|
e.id === comment.id ? commentToTimelineEntry(comment) : e,
|
|
),
|
|
);
|
|
},
|
|
[qc, issueId],
|
|
),
|
|
);
|
|
|
|
useWSEvent(
|
|
"comment:unresolved",
|
|
useCallback(
|
|
(payload: unknown) => {
|
|
const { comment } = payload as CommentUnresolvedPayload;
|
|
if (comment.issue_id !== issueId) return;
|
|
qc.setQueryData<TLCache>(issueKeys.timeline(issueId), (old) =>
|
|
old?.map((e) =>
|
|
e.id === comment.id ? commentToTimelineEntry(comment) : e,
|
|
),
|
|
);
|
|
},
|
|
[qc, issueId],
|
|
),
|
|
);
|
|
|
|
useWSEvent(
|
|
"comment:deleted",
|
|
useCallback(
|
|
(payload: unknown) => {
|
|
const { comment_id, issue_id } = payload as CommentDeletedPayload;
|
|
if (issue_id !== issueId) return;
|
|
qc.setQueryData<TLCache>(issueKeys.timeline(issueId), (old) => {
|
|
if (!old) return old;
|
|
// Cascade through replies (full timeline now lives in this single
|
|
// cache, so a flat sweep is sufficient).
|
|
const idsToRemove = new Set<string>([comment_id]);
|
|
let changed = true;
|
|
while (changed) {
|
|
changed = false;
|
|
for (const e of old) {
|
|
if (
|
|
e.parent_id &&
|
|
idsToRemove.has(e.parent_id) &&
|
|
!idsToRemove.has(e.id)
|
|
) {
|
|
idsToRemove.add(e.id);
|
|
changed = true;
|
|
}
|
|
}
|
|
}
|
|
return old.filter((e) => !idsToRemove.has(e.id));
|
|
});
|
|
},
|
|
[qc, issueId],
|
|
),
|
|
);
|
|
|
|
useWSEvent(
|
|
"activity:created",
|
|
useCallback(
|
|
(payload: unknown) => {
|
|
const p = payload as ActivityCreatedPayload;
|
|
if (p.issue_id !== issueId) return;
|
|
const entry = p.entry;
|
|
if (!entry || !entry.id) return;
|
|
qc.setQueryData<TLCache>(issueKeys.timeline(issueId), (old) => {
|
|
if (!old) return [entry];
|
|
if (old.some((e) => e.id === entry.id)) return old;
|
|
return sortTimelineEntriesAsc([...old, entry]);
|
|
});
|
|
},
|
|
[qc, issueId],
|
|
),
|
|
);
|
|
|
|
useWSEvent(
|
|
"reaction:added",
|
|
useCallback(
|
|
(payload: unknown) => {
|
|
const { reaction, issue_id } = payload as ReactionAddedPayload;
|
|
if (issue_id !== issueId) return;
|
|
qc.setQueryData<TLCache>(issueKeys.timeline(issueId), (old) =>
|
|
old?.map((e) => {
|
|
if (e.id !== reaction.comment_id) return e;
|
|
const existing = e.reactions ?? [];
|
|
if (existing.some((r) => r.id === reaction.id)) return e;
|
|
return { ...e, reactions: [...existing, reaction] };
|
|
}),
|
|
);
|
|
},
|
|
[qc, issueId],
|
|
),
|
|
);
|
|
|
|
useWSEvent(
|
|
"reaction:removed",
|
|
useCallback(
|
|
(payload: unknown) => {
|
|
const p = payload as ReactionRemovedPayload;
|
|
if (p.issue_id !== issueId) return;
|
|
qc.setQueryData<TLCache>(issueKeys.timeline(issueId), (old) =>
|
|
old?.map((e) => {
|
|
if (e.id !== p.comment_id) return e;
|
|
return {
|
|
...e,
|
|
reactions: (e.reactions ?? []).filter(
|
|
(r) =>
|
|
!(
|
|
r.emoji === p.emoji &&
|
|
r.actor_type === p.actor_type &&
|
|
r.actor_id === p.actor_id
|
|
),
|
|
),
|
|
};
|
|
}),
|
|
);
|
|
},
|
|
[qc, issueId],
|
|
),
|
|
);
|
|
|
|
// --- Mutation functions ---
|
|
|
|
// Returns true on success, false on failure. The composer keeps the user's
|
|
// text (editor locked + button spinning) until this settles and clears only
|
|
// on success — so a slow send no longer leaves the box full next to an
|
|
// already-posted comment, and a failed send keeps the draft.
|
|
const submitComment = useCallback(
|
|
async (content: string, attachmentIds?: string[], suppressAgentIds?: string[]): Promise<boolean> => {
|
|
if (!content.trim() || !userId) return false;
|
|
try {
|
|
await createComment({ content, attachmentIds, suppressAgentIds });
|
|
return true;
|
|
} catch (err) {
|
|
toast.error(
|
|
err instanceof Error && err.message
|
|
? err.message
|
|
: t(($) => $.comment.send_failed),
|
|
);
|
|
return false;
|
|
}
|
|
},
|
|
[userId, createComment, t],
|
|
);
|
|
|
|
const submitReply = useCallback(
|
|
async (parentId: string, content: string, attachmentIds?: string[], suppressAgentIds?: string[]): Promise<boolean> => {
|
|
if (!content.trim() || !userId) return false;
|
|
try {
|
|
await createComment({
|
|
content,
|
|
type: "comment",
|
|
parentId,
|
|
attachmentIds,
|
|
suppressAgentIds,
|
|
});
|
|
return true;
|
|
} catch (err) {
|
|
toast.error(
|
|
err instanceof Error && err.message
|
|
? err.message
|
|
: t(($) => $.comment.send_reply_failed),
|
|
);
|
|
return false;
|
|
}
|
|
},
|
|
[userId, createComment, t],
|
|
);
|
|
|
|
const editComment = useCallback(
|
|
async (commentId: string, content: string, attachmentIds: string[], suppressAgentIds?: string[]) => {
|
|
try {
|
|
await updateComment({ commentId, content, attachmentIds, suppressAgentIds });
|
|
} catch (err) {
|
|
toast.error(
|
|
err instanceof Error && err.message
|
|
? err.message
|
|
: t(($) => $.comment.update_failed),
|
|
);
|
|
}
|
|
},
|
|
[updateComment, t],
|
|
);
|
|
|
|
const deleteComment = useCallback(
|
|
async (commentId: string) => {
|
|
try {
|
|
await deleteCommentAsync(commentId);
|
|
} catch (err) {
|
|
toast.error(
|
|
err instanceof Error && err.message
|
|
? err.message
|
|
: t(($) => $.comment.delete_failed),
|
|
);
|
|
}
|
|
},
|
|
[deleteCommentAsync, t],
|
|
);
|
|
|
|
const toggleResolveComment = useCallback(
|
|
async (commentId: string, resolved: boolean) => {
|
|
try {
|
|
await resolveCommentAsync({ commentId, resolved });
|
|
} catch (err) {
|
|
toast.error(
|
|
err instanceof Error && err.message
|
|
? err.message
|
|
: resolved
|
|
? t(($) => $.comment.resolve.resolve_failed)
|
|
: t(($) => $.comment.resolve.unresolve_failed),
|
|
);
|
|
}
|
|
},
|
|
[resolveCommentAsync, t],
|
|
);
|
|
|
|
// --- Optimistic UI for comment reactions ---
|
|
// Derive at render time from pending mutation variables instead of writing
|
|
// temp data into the cache (which would race with WS events).
|
|
|
|
const pendingReactionVars = useMutationState({
|
|
filters: {
|
|
mutationKey: ["toggleCommentReaction", issueId],
|
|
status: "pending",
|
|
},
|
|
select: (m) =>
|
|
m.state.variables as ToggleCommentReactionVars | undefined,
|
|
});
|
|
|
|
const optimisticTimeline = useMemo(() => {
|
|
if (pendingReactionVars.length === 0) return timeline;
|
|
|
|
return timeline.map((entry) => {
|
|
const pendingForEntry = pendingReactionVars.filter(
|
|
(v) => v && v.commentId === entry.id,
|
|
);
|
|
if (pendingForEntry.length === 0) return entry;
|
|
|
|
let reactions = entry.reactions ?? [];
|
|
for (const vars of pendingForEntry) {
|
|
if (!vars) continue;
|
|
if (vars.existing) {
|
|
reactions = reactions.filter((r) => r.id !== vars.existing!.id);
|
|
} else {
|
|
const alreadyExists = reactions.some(
|
|
(r) =>
|
|
r.emoji === vars.emoji &&
|
|
r.actor_type === "member" &&
|
|
r.actor_id === userId,
|
|
);
|
|
if (!alreadyExists) {
|
|
reactions = [
|
|
...reactions,
|
|
{
|
|
id: `optimistic-${vars.emoji}`,
|
|
comment_id: vars.commentId,
|
|
actor_type: "member",
|
|
actor_id: userId ?? "",
|
|
emoji: vars.emoji,
|
|
created_at: "",
|
|
},
|
|
];
|
|
}
|
|
}
|
|
}
|
|
return { ...entry, reactions };
|
|
});
|
|
}, [timeline, pendingReactionVars, userId]);
|
|
|
|
// toggleReaction reads from a ref so its identity does not change with
|
|
// every WS event. Without this every memoized CommentCard down-tree would
|
|
// re-render on each timeline mutation, defeating the React.memo cost
|
|
// savings on long timelines (#1968).
|
|
const timelineRef = useRef(timeline);
|
|
useEffect(() => {
|
|
timelineRef.current = timeline;
|
|
}, [timeline]);
|
|
|
|
const toggleReaction = useCallback(
|
|
async (commentId: string, emoji: string) => {
|
|
if (!userId) return;
|
|
const entry = timelineRef.current.find((e) => e.id === commentId);
|
|
const existing: Reaction | undefined = (entry?.reactions ?? []).find(
|
|
(r) =>
|
|
r.emoji === emoji &&
|
|
r.actor_type === "member" &&
|
|
r.actor_id === userId,
|
|
);
|
|
toggleCommentReaction({ commentId, emoji, existing });
|
|
},
|
|
[userId, toggleCommentReaction],
|
|
);
|
|
|
|
return {
|
|
timeline: optimisticTimeline,
|
|
loading,
|
|
submitComment,
|
|
submitReply,
|
|
editComment,
|
|
deleteComment,
|
|
toggleResolveComment,
|
|
toggleReaction,
|
|
};
|
|
}
|