mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-25 20:15:37 +02:00
* MUL-3328: add retry button for failed agent comments Co-authored-by: multica-agent <github@multica.ai> * MUL-3328: cover child-done system comments Co-authored-by: multica-agent <github@multica.ai> * MUL-3328: restrict retry affordance to failures Co-authored-by: multica-agent <github@multica.ai> * MUL-3328: clean migration whitespace Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Lambda <lambda@multica.ai> Co-authored-by: multica-agent <github@multica.ai>
372 lines
11 KiB
TypeScript
372 lines
11 KiB
TypeScript
/**
|
|
* Mobile-owned WS cache patchers for the issue domain. These are pure
|
|
* functions over `QueryClient` — no React, no WS plumbing. The hooks in
|
|
* `use-issue-realtime.ts` and `use-my-issues-realtime.ts` translate WS
|
|
* events into calls into this module.
|
|
*
|
|
* Why mobile-owned (and not importing from packages/core/issues/ws-updaters):
|
|
* - Web's updaters reference `issueKeys` from `packages/core/issues/queries`,
|
|
* a different runtime instance than mobile's `data/queries/issue-keys.ts`.
|
|
* TanStack Query keys are compared structurally so this would *appear*
|
|
* to work, but binding cache mutation to a foreign key factory invites
|
|
* drift the moment either side adjusts its key shape.
|
|
* - Mobile cache shapes are simpler: no status-bucketed list, no children
|
|
* subtree, no childProgress, no label-byIssue cache. A direct mirror
|
|
* would be ~120 lines of conditional dead-code paths.
|
|
*
|
|
* Cache shapes (the design contract here):
|
|
* - Issue detail: Issue (keyed by detail(wsId, id))
|
|
* - Issue timeline: TimelineEntry[] (keyed by timeline(wsId, id))
|
|
* ASC oldest-first; new entries inserted at sorted position.
|
|
* - My Issues list: Issue[] (keyed by myList(wsId, scope, filter))
|
|
* Multiple list caches per wsId (one per scope/filter combo).
|
|
* Patch ALL of them via setQueriesData on myAll(wsId).
|
|
* - Workspace list: Issue[] (keyed by list(wsId))
|
|
* Single cache per wsId (no scope/filter in the key —
|
|
* filtering happens client-side off the same list).
|
|
*/
|
|
import type { QueryClient } from "@tanstack/react-query";
|
|
import type {
|
|
Comment,
|
|
Issue,
|
|
IssueReaction,
|
|
Label,
|
|
Reaction,
|
|
TimelineEntry,
|
|
} from "@multica/core/types";
|
|
import { issueKeys } from "@/data/queries/issue-keys";
|
|
|
|
type TimelinePredicate = (entry: TimelineEntry) => boolean;
|
|
type TimelineMutate = (entry: TimelineEntry) => TimelineEntry;
|
|
|
|
// =====================================================
|
|
// Issue detail cache (single Issue per id)
|
|
// =====================================================
|
|
|
|
export function patchIssueDetail(
|
|
qc: QueryClient,
|
|
wsId: string,
|
|
partial: Partial<Issue> & { id: string },
|
|
) {
|
|
qc.setQueryData<Issue>(issueKeys.detail(wsId, partial.id), (old) =>
|
|
old ? { ...old, ...partial } : old,
|
|
);
|
|
}
|
|
|
|
export function clearIssueDetail(
|
|
qc: QueryClient,
|
|
wsId: string,
|
|
issueId: string,
|
|
) {
|
|
qc.removeQueries({ queryKey: issueKeys.detail(wsId, issueId) });
|
|
qc.removeQueries({ queryKey: issueKeys.timeline(wsId, issueId) });
|
|
}
|
|
|
|
// =====================================================
|
|
// Issue timeline (flat TimelineEntry[], ASC oldest-first)
|
|
// =====================================================
|
|
|
|
export function appendTimelineEntry(
|
|
qc: QueryClient,
|
|
wsId: string,
|
|
issueId: string,
|
|
entry: TimelineEntry,
|
|
) {
|
|
qc.setQueryData<TimelineEntry[]>(
|
|
issueKeys.timeline(wsId, issueId),
|
|
(old) => {
|
|
if (!old) return old;
|
|
// Skip if the entry is already present — backend can re-emit on
|
|
// reconnect or two clients can echo the same comment.
|
|
if (old.some((e) => e.id === entry.id && e.type === entry.type)) {
|
|
return old;
|
|
}
|
|
const next = [...old, entry];
|
|
next.sort((a, b) => {
|
|
if (a.created_at !== b.created_at) return a.created_at < b.created_at ? -1 : 1;
|
|
return a.id < b.id ? -1 : 1;
|
|
});
|
|
return next;
|
|
},
|
|
);
|
|
}
|
|
|
|
export function patchTimelineEntry(
|
|
qc: QueryClient,
|
|
wsId: string,
|
|
issueId: string,
|
|
predicate: TimelinePredicate,
|
|
mutate: TimelineMutate,
|
|
) {
|
|
qc.setQueryData<TimelineEntry[]>(
|
|
issueKeys.timeline(wsId, issueId),
|
|
(old) => (old ? old.map((e) => (predicate(e) ? mutate(e) : e)) : old),
|
|
);
|
|
}
|
|
|
|
export function removeTimelineEntry(
|
|
qc: QueryClient,
|
|
wsId: string,
|
|
issueId: string,
|
|
predicate: TimelinePredicate,
|
|
) {
|
|
qc.setQueryData<TimelineEntry[]>(
|
|
issueKeys.timeline(wsId, issueId),
|
|
(old) => (old ? old.filter((e) => !predicate(e)) : old),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Cascade-delete a comment and every descendant reply (reply-to-reply
|
|
* chains included). Mirrors the server's cascade in `comment.go:DeleteComment`
|
|
* and web's `comment:deleted` handler at
|
|
* `packages/views/issues/hooks/use-issue-timeline.ts:164-194`.
|
|
*
|
|
* Without this, removing only the root entry leaves the replies as
|
|
* "orphans" — `buildTimelineRows` then promotes them to top-level rows
|
|
* (its orphan-rescue branch), so the user would see ghost replies after a
|
|
* thread delete on another client. Same-N rule violation.
|
|
*
|
|
* BFS rather than recursive Set-union: cheaper for arbitrary depth chains
|
|
* and avoids recursion-depth concerns on large threads.
|
|
*/
|
|
export function removeCommentCascade(
|
|
qc: QueryClient,
|
|
wsId: string,
|
|
issueId: string,
|
|
commentId: string,
|
|
) {
|
|
qc.setQueryData<TimelineEntry[]>(
|
|
issueKeys.timeline(wsId, issueId),
|
|
(old) => {
|
|
if (!old) return old;
|
|
const removed = new Set<string>([commentId]);
|
|
// Iterate to fixed point — a single forward pass catches direct
|
|
// children; later passes catch reply-to-reply chains. Bounded by
|
|
// the timeline length, so worst case O(N²) on a degenerate chain
|
|
// but N is p99 30 and chains are typically depth 1-2.
|
|
let changed = true;
|
|
while (changed) {
|
|
changed = false;
|
|
for (const e of old) {
|
|
if (
|
|
e.type === "comment" &&
|
|
e.parent_id &&
|
|
removed.has(e.parent_id) &&
|
|
!removed.has(e.id)
|
|
) {
|
|
removed.add(e.id);
|
|
changed = true;
|
|
}
|
|
}
|
|
}
|
|
return old.filter(
|
|
(e) => !(e.type === "comment" && removed.has(e.id)),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
// =====================================================
|
|
// My Issues list (flat Issue[] across many keys)
|
|
// =====================================================
|
|
|
|
export function patchMyIssuesList(
|
|
qc: QueryClient,
|
|
wsId: string,
|
|
partial: Partial<Issue> & { id: string },
|
|
) {
|
|
// myList is keyed by (wsId, scope, filter); we don't know which entries
|
|
// the issue belongs to, so update every cached one. Any not-yet-loaded
|
|
// list will fetch fresh on mount.
|
|
qc.setQueriesData<Issue[]>({ queryKey: issueKeys.myAll(wsId) }, (old) =>
|
|
old ? old.map((i) => (i.id === partial.id ? { ...i, ...partial } : i)) : old,
|
|
);
|
|
}
|
|
|
|
export function removeFromMyIssuesList(
|
|
qc: QueryClient,
|
|
wsId: string,
|
|
issueId: string,
|
|
) {
|
|
qc.setQueriesData<Issue[]>({ queryKey: issueKeys.myAll(wsId) }, (old) =>
|
|
old ? old.filter((i) => i.id !== issueId) : old,
|
|
);
|
|
}
|
|
|
|
// =====================================================
|
|
// Workspace Issues list (flat Issue[] under list(wsId))
|
|
// =====================================================
|
|
|
|
export function patchIssuesList(
|
|
qc: QueryClient,
|
|
wsId: string,
|
|
partial: Partial<Issue> & { id: string },
|
|
) {
|
|
qc.setQueryData<Issue[]>(issueKeys.list(wsId), (old) =>
|
|
old ? old.map((i) => (i.id === partial.id ? { ...i, ...partial } : i)) : old,
|
|
);
|
|
}
|
|
|
|
export function prependToIssuesList(
|
|
qc: QueryClient,
|
|
wsId: string,
|
|
issue: Issue,
|
|
) {
|
|
qc.setQueryData<Issue[]>(issueKeys.list(wsId), (old) => {
|
|
if (!old) return old;
|
|
if (old.some((i) => i.id === issue.id)) return old;
|
|
return [issue, ...old];
|
|
});
|
|
}
|
|
|
|
export function removeFromIssuesList(
|
|
qc: QueryClient,
|
|
wsId: string,
|
|
issueId: string,
|
|
) {
|
|
qc.setQueryData<Issue[]>(issueKeys.list(wsId), (old) =>
|
|
old ? old.filter((i) => i.id !== issueId) : old,
|
|
);
|
|
}
|
|
|
|
// =====================================================
|
|
// Reactions
|
|
// =====================================================
|
|
|
|
export function addCommentReaction(
|
|
qc: QueryClient,
|
|
wsId: string,
|
|
issueId: string,
|
|
commentId: string,
|
|
reaction: Reaction,
|
|
) {
|
|
patchTimelineEntry(
|
|
qc,
|
|
wsId,
|
|
issueId,
|
|
(e) => e.type === "comment" && e.id === commentId,
|
|
(e) => ({
|
|
...e,
|
|
reactions: [
|
|
...(e.reactions ?? []).filter((r) => r.id !== reaction.id),
|
|
reaction,
|
|
],
|
|
}),
|
|
);
|
|
}
|
|
|
|
export function removeCommentReaction(
|
|
qc: QueryClient,
|
|
wsId: string,
|
|
issueId: string,
|
|
commentId: string,
|
|
emoji: string,
|
|
actorId: string,
|
|
) {
|
|
patchTimelineEntry(
|
|
qc,
|
|
wsId,
|
|
issueId,
|
|
(e) => e.type === "comment" && e.id === commentId,
|
|
(e) => ({
|
|
...e,
|
|
reactions: (e.reactions ?? []).filter(
|
|
(r) => !(r.emoji === emoji && r.actor_id === actorId),
|
|
),
|
|
}),
|
|
);
|
|
}
|
|
|
|
export function addIssueReaction(
|
|
qc: QueryClient,
|
|
wsId: string,
|
|
issueId: string,
|
|
reaction: IssueReaction,
|
|
) {
|
|
qc.setQueryData<Issue>(issueKeys.detail(wsId, issueId), (old) => {
|
|
if (!old) return old;
|
|
const existing = old.reactions ?? [];
|
|
if (existing.some((r) => r.id === reaction.id)) return old;
|
|
return { ...old, reactions: [...existing, reaction] };
|
|
});
|
|
}
|
|
|
|
export function removeIssueReaction(
|
|
qc: QueryClient,
|
|
wsId: string,
|
|
issueId: string,
|
|
emoji: string,
|
|
actorId: string,
|
|
) {
|
|
qc.setQueryData<Issue>(issueKeys.detail(wsId, issueId), (old) =>
|
|
old
|
|
? {
|
|
...old,
|
|
reactions: (old.reactions ?? []).filter(
|
|
(r) => !(r.emoji === emoji && r.actor_id === actorId),
|
|
),
|
|
}
|
|
: old,
|
|
);
|
|
}
|
|
|
|
// =====================================================
|
|
// Labels
|
|
// =====================================================
|
|
|
|
export function patchIssueLabels(
|
|
qc: QueryClient,
|
|
wsId: string,
|
|
issueId: string,
|
|
labels: Label[],
|
|
) {
|
|
qc.setQueryData<Issue>(issueKeys.detail(wsId, issueId), (old) =>
|
|
old ? { ...old, labels } : old,
|
|
);
|
|
qc.setQueriesData<Issue[]>({ queryKey: issueKeys.myAll(wsId) }, (old) =>
|
|
old
|
|
? old.map((i) => (i.id === issueId ? { ...i, labels } : i))
|
|
: old,
|
|
);
|
|
qc.setQueryData<Issue[]>(issueKeys.list(wsId), (old) =>
|
|
old
|
|
? old.map((i) => (i.id === issueId ? { ...i, labels } : i))
|
|
: old,
|
|
);
|
|
}
|
|
|
|
// =====================================================
|
|
// Helpers — payload normalization
|
|
// =====================================================
|
|
|
|
/**
|
|
* Convert a Comment WS payload into a TimelineEntry. The two types share
|
|
* most fields but use different actor-key names (Comment uses
|
|
* `author_type/author_id`; TimelineEntry uses `actor_type/actor_id`).
|
|
*/
|
|
export function commentToTimelineEntry(comment: Comment): TimelineEntry {
|
|
return {
|
|
type: "comment",
|
|
id: comment.id,
|
|
actor_type: comment.author_type,
|
|
actor_id: comment.author_id,
|
|
created_at: comment.created_at,
|
|
content: comment.content,
|
|
parent_id: comment.parent_id,
|
|
updated_at: comment.updated_at,
|
|
comment_type: comment.type,
|
|
reactions: comment.reactions,
|
|
attachments: comment.attachments,
|
|
// Carry resolve state through. Web's commentToTimelineEntry includes
|
|
// these (`packages/views/issues/hooks/use-issue-timeline.ts:42-58`); the
|
|
// earlier mobile copy dropped them, so a `comment:updated` event on a
|
|
// resolved comment would silently strip the resolved flag from the
|
|
// cache and the UI would un-dim.
|
|
resolved_at: comment.resolved_at,
|
|
resolved_by_type: comment.resolved_by_type,
|
|
resolved_by_id: comment.resolved_by_id,
|
|
source_task_id: comment.source_task_id,
|
|
};
|
|
}
|