feat(issues): frontend plumbing for issue-trigger preview + handoff (core)

Add api.previewIssueTrigger + IssueTriggerPreviewSchema (zod parseWithFallback),
the use-issue-trigger-preview hook, issueKeys.issueTriggerPreview(+All) with WS
queue-state invalidation, suppress_run/handoff_note on UpdateIssueRequest, the
'handoff' CommentType, and stripping of the control fields from optimistic
update/batch cache patches (MUL-3375 §9).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
Walt
2026-06-22 12:23:59 +08:00
parent b8a0491c85
commit ad2036500b
8 changed files with 179 additions and 6 deletions

View File

@@ -31,6 +31,8 @@ import type {
IssueSubscriber,
Comment,
CommentTriggerPreview,
IssueTriggerPreview,
IssueTriggerPreviewParams,
Reaction,
IssueReaction,
Workspace,
@@ -140,6 +142,7 @@ import {
ChildIssuesResponseSchema,
CommentsListSchema,
CommentTriggerPreviewSchema,
IssueTriggerPreviewSchema,
CloudRuntimeNodeListSchema,
CloudRuntimeNodeSchema,
CreateAgentFromTemplateResponseSchema,
@@ -686,6 +689,26 @@ export class ApiClient {
});
}
/** Dry-run the unified run-enqueue predicate for a prospective issue write
* (create / single assign / single status / batch). Returns the runs that
* would start; no side effect. The four entry points consult this instead
* of re-implementing the rule (MUL-3375). */
async previewIssueTrigger(params: IssueTriggerPreviewParams): Promise<IssueTriggerPreview> {
const raw = await this.fetch<unknown>("/api/issues/preview-trigger", {
method: "POST",
body: JSON.stringify({
...(params.issueIds?.length ? { issue_ids: params.issueIds } : {}),
...(params.isCreate ? { is_create: true } : {}),
...(params.assigneeType ? { assignee_type: params.assigneeType } : {}),
...(params.assigneeId ? { assignee_id: params.assigneeId } : {}),
...(params.status ? { status: params.status } : {}),
}),
});
return parseWithFallback(raw, IssueTriggerPreviewSchema, { triggers: [], total_count: 0 }, {
endpoint: "POST /api/issues/preview-trigger",
});
}
async listTimeline(issueId: string): Promise<TimelineEntry[]> {
const raw = await this.fetch<unknown>(
`/api/issues/${issueId}/timeline`,

View File

@@ -220,6 +220,18 @@ export const CommentTriggerPreviewSchema = z.object({
agents: z.array(CommentTriggerPreviewAgentSchema).default([]),
}).loose();
const IssueTriggerPreviewItemSchema = z.object({
issue_id: z.string(),
agent_id: z.string().default(""),
source: z.string().default(""),
handoff_supported: z.boolean().default(false),
}).loose();
export const IssueTriggerPreviewSchema = z.object({
triggers: z.array(IssueTriggerPreviewItemSchema).default([]),
total_count: z.number().default(0),
}).loose();
// Metadata is primitive-only by API/DB contract. Stay lenient on shape:
// unknown keys land as `unknown` to a caller, but the field itself defaults
// to {} so consumers never need to nil-guard `issue.metadata`.

View File

@@ -215,6 +215,11 @@ export function useUpdateIssue() {
mutationFn: ({ id, ...data }: { id: string } & UpdateIssueRequest) =>
api.updateIssue(id, data),
onMutate: ({ id, ...data }) => {
// suppress_run / handoff_note are write-time control fields, not Issue
// columns — they steer enqueue/injection on the server and must never be
// written into the query cache (MUL-3375). Strip them from the patch; the
// mutationFn above still sends the full payload to the API.
const { suppress_run: _suppressRun, handoff_note: _handoffNote, ...patch } = data;
// Fire-and-forget cancelQueries — keeps onMutate synchronous so the
// cache update happens in the same tick as mutate(). Awaiting would
// yield to the event loop, letting @dnd-kit reset its visual state
@@ -251,16 +256,16 @@ export function useUpdateIssue() {
: undefined;
for (const [key, cached] of prevLists) {
if (cached) qc.setQueryData<ListIssuesCache>(key, patchIssueInBuckets(cached, id, data));
if (cached) qc.setQueryData<ListIssuesCache>(key, patchIssueInBuckets(cached, id, patch));
}
qc.setQueryData<Issue>(issueKeys.detail(wsId, id), (old) =>
old ? { ...old, ...data } : old,
old ? { ...old, ...patch } : old,
);
if (parentId) {
qc.setQueryData<Issue[]>(
issueKeys.children(wsId, parentId),
(old) =>
old?.map((c) => (c.id === id ? { ...c, ...data } : c)),
old?.map((c) => (c.id === id ? { ...c, ...patch } : c)),
);
}
return { prevLists, prevDetail, prevChildren, parentId, id };
@@ -408,12 +413,15 @@ export function useBatchUpdateIssues() {
updates: UpdateIssueRequest;
}) => api.batchUpdateIssues(ids, updates),
onMutate: async ({ ids, updates }) => {
// Control fields steer the server; they are not Issue columns and must
// not enter the cache (MUL-3375). mutationFn still sends them.
const { suppress_run: _suppressRun, handoff_note: _handoffNote, ...patch } = updates;
await qc.cancelQueries({ queryKey: issueKeys.list(wsId) });
const prevLists = qc.getQueriesData<ListIssuesCache>({ queryKey: issueKeys.list(wsId) });
for (const [key, cached] of prevLists) {
if (!cached) continue;
let next = cached;
for (const id of ids) next = patchIssueInBuckets(next, id, updates);
for (const id of ids) next = patchIssueInBuckets(next, id, patch);
qc.setQueryData<ListIssuesCache>(key, next);
}
@@ -432,7 +440,7 @@ export function useBatchUpdateIssues() {
affectedParentIds.add(parentId);
prevChildren.set(parentId, data);
qc.setQueryData<Issue[]>(issueKeys.children(wsId, parentId), (old) =>
old?.map((c) => (idSet.has(c.id) ? { ...c, ...updates } : c)),
old?.map((c) => (idSet.has(c.id) ? { ...c, ...patch } : c)),
);
}

View File

@@ -83,6 +83,14 @@ export const issueKeys = {
/** PREFIX for invalidation — the composer hook appends parent + content signature. */
commentTriggerPreview: (issueId: string) =>
[...issueKeys.commentTriggerPreviewAll(), issueId] as const,
/** Prefix across all issue-trigger previews (assign/status/create/batch).
* WS task lifecycle events invalidate here so the answer revalidates when an
* agent's queue state changes (the status source's pending dedup makes it
* queue-dependent, mirroring commentTriggerPreviewAll). */
issueTriggerPreviewAll: () => ["issues", "issue-trigger-preview"] as const,
/** PREFIX — the picker hook appends a signature of the prospective write. */
issueTriggerPreview: (signature: string) =>
[...issueKeys.issueTriggerPreviewAll(), signature] as const,
reactionsAll: () => ["issues", "reactions"] as const,
reactions: (issueId: string) =>
[...issueKeys.reactionsAll(), issueId] as const,

View File

@@ -517,6 +517,11 @@ export function useRealtimeSync(
// open composer's chips (e.g. an agent finishing its run becomes
// triggerable again mid-typing).
qc.invalidateQueries({ queryKey: issueKeys.commentTriggerPreviewAll() });
// Issue-trigger previews (assign/status/create/batch) are queue-
// dependent the same way — the status source's pending dedup means a
// task finishing changes "will this start a run", so refresh any open
// picker/modal preview (MUL-3375).
qc.invalidateQueries({ queryKey: issueKeys.issueTriggerPreviewAll() });
},
};

View File

@@ -33,6 +33,40 @@ export interface UpdateIssueRequest {
* Used by the description editor to register newly uploaded files so they
* surface in `issueAttachments` and keep their preview Eye on refresh. */
attachment_ids?: string[];
/** Skip starting the agent run this write would trigger ("暂时不启动",
* MUL-3375). The assignee/status change still applies. Control field —
* strip from optimistic cache patches; never written onto the Issue. */
suppress_run?: boolean;
/** Free-text handoff instruction injected into the started run's opening
* context (MUL-3375). Only consumed when a run actually starts. Control
* field — strip from optimistic cache patches. */
handoff_note?: string;
}
/** Inputs to `POST /api/issues/preview-trigger`. A nil prospective field means
* "leave unchanged"; `isCreate` previews a not-yet-persisted issue. */
export interface IssueTriggerPreviewParams {
issueIds?: string[];
isCreate?: boolean;
assigneeType?: IssueAssigneeType | null;
assigneeId?: string | null;
status?: IssueStatus;
}
/** One issue that WILL start a run under the prospective write. `agent_id` is
* the runnable agent (squad leader for squads). `handoff_supported` is the
* soft-gate signal: false when the target runtime is too old to render a
* handoff note (gray the note box; the assignment still works). */
export interface IssueTriggerPreviewItem {
issue_id: string;
agent_id: string;
source: string;
handoff_supported: boolean;
}
export interface IssueTriggerPreview {
triggers: IssueTriggerPreviewItem[];
total_count: number;
}
export interface ListIssuesParams {

View File

@@ -1,4 +1,8 @@
export type CommentType = "comment" | "status_change" | "progress_update" | "system";
// `handoff` is a display-only timeline record left when an issue is handed off
// to an agent/squad with a handoff note (MUL-3375). It never triggers a run and
// is excluded from conversation/comment counting; the UI renders it as a
// handoff card, not a normal comment.
export type CommentType = "comment" | "status_change" | "progress_update" | "system" | "handoff";
// `system` is used by platform-generated rows (e.g. the parent-issue
// child-done notification, MUL-2538). System rows carry a zero UUID for

View File

@@ -0,0 +1,79 @@
"use client";
import { useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import { api } from "@multica/core/api";
import { issueKeys } from "@multica/core/issues/queries";
import type { IssueAssigneeType, IssueStatus, IssueTriggerPreviewItem } from "@multica/core/types";
export interface UseIssueTriggerPreviewParams {
/** Existing issues to evaluate (single assign/status or batch). */
issueIds?: string[];
/** Preview a not-yet-persisted issue from assignee/status (create modal). */
isCreate?: boolean;
assigneeType?: IssueAssigneeType | null;
assigneeId?: string | null;
status?: IssueStatus;
/** Caller gate — e.g. only fetch while a picker/modal is open. */
enabled?: boolean;
}
export interface UseIssueTriggerPreviewResult {
triggers: IssueTriggerPreviewItem[];
totalCount: number;
isLoading: boolean;
/** True when every trigger's target runtime can render a handoff note, so
* the note box is safe to enable. False if any started run would drop it. */
handoffSupported: boolean;
}
const EMPTY: IssueTriggerPreviewItem[] = [];
function previewSignature(params: UseIssueTriggerPreviewParams): string {
return JSON.stringify({
ids: [...(params.issueIds ?? [])].sort(),
create: params.isCreate ?? false,
at: params.assigneeType ?? null,
aid: params.assigneeId ?? null,
status: params.status ?? null,
});
}
/** Reads the unified backend predicate via POST /api/issues/preview-trigger so
* the four entry points never re-implement "will this start a run" (MUL-3375).
* The answer is queue-dependent (status-source pending dedup), so it is never
* cached stale: staleTime 0, and WS task events invalidate issueTriggerPreviewAll. */
export function useIssueTriggerPreview(
params: UseIssueTriggerPreviewParams,
): UseIssueTriggerPreviewResult {
const hasTarget =
(!!params.assigneeType && !!params.assigneeId) ||
!!params.status ||
(params.isCreate ?? false);
const enabled = (params.enabled ?? true) && hasTarget;
const signature = useMemo(() => previewSignature(params), [params]);
const previewQuery = useQuery({
queryKey: issueKeys.issueTriggerPreview(signature),
queryFn: () =>
api.previewIssueTrigger({
issueIds: params.issueIds,
isCreate: params.isCreate,
assigneeType: params.assigneeType,
assigneeId: params.assigneeId,
status: params.status,
}),
enabled,
retry: false,
staleTime: 0,
});
const triggers = previewQuery.data?.triggers ?? EMPTY;
return {
triggers,
totalCount: previewQuery.data?.total_count ?? 0,
isLoading: enabled && previewQuery.isFetching,
handoffSupported: triggers.length > 0 && triggers.every((t) => t.handoff_supported === true),
};
}