From 2f7d150d01c3b1036c86e3696d0d819f5af8e2e5 Mon Sep 17 00:00:00 2001 From: ajnart Date: Thu, 20 Aug 2026 15:36:34 +0200 Subject: [PATCH] refactor: simplify assistant approvals --- apps/docs/docs/management/assistant.mdx | 20 +--- .../assistant/assistant-auto-approval.tsx | 97 +++++-------------- .../components/assistant/assistant-panel.tsx | 1 - 3 files changed, 27 insertions(+), 91 deletions(-) diff --git a/apps/docs/docs/management/assistant.mdx b/apps/docs/docs/management/assistant.mdx index bbbdc1a63..aec1bde4e 100644 --- a/apps/docs/docs/management/assistant.mdx +++ b/apps/docs/docs/management/assistant.mdx @@ -41,32 +41,18 @@ host gateway address. - Ask from `Cmd/Ctrl + K`. - Add the [Assistant widget](../widgets/assistant) to a board. -On desktop, the full conversation opens as a side panel so the current dashboard remains visible. On mobile, it uses -the full screen. Homarr keeps one active conversation surface: while the side panel is open, the board widget becomes -a compact return control, and a visible board widget replaces the floating activity bar. If a board contains multiple -Assistant widgets, use a compact widget's action to move the active conversation there. Minimize returns the -conversation to its widget or activity state; Close also dismisses completed activity. - The assistant can work with boards, apps, integrations, widgets, Custom Widgets, and connected services. Use `@` to add Homarr items as context, `/` for shortcuts, and the paperclip for supported files. -In supported browsers, use the response action menu to read an answer aloud. - -Suggested prompts fill the composer first so you can review or edit them before sending. Questions and approval requests -stay next to the composer without hiding the transcript. - -Use **Conversations** to search and switch threads. Open a conversation's action menu to rename it, export it as -Markdown, or delete it after confirmation. +Use **Conversations** to search, switch, rename, export, or delete threads. Each request includes the user's name, current page and board, home board, server time, browser time zone, and a small summary of accessible Homarr resources. Live status is fetched only when needed. ## Permissions and privacy -- Read-only actions can run automatically. Changes require approval by default. -- **Automatic approvals** can prepare proposed form details and approve protected changes for the current conversation. - It turns off when you switch conversations and still respects Homarr permissions. -- Questions always wait for your answer, even when automatic approvals are enabled. +- Read-only actions can run automatically. Changes require approval by default. Automatic approvals apply only to the + current conversation, turn off when you switch conversations, and never answer questions for you. - Homarr permissions apply to every tool call and mentioned item. - API keys and custom headers are encrypted and never returned to the browser. - Conversations are stored per user and can be exported or deleted. diff --git a/apps/nextjs/src/components/assistant/assistant-auto-approval.tsx b/apps/nextjs/src/components/assistant/assistant-auto-approval.tsx index c4eddb62a..64b4a3ec6 100644 --- a/apps/nextjs/src/components/assistant/assistant-auto-approval.tsx +++ b/apps/nextjs/src/components/assistant/assistant-auto-approval.tsx @@ -36,70 +36,41 @@ export const createAssistantAutoApprovalTracker = (maximumAttempts = Number.POSI interface AssistantAutoApprovalContextValue { enabled: boolean; setEnabled: (enabled: boolean) => void; - preparationEnabled: boolean; - approvalEnabled: boolean; - setPreparationEnabled: (enabled: boolean) => void; - setApprovalEnabled: (enabled: boolean) => void; - requestAction: (toolCallId: string, confirm: () => void, mode?: AssistantAutomaticActionMode) => boolean; + requestAction: (toolCallId: string, confirm: () => void) => boolean; completeAction: (toolCallId: string) => void; retryRevision: number; } -export type AssistantAutomaticActionMode = "preparation" | "approval"; - const AssistantAutoApprovalContext = createContext(null); export const AssistantAutoApprovalProvider = ({ children, conversationId, }: PropsWithChildren<{ conversationId: string }>) => { - const [preparationEnabled, setPreparationEnabledState] = useState(false); - const [approvalEnabled, setApprovalEnabledState] = useState(false); + const [enabled, setEnabledState] = useState(false); const [retryRevision, setRetryRevision] = useState(0); const trackerRef = useRef(createAssistantAutoApprovalTracker(AUTO_APPROVAL_MAX_ATTEMPTS)); - const retryTimersRef = useRef(new Map()); + const retryTimersRef = useRef(new Map()); const previousConversationIdRef = useRef(conversationId); const conversationMatches = previousConversationIdRef.current === conversationId; - const effectivePreparationEnabled = conversationMatches && preparationEnabled; - const effectiveApprovalEnabled = conversationMatches && approvalEnabled; + const effectiveEnabled = conversationMatches && enabled; const clearRetryTimer = useCallback((toolCallId: string) => { - const retry = retryTimersRef.current.get(toolCallId); - if (retry !== undefined) window.clearTimeout(retry.timer); + const timer = retryTimersRef.current.get(toolCallId); + if (timer !== undefined) window.clearTimeout(timer); retryTimersRef.current.delete(toolCallId); }, []); - const clearAutomaticActions = useCallback((mode?: AssistantAutomaticActionMode) => { - for (const [toolCallId, retry] of retryTimersRef.current) { - if (mode !== undefined && retry.mode !== mode) continue; - window.clearTimeout(retry.timer); - retryTimersRef.current.delete(toolCallId); - trackerRef.current.complete(toolCallId); - } - if (mode === undefined) trackerRef.current.clear(); + const clearAutomaticActions = useCallback(() => { + for (const timer of retryTimersRef.current.values()) window.clearTimeout(timer); + retryTimersRef.current.clear(); + trackerRef.current.clear(); }, []); const setEnabled = useCallback( (nextEnabled: boolean) => { if (!nextEnabled) clearAutomaticActions(); - setPreparationEnabledState(nextEnabled); - setApprovalEnabledState(nextEnabled); - }, - [clearAutomaticActions], - ); - - const setPreparationEnabled = useCallback( - (nextEnabled: boolean) => { - if (!nextEnabled) clearAutomaticActions("preparation"); - setPreparationEnabledState(nextEnabled); - }, - [clearAutomaticActions], - ); - - const setApprovalEnabled = useCallback( - (nextEnabled: boolean) => { - if (!nextEnabled) clearAutomaticActions("approval"); - setApprovalEnabledState(nextEnabled); + setEnabledState(nextEnabled); }, [clearAutomaticActions], ); @@ -110,35 +81,33 @@ export const AssistantAutoApprovalProvider = ({ if (previousConversationId === conversationId) return; clearAutomaticActions(); - setPreparationEnabledState(false); - setApprovalEnabledState(false); + setEnabledState(false); }, [clearAutomaticActions, conversationId]); useEffect(() => clearAutomaticActions, [clearAutomaticActions]); const requestAction = useCallback( - (toolCallId: string, confirm: () => void, mode: AssistantAutomaticActionMode = "preparation") => { - const modeEnabled = mode === "approval" ? effectiveApprovalEnabled : effectivePreparationEnabled; - if (!modeEnabled || !trackerRef.current.claim(toolCallId)) return false; + (toolCallId: string, confirm: () => void) => { + if (!effectiveEnabled || !trackerRef.current.claim(toolCallId)) return false; try { confirm(); clearRetryTimer(toolCallId); - retryTimersRef.current.set(toolCallId, { - mode, - timer: window.setTimeout(() => { + retryTimersRef.current.set( + toolCallId, + window.setTimeout(() => { retryTimersRef.current.delete(toolCallId); trackerRef.current.release(toolCallId); setRetryRevision((revision) => revision + 1); }, AUTO_APPROVAL_RETRY_TIMEOUT_MS), - }); + ); return true; } catch { trackerRef.current.release(toolCallId); return false; } }, - [clearRetryTimer, effectiveApprovalEnabled, effectivePreparationEnabled], + [clearRetryTimer, effectiveEnabled], ); const completeAction = useCallback( @@ -150,28 +119,14 @@ export const AssistantAutoApprovalProvider = ({ ); const value = useMemo(() => { - const enabled = effectivePreparationEnabled || effectiveApprovalEnabled; return { - enabled, + enabled: effectiveEnabled, setEnabled, - preparationEnabled: effectivePreparationEnabled, - approvalEnabled: effectiveApprovalEnabled, - setPreparationEnabled, - setApprovalEnabled, requestAction, completeAction, retryRevision, }; - }, [ - completeAction, - effectiveApprovalEnabled, - effectivePreparationEnabled, - requestAction, - retryRevision, - setApprovalEnabled, - setEnabled, - setPreparationEnabled, - ]); + }, [completeAction, effectiveEnabled, requestAction, retryRevision, setEnabled]); return {children}; }; @@ -187,20 +142,16 @@ export const useAssistantAutomaticAction = ({ ready, completed, confirm, - mode = "preparation", }: { toolCallId: string; ready: boolean; completed: boolean; confirm: () => void; - mode?: AssistantAutomaticActionMode; }) => { - const { approvalEnabled, completeAction, preparationEnabled, requestAction, retryRevision } = - useAssistantAutoApproval(); + const { completeAction, enabled, requestAction, retryRevision } = useAssistantAutoApproval(); const [inProgress, setInProgress] = useState(false); const confirmRef = useRef(confirm); confirmRef.current = confirm; - const enabled = mode === "approval" ? approvalEnabled : preparationEnabled; useEffect(() => { if (!ready || completed || !enabled) { @@ -209,9 +160,9 @@ export const useAssistantAutomaticAction = ({ return; } - const requested = requestAction(toolCallId, () => confirmRef.current(), mode); + const requested = requestAction(toolCallId, () => confirmRef.current()); setInProgress(requested); - }, [completeAction, completed, enabled, mode, ready, requestAction, retryRevision, toolCallId]); + }, [completeAction, completed, enabled, ready, requestAction, retryRevision, toolCallId]); return inProgress; }; diff --git a/apps/nextjs/src/components/assistant/assistant-panel.tsx b/apps/nextjs/src/components/assistant/assistant-panel.tsx index e9602fbb5..80f448266 100644 --- a/apps/nextjs/src/components/assistant/assistant-panel.tsx +++ b/apps/nextjs/src/components/assistant/assistant-panel.tsx @@ -897,7 +897,6 @@ const ToolPart = ({ toolCallId, ready: awaitingApproval, completed: !awaitingApproval, - mode: "approval", confirm: () => { respondToApproval({ approved: true, reason: "Approved automatically by the user." }); },