mirror of
https://github.com/block/buzz.git
synced 2026-11-08 10:21:46 +00:00
## 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>
249 lines
8.2 KiB
TypeScript
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",
|
|
});
|
|
},
|
|
});
|
|
}
|