Add recipe install warning (#3537)

Co-authored-by: Douwe Osinga <douwe@squareup.com>
This commit is contained in:
Zane
2025-07-22 08:34:25 -07:00
committed by GitHub
parent 9f4e7a88f1
commit ab8089499e
6 changed files with 205 additions and 8 deletions
+21 -3
View File
@@ -60,6 +60,7 @@ import { type View, ViewOptions } from '../App';
import { MainPanelLayout } from './Layout/MainPanelLayout';
import ChatInput from './ChatInput';
import { ScrollArea, ScrollAreaHandle } from './ui/scroll-area';
import { RecipeWarningModal } from './ui/RecipeWarningModal';
import { useChatEngine } from '../hooks/useChatEngine';
import { useRecipeManager } from '../hooks/useRecipeManager';
import { useSessionContinuation } from '../hooks/useSessionContinuation';
@@ -195,6 +196,10 @@ function BaseChatContent({
handleAutoExecution,
recipeError,
setRecipeError,
isRecipeWarningModalOpen,
recipeAccepted,
handleRecipeAccept,
handleRecipeCancel,
} = useRecipeManager(messages, location.state);
// Reset recipe usage tracking when recipe changes
@@ -356,9 +361,9 @@ function BaseChatContent({
{
// Check if we should show splash instead of messages
(() => {
// Show splash if we have a recipe and user hasn't started using it yet
// Show splash if we have a recipe and user hasn't started using it yet, and recipe has been accepted
const shouldShowSplash =
recipeConfig && !hasStartedUsingRecipe && !suppressEmptyState;
recipeConfig && recipeAccepted && !hasStartedUsingRecipe && !suppressEmptyState;
return shouldShowSplash;
})() ? (
@@ -377,7 +382,8 @@ function BaseChatContent({
<PopularChatTopics append={(text: string) => appendWithTracking(text)} />
) : null}
</>
) : filteredMessages.length > 0 || (recipeConfig && hasStartedUsingRecipe) ? (
) : filteredMessages.length > 0 ||
(recipeConfig && recipeAccepted && hasStartedUsingRecipe) ? (
<>
{disableSearch ? (
// Render messages without SearchView wrapper when search is disabled
@@ -523,6 +529,18 @@ function BaseChatContent({
summaryContent={summaryContent}
/>
{/* Recipe Warning Modal */}
<RecipeWarningModal
isOpen={isRecipeWarningModalOpen}
onConfirm={handleRecipeAccept}
onCancel={handleRecipeCancel}
recipeDetails={{
title: recipeConfig?.title,
description: recipeConfig?.description,
instructions: recipeConfig?.instructions,
}}
/>
{/* Recipe Error Modal */}
{recipeError && (
<div className="fixed inset-0 z-[300] flex items-center justify-center bg-black/50">
@@ -0,0 +1,71 @@
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from './dialog';
import { Button } from './button';
interface RecipeWarningModalProps {
isOpen: boolean;
onConfirm: () => void;
onCancel: () => void;
recipeDetails: {
title?: string;
description?: string;
instructions?: string;
};
}
export function RecipeWarningModal({
isOpen,
onConfirm,
onCancel,
recipeDetails,
}: RecipeWarningModalProps) {
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onCancel()}>
<DialogContent className="sm:max-w-[600px]">
<DialogHeader>
<DialogTitle> New Recipe Warning</DialogTitle>
<DialogDescription>
You are about to execute a recipe that you haven't run before. Only proceed if you trust
the source of this recipe.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="bg-background-muted p-4 rounded-lg">
<h3 className="font-medium mb-2 text-text-standard">Recipe Details:</h3>
<div className="space-y-2 text-sm">
{recipeDetails.title && (
<p className="text-text-standard">
<strong>Title:</strong> {recipeDetails.title}
</p>
)}
{recipeDetails.description && (
<p className="text-text-standard">
<strong>Description:</strong> {recipeDetails.description}
</p>
)}
{recipeDetails.instructions && (
<p className="text-text-standard">
<strong>Instructions:</strong> {recipeDetails.instructions}
</p>
)}
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={onCancel}>
Cancel
</Button>
<Button onClick={onConfirm}>Trust and Execute</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+57 -5
View File
@@ -16,6 +16,8 @@ export const useRecipeManager = (messages: Message[], locationState?: LocationSt
const [recipeParameters, setRecipeParameters] = useState<Record<string, string> | null>(null);
const [readyForAutoUserPrompt, setReadyForAutoUserPrompt] = useState(false);
const [recipeError, setRecipeError] = useState<string | null>(null);
const [isRecipeWarningModalOpen, setIsRecipeWarningModalOpen] = useState(false);
const [recipeAccepted, setRecipeAccepted] = useState(false);
// Get chat context to access persisted recipe
const chatContext = useChatContext();
@@ -72,15 +74,37 @@ export const useRecipeManager = (messages: Message[], locationState?: LocationSt
}
}, [chatContext, locationState]);
// Check if recipe has been accepted before
useEffect(() => {
const checkRecipeAcceptance = async () => {
if (recipeConfig) {
try {
const hasAccepted = await window.electron.hasAcceptedRecipeBefore(recipeConfig);
if (!hasAccepted) {
setIsRecipeWarningModalOpen(true);
} else {
setRecipeAccepted(true);
}
} catch (error) {
console.error('Error checking recipe acceptance:', error);
// If there's an error, assume the recipe hasn't been accepted
setIsRecipeWarningModalOpen(true);
}
}
};
checkRecipeAcceptance();
}, [recipeConfig]);
// Show parameter modal if recipe has parameters and they haven't been set yet
useEffect(() => {
if (recipeConfig?.parameters && recipeConfig.parameters.length > 0) {
if (recipeConfig?.parameters && recipeConfig.parameters.length > 0 && recipeAccepted) {
// If we have parameters and they haven't been set yet, open the modal.
if (!recipeParameters) {
setIsParameterModalOpen(true);
}
}
}, [recipeConfig, recipeParameters]);
}, [recipeConfig, recipeParameters, recipeAccepted]);
// Set ready for auto user prompt after component initialization
useEffect(() => {
@@ -101,7 +125,7 @@ export const useRecipeManager = (messages: Message[], locationState?: LocationSt
// Pre-fill input with recipe prompt instead of auto-sending it
const initialPrompt = useMemo(() => {
if (!recipeConfig?.prompt) return '';
if (!recipeConfig?.prompt || !recipeAccepted) return '';
const hasRequiredParams = recipeConfig.parameters && recipeConfig.parameters.length > 0;
@@ -117,7 +141,7 @@ export const useRecipeManager = (messages: Message[], locationState?: LocationSt
// Otherwise, we are waiting for parameters, so the input should be empty.
return '';
}, [recipeConfig, recipeParameters]);
}, [recipeConfig, recipeParameters, recipeAccepted]);
// Handle parameter submission
const handleParameterSubmit = async (inputValues: Record<string, string>) => {
@@ -132,6 +156,28 @@ export const useRecipeManager = (messages: Message[], locationState?: LocationSt
}
};
// Handle recipe acceptance
const handleRecipeAccept = async () => {
try {
if (recipeConfig) {
await window.electron.recordRecipeHash(recipeConfig);
setRecipeAccepted(true);
setIsRecipeWarningModalOpen(false);
}
} catch (error) {
console.error('Error recording recipe hash:', error);
// Even if recording fails, we should still allow the user to proceed
setRecipeAccepted(true);
setIsRecipeWarningModalOpen(false);
}
};
// Handle recipe cancellation
const handleRecipeCancel = () => {
setIsRecipeWarningModalOpen(false);
window.electron.closeWindow();
};
// Auto-execution handler for scheduled recipes
const handleAutoExecution = (append: (message: Message) => void, isLoading: boolean) => {
const hasRequiredParams = recipeConfig?.parameters && recipeConfig.parameters.length > 0;
@@ -142,7 +188,8 @@ export const useRecipeManager = (messages: Message[], locationState?: LocationSt
(!hasRequiredParams || recipeParameters) &&
messages.length === 0 &&
!isLoading &&
readyForAutoUserPrompt
readyForAutoUserPrompt &&
recipeAccepted
) {
// Substitute parameters if they exist
const finalPrompt = recipeParameters
@@ -249,5 +296,10 @@ export const useRecipeManager = (messages: Message[], locationState?: LocationSt
handleAutoExecution,
recipeError,
setRecipeError,
isRecipeWarningModalOpen,
setIsRecipeWarningModalOpen,
recipeAccepted,
handleRecipeAccept,
handleRecipeCancel,
};
};
+1
View File
@@ -50,6 +50,7 @@ import {
} from './utils/autoUpdater';
import { UPDATES_ENABLED } from './updates';
import { Recipe } from './recipe';
import './utils/recipeHash';
// API URL constructor for main process before window is ready
function getApiUrlMain(endpoint: string, dynamicPort: number): string {
+9
View File
@@ -105,6 +105,10 @@ type ElectronAPI = {
restartApp: () => void;
onUpdaterEvent: (callback: (event: UpdaterEvent) => void) => void;
getUpdateState: () => Promise<{ updateAvailable: boolean; latestVersion?: string } | null>;
// Recipe warning functions
closeWindow: () => void;
hasAcceptedRecipeBefore: (recipeConfig: Recipe) => Promise<boolean>;
recordRecipeHash: (recipeConfig: Recipe) => Promise<boolean>;
};
type AppConfigAPI = {
@@ -215,6 +219,11 @@ const electronAPI: ElectronAPI = {
getUpdateState: (): Promise<{ updateAvailable: boolean; latestVersion?: string } | null> => {
return ipcRenderer.invoke('get-update-state');
},
closeWindow: () => ipcRenderer.send('close-window'),
hasAcceptedRecipeBefore: (recipeConfig: Recipe) =>
ipcRenderer.invoke('has-accepted-recipe-before', recipeConfig),
recordRecipeHash: (recipeConfig: Recipe) =>
ipcRenderer.invoke('record-recipe-hash', recipeConfig),
};
const appConfigAPI: AppConfigAPI = {
+46
View File
@@ -0,0 +1,46 @@
import { ipcMain, app, BrowserWindow } from 'electron';
import fs from 'node:fs/promises';
import path from 'node:path';
import crypto from 'crypto';
function calculateRecipeHash(recipeConfig: unknown): string {
const hash = crypto.createHash('sha256');
hash.update(JSON.stringify(recipeConfig));
return hash.digest('hex');
}
async function getRecipeHashesDir(): Promise<string> {
const userDataPath = app.getPath('userData');
const hashesDir = path.join(userDataPath, 'recipe_hashes');
await fs.mkdir(hashesDir, { recursive: true });
return hashesDir;
}
ipcMain.handle('has-accepted-recipe-before', async (_event, recipeConfig) => {
const hash = calculateRecipeHash(recipeConfig);
const hashFile = path.join(await getRecipeHashesDir(), `${hash}.hash`);
try {
await fs.access(hashFile);
return true;
} catch (err) {
if (typeof err === 'object' && err !== null && 'code' in err && err.code === 'ENOENT') {
return false;
}
throw err;
}
});
ipcMain.handle('record-recipe-hash', async (_event, recipeConfig) => {
const hash = calculateRecipeHash(recipeConfig);
const filePath = path.join(await getRecipeHashesDir(), `${hash}.hash`);
const timestamp = new Date().toISOString();
await fs.writeFile(filePath, timestamp);
return true;
});
ipcMain.on('close-window', () => {
const currentWindow = BrowserWindow.getFocusedWindow();
if (currentWindow) {
currentWindow.close();
}
});