Files
buzz/desktop/src/features/workflows/hooks.ts
Wes 59f613c404 fix(desktop): defer foreground resume work (#5696)
## Summary

- defer foreground resume work until the activation task has returned, a
frame has painted, and a trailing task gets a turn
- centralize app-focus subscribers and remove the broad TanStack
`refetchOnWindowFocus` fan-out
- coalesce relay recovery and preserve an explicit deferred refresh only
for workflow data without a polling/push freshness path
- defer the notification permission native check while keeping blur and
cheap correctness signals immediate

## Why

Buzz Desktop 0.5.10 can spend roughly 1.5 seconds in the WebKit
window-focus listener/microtask checkpoint before returning to the run
loop. Focus currently fans out into query refetches, React polling
updates, relay reconnect/replay, and native work in one activation turn.
This patch establishes an interaction-first foreground boundary rather
than letting those consumers compete with the activating input and first
paint.

## Validation

- focused foreground/workflow/relay tests: 18/18 passed before commit
- `pnpm --dir desktop typecheck`: passed before commit
- pre-commit desktop check and file-size gate: passed
- pre-push desktop check, typecheck, and full desktop unit suite:
4,743/4,743 passed at `704e7b4b6618fafce655bb2b07c7a9fe0fc8c643`
- Princess Donut independent adversarial review: PASS after two
lifecycle/freshness blockers were resolved

## Manual test

1. Install the PR build and use Buzz long enough to populate channels,
home, workflows, agents, and other polling surfaces.
2. Switch to another app for 30-60 seconds.
3. Return by clicking Buzz and immediately click a channel or scroll.
4. Confirm the first interaction and paint are prompt, then confirm
channels/home/workflows refresh and a degraded relay reconnects after
the activation boundary.
5. Repeat while rapidly switching away again to verify no resume work
starts after focus has been lost.

---------

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Carl <c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@buzz.block.builderlab.xyz>
2026-08-12 13:46:49 -07:00

249 lines
8.2 KiB
TypeScript

import * as React from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { WorkflowRun, WorkflowRunStatus } from "@/shared/api/types";
import {
useAppFocused,
useFocusedRefetchInterval,
} from "@/shared/lib/useDocumentVisible";
import {
createWorkflow,
deleteWorkflow,
denyApproval,
getChannelWorkflows,
getRunApprovals,
getWorkflow,
getWorkflowRuns,
grantApproval,
triggerWorkflow,
updateWorkflow,
} from "@/shared/api/tauriWorkflows";
/** Stale gate for workflow list queries (useChannelWorkflowsQuery, allWorkflowsQuery).
* These queries have no poll and no relay push subscription; foreground return
* explicitly refreshes them after the interaction-first activation boundary. */
export const WORKFLOW_LIST_FOCUS_STALE_TIME_MS = 10_000;
/** Keeps focused polling for run approvals at the established 10-second cadence. */
export const RUN_APPROVALS_REFETCH_INTERVAL_MS = 10_000;
/** Tighter stale gate for the workflow-runs detail query.
* The refetchInterval is conditional — 1 s while any run is active, false
* otherwise — so when there are no active runs the poll stops. Remote-started
* runs (webhook / another user) do not push an invalidation, meaning a 5-min
* focus gate would leave the detail view stale for up to 5 minutes. 10 s
* restores the pre-PR behavior and surfaces remote starts quickly without
* excess load. (Completed runs self-heal: the stale "active" cache keeps the
* 1 s poll alive until the run finishes, so the fix only matters for the
* "no active runs" → "new remote run" transition.) */
export const WORKFLOW_RUNS_FOCUS_STALE_TIME_MS = 10_000;
/** Suppresses redundant focus refetches for the run-approvals query.
* The focused 10-second poll (RUN_APPROVALS_REFETCH_INTERVAL_MS) already keeps
* this data fresh; a focus return within 5 minutes adds no new information. */
export const RUN_APPROVALS_FOCUS_STALE_TIME_MS = 5 * 60_000;
/** Focus-refetch policy for workflow list queries; consumed by focusRefetchPolicy.test.mjs. */
export const workflowListFocusRefetchPolicy = {
staleTime: WORKFLOW_LIST_FOCUS_STALE_TIME_MS,
refetchOnWindowFocus: false,
} as const;
/** Focus-refetch policy for the workflow-runs query; consumed by focusRefetchPolicy.test.mjs. */
export const workflowRunsFocusRefetchPolicy = {
staleTime: WORKFLOW_RUNS_FOCUS_STALE_TIME_MS,
refetchOnWindowFocus: false,
} as const;
/** Focus-refetch policy for the run-approvals query; consumed by focusRefetchPolicy.test.mjs. */
export const runApprovalsFocusRefetchPolicy = {
staleTime: RUN_APPROVALS_FOCUS_STALE_TIME_MS,
refetchOnWindowFocus: false,
} as const;
export function shouldRefreshQueryOnForeground(
queryKey: readonly unknown[],
data: unknown,
): boolean {
const family = queryKey[0];
if (family === "workflows" || family === "workflows-all") return true;
if (family === "project" && queryKey[2] === "repo-sync-status") return true;
if (family !== "workflow-runs") return false;
const runs = data as WorkflowRun[] | undefined;
return !runs?.some((run) => isActiveWorkflowRunStatus(run.status));
}
export function useForegroundQueryRefresh(): void {
const queryClient = useQueryClient();
const appFocused = useAppFocused();
const wasFocused = React.useRef(appFocused);
React.useEffect(() => {
const returnedToForeground = appFocused && !wasFocused.current;
wasFocused.current = appFocused;
if (!returnedToForeground) return;
void queryClient.refetchQueries({
predicate: (query) =>
shouldRefreshQueryOnForeground(query.queryKey, query.state.data),
stale: true,
type: "active",
});
}, [appFocused, queryClient]);
}
export const allWorkflowsQueryKey = (channelIdKey: string) =>
["workflows-all", channelIdKey] as const;
export const workflowsQueryKey = (channelId: string) =>
["workflows", channelId] as const;
export const workflowQueryKey = (workflowId: string) =>
["workflow", workflowId] as const;
export const workflowRunsQueryKey = (workflowId: string) =>
["workflow-runs", workflowId] as const;
export const runApprovalsQueryKey = (workflowId: string, runId: string) =>
["run-approvals", workflowId, runId] as const;
function invalidateWorkflowListQueries(
queryClient: ReturnType<typeof useQueryClient>,
) {
void queryClient.invalidateQueries({
predicate: (query) =>
query.queryKey[0] === "workflows" ||
query.queryKey[0] === "workflows-all",
});
}
function isActiveWorkflowRunStatus(status: WorkflowRunStatus) {
return (
status === "pending" ||
status === "running" ||
status === "waiting_approval"
);
}
export function useChannelWorkflowsQuery(channelId: string | null) {
return useQuery({
queryKey: workflowsQueryKey(channelId ?? ""),
queryFn: ({ queryKey: [, resolvedChannelId] }) =>
getChannelWorkflows(resolvedChannelId),
enabled: channelId !== null,
...workflowListFocusRefetchPolicy,
});
}
export function useWorkflowQuery(workflowId: string | null) {
return useQuery({
queryKey: workflowQueryKey(workflowId ?? ""),
queryFn: ({ queryKey: [, resolvedWorkflowId] }) =>
getWorkflow(resolvedWorkflowId),
enabled: workflowId !== null,
staleTime: 30_000,
});
}
export function useWorkflowRunsQuery(workflowId: string | null) {
const appFocused = useAppFocused();
return useQuery({
queryKey: workflowRunsQueryKey(workflowId ?? ""),
queryFn: ({ queryKey: [, resolvedWorkflowId] }) =>
getWorkflowRuns(resolvedWorkflowId),
enabled: workflowId !== null,
refetchInterval: (query) => {
if (!appFocused) return false;
const runs = query.state.data as WorkflowRun[] | undefined;
return runs?.some((run) => isActiveWorkflowRunStatus(run.status))
? 1_000
: false;
},
...workflowRunsFocusRefetchPolicy,
});
}
export function useRunApprovalsQuery(
workflowId: string | null,
runId: string | null,
) {
const refetchInterval = useFocusedRefetchInterval(
RUN_APPROVALS_REFETCH_INTERVAL_MS,
);
return useQuery({
queryKey: runApprovalsQueryKey(workflowId ?? "", runId ?? ""),
queryFn: ({ queryKey: [, resolvedWorkflowId, resolvedRunId] }) =>
getRunApprovals(resolvedWorkflowId, resolvedRunId),
enabled: workflowId !== null && runId !== null,
refetchInterval,
...runApprovalsFocusRefetchPolicy,
});
}
export function useCreateWorkflowMutation(channelId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (yamlDefinition: string) =>
createWorkflow(channelId, yamlDefinition),
onSuccess: () => {
invalidateWorkflowListQueries(queryClient);
},
});
}
export function useUpdateWorkflowMutation(workflowId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (yamlDefinition: string) =>
updateWorkflow(workflowId, yamlDefinition),
onSuccess: () => {
void queryClient.invalidateQueries({
queryKey: workflowQueryKey(workflowId),
});
invalidateWorkflowListQueries(queryClient);
},
});
}
export function useDeleteWorkflowMutation(workflowId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: () => deleteWorkflow(workflowId),
onSuccess: () => {
invalidateWorkflowListQueries(queryClient);
},
});
}
export function useTriggerWorkflowMutation(workflowId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: () => triggerWorkflow(workflowId),
onSuccess: () => {
void queryClient.invalidateQueries({
queryKey: workflowRunsQueryKey(workflowId),
});
},
});
}
export function useApprovalMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input: {
token: string;
action: "grant" | "deny";
note?: string;
}) =>
input.action === "grant"
? grantApproval(input.token, input.note)
: denyApproval(input.token, input.note),
onSuccess: (_data, _variables) => {
void queryClient.invalidateQueries({
predicate: (query) =>
query.queryKey[0] === "workflow-runs" ||
query.queryKey[0] === "workflow" ||
query.queryKey[0] === "run-approvals",
});
},
});
}