Compare commits

...

1 Commits

Author SHA1 Message Date
Naiyuan Qing
98a82ba657 fix(issues): keep comment trigger preview fresh against live queue state
The preview answer depends on live queue state (pending-task dedup), not
just the mention set, so three staleness bugs showed up around it:

- staleTime: Infinity pinned a "nobody triggers" snapshot taken while
  the mentioned agent was still queued — the chip never appeared even
  though sending really did wake the agent (create recomputes).
  -> staleTime: 0, cached signatures revalidate in the background.
- The in-flight gap on a signature change rendered as an empty agent
  list, flickering the chips and wiping the composer's suppressed-id
  set via the pruning effect. -> placeholderData: keepPreviousData.
- Nothing refreshed an open composer when an agent's task finished.
  -> the WS task-lifecycle handler now also invalidates the
  commentTriggerPreviewAll prefix, so chips appear mid-typing the
  moment the agent becomes triggerable again.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 17:37:57 +08:00
4 changed files with 33 additions and 7 deletions

View File

@@ -73,9 +73,13 @@ export const issueKeys = {
/** Full-issue timeline (single TanStack Query, no cursor). */
timeline: (issueId: string) =>
[...issueKeys.timelineAll(), issueId] as const,
/** Prefix across all issues — WS task lifecycle events invalidate here so
* an open composer's trigger preview refreshes when an agent's queue
* state changes (the dedup guard makes the answer queue-dependent). */
commentTriggerPreviewAll: () => ["issues", "comment-trigger-preview"] as const,
/** PREFIX for invalidation — the composer hook appends parent + content signature. */
commentTriggerPreview: (issueId: string) =>
["issues", "comment-trigger-preview", issueId] as const,
[...issueKeys.commentTriggerPreviewAll(), issueId] as const,
reactionsAll: () => ["issues", "reactions"] as const,
reactions: (issueId: string) =>
[...issueKeys.reactionsAll(), issueId] as const,

View File

@@ -504,6 +504,12 @@ export function useRealtimeSync(
// Squad members-status reads the same task lifecycle to flip
// working ↔ idle for each agent member.
invalidateSquadMemberStatusQueries(qc, wsId);
// Comment trigger previews answer "who would a send wake right
// now" — the pending-task dedup guard makes that answer
// queue-dependent, so any task lifecycle change must refresh an
// open composer's chips (e.g. an agent finishing its run becomes
// triggerable again mid-typing).
qc.invalidateQueries({ queryKey: issueKeys.commentTriggerPreviewAll() });
},
};

View File

@@ -69,10 +69,14 @@ describe("useCommentTriggerPreview", () => {
);
});
it("uses the TanStack Query cache when the debounced signature repeats", async () => {
it("revalidates when the debounced signature repeats — the answer is queue-state dependent", async () => {
const agentA = "00000000-0000-0000-0000-000000000001";
const content = `[@A](mention://agent/${agentA})`;
const { rerender } = renderHook(
const agents = [
{ id: agentA, name: "A", source: "mention_agent", reason: "" },
];
previewCommentTriggers.mockResolvedValue({ agents });
const { result, rerender } = renderHook(
({ content }) => useCommentTriggerPreview({ issueId: "issue-1", content }),
{
wrapper: createWrapper(),
@@ -85,9 +89,13 @@ describe("useCommentTriggerPreview", () => {
rerender({ content: "" });
rerender({ content });
// Cached agents render immediately for the repeated signature (no
// flicker)…
await advancePreviewDebounce();
expect(previewCommentTriggers).toHaveBeenCalledTimes(1);
expect(result.current.agents).toEqual(agents);
// …but a background revalidation still fires: an agent finishing its
// queued task changes the answer for the very same mention set.
expect(previewCommentTriggers).toHaveBeenCalledTimes(2);
});
it("fetches again when routing mention tokens change", async () => {

View File

@@ -1,7 +1,7 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { api } from "@multica/core/api";
import { issueKeys } from "@multica/core/issues/queries";
import type { CommentTriggerPreviewAgent } from "@multica/core/types";
@@ -77,7 +77,15 @@ export function useCommentTriggerPreview({
queryFn: () => api.previewCommentTriggers(issueId, contentRef.current, parentId),
enabled: signature !== "empty" && debouncedSignature !== "empty",
retry: false,
staleTime: Infinity,
// The answer depends on live queue state (pending-task dedup), not just
// the mention set, so a cached result must revalidate when its signature
// reappears — Infinity here once pinned a stale "nobody triggers"
// snapshot taken while the agent was still queued.
staleTime: 0,
// Keep the previous agent list while a new signature is fetching:
// without it the in-flight gap renders as "no agents", flickering the
// chips and wiping the composer's suppressed-id set.
placeholderData: keepPreviousData,
});
// Loading and errors intentionally surface as "no agents": the preview is