diff --git a/packages/core/api/client.ts b/packages/core/api/client.ts index dd326db9fe..b44e1f69ec 100644 --- a/packages/core/api/client.ts +++ b/packages/core/api/client.ts @@ -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 { + const raw = await this.fetch("/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 { const raw = await this.fetch( `/api/issues/${issueId}/timeline`, diff --git a/packages/core/api/schemas.ts b/packages/core/api/schemas.ts index 52bf28f96b..f0e7be3f31 100644 --- a/packages/core/api/schemas.ts +++ b/packages/core/api/schemas.ts @@ -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`. diff --git a/packages/core/issues/mutations.ts b/packages/core/issues/mutations.ts index e072ce94a2..f993b52efb 100644 --- a/packages/core/issues/mutations.ts +++ b/packages/core/issues/mutations.ts @@ -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(key, patchIssueInBuckets(cached, id, data)); + if (cached) qc.setQueryData(key, patchIssueInBuckets(cached, id, patch)); } qc.setQueryData(issueKeys.detail(wsId, id), (old) => - old ? { ...old, ...data } : old, + old ? { ...old, ...patch } : old, ); if (parentId) { qc.setQueryData( 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({ 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(key, next); } @@ -432,7 +440,7 @@ export function useBatchUpdateIssues() { affectedParentIds.add(parentId); prevChildren.set(parentId, data); qc.setQueryData(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)), ); } diff --git a/packages/core/issues/queries.ts b/packages/core/issues/queries.ts index 50c963f5a8..ee1c3bb827 100644 --- a/packages/core/issues/queries.ts +++ b/packages/core/issues/queries.ts @@ -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, diff --git a/packages/core/realtime/use-realtime-sync.ts b/packages/core/realtime/use-realtime-sync.ts index 4ffef07f73..23489101a8 100644 --- a/packages/core/realtime/use-realtime-sync.ts +++ b/packages/core/realtime/use-realtime-sync.ts @@ -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() }); }, }; diff --git a/packages/core/types/api.ts b/packages/core/types/api.ts index 5865c1badd..697147c367 100644 --- a/packages/core/types/api.ts +++ b/packages/core/types/api.ts @@ -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 { diff --git a/packages/core/types/comment.ts b/packages/core/types/comment.ts index 02c1b65ac2..9bb712b2ff 100644 --- a/packages/core/types/comment.ts +++ b/packages/core/types/comment.ts @@ -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 diff --git a/packages/views/issues/hooks/use-issue-trigger-preview.ts b/packages/views/issues/hooks/use-issue-trigger-preview.ts new file mode 100644 index 0000000000..a61c6acc60 --- /dev/null +++ b/packages/views/issues/hooks/use-issue-trigger-preview.ts @@ -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), + }; +}