Files
multica/packages/views/issues/hooks/use-issue-trigger-preview.ts
Naiyuan Qing 3ce97453b3 fix(issues): pre-trigger preview + run-confirm + handoff UX polish (MUL-3375) (#4454)
* fix(issues): stop issue-trigger preview flicker

The pre-trigger preview re-rendered/refetched on every workspace task
event: WS task lifecycle invalidated issueTriggerPreviewAll (staleTime 0),
forcing a background refetch whose isFetching was surfaced as isLoading,
collapsing and reopening CreateRunHint's reveal band.

The assign source (create / assignee change) cancels existing tasks before
enqueuing, so its verdict can't shift from a task event at all; the status
source's pending dedup could, but the preview is advisory and the write
path re-evaluates authoritatively, so a rare stale label is harmless. Drop
the WS invalidation so the preview refetches only on input (signature)
change. Keep the comment-trigger invalidation — its verdict genuinely
changes mid-compose and its chips drive an immediate, unconfirmed send.

Align the hook's data handling with the comment-trigger preview:
keepPreviousData so an input switch swaps in place instead of collapsing,
and treat only the first load (no prior data) as loading.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(issues): skip run-confirm modal for backlog assign

Assigning a Backlog issue to an agent/squad never starts a run (the
parking lot — server/internal/service/issue_trigger.go), so the
pre-trigger confirm modal only rendered an empty "won't start" box with
a single Apply button. Apply directly instead: the single path checks
issue.status, the batch path skips only when every selected issue is
Backlog (mixed selections still confirm — the non-backlog ones trigger).
Mirrors the existing backlog short-circuit in handleBatchStatus.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(modals): run-confirm loading state + submit spinner

The dialog grew in height after open: it rendered the short "won't
start" variant while POST /api/issues/preview-trigger was in flight, then
the note box appeared when the predicate landed. Keep the note box
mounted (disabled) during loading so assign mode opens at its resolved
height, and show a Spinner + 'checking' headline while loading.

Submit had no feedback — buttons only disabled, which read as frozen for
note assigns (the request starts an agent server-side). Track which
footer action is in flight and show a Spinner on the clicked button.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(issues): show handoff note in execution-log trigger text

An assignment-triggered run that carried a handoff note showed the
generic "Initial run" label. Surface the note inline (truncated, like
comment triggers show their text) so the row reads as the handoff.

taskToResponse now populates handoff_note for all callers (dropping the
now-redundant explicit set in ClaimTaskByRuntime); the field is added to
the AgentTask type + zod schema (optional, additive — old clients ignore
it via the loose schema, new clients fall back to "Initial run").

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 16:15:44 +08:00

96 lines
3.8 KiB
TypeScript

"use client";
import { useMemo } from "react";
import { keepPreviousData, 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 verdict changes only with the inputs (assignee / status), so the query
* refetches solely on signature change — it is deliberately NOT invalidated by
* WS task events. The assign source (create / assignee change) cancels existing
* tasks before enqueuing, so its verdict can't shift from a task event at all;
* the status source's pending dedup could, but the preview is advisory and the
* write path re-evaluates authoritatively, so a rare stale status label is
* harmless — far better than refetching every mounted preview on every
* workspace task event (the source of the visible flicker, MUL-3375).
*
* Mirrors the comment-trigger preview's data handling: keepPreviousData so an
* input switch swaps the answer in place instead of collapsing, and only the
* very first load (no prior data) counts as loading. */
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,
// Keep the prior verdict visible while a new signature (assignee/status
// switch) refetches, so the hint swaps in place rather than collapsing.
placeholderData: keepPreviousData,
});
const triggers = previewQuery.data?.triggers ?? EMPTY;
return {
triggers,
totalCount: previewQuery.data?.total_count ?? 0,
// Only the first load (no prior data) is "loading"; a background/placeholder
// refetch is not, so reveal animations gated on this never collapse mid-fetch.
isLoading: enabled && previewQuery.isLoading,
handoffSupported: triggers.length > 0 && triggers.every((t) => t.handoff_supported === true),
};
}