mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-29 14:37:44 +02:00
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:
@@ -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`,
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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)),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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() });
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
79
packages/views/issues/hooks/use-issue-trigger-preview.ts
Normal file
79
packages/views/issues/hooks/use-issue-trigger-preview.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user