Files
fusion/packages/dashboard/app/hooks/useConfirm.ts
gsxdsm 68ce5c31da fix: harden onboarding/git/desktop batch per multi-agent review findings
- uninstaller: taskkill only the first, digits-only postmaster.pid line
  (the for /f loop ran taskkill on the port/epoch lines — potential
  unrelated-process kill)
- git-missing dialogs use new ConfirmOptions.alwaysAsk so global
  skip-confirmations cannot silently pick an unseen choice
- Windows quit prompt: embedded-local runtimes only, skipped during OS
  session end (sync dialog blocked Windows shutdown)
- 'leave it running' detaches the embedded lifecycle (disarms its
  process shutdown hook) so Electron exit cannot kill the postmaster
  the operator chose to keep (new detachKeepingEmbedded)
- wizard: ref-based double-submit guard around the async git preflight
- clone route: ENOENT invalidate-and-retry matching runGitCommand
- openExternalUrl: drop the async window.open fallback (always
  popup-blocked); log bridge failures instead
- DirectoryPicker: close the panel when listing the created folder
  fails so Select cannot re-commit the parent
- git status probe bounded to two spawns (PATH + first candidate)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 00:33:14 -07:00

158 lines
5.1 KiB
TypeScript

import { createContext, useCallback, useContext, useMemo, useRef, useState } from "react";
import type { ReactNode } from "react";
import React from "react";
import { ConfirmDialog } from "../components/ConfirmDialog";
export interface ConfirmOptions {
title: string;
message: string;
/*
FNXC:ConfirmDialogs 2026-07-18-06:00:
Dialogs that GATE an action on an informed choice (e.g. "git is missing —
create without a repo?") must render even when the operator globally skips
critical-action confirmations: auto-resolving "primary" would silently pick
an option the operator never saw (review finding: skip-confirmations turned
the git-missing clone dialog into an endless silent browser-tab opener).
*/
alwaysAsk?: boolean;
confirmLabel?: string;
cancelLabel?: string;
danger?: boolean;
tertiaryLabel?: string;
tertiaryDanger?: boolean;
checkbox?: {
label: string;
description?: string;
defaultChecked?: boolean;
};
}
export type ConfirmChoice = "primary" | "tertiary" | "cancel";
interface PendingConfirm {
options: ConfirmOptions;
checkboxValue: boolean;
resolve: (value: { choice: ConfirmChoice; checkboxValue: boolean }) => void;
}
interface ConfirmContextValue {
confirm: (options: ConfirmOptions) => Promise<boolean>;
confirmWithChoice: (options: ConfirmOptions) => Promise<ConfirmChoice>;
confirmWithCheckbox: (options: ConfirmOptions) => Promise<{ choice: ConfirmChoice; checkboxValue: boolean }>;
}
const ConfirmContext = createContext<ConfirmContextValue | null>(null);
export function ConfirmDialogProvider({
children,
skipConfirmations = false,
}: {
children: ReactNode;
skipConfirmations?: boolean;
}) {
const [queue, setQueue] = useState<PendingConfirm[]>([]);
const queueRef = useRef<PendingConfirm[]>([]);
const skipConfirmationsRef = useRef(skipConfirmations);
skipConfirmationsRef.current = skipConfirmations;
const updateQueue = useCallback((updater: (current: PendingConfirm[]) => PendingConfirm[]) => {
setQueue((current) => {
const next = updater(current);
queueRef.current = next;
return next;
});
}, []);
const confirmWithCheckbox = useCallback((options: ConfirmOptions) => {
/*
FNXC:ConfirmDialogs 2026-07-16-05:30:
Operators who globally skip critical-action confirmations must receive the same primary/default result as clicking the dialog's primary button. Never invent a different outcome or enqueue a hidden dialog; checkbox prompts retain their configured default value.
*/
if (skipConfirmationsRef.current && !options.alwaysAsk) {
return Promise.resolve({
choice: "primary" as const,
checkboxValue: options.checkbox?.defaultChecked ?? false,
});
}
return new Promise<{ choice: ConfirmChoice; checkboxValue: boolean }>((resolve) => {
updateQueue((current) => [
...current,
{
options,
checkboxValue: options.checkbox?.defaultChecked ?? false,
resolve,
},
]);
});
}, [updateQueue]);
const confirmWithChoice = useCallback(async (options: ConfirmOptions) => {
const { choice } = await confirmWithCheckbox(options);
return choice;
}, [confirmWithCheckbox]);
const confirm = useCallback(async (options: ConfirmOptions) => {
const choice = await confirmWithChoice(options);
return choice === "primary";
}, [confirmWithChoice]);
const resolveCurrent = useCallback((value: ConfirmChoice) => {
const current = queueRef.current[0];
if (!current) {
return;
}
current.resolve({ choice: value, checkboxValue: current.checkboxValue });
updateQueue((items) => items.slice(1));
}, [updateQueue]);
const active = queue[0] ?? null;
const contextValue = useMemo<ConfirmContextValue>(
() => ({ confirm, confirmWithChoice, confirmWithCheckbox }),
[confirm, confirmWithCheckbox, confirmWithChoice]
);
return React.createElement(
ConfirmContext.Provider,
{ value: contextValue },
children,
React.createElement(ConfirmDialog, {
isOpen: active !== null,
options: active?.options ?? null,
onConfirm: () => resolveCurrent("primary"),
onTertiary: () => resolveCurrent("tertiary"),
onCancel: () => resolveCurrent("cancel"),
checkboxLabel: active?.options.checkbox?.label,
checkboxDescription: active?.options.checkbox?.description,
checkboxChecked: active?.checkboxValue ?? false,
onCheckboxChange: (next) => {
updateQueue((current) => {
if (current.length === 0) {
return current;
}
const [head, ...tail] = current;
return [{ ...head, checkboxValue: next }, ...tail];
});
},
})
);
}
export function useConfirm(): ConfirmContextValue {
const context = useContext(ConfirmContext);
if (context) {
return context;
}
return {
confirm: async (_options: ConfirmOptions) => false,
confirmWithChoice: async (_options: ConfirmOptions) => "cancel",
confirmWithCheckbox: async (options: ConfirmOptions) => ({
choice: "cancel",
checkboxValue: options.checkbox?.defaultChecked ?? false,
}),
};
}